papilot 0.4.0 → 0.5.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/dist/index.html CHANGED
@@ -8,6 +8,6 @@
8
8
  document.documentElement.dataset.theme = dark ? 'dark' : 'light';
9
9
  document.querySelector('meta[name="theme-color"]').content = dark ? '#26272b' : '#ffffff';
10
10
  })();
11
- </script><style>html{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;background:#fff}html[data-theme=dark]{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;background:#26272b}</style> <script type="module" crossorigin src="/assets/index-DTScOfwx.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-BB0zC0wj.css">
11
+ </script><style>html{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;background:#fff}html[data-theme=dark]{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;background:#26272b}</style> <script type="module" crossorigin src="/assets/index-BqX4tIMz.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-C0hHDQdZ.css">
13
13
  </head><body><div id="root"></div></body></html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "papilot",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "A Cursor-like workspace for your papers: annotate and review PDFs directly without ever touching LaTeX.",
5
5
  "keywords": [
6
6
  "latex",
package/src/server/app.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import Fastify from 'fastify';
2
2
  import compress from '@fastify/compress';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
3
5
  import path from 'node:path';
4
6
  import { randomUUID } from 'node:crypto';
5
7
  import { registerFrontend } from './frontend.ts';
@@ -7,7 +9,8 @@ import type { Store } from './store.ts';
7
9
  import type { Workspace } from './workspace.ts';
8
10
  import { reviewClient, allowedReviewRoute, rewriteReviewHtml, rewriteReviewJson, confirmedReviewResponse, annotateReviewState } from './review.ts';
9
11
  import { fullPaperPdf } from './pdf.ts';
10
- import { type Manuscripts, annotationTask, gitAbortMerge, gitCommit, gitDiff, gitFinishMerge, gitIgnore, gitRemote, gitStatus } from './manuscript.ts';
12
+ import { type ChangeAction, Changes } from './changes.ts';
13
+ import { type Manuscripts, annotationTask, gitAbortMerge, gitCommit, gitDiff, gitFinishMerge, gitIgnore, gitRemote, gitStage, gitStatus } from './manuscript.ts';
11
14
  import { findSkill, listedSkill, loadSkills } from './skills.ts';
12
15
  import { type Accounts, deviceLabel } from './accounts.ts';
13
16
  import { type Listener, Limiter, SESSION_COOKIE, author, clientAddress, cookie, equals, guard, viewerInfo, visibleSnapshot } from './access.ts';
@@ -25,7 +28,7 @@ export function gitQueue() {
25
28
  }
26
29
 
27
30
  export interface AppOptions {
28
- listener: Listener; store: Store; workspace: Workspace; manuscripts: Manuscripts; accounts: Accounts;
31
+ listener: Listener; store: Store; workspace: Workspace; manuscripts: Manuscripts; accounts: Accounts; changes?: Changes;
29
32
  csrf: string; serialGit: ReturnType<typeof gitQueue>; dist: string | null; version: string;
30
33
  connectReview?: typeof reviewClient;
31
34
  }
@@ -34,6 +37,7 @@ export interface AppOptions {
34
37
  // the account one for the public URL. Both serve the same workspace.
35
38
  export async function buildApp(options: AppOptions) {
36
39
  const { listener, store, workspace, manuscripts, accounts, serialGit } = options, connectReview = options.connectReview || reviewClient;
40
+ const changes = options.changes || new Changes(fs.mkdtempSync(path.join(os.tmpdir(), 'papilot-changes-')));
37
41
  // A public request must arrive in full within a minute; held-open bodies tie up nothing.
38
42
  const app = Fastify({ logger: false, bodyLimit: 1024 * 1024, ...(listener.kind === 'account' ? { requestTimeout: 60000 } : {}) });
39
43
  const { csrfFor } = guard(app, { listener, accounts, store, csrf: options.csrf, request: id => workspace.requests.get(id) });
@@ -126,17 +130,28 @@ export async function buildApp(options: AppOptions) {
126
130
  app.get<{ Params: { id: string } }>('/api/projects/:id/manuscript', project(), async request => ({ ...(await manuscripts.state(request.params.id, store.project(request.params.id).path)), annotations: store.annotations(request.params.id).filter(note => !note.taskId) }));
127
131
  app.get<{ Params: { id: string } }>('/api/projects/:id/git', project(), request => gitStatus(store.project(request.params.id).path));
128
132
  app.get<{ Params: { id: string }; Querystring: { path?: string } }>('/api/projects/:id/git/diff', project(), request => gitDiff(store.project(request.params.id).path, string(request.query.path, 'File path', 4096)));
133
+ const paths = (value: unknown): string[] => Array.isArray(value) ? value.filter((p: unknown): p is string => typeof p === 'string').slice(0, 5000) : [];
129
134
  app.post<{ Params: { id: string } }>('/api/projects/:id/git/commit', project(), request => {
130
135
  const project = store.project(request.params.id), b = request.body as any;
131
- const message = string(b.message, 'Commit message', 5000), paths = Array.isArray(b.paths) ? b.paths.filter((p: unknown) => typeof p === 'string').slice(0, 5000) : [];
132
- return serialGit(project.id, () => workspace.exclusiveCommit(project.id, async () => ({ commit: await gitCommit(project.path, message, paths), git: await gitStatus(project.path) })));
136
+ const message = string(b.message, 'Commit message', 5000), chosen = b.staged === true ? null : paths(b.paths);
137
+ let pushError: string | undefined;
138
+ return serialGit(project.id, () => workspace.exclusiveCommit(project.id, async () => {
139
+ const commit = await gitCommit(project.path, message, chosen);
140
+ // The commit stands even when the push fails; the author can push again.
141
+ if (b.push === true) try { await gitRemote(project.path, 'push'); } catch (error) { pushError = (error as Error).message; }
142
+ return { commit, git: await gitStatus(project.path), ...(pushError ? { pushError } : {}) };
143
+ }));
144
+ });
145
+ // The Git panel's checkboxes stage or unstage whole files.
146
+ app.post<{ Params: { id: string } }>('/api/projects/:id/git/stage', project(), request => {
147
+ const project = store.project(request.params.id), b = request.body as any;
148
+ return serialGit(project.id, async () => ({ git: await gitStage(project.path, paths(b.paths), b.stage !== false) }));
133
149
  });
134
- const paths = (value: unknown): string[] => Array.isArray(value) ? value.filter((p: unknown): p is string => typeof p === 'string').slice(0, 5000) : [];
135
150
  // An agent writes a commit message for the chosen files, or a fresh one
136
151
  // instead of the message it already wrote for them; nothing is written.
137
152
  app.post<{ Params: { id: string } }>('/api/projects/:id/git/message', project(), request => {
138
153
  const b = request.body as any;
139
- return workspace.commitMessage(store.project(request.params.id).id, paths(b.paths), { agent: b.agent === undefined ? undefined : agent(b.agent), fresh: b.fresh === true });
154
+ return workspace.commitMessage(store.project(request.params.id).id, paths(b.paths), { agent: b.agent === undefined ? undefined : agent(b.agent), fresh: b.fresh === true, staged: b.staged === true });
140
155
  });
141
156
  // A merge in progress is resolved by an agent, committed, or abandoned.
142
157
  app.post<{ Params: { id: string } }>('/api/projects/:id/git/resolve', project(), request => {
@@ -239,6 +254,38 @@ export async function buildApp(options: AppOptions) {
239
254
  });
240
255
  }
241
256
 
257
+ // The review viewer also shows a paper's uncommitted changes against its
258
+ // last commit. Papilot answers those routes itself, ahead of the engine's
259
+ // below, and the viewer posts with Papilot's token. Staging writes the
260
+ // index; reverting writes files, so it waits for review actions and commits.
261
+ const changesAccess = { config: { access: { project: 'projectId' }, csrf: false as const } };
262
+ const changesToken = (request: { headers: Record<string, unknown>; viewer?: Parameters<typeof csrfFor>[0] }) => {
263
+ if (!equals(String(request.headers['x-review-token'] || ''), csrfFor(request.viewer!))) throw Object.assign(new Error('This page has expired. Refresh it and try again.'), { statusCode: 403 });
264
+ };
265
+ const CHANGE_ACTIONS: Record<string, ChangeAction> = { accept: 'stage', unstage: 'unstage', reject: 'revert' };
266
+ app.get<{ Params: { projectId: string }; Querystring: { round?: string } }>('/review/:projectId/api/changes/state', changesAccess, request => {
267
+ const project = store.project(request.params.projectId), round = Number(request.query.round);
268
+ return changes.state(project.id, project.path, csrfFor(request.viewer!), Number.isSafeInteger(round) && round > 0 ? round : undefined);
269
+ });
270
+ app.get<{ Params: { projectId: string; number: string } }>('/review/:projectId/api/changes/rounds/:number', changesAccess, request => changes.detail(store.project(request.params.projectId).id, Number(request.params.number)));
271
+ app.get<{ Params: { projectId: string; number: string; file: string } }>('/review/:projectId/changes/files/:number/:file', changesAccess, (request, reply) => {
272
+ const file = changes.file(store.project(request.params.projectId).id, Number(request.params.number), request.params.file);
273
+ return reply.header('Cache-Control', 'private, max-age=31536000, immutable').type(file.endsWith('.svg') ? 'image/svg+xml' : 'image/png').send(fs.createReadStream(file));
274
+ });
275
+ app.post<{ Params: { projectId: string } }>('/review/:projectId/api/changes/decide', changesAccess, request => {
276
+ changesToken(request);
277
+ const project = store.project(request.params.projectId), b = request.body as any, action = CHANGE_ACTIONS[String(b?.action)];
278
+ if (!action) throw new Error('Choose stage, unstage or revert.');
279
+ const ids = Array.isArray(b.changes) ? b.changes.filter((id: unknown): id is string => typeof id === 'string').slice(0, 2000) : [];
280
+ const act = () => changes.act(project.id, project.path, csrfFor(request.viewer!), action, Number(b.round), ids);
281
+ return serialGit(project.id, () => action === 'revert' ? workspace.exclusiveCommit(project.id, act) : act());
282
+ });
283
+ app.post<{ Params: { projectId: string } }>('/review/:projectId/api/changes/undo', changesAccess, request => {
284
+ changesToken(request);
285
+ const project = store.project(request.params.projectId), id = string((request.body as any)?.id, 'Revert ID', 80);
286
+ return serialGit(project.id, () => workspace.exclusiveCommit(project.id, () => changes.undo(project.id, project.path, csrfFor(request.viewer!), id)));
287
+ });
288
+
242
289
  app.route<{ Params: { projectId: string; '*': string } }>({
243
290
  // The review page posts with the review engine's token rather than Papilot's.
244
291
  method: ['GET', 'POST'], url: '/review/:projectId/*', config: { access: { project: 'projectId' }, csrf: false }, handler: async (request, reply) => {
@@ -0,0 +1,295 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { DATA, ENGINE } from './paths.ts';
6
+ import { mergeInProgress, workingTree } from './manuscript.ts';
7
+ import { ignorePattern } from '../commit.ts';
8
+
9
+ // The manuscript's uncommitted changes as a PDF comparison: the last commit
10
+ // against the working tree, rendered by the review engine like a task's round
11
+ // and shown in its viewer. Each change can be staged or unstaged in the real
12
+ // index, or reverted on disk; a revert can be undone. A commit then takes what
13
+ // is staged, so one set of edits can become several commits.
14
+
15
+ type Op = ['equal' | 'replace' | 'insert' | 'delete', number, number, number, number];
16
+ export interface Unit { id: string; file: string; status: 'added' | 'deleted' | 'modified'; kind: string; whole_file: boolean; ops: Op[]; old_start: number; old_end: number; new_start: number; new_end: number; old_text: string; new_text: string; truncated: boolean }
17
+ type Decision = 'pending' | 'accepted' | 'rejected' | 'commented';
18
+ export type ChangeAction = 'stage' | 'unstage' | 'revert';
19
+ interface Round { number: number; before: string; after: string; units: Unit[]; folder: string; warnings: string[]; decisions: Record<string, Decision> }
20
+ interface Job { key: string; phase: 'build' | 'render'; status: 'running' | 'failed'; created: number; error?: string; details?: string[]; log?: string }
21
+ interface Snapshot { before: string | null; after: string }
22
+ interface Entry { mode: string; sha: string }
23
+ interface Revert { id: string; files: { path: string; disk: Buffer | null; written: Buffer | null; index: Entry | null; indexAfter: Entry | null }[]; signatures: string[] }
24
+ interface Comparison { rounds: Round[]; job: Job | null; next: number; undo: Revert | null; reverted: Set<string>; snapshot?: { at: number; value: Promise<Snapshot> }; current?: { key: string; units: Unit[] } }
25
+
26
+ const conflict = (message: string) => Object.assign(new Error(message), { statusCode: 409 });
27
+
28
+ async function engine() {
29
+ const load = (file: string) => import(pathToFileURL(path.join(ENGINE, file)).href);
30
+ const [{ Builder, BuildError }, { Repo }, { loadProject, isFigure }, { diffUnits, revertUnits, splitLines, textOf }, { opcodes }, { render }] = await Promise.all(
31
+ ['src/core/build.ts', 'src/core/git.ts', 'src/core/config.ts', 'src/core/changes.ts', 'src/core/diff.ts', 'src/core/publish.ts'].map(load));
32
+ return { Builder, BuildError, Repo, loadProject, isFigure, diffUnits, revertUnits, splitLines, textOf, opcodes, render } as {
33
+ Builder: any; BuildError: any; Repo: any; loadProject: (root: string) => any; isFigure: (project: any, file: string) => boolean;
34
+ diffUnits: (repo: any, before: string, after: string, isFigure: (file: string) => boolean) => Unit[];
35
+ revertUnits: (old: Buffer | null, next: Buffer | null, units: Unit[]) => Buffer | null;
36
+ splitLines: (text: string) => string[]; textOf: (data: Buffer | null) => string | null;
37
+ opcodes: (a: string[], b: string[]) => Op[]; render: (output: string, request: unknown) => Promise<void>;
38
+ };
39
+ }
40
+
41
+ // A change is the same change in a later comparison when it replaces the same
42
+ // committed lines with the same text; reverting another change leaves both.
43
+ export const signature = (unit: Unit) => createHash('sha256').update(JSON.stringify([unit.file, unit.status, unit.old_start, unit.old_end, unit.old_text, unit.new_text])).digest('hex').slice(0, 24);
44
+
45
+ // Where a change sits in the committed file and in the working file, as line ranges.
46
+ const headRange = (unit: Unit): [number, number] => [unit.ops[0][1], unit.ops[unit.ops.length - 1][2]];
47
+ const workRange = (unit: Unit): [number, number] => [unit.ops[0][3], unit.ops[unit.ops.length - 1][4]];
48
+ // Two line ranges meet when they overlap, or when one is empty and sits inside or at the edge of the other.
49
+ const meets = ([a, b]: [number, number], [c, d]: [number, number]) => a === b || c === d ? a <= d && c <= b : a < d && c < b;
50
+
51
+ // The index's lines with one change's working-tree lines staged: every
52
+ // difference between the index and the working file inside the change.
53
+ export function stageLines(index: string[], work: string[], range: [number, number], opcodes: (a: string[], b: string[]) => Op[]) {
54
+ const result = [...index];
55
+ for (const [tag, i, j, x, y] of opcodes(index, work).reverse()) if (tag !== 'equal' && meets([x, y], range)) result.splice(i, j - i, ...work.slice(x, y));
56
+ return result;
57
+ }
58
+ // The index's lines with one change unstaged: the committed lines back in its place.
59
+ export function unstageLines(head: string[], index: string[], range: [number, number], opcodes: (a: string[], b: string[]) => Op[]) {
60
+ const result = [...index];
61
+ for (const [tag, i, j, x, y] of opcodes(head, index).reverse()) if (tag !== 'equal' && meets([i, j], range)) result.splice(x, y - x, ...head.slice(i, j));
62
+ return result;
63
+ }
64
+ // A change is staged when the index already has the working tree's text there.
65
+ export const stagedLines = (index: string[], work: string[], range: [number, number], opcodes: (a: string[], b: string[]) => Op[]) =>
66
+ !opcodes(index, work).some(([tag, , , x, y]) => tag !== 'equal' && meets([x, y], range));
67
+
68
+ // A path's index entry, if it has one.
69
+ function indexEntry(repo: any, file: string): Entry | null {
70
+ const [mode, sha] = repo.text(['ls-files', '-s', '-z', '--', file]).split(/\s+/);
71
+ return sha ? { mode, sha } : null;
72
+ }
73
+ const same = (a: Buffer | null, b: Buffer | null) => a === null || b === null ? a === b : a.equals(b);
74
+ const readBytes = (file: string) => { try { return fs.readFileSync(file); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error; } };
75
+
76
+ export class Changes {
77
+ private comparisons = new Map<string, Comparison>();
78
+ constructor(private dir = path.join(DATA, 'changes')) { fs.rmSync(dir, { recursive: true, force: true }); }
79
+
80
+ private comparison(projectId: string) {
81
+ let comparison = this.comparisons.get(projectId);
82
+ if (!comparison) this.comparisons.set(projectId, comparison = { rounds: [], job: null, next: 1, undo: null, reverted: new Set() });
83
+ return comparison;
84
+ }
85
+
86
+ // The last commit's tree and the working tree, read at most once a second.
87
+ private snapshot(projectId: string, root: string, fresh = false): Promise<Snapshot> {
88
+ const comparison = this.comparison(projectId);
89
+ if (!fresh && comparison.snapshot && Date.now() - comparison.snapshot.at < 1000) return comparison.snapshot.value;
90
+ const value = (async () => {
91
+ const { Repo } = await engine(), repo = new Repo(root);
92
+ return { before: repo.hasHead() ? repo.treeId('HEAD') : null, after: await workingTree(root) };
93
+ })();
94
+ comparison.snapshot = { at: Date.now(), value };
95
+ value.catch(() => { if (comparison.snapshot?.value === value) comparison.snapshot = undefined; });
96
+ return value;
97
+ }
98
+
99
+ // The changes between the two trees, leaving out untracked build output,
100
+ // which the Git panel offers to ignore.
101
+ private async units(projectId: string, root: string, snapshot: Snapshot) {
102
+ const comparison = this.comparison(projectId), key = `${snapshot.before}:${snapshot.after}`;
103
+ if (comparison.current?.key === key) return comparison.current.units;
104
+ const { Repo, loadProject, isFigure, diffUnits } = await engine(), project = loadProject(root);
105
+ const units = diffUnits(new Repo(root), snapshot.before!, snapshot.after, file => isFigure(project, file)).filter(unit => !(unit.status === 'added' && ignorePattern(unit.file)));
106
+ comparison.current = { key, units };
107
+ return units;
108
+ }
109
+
110
+ // Which of the current changes are staged, read from the index.
111
+ private async staged(root: string, snapshot: Snapshot, units: Unit[]) {
112
+ const { Repo, splitLines, textOf, opcodes } = await engine(), repo = new Repo(root);
113
+ const files = [...new Set(units.map(unit => unit.file))];
114
+ const work = repo.blobs(snapshot.after, files), index = repo.blobs('', files);
115
+ const staged = new Set<string>();
116
+ for (const unit of units) {
117
+ const ready = unit.whole_file ? same(index[unit.file], work[unit.file])
118
+ : index[unit.file] !== null && stagedLines(splitLines(textOf(index[unit.file]) ?? ''), splitLines(textOf(work[unit.file]) ?? ''), workRange(unit), opcodes);
119
+ if (ready) staged.add(unit.id);
120
+ }
121
+ return staged;
122
+ }
123
+
124
+ // Compile both trees once the working tree or the last commit changes, and
125
+ // render their comparison. The previous comparison stays readable meanwhile.
126
+ private schedule(projectId: string, root: string, snapshot: Snapshot) {
127
+ const comparison = this.comparison(projectId), latest = comparison.rounds.at(-1), key = `${snapshot.before}:${snapshot.after}`;
128
+ if (!snapshot.before || (latest && `${latest.before}:${latest.after}` === key) || comparison.job?.status === 'running' || comparison.job?.key === key) return;
129
+ const job: Job = { key, phase: 'build', status: 'running', created: Date.now() };
130
+ comparison.job = job;
131
+ void (async () => {
132
+ const { Builder, BuildError, Repo, loadProject, render } = await engine();
133
+ const project = loadProject(root), repo = new Repo(root), number = comparison.next++;
134
+ const units = await this.units(projectId, root, snapshot), folder = path.join(this.dir, projectId, String(number));
135
+ let warnings: string[] = [];
136
+ fs.rmSync(folder, { recursive: true, force: true }); fs.mkdirSync(folder, { recursive: true });
137
+ if (units.length) {
138
+ const builder = new Builder(project, repo);
139
+ const build = async (tree: string, what: string) => {
140
+ try { return await builder.build(tree, ['full']); }
141
+ catch (error) { throw error instanceof BuildError ? Object.assign(new Error(what), { details: (error as any).details, log: (error as any).log }) : error; }
142
+ };
143
+ const old = await build(snapshot.before!, 'The last commit could not be compiled.'), next = await build(snapshot.after, 'The working tree could not be compiled.');
144
+ job.phase = 'render';
145
+ await render(folder, { main: project.main, source_dir: project.source_dir, figures: project.figures, old, new: next, units });
146
+ warnings = next.warnings.filter((warning: string) => !old.warnings.includes(warning));
147
+ } else fs.writeFileSync(path.join(folder, 'round.json'), JSON.stringify({ units: {}, sections: [], loose: [] }));
148
+ comparison.rounds.push({ number, before: snapshot.before!, after: snapshot.after, units, folder, warnings, decisions: {} });
149
+ // The viewer may still show the round before; older ones go.
150
+ for (const old of comparison.rounds.splice(0, Math.max(0, comparison.rounds.length - 3))) fs.rmSync(old.folder, { recursive: true, force: true });
151
+ if (comparison.job === job) comparison.job = null;
152
+ })().catch(error => {
153
+ const detail = error as { message?: string; details?: string[]; log?: string };
154
+ Object.assign(job, { status: 'failed', error: detail.message || 'The comparison could not be built.', details: detail.details || [], ...(detail.log ? { log: detail.log } : {}) });
155
+ });
156
+ }
157
+
158
+ // What the viewer polls, in the review engine's page state shape: the
159
+ // latest comparison, and for the round it shows, which changes are staged
160
+ // (accepted), unstaged (pending), reverted (rejected) or edited since.
161
+ async state(projectId: string, root: string, csrf: string, number?: number) {
162
+ const comparison = this.comparison(projectId), snapshot = await this.snapshot(projectId, root);
163
+ this.schedule(projectId, root, snapshot);
164
+ const latest = comparison.rounds.at(-1) ?? null, round = comparison.rounds.find(item => item.number === number) ?? latest;
165
+ let decisionLock: string | null = mergeInProgress(root) ? 'Finish or abort the merge in the Git panel before staging or reverting changes.' : null;
166
+ if (round && snapshot.before && round.before === snapshot.before) {
167
+ const units = await this.units(projectId, root, snapshot), staged = await this.staged(root, snapshot, units);
168
+ const current = new Map(units.map(unit => [signature(unit), unit]));
169
+ round.decisions = Object.fromEntries(round.units.map(unit => {
170
+ const key = signature(unit), found = current.get(key);
171
+ return [unit.id, found ? (staged.has(found.id) ? 'accepted' : 'pending') : comparison.reverted.has(key) ? 'rejected' : 'commented'];
172
+ }));
173
+ } else if (round) decisionLock ??= 'The last commit changed. The comparison is being updated.';
174
+ const job = comparison.job, decisions = round?.decisions ?? {}, values = Object.values(decisions);
175
+ const building = job?.status === 'running' && (!latest || job.key !== `${latest.before}:${latest.after}`);
176
+ const undo = comparison.undo && round ? round.units.filter(unit => comparison.undo!.signatures.includes(signature(unit))).map(unit => unit.id) : [];
177
+ return {
178
+ csrf, task: 'changes', title: 'Uncommitted changes', closed: false, decisionLock, commentLock: null,
179
+ state: !snapshot.before ? 'build_failed' : building || (!latest && job?.status !== 'failed') ? 'building' : job?.status === 'failed' ? 'build_failed' : values.includes('pending') ? 'reviewing' : 'done',
180
+ latest: latest?.number ?? null, round: round?.number ?? null, revision: round?.after ?? null,
181
+ decisions, comments: {},
182
+ undo: comparison.undo ? { id: comparison.undo.id, kind: 'reject', changes: undo } : null,
183
+ job: !snapshot.before ? { id: 'changes', status: 'failed', phase: 'build', created: 0, ahead: 0, error: 'This repository has no commits yet. Commit once to compare later changes in the PDF.' }
184
+ : job ? { id: job.key, status: job.status === 'running' ? 'running' : 'failed', phase: job.phase, created: job.created, ahead: 0, ...(job.error ? { error: job.error, details: job.details, log: job.log } : {}) } : null,
185
+ };
186
+ }
187
+
188
+ detail(projectId: string, number: number) {
189
+ const round = this.comparison(projectId).rounds.find(item => item.number === number);
190
+ if (!round) throw Object.assign(new Error('This comparison is out of date. Refresh the page.'), { statusCode: 404 });
191
+ let render: { units: Record<string, object>; sections: unknown[]; loose: unknown[] } = { units: {}, sections: [], loose: [] };
192
+ try { render = JSON.parse(fs.readFileSync(path.join(round.folder, 'round.json'), 'utf8')); } catch { /* An empty comparison has no render. */ }
193
+ return {
194
+ task: 'changes', round: round.number, before: round.before, after: round.after,
195
+ units: round.units.map(unit => ({ ...unit, ...render.units[unit.id] })), sections: render.sections, loose: render.loose,
196
+ warnings: round.warnings, files: `/review/${projectId}/changes/files/${round.number}/`,
197
+ };
198
+ }
199
+
200
+ file(projectId: string, number: number, name: string) {
201
+ const round = this.comparison(projectId).rounds.find(item => item.number === number);
202
+ const file = round && /^[\w.-]+\.(svg|png)$/.test(name) ? path.join(round.folder, name) : '';
203
+ if (!file || !fs.existsSync(file)) throw Object.assign(new Error('No such file.'), { statusCode: 404 });
204
+ return file;
205
+ }
206
+
207
+ // The chosen changes of a round the viewer shows, found again among the
208
+ // changes on disk now. Each must still be there exactly as shown.
209
+ private async chosen(projectId: string, root: string, number: number, ids: string[]) {
210
+ const comparison = this.comparison(projectId), round = comparison.rounds.find(item => item.number === number);
211
+ if (!round) throw conflict('This comparison is out of date. Wait for it to update.');
212
+ const snapshot = await this.snapshot(projectId, root, true);
213
+ if (round.before !== snapshot.before) throw conflict('The last commit changed. Wait for the comparison to update.');
214
+ if (mergeInProgress(root)) throw conflict('Finish or abort the merge in the Git panel before staging or reverting changes.');
215
+ const current = new Map((await this.units(projectId, root, snapshot)).map(unit => [signature(unit), unit]));
216
+ const units = ids.map(id => {
217
+ const shown = round.units.find(unit => unit.id === id);
218
+ const found = shown && current.get(signature(shown));
219
+ if (!found) throw conflict(shown ? `${shown.file} changed since this comparison. Wait for it to update.` : 'Select a change from this comparison.');
220
+ return found;
221
+ });
222
+ if (!units.length) throw new Error('Select at least one change.');
223
+ return { comparison, snapshot, units };
224
+ }
225
+
226
+ // Stage or unstage writes the index only; revert writes the files too, and
227
+ // keeps what it replaced for undo.
228
+ async act(projectId: string, root: string, csrf: string, action: ChangeAction, number: number, ids: string[]) {
229
+ const { comparison, snapshot, units } = await this.chosen(projectId, root, number, ids);
230
+ const { Repo, splitLines, textOf, opcodes, revertUnits } = await engine(), repo = new Repo(root);
231
+ const files = [...new Set(units.map(unit => unit.file))];
232
+ const head = repo.blobs(snapshot.before, files), work = repo.blobs(snapshot.after, files), index = repo.blobs('', files);
233
+ const lines = (data: Buffer | null) => splitLines(textOf(data) ?? '');
234
+ const entry = (file: string) => indexEntry(repo, file);
235
+ const writeIndex = (file: string, text: string[]) => {
236
+ const mode = entry(file)?.mode || repo.mode(snapshot.before, file) || '100644';
237
+ repo.run(['update-index', '--add', '--cacheinfo', `${mode},${repo.hashObject(Buffer.from(text.join('')))},${file}`]);
238
+ };
239
+ const unstage = (file: string, part: Unit[]) => {
240
+ if (part.some(unit => unit.whole_file)) { repo.run(['reset', '-q', 'HEAD', '--', file]); return; }
241
+ if (index[file] === null) return;
242
+ let next = lines(index[file]);
243
+ for (const unit of part) next = unstageLines(lines(head[file]), next, headRange(unit), opcodes);
244
+ if (next.join('') !== lines(index[file]).join('')) writeIndex(file, next);
245
+ };
246
+ const revert: Revert = { id: randomUUID(), files: [], signatures: units.map(signature) };
247
+ for (const file of files) {
248
+ const part = units.filter(unit => unit.file === file), whole = part.some(unit => unit.whole_file);
249
+ if (action === 'stage') {
250
+ if (whole) repo.run(['add', '-A', '--', file]);
251
+ else {
252
+ let next = index[file] === null ? lines(head[file]) : lines(index[file]);
253
+ for (const unit of part) next = stageLines(next, lines(work[file]), workRange(unit), opcodes);
254
+ writeIndex(file, next);
255
+ }
256
+ } else if (action === 'unstage') unstage(file, part);
257
+ else {
258
+ const absolute = path.join(root, file), disk = readBytes(absolute), before = entry(file);
259
+ if (!same(disk, work[file])) throw conflict(`${file} changed on disk since this comparison. Wait for it to update.`);
260
+ const stat = fs.lstatSync(absolute, { throwIfNoEntry: false });
261
+ if (stat && !stat.isFile()) throw new Error(`${file} is not a regular file, so it cannot be reverted here.`);
262
+ if (head[file] === null) { repo.run(['rm', '--cached', '-q', '--ignore-unmatch', '--', file]); fs.rmSync(absolute, { force: true }); }
263
+ else if (whole) repo.run(['checkout', 'HEAD', '--', file]);
264
+ else { fs.writeFileSync(absolute, revertUnits(head[file], work[file], part)!); unstage(file, part); }
265
+ revert.files.push({ path: file, disk, written: readBytes(absolute), index: before, indexAfter: entry(file) });
266
+ }
267
+ }
268
+ if (action === 'revert') { comparison.undo = revert; revert.signatures.forEach(key => comparison.reverted.add(key)); }
269
+ comparison.snapshot = undefined;
270
+ return this.state(projectId, root, csrf, number);
271
+ }
272
+
273
+ // Undo puts back what the last revert replaced, unless a file changed since.
274
+ async undo(projectId: string, root: string, csrf: string, id: string) {
275
+ const comparison = this.comparison(projectId), revert = comparison.undo;
276
+ if (!revert || revert.id !== id) throw conflict('There is no revert to undo.');
277
+ const { Repo } = await engine(), repo = new Repo(root);
278
+ for (const file of revert.files) if (!same(readBytes(path.join(root, file.path)), file.written)) throw conflict(`${file.path} changed after the revert, so it was not restored.`);
279
+ for (const file of revert.files) {
280
+ const absolute = path.join(root, file.path);
281
+ if (file.disk === null) fs.rmSync(absolute, { force: true });
282
+ else { fs.mkdirSync(path.dirname(absolute), { recursive: true }); fs.writeFileSync(absolute, file.disk); }
283
+ const now = indexEntry(repo, file.path);
284
+ if (now?.sha === file.indexAfter?.sha && now?.mode === file.indexAfter?.mode) {
285
+ if (file.index) repo.run(['update-index', '--add', '--cacheinfo', `${file.index.mode},${file.index.sha},${file.path}`]);
286
+ else if (now) repo.run(['update-index', '--force-remove', '--', file.path]);
287
+ }
288
+ }
289
+ comparison.undo = null;
290
+ revert.signatures.forEach(key => comparison.reverted.delete(key));
291
+ comparison.snapshot = undefined;
292
+ const latest = comparison.rounds.at(-1);
293
+ return { ...(await this.state(projectId, root, csrf)), restored: latest ? latest.units.filter(unit => revert.signatures.includes(signature(unit))).map(unit => unit.id) : [] };
294
+ }
295
+ }
@@ -7,6 +7,7 @@ import { Accounts } from './accounts.ts';
7
7
  import { ROOT, DATA, PORT, PUBLIC_ORIGIN, OWNER, VERSION, ACCOUNT_ORIGIN, ACCOUNT_PORT } from './paths.ts';
8
8
  import { accountHsts, accountProblem } from './security.ts';
9
9
  import { Manuscripts } from './manuscript.ts';
10
+ import { Changes } from './changes.ts';
10
11
  import { buildApp, gitQueue } from './app.ts';
11
12
 
12
13
  const problem = accountProblem(ACCOUNT_ORIGIN, ACCOUNT_PORT, PORT, PUBLIC_ORIGIN);
@@ -15,7 +16,7 @@ const store = new Store(path.join(DATA, 'papilot.sqlite'));
15
16
  const workspace = new Workspace(store);
16
17
  const accounts = new Accounts(store, ACCOUNT_ORIGIN || null);
17
18
  const shared = {
18
- store, workspace, accounts, manuscripts: new Manuscripts(), csrf: randomBytes(32).toString('base64url'), serialGit: gitQueue(),
19
+ store, workspace, accounts, manuscripts: new Manuscripts(), changes: new Changes(), csrf: randomBytes(32).toString('base64url'), serialGit: gitQueue(),
19
20
  dist: fs.existsSync(path.join(ROOT, 'dist/index.html')) ? path.join(ROOT, 'dist') : null, version: VERSION,
20
21
  };
21
22
  const apps = [await buildApp({ ...shared, listener: { kind: 'local', port: PORT, publicOrigin: PUBLIC_ORIGIN, owner: OWNER } })];
@@ -37,14 +37,15 @@ export function parseStatus(output: string, numstat: string): Omit<GitStatus, 'h
37
37
  if (field.startsWith('# branch.head ')) status.branch = field.slice(14) === '(detached)' ? null : field.slice(14);
38
38
  else if (field.startsWith('# branch.upstream ')) status.upstream = field.slice(18);
39
39
  else if (field.startsWith('# branch.ab ')) { const [ahead, behind] = field.slice(12).split(' '); status.ahead = Number(ahead); status.behind = -Number(behind); }
40
- else if (field.startsWith('? ')) status.files.push({ path: field.slice(2), status: '?', staged: false });
40
+ else if (field.startsWith('? ')) status.files.push({ path: field.slice(2), status: '?', staged: false, unstaged: true });
41
41
  else if (/^[12u] /.test(field)) {
42
42
  const parts = field.split(' '), xy = parts[1], kind = field[0];
43
43
  const file = parts.slice(kind === '1' ? 8 : kind === '2' ? 9 : 10).join(' ');
44
44
  // A rename or copy is followed by its original path.
45
45
  const from = kind === '2' ? fields[++i] : undefined;
46
46
  const code = kind === 'u' ? 'U' : xy[1] !== '.' ? xy[1] : xy[0];
47
- status.files.push({ path: file, status: code, staged: xy[0] !== '.', lines: lines.get(file), ...(from ? { from } : {}) });
47
+ // X is the index against HEAD, Y the working tree against the index.
48
+ status.files.push({ path: file, status: code, staged: xy[0] !== '.', unstaged: xy[1] !== '.', lines: lines.get(file), ...(from ? { from } : {}) });
48
49
  }
49
50
  }
50
51
  return status;
@@ -91,23 +92,26 @@ export async function gitDiff(root: string, file: string) {
91
92
  const diff = await fileDiff(root, entry);
92
93
  return { path: file, diff: diff.slice(0, DIFF_LIMIT), truncated: diff.length > DIFF_LIMIT };
93
94
  }
94
- function fileDiff(root: string, entry: GitFile) {
95
+ // A file's diff against HEAD, or with staged, what its index holds.
96
+ function fileDiff(root: string, entry: GitFile, staged = false) {
97
+ const names = ['--', ...(entry.from ? [entry.from] : []), entry.path];
98
+ if (staged) return git(root, ['diff', '--cached', '--no-color', '--find-renames', ...names], literal);
95
99
  return entry.status === '?'
96
100
  // `git diff --no-index` exits with 1 when the files differ.
97
101
  ? exec('git', ['diff', '--no-index', '--no-color', '--', '/dev/null', entry.path], { cwd: root, maxBuffer: 64 * 1024 * 1024 }).then(r => r.stdout, (e: { stdout?: string }) => e.stdout || '')
98
- : git(root, ['diff', '--no-color', '--find-renames', 'HEAD', '--', ...(entry.from ? [entry.from] : []), entry.path], literal);
102
+ : git(root, ['diff', '--no-color', '--find-renames', 'HEAD', ...names], literal);
99
103
  }
100
104
 
101
- // What a commit message is written from: the chosen files' diffs, each
102
- // shortened so that a large change still fits, and the repository's recent
103
- // commit subjects, whose style and language the message follows.
104
- export async function commitChanges(root: string, paths: string[]) {
105
- const files = (await gitStatus(root)).files.filter(item => paths.includes(item.path));
106
- if (!files.length) throw new Error('Select at least one changed file.');
105
+ // What a commit message is written from: the chosen files' diffs, or what is
106
+ // staged in them, each shortened so that a large change still fits, and the
107
+ // repository's recent commit subjects, whose style and language the message follows.
108
+ export async function commitChanges(root: string, paths: string[], staged = false) {
109
+ const files = (await gitStatus(root)).files.filter(item => paths.includes(item.path) && (!staged || item.staged));
110
+ if (!files.length) throw new Error(staged ? 'Stage at least one change.' : 'Select at least one changed file.');
107
111
  let patch = '';
108
112
  for (const [index, file] of files.entries()) {
109
113
  if (patch.length > 40_000) { patch += `\n[${files.length - index} more changed files are not shown.]\n`; break; }
110
- const diff = await fileDiff(root, file);
114
+ const diff = await fileDiff(root, file, staged);
111
115
  patch += diff.length > 8_000 ? `${diff.slice(0, 8_000)}\n[The rest of this file's diff is not shown.]\n` : diff;
112
116
  }
113
117
  const recent = (await git(root, ['log', '-8', '--format=%s']).catch(() => '')).split('\n').filter(Boolean);
@@ -128,11 +132,29 @@ export async function gitIgnore(root: string, paths: string[]) {
128
132
  return gitStatus(root);
129
133
  }
130
134
 
131
- // Commit exactly the chosen files as they are on disk. Changes staged
132
- // elsewhere for other files stay staged and out of this commit.
133
- export async function gitCommit(root: string, message: string, paths: string[]) {
135
+ // Stage or unstage whole files, as the Git panel's checkboxes do. A rename
136
+ // moves with both of its paths.
137
+ export async function gitStage(root: string, paths: string[], stage: boolean) {
134
138
  const status = await gitStatus(root), files = status.files.filter(item => paths.includes(item.path));
139
+ if (status.merging) throw new Error('Finish or abort the merge before staging other changes.');
140
+ if (!files.length) throw new Error('Select at least one changed file.');
141
+ const names = [...new Set(files.flatMap(item => item.from ? [item.from, item.path] : [item.path]))];
142
+ if (stage) await gitAction(root, ['add', '-A', '--', ...names]);
143
+ else await gitAction(root, status.head ? ['reset', '-q', 'HEAD', '--', ...names] : ['rm', '-r', '-q', '--cached', '--ignore-unmatch', '--', ...names]);
144
+ return gitStatus(root);
145
+ }
146
+
147
+ // Commit what is staged, or with paths, exactly those files as they are on
148
+ // disk; changes staged for other files then stay staged and out of the commit.
149
+ export async function gitCommit(root: string, message: string, paths: string[] | null) {
150
+ const status = await gitStatus(root);
135
151
  if (status.merging) throw new Error('Finish or abort the merge before committing other changes.');
152
+ if (!paths) {
153
+ if (!status.files.some(item => item.staged)) throw new Error('Stage at least one change to commit.');
154
+ await gitAction(root, ['commit', '-m', message]);
155
+ return (await git(root, ['rev-parse', '--short', 'HEAD'])).trim();
156
+ }
157
+ const files = status.files.filter(item => paths.includes(item.path));
136
158
  if (!files.length) throw new Error('Select at least one changed file to commit.');
137
159
  if (files.some(item => item.status === 'U')) throw new Error('Resolve the merge conflicts in the selected files before committing.');
138
160
  // A rename is already staged, so only its new path is added; the commit
@@ -580,9 +580,9 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
580
580
  // generate button: one read-only turn on the agent's small model, with the
581
581
  // lightest reasoning it offers. The same changes get the message already
582
582
  // written, or being written, for them unless the author asks for a fresh one.
583
- async commitMessage(projectId: string, paths: string[], choice: { agent?: AgentId; fresh?: boolean } = {}) {
583
+ async commitMessage(projectId: string, paths: string[], choice: { agent?: AgentId; fresh?: boolean; staged?: boolean } = {}) {
584
584
  const project = this.store.project(projectId), { id, model } = this.helper(choice, 'write a commit message', true);
585
- const { files, patch, recent } = await commitChanges(project.path, paths);
585
+ const { files, patch, recent } = await commitChanges(project.path, paths, choice.staged);
586
586
  const tasks = this.store.tasks().filter(task => task.projectId === projectId && task.review?.files?.some(file => paths.includes(file))).map(task => task.title);
587
587
  const prompt = [
588
588
  recent.length ? `Recent commit subjects in this repository:\n${recent.map(subject => `- ${subject}`).join('\n')}` : 'This repository has no commits yet.',
package/src/shared.ts CHANGED
@@ -45,7 +45,8 @@ export interface Person { id: string; name: string; role: 'owner' | 'member'; jo
45
45
  // A passkey or a signed-in browser session, either of which the owner can revoke.
46
46
  export interface Device { id: string; kind: 'passkey' | 'session'; label: string; createdAt: number; lastUsedAt: number | null }
47
47
  export interface People { origin: string | null; people: Person[] }
48
- export interface GitFile { path: string; status: string; staged: boolean; lines?: [number, number]; from?: string }
48
+ // staged: the index has changes to this file; unstaged: the working tree has more.
49
+ export interface GitFile { path: string; status: string; staged: boolean; unstaged: boolean; lines?: [number, number]; from?: string }
49
50
  // merging is a merge in progress, with the files whose conflict markers remain.
50
51
  export interface GitStatus { branch: string | null; upstream: string | null; ahead: number; behind: number; files: GitFile[]; head: { hash: string; subject: string; date: number } | null; remote?: boolean; merging?: { message: string; conflicts: string[] } | null }
51
52
  export interface GitDiff { path: string; diff: string; truncated: boolean }