sidediff 0.2.2 → 0.3.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.
Files changed (85) hide show
  1. package/README.md +15 -0
  2. package/dist/cli.js +59 -14
  3. package/dist/notes.js +50 -18
  4. package/dist/protocol.js +58 -0
  5. package/dist/server.js +90 -22
  6. package/dist/web/assets/{angular-html-KvMUz706.js → angular-html-dpkqgbRV.js} +1 -1
  7. package/dist/web/assets/{angular-ts-NlM5GifH.js → angular-ts-CsmXxkwl.js} +1 -1
  8. package/dist/web/assets/{apl-BXWiWEVo.js → apl-Cb01Bf5P.js} +1 -1
  9. package/dist/web/assets/{astro-Bhx06L1l.js → astro-C66OEn5O.js} +1 -1
  10. package/dist/web/assets/{blade-BlUBx0PC.js → blade-El75k66R.js} +1 -1
  11. package/dist/web/assets/{c-C0eQNQNW.js → c-Bhdr_xah.js} +1 -1
  12. package/dist/web/assets/{chapel-CKFpmDLJ.js → chapel-C5KQsiHG.js} +1 -1
  13. package/dist/web/assets/{cobol-DwsyCr9b.js → cobol-BRb1H5HB.js} +1 -1
  14. package/dist/web/assets/{coffee-C4hkhraW.js → coffee-NR0si6Vq.js} +1 -1
  15. package/dist/web/assets/{cpp-Bs3Ktq9r.js → cpp-DVyXcCha.js} +1 -1
  16. package/dist/web/assets/{crystal-DMpaaMhJ.js → crystal-0irrWVMC.js} +1 -1
  17. package/dist/web/assets/{css-DtPqiQGm.js → css-B69tIC5-.js} +1 -1
  18. package/dist/web/assets/{edge-ZfatZ85-.js → edge-Dfies4ns.js} +1 -1
  19. package/dist/web/assets/{elixir-F3RMzoCL.js → elixir-CkHYLBMl.js} +1 -1
  20. package/dist/web/assets/{elm-BGJ0PgxU.js → elm-BM3XZF_H.js} +1 -1
  21. package/dist/web/assets/{erb-C7WsHgWS.js → erb-ibsMVJQV.js} +1 -1
  22. package/dist/web/assets/{git-rebase-hktnMhXh.js → git-rebase-v0T8i51i.js} +1 -1
  23. package/dist/web/assets/{glimmer-js-DajRjYwP.js → glimmer-js-DLkiF188.js} +1 -1
  24. package/dist/web/assets/{glimmer-ts-dnv4My2j.js → glimmer-ts-C1MfSiD_.js} +1 -1
  25. package/dist/web/assets/{glsl-BVmpNKLS.js → glsl-DmJpMzIS.js} +1 -1
  26. package/dist/web/assets/{graphql-DCDJyx6a.js → graphql-Bx_gWPNL.js} +1 -1
  27. package/dist/web/assets/{hack-bEVwbA5b.js → hack-zuzo4z8C.js} +1 -1
  28. package/dist/web/assets/{haml-CdgCZHNu.js → haml-2O-haJVu.js} +1 -1
  29. package/dist/web/assets/{handlebars-BgotjozU.js → handlebars-DW9-jkUL.js} +1 -1
  30. package/dist/web/assets/{html-DSG7GFxE.js → html-4b-UbCWm.js} +1 -1
  31. package/dist/web/assets/{html-derivative-CYDMDI-0.js → html-derivative-lCJLFQ95.js} +1 -1
  32. package/dist/web/assets/{http-XatvGKBy.js → http-CkqYUsMn.js} +1 -1
  33. package/dist/web/assets/{hurl-BCQmW8qD.js → hurl-BpcUpblR.js} +1 -1
  34. package/dist/web/assets/index-BxMmt431.js +1584 -0
  35. package/dist/web/assets/{index-BQY0pgMH.css → index-Vivty9_l.css} +1 -1
  36. package/dist/web/assets/{java-Dm2W5EfJ.js → java-p9UcnYwP.js} +1 -1
  37. package/dist/web/assets/{javascript-C2_AZCD8.js → javascript-Dlz1Uo-L.js} +1 -1
  38. package/dist/web/assets/{jinja-CwzMoVwz.js → jinja-Bpg33dsD.js} +1 -1
  39. package/dist/web/assets/{jison-DsBwpiNe.js → jison-By-VToHw.js} +1 -1
  40. package/dist/web/assets/{json-jdIt9h50.js → json-BxAha_TD.js} +1 -1
  41. package/dist/web/assets/{jsx-Btu-zt-Q.js → jsx-DSiXkHRj.js} +1 -1
  42. package/dist/web/assets/{julia-CZUYdDvB.js → julia-D0yc7GFR.js} +1 -1
  43. package/dist/web/assets/{just-D532zIY8.js → just-C2DI3pUP.js} +1 -1
  44. package/dist/web/assets/{latex-D2J5-TH0.js → latex-DMT_f-3C.js} +1 -1
  45. package/dist/web/assets/{liquid-WY7D0fMm.js → liquid-DiASchGu.js} +1 -1
  46. package/dist/web/assets/{lua-Baa_WEf0.js → lua-BafzqdQU.js} +1 -1
  47. package/dist/web/assets/{marko-DsygGl2f.js → marko-oJq8ByTy.js} +1 -1
  48. package/dist/web/assets/{mdc-BcGy04Fp.js → mdc-RuZg9ymz.js} +1 -1
  49. package/dist/web/assets/{nginx-B86ZxrAw.js → nginx-B4H34M9v.js} +1 -1
  50. package/dist/web/assets/{nim-BM686CfU.js → nim-Cw8zuJky.js} +1 -1
  51. package/dist/web/assets/{org-RvFkgp46.js → org-hVregGpI.js} +1 -1
  52. package/dist/web/assets/{perl-C6U9yGM0.js → perl-CwTUPbAz.js} +1 -1
  53. package/dist/web/assets/{php-uAYB-Mtd.js → php-gQC_Fmii.js} +1 -1
  54. package/dist/web/assets/{pug-6ncXSInX.js → pug-D6dZj3jJ.js} +1 -1
  55. package/dist/web/assets/{qml-BE1B7Quf.js → qml-E4rdecj4.js} +1 -1
  56. package/dist/web/assets/{r-BZJcu9wg.js → r-DR6ad8yZ.js} +1 -1
  57. package/dist/web/assets/{razor-BKwfOyyl.js → razor-D8M2A4M7.js} +1 -1
  58. package/dist/web/assets/{regexp-Dih_I1qs.js → regexp-DSjZ8XWG.js} +1 -1
  59. package/dist/web/assets/{rst-DHEdAr-V.js → rst-DQnNZTXl.js} +1 -1
  60. package/dist/web/assets/{ruby-BbWx5B9V.js → ruby-BJSAqM9X.js} +1 -1
  61. package/dist/web/assets/{sas-Df5EO-Nu.js → sas-cMF9FoL4.js} +1 -1
  62. package/dist/web/assets/{scss-XJX16h32.js → scss-D1ikNPGF.js} +1 -1
  63. package/dist/web/assets/{shellscript-Dxd4inRL.js → shellscript-D4nS65SB.js} +1 -1
  64. package/dist/web/assets/{shellsession-Df2uEcSZ.js → shellsession-BX-bfrSn.js} +1 -1
  65. package/dist/web/assets/{soy-B9Ytzufd.js → soy-B8DHGyol.js} +1 -1
  66. package/dist/web/assets/{sql-Eeo1bdO9.js → sql-BXnvc70Q.js} +1 -1
  67. package/dist/web/assets/{stata-DJp7s67I.js → stata-i4_4KZkj.js} +1 -1
  68. package/dist/web/assets/{surrealql-CDYi7ARY.js → surrealql-CJYktgMP.js} +1 -1
  69. package/dist/web/assets/{svelte-BjXPUNXf.js → svelte-DkTFRfzJ.js} +1 -1
  70. package/dist/web/assets/{templ-CfTV__LG.js → templ-DR-sPCuW.js} +1 -1
  71. package/dist/web/assets/{tex-yGzDqfiz.js → tex-Bm81cpcu.js} +1 -1
  72. package/dist/web/assets/{ts-tags-H73Fp8-B.js → ts-tags-8o0M5w05.js} +1 -1
  73. package/dist/web/assets/{tsx-D0NsN8tR.js → tsx-DPlNg7Ry.js} +1 -1
  74. package/dist/web/assets/{twig-BxcdexCK.js → twig-Cteaw1dL.js} +1 -1
  75. package/dist/web/assets/{typescript-CTohDgGQ.js → typescript-CZO0lWCV.js} +1 -1
  76. package/dist/web/assets/{typst-BHhe5E0i.js → typst-BFzy4EUn.js} +1 -1
  77. package/dist/web/assets/{vue-xxrWY3vq.js → vue-Cvhq1cTP.js} +1 -1
  78. package/dist/web/assets/{vue-html-DZJ0eUb2.js → vue-html-BTAnySQ7.js} +1 -1
  79. package/dist/web/assets/{vue-vine-b741IG4q.js → vue-vine-BLuytsGS.js} +1 -1
  80. package/dist/web/assets/{xml-CVHrHsRm.js → xml-HIAzbxU_.js} +1 -1
  81. package/dist/web/assets/{xsl-Bo14RTqx.js → xsl-ZYoNgca3.js} +1 -1
  82. package/dist/web/assets/{yaml-769gRZTS.js → yaml-Bc5rHCoM.js} +1 -1
  83. package/dist/web/index.html +2 -2
  84. package/package.json +1 -1
  85. package/dist/web/assets/index-C7UosTBH.js +0 -1584
package/README.md CHANGED
@@ -51,6 +51,21 @@ sidediff note clear [--file src/app.ts]
51
51
 
52
52
  Notes are stored one file per note under `<git dir>/sidediff/notes`, so concurrent writers never collide and nothing is committed to the repository.
53
53
 
54
+ ## Review threads
55
+
56
+ Hover a line in the diff and press `+` to start a thread on it. Every note is a thread: it lists its replies and has a reply box and Resolve. Threads, replies and resolved state are saved with the note, so they survive restarts.
57
+
58
+ The agent side talks to the running server, so the page updates the moment it replies:
59
+
60
+ ```sh
61
+ sidediff threads [--after <seq>] [--wait 600] [--all] # blocks until the reviewer starts a thread, replies or resolves
62
+ sidediff thread <id> # print a thread: the first comment and every reply
63
+ sidediff reply <id> "text" # reply as the agent
64
+ sidediff resolve <id> [--reopen]
65
+ ```
66
+
67
+ `sidediff threads` prints `seq kind note-id file:line author: text` and skips the agent's own events unless `--all` is passed. Pass the last `seq` as `--after` to pick up where you left off. Threads started in the page are attributed to `git config user.name`.
68
+
54
69
  ## Guided tours and conversation
55
70
 
56
71
  A running server accepts commands that drive the open page, so a person or an agent can walk someone through a change:
package/dist/cli.js CHANGED
@@ -4,6 +4,7 @@ import { Console, Context, Duration, Effect, FileSystem, Layer, Option, Schema,
4
4
  import { Argument, Command, Flag } from 'effect/cli';
5
5
  import { ChildProcess, ChildProcessSpawner } from 'effect/process';
6
6
  import { RpcClient, RpcSerialization } from 'effect/rpc';
7
+ import { createRequire } from 'node:module';
7
8
  import { Git } from './git.js';
8
9
  import { Notes } from './notes.js';
9
10
  import { NoteBatch, SidediffRpcs, Steps } from './protocol.js';
@@ -65,6 +66,7 @@ const listen = Command.make('listen', {
65
66
  const heard = yield* client.Listen(after === undefined ? {} : { after }).pipe(Stream.take(1), Stream.runHead, Effect.timeoutOption(Duration.seconds(wait)), Effect.map(Option.flatten));
66
67
  yield* Effect.forEach(Option.getOrElse(heard, () => []), (utterance) => Console.log(`${utterance.id}\t${utterance.text}`));
67
68
  }).pipe(withClient)).pipe(Command.withDescription('wait for the next thing said into the mic'));
69
+ const serverOr = (remote, local) => Client.use(remote).pipe(withClient, Effect.catchTag(['NoServer', 'RpcClientError'], () => local));
68
70
  const noteAdd = Command.make('add', {
69
71
  file: Flag.String('file'),
70
72
  newLine: Flag.Int('new-line').pipe(Flag.optional, Flag.map(Option.getOrUndefined)),
@@ -73,35 +75,60 @@ const noteAdd = Command.make('add', {
73
75
  rationale: optionalText('rationale'),
74
76
  author: optionalText('author'),
75
77
  }, ({ file, newLine, oldLine, summary, rationale, author }) => Effect.gen(function* () {
76
- const notes = yield* Notes;
77
- const [note] = yield* notes.add([
78
- { filePath: file, summary, ...defined({ newLine, oldLine, rationale, author }) },
79
- ]);
78
+ const input = { filePath: file, summary, ...defined({ newLine, oldLine, rationale, author }) };
79
+ const [note] = yield* serverOr((client) => client.AddNotes({ notes: [input] }), Notes.use((notes) => notes.add([input])));
80
80
  yield* Console.log(`added ${note?.id} on ${note?.filePath}:${note?.line}`);
81
81
  })).pipe(Command.withDescription('add one note'));
82
82
  const noteApply = Command.make('apply', {}, () => Effect.gen(function* () {
83
83
  const stdio = yield* Stdio.Stdio;
84
- const notes = yield* Notes;
85
84
  const text = yield* Stream.mkString(Stream.decodeText(stdio.stdin));
86
85
  const batch = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(NoteBatch))(text);
87
- const added = yield* notes.add('comments' in batch ? batch.comments : batch);
86
+ const inputs = 'comments' in batch ? batch.comments : batch;
87
+ const added = yield* serverOr((client) => client.AddNotes({ notes: inputs }), Notes.use((notes) => notes.add(inputs)));
88
88
  yield* Console.log(`added ${added.length} notes`);
89
89
  })).pipe(Command.withDescription('add a batch from stdin: {"comments":[{filePath,newLine|oldLine,summary,...}]}'));
90
90
  const noteList = Command.make('list', { json: Flag.Boolean('json').pipe(Flag.withDefault(false)), file: optionalText('file') }, ({ json, file }) => Effect.gen(function* () {
91
- const notes = yield* Notes;
92
- const shown = (yield* notes.list).filter((note) => file === undefined || note.filePath === file);
91
+ const all = yield* serverOr((client) => client.ListNotes(), Notes.use((notes) => notes.list));
92
+ const shown = all.filter((note) => file === undefined || note.filePath === file);
93
93
  if (json)
94
94
  return yield* Console.log(JSON.stringify(shown, null, 2));
95
95
  yield* Effect.forEach(shown, (note) => Console.log(`${note.id} ${note.filePath}:${note.line} ${note.summary}`));
96
96
  })).pipe(Command.withDescription('list notes'));
97
+ const eventLine = (event) => `${event.seq}\t${event.kind}\t${event.noteId}\t${event.filePath}:${event.line}\t${event.author}: ${event.body}`;
98
+ const threads = Command.make('threads', {
99
+ after: Flag.Int('after').pipe(Flag.optional, Flag.map(Option.getOrUndefined)),
100
+ wait: Flag.Int('wait').pipe(Flag.withDefault(600)),
101
+ all: Flag.Boolean('all').pipe(Flag.withDefault(false)),
102
+ }, ({ after, wait, all }) => Effect.gen(function* () {
103
+ const client = yield* Client;
104
+ const next = yield* client.Threads(after === undefined ? {} : { after }).pipe(Stream.map((batch) => batch.filter((event) => all || event.author !== 'agent')), Stream.filter((batch) => batch.length > 0), Stream.take(1), Stream.runHead, Effect.timeoutOption(Duration.seconds(wait)), Effect.map(Option.flatten));
105
+ yield* Effect.forEach(Option.getOrElse(next, () => []), (event) => Console.log(eventLine(event)), {
106
+ discard: true,
107
+ });
108
+ }).pipe(withClient)).pipe(Command.withDescription('wait for the next review thread, reply or resolve from the reviewer; prints seq, kind, note id, file:line and text'));
109
+ const thread = Command.make('thread', { id: Argument.String('id') }, ({ id }) => Effect.gen(function* () {
110
+ const all = yield* serverOr((client) => client.ListNotes(), Notes.use((notes) => notes.list));
111
+ const found = all.find((note) => note.id === id);
112
+ if (found === undefined)
113
+ return yield* Console.error(`no note ${id}`);
114
+ yield* Console.log(`${found.filePath}:${found.line}${found.resolved === true ? ' (resolved)' : ''}`);
115
+ yield* Console.log(`${found.author}: ${found.summary}`);
116
+ yield* Effect.forEach(found.replies ?? [], (entry) => Console.log(`${entry.author}: ${entry.body}`), {
117
+ discard: true,
118
+ });
119
+ })).pipe(Command.withDescription('print a thread: the first comment and every reply'));
120
+ const reply = Command.make('reply', {
121
+ id: Argument.String('id'),
122
+ text: Argument.String('text').pipe(Argument.variadic({ min: 1 })),
123
+ author: Flag.String('author').pipe(Flag.withDefault('agent')),
124
+ }, ({ id, text, author }) => Client.use((client) => client.Reply({ noteId: id, body: text.join(' '), author })).pipe(Effect.flatMap((note) => Console.log(`replied on ${note.id} (${note.replies?.length ?? 0} replies)`)), withClient)).pipe(Command.withDescription('reply to a review thread; it appears in the browser immediately'));
125
+ const resolve = Command.make('resolve', { id: Argument.String('id'), reopen: Flag.Boolean('reopen').pipe(Flag.withDefault(false)) }, ({ id, reopen }) => Client.use((client) => client.Resolve({ noteId: id, resolved: !reopen, author: 'agent' })).pipe(Effect.flatMap((note) => Console.log(`${note.id} ${note.resolved === true ? 'resolved' : 'reopened'}`)), withClient)).pipe(Command.withDescription('resolve a review thread, or reopen it with --reopen'));
97
126
  const noteRemove = Command.make('rm', { id: Argument.String('id') }, ({ id }) => Effect.gen(function* () {
98
- const notes = yield* Notes;
99
- yield* notes.remove(id);
127
+ yield* serverOr((client) => client.RemoveNote({ id }), Notes.use((notes) => notes.remove(id)));
100
128
  yield* Console.log(`removed ${id}`);
101
129
  })).pipe(Command.withDescription('remove a note'));
102
130
  const noteClear = Command.make('clear', { file: optionalText('file') }, ({ file }) => Effect.gen(function* () {
103
- const notes = yield* Notes;
104
- const count = yield* notes.clear(file);
131
+ const count = yield* serverOr((client) => client.ClearNotes(file === undefined ? {} : { filePath: file }), Notes.use((notes) => notes.clear(file)));
105
132
  yield* Console.log(`cleared ${count} notes`);
106
133
  })).pipe(Command.withDescription('remove every note, or every note on one file'));
107
134
  const note = Command.make('note').pipe(Command.withDescription('notes live in <git dir>/sidediff/notes and appear in the browser as they are written'), Command.withSubcommands([noteAdd, noteApply, noteList, noteRemove, noteClear]));
@@ -120,6 +147,24 @@ const sidediff = Command.make('sidediff', {
120
147
  const chosen = yield* freePort(port, host);
121
148
  const url = `http://${host}:${chosen}`;
122
149
  yield* Layer.launch(serverLayer({ range, port: chosen, host, watch }).pipe(Layer.merge(Layer.effectDiscard(Effect.andThen(Console.log(`sidediff: ${url} (${watch ? 'watching for changes' : 'watch off'})`), open ? openBrowser(url) : Effect.void)))));
123
- })).pipe(Command.withDescription('GitHub-style diff review in the browser, with a live notes column'), Command.withSubcommands([note, show, highlight, explain, say, clear, tour, next, back, goto, where, listen]));
150
+ })).pipe(Command.withDescription('GitHub-style diff review in the browser, with a live notes column'), Command.withSubcommands([
151
+ note,
152
+ show,
153
+ highlight,
154
+ explain,
155
+ say,
156
+ clear,
157
+ tour,
158
+ next,
159
+ back,
160
+ goto,
161
+ where,
162
+ listen,
163
+ threads,
164
+ thread,
165
+ reply,
166
+ resolve,
167
+ ]));
124
168
  const AppLayer = Notes.layer.pipe(Layer.provideMerge(Repo.layer), Layer.provideMerge(Git.layer), Layer.provideMerge(NodeServices.layer));
125
- Command.run(sidediff, { version: '0.2.0' }).pipe(Effect.provide(AppLayer), NodeRuntime.runMain);
169
+ const { version } = createRequire(import.meta.url)('../package.json');
170
+ Command.run(sidediff, { version }).pipe(Effect.provide(AppLayer), NodeRuntime.runMain);
package/dist/notes.js CHANGED
@@ -1,5 +1,5 @@
1
- import { Context, DateTime, Effect, FileSystem, Layer, Option, Path, Random, Schema } from 'effect';
2
- import { Note } from './protocol.js';
1
+ import { Context, DateTime, Effect, FileSystem, Layer, Option, Path, Random, Schema, SubscriptionRef } from 'effect';
2
+ import { Note, NoteMissing } from './protocol.js';
3
3
  import { Repo } from './repo.js';
4
4
  export class NoteWithoutLine extends Schema.TaggedError()('NoteWithoutLine', {
5
5
  filePath: Schema.String,
@@ -17,6 +17,7 @@ export class NotesUnwritable extends Schema.TaggedError()('NotesUnwritable', {
17
17
  }
18
18
  const decodeNote = Schema.decodeUnknownOption(Schema.fromJsonString(Note));
19
19
  const hex = (value) => value.toString(16).padStart(8, '0');
20
+ const ordered = (notes) => notes.toSorted((a, b) => a.filePath.localeCompare(b.filePath) || a.line - b.line);
20
21
  export class Notes extends Context.Service()('sidediff/Notes') {
21
22
  static layer = Layer.effect(Notes, Effect.gen(function* () {
22
23
  const fs = yield* FileSystem.FileSystem;
@@ -25,7 +26,17 @@ export class Notes extends Context.Service()('sidediff/Notes') {
25
26
  const unwritable = () => new NotesUnwritable({ directory });
26
27
  const fileOf = (id) => path.join(directory, `${id}.json`);
27
28
  const newId = Effect.map(Effect.all([Random.nextIntBetween(0, 0xffffffff), Random.nextIntBetween(0, 0xffffffff)]), ([a, b]) => `${hex(a)}${hex(b)}`);
28
- const toNote = (input) => Effect.gen(function* () {
29
+ const names = yield* fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => []));
30
+ const loaded = yield* Effect.forEach(names.filter((name) => name.endsWith('.json')), (name) => fs.readFileString(path.join(directory, name)).pipe(Effect.map(decodeNote), Effect.orElseSucceed(() => Option.none())), { concurrency: 16 });
31
+ const state = yield* SubscriptionRef.make(ordered(loaded.flatMap((note) => (Option.isSome(note) ? [note.value] : []))));
32
+ const write = (note) => fs.makeDirectory(directory, { recursive: true }).pipe(Effect.andThen(fs.writeFileString(fileOf(note.id), JSON.stringify(note))), Effect.mapError(unwritable));
33
+ const erase = (id) => fs.remove(fileOf(id), { force: true }).pipe(Effect.mapError(unwritable));
34
+ const put = (notes) => SubscriptionRef.update(state, (current) => ordered([...current.filter((note) => !notes.some((next) => next.id === note.id)), ...notes]));
35
+ const find = (id) => Effect.flatMap(SubscriptionRef.get(state), (current) => Option.match(Option.fromNullishOr(current.find((note) => note.id === id)), {
36
+ onNone: () => Effect.fail(new NoteMissing({ id })),
37
+ onSome: Effect.succeed,
38
+ }));
39
+ const toNote = Effect.fnUntraced(function* (input) {
29
40
  const line = input.newLine ?? input.oldLine;
30
41
  if (line === undefined)
31
42
  return yield* new NoteWithoutLine({ filePath: input.filePath });
@@ -40,27 +51,48 @@ export class Notes extends Context.Service()('sidediff/Notes') {
40
51
  ...(input.rationale === undefined ? {} : { rationale: input.rationale }),
41
52
  author: input.author ?? 'agent',
42
53
  createdAt: DateTime.formatIso(now),
54
+ ...(input.kind === undefined ? {} : { kind: input.kind }),
43
55
  };
44
56
  });
45
- const list = Effect.gen(function* () {
46
- const names = yield* fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => []));
47
- const read = yield* Effect.forEach(names.filter((name) => name.endsWith('.json')), (name) => fs.readFileString(path.join(directory, name)).pipe(Effect.map(decodeNote), Effect.orElseSucceed(() => Option.none())), { concurrency: 16 });
48
- return read
49
- .flatMap((note) => (Option.isSome(note) ? [note.value] : []))
50
- .toSorted((a, b) => a.filePath.localeCompare(b.filePath) || a.line - b.line);
51
- });
52
- const remove = (id) => fs.remove(fileOf(id), { force: true }).pipe(Effect.mapError(unwritable));
53
- const add = (inputs) => Effect.gen(function* () {
57
+ const add = Effect.fn('Notes.add')(function* (inputs) {
54
58
  const created = yield* Effect.forEach(inputs, toNote);
55
- yield* fs.makeDirectory(directory, { recursive: true }).pipe(Effect.mapError(unwritable));
56
- yield* Effect.forEach(created, (note) => fs.writeFileString(fileOf(note.id), JSON.stringify(note)).pipe(Effect.mapError(unwritable)), { concurrency: 16, discard: true });
59
+ yield* Effect.forEach(created, write, { concurrency: 16, discard: true });
60
+ yield* put(created);
57
61
  return created;
58
62
  });
59
- const clear = (filePath) => Effect.gen(function* () {
60
- const doomed = (yield* list).filter((note) => filePath === undefined || note.filePath === filePath);
61
- yield* Effect.forEach(doomed, (note) => remove(note.id), { concurrency: 16, discard: true });
63
+ const remove = Effect.fn('Notes.remove')(function* (id) {
64
+ yield* erase(id);
65
+ yield* SubscriptionRef.update(state, (current) => current.filter((note) => note.id !== id));
66
+ });
67
+ const clear = Effect.fn('Notes.clear')(function* (filePath) {
68
+ const doomed = (yield* SubscriptionRef.get(state)).filter((note) => filePath === undefined || note.filePath === filePath);
69
+ yield* Effect.forEach(doomed, (note) => erase(note.id), { concurrency: 16, discard: true });
70
+ yield* SubscriptionRef.update(state, (current) => current.filter((note) => !doomed.includes(note)));
62
71
  return doomed.length;
63
72
  });
64
- return { add, list, remove, clear };
73
+ const change = Effect.fnUntraced(function* (id, edit) {
74
+ const note = yield* find(id);
75
+ const next = yield* edit(note);
76
+ yield* write(next);
77
+ yield* put([next]);
78
+ return next;
79
+ });
80
+ const reply = (id, author, body) => change(id, (note) => Effect.map(Effect.all([newId, DateTime.now]), ([replyId, now]) => ({
81
+ ...note,
82
+ kind: 'comment',
83
+ replies: [...(note.replies ?? []), { id: replyId, author, body, createdAt: DateTime.formatIso(now) }],
84
+ })));
85
+ const resolve = (id, resolved) => change(id, (note) => Effect.succeed({ ...note, resolved }));
86
+ const toComment = (id) => change(id, (note) => Effect.succeed({ ...note, kind: 'comment' }));
87
+ return Notes.of({
88
+ list: SubscriptionRef.get(state),
89
+ changes: SubscriptionRef.changes(state),
90
+ add,
91
+ remove,
92
+ clear,
93
+ reply,
94
+ resolve,
95
+ toComment,
96
+ });
65
97
  }));
66
98
  }
package/dist/protocol.js CHANGED
@@ -2,6 +2,14 @@ import { Schema } from 'effect';
2
2
  import { Rpc, RpcGroup } from 'effect/rpc';
3
3
  export const Side = Schema.Literals(['additions', 'deletions']);
4
4
  const Line = Schema.Int.check(Schema.isGreaterThan(0));
5
+ export const NoteKind = Schema.Literals(['annotation', 'comment']);
6
+ export const isComment = (note) => note.kind === 'comment';
7
+ export const Reply = Schema.Struct({
8
+ id: Schema.String,
9
+ author: Schema.String,
10
+ body: Schema.NonEmptyString,
11
+ createdAt: Schema.String,
12
+ });
5
13
  export const Note = Schema.Struct({
6
14
  id: Schema.String,
7
15
  filePath: Schema.String,
@@ -11,6 +19,9 @@ export const Note = Schema.Struct({
11
19
  rationale: Schema.optionalKey(Schema.String),
12
20
  author: Schema.String,
13
21
  createdAt: Schema.String,
22
+ replies: Schema.optionalKey(Schema.Array(Reply)),
23
+ resolved: Schema.optionalKey(Schema.Boolean),
24
+ kind: Schema.optionalKey(NoteKind),
14
25
  });
15
26
  export const NoteInput = Schema.Struct({
16
27
  filePath: Schema.NonEmptyString,
@@ -19,6 +30,7 @@ export const NoteInput = Schema.Struct({
19
30
  summary: Schema.NonEmptyString,
20
31
  rationale: Schema.optionalKey(Schema.String),
21
32
  author: Schema.optionalKey(Schema.String),
33
+ kind: Schema.optionalKey(NoteKind),
22
34
  });
23
35
  export const NoteBatch = Schema.Union([
24
36
  Schema.Array(NoteInput),
@@ -31,6 +43,7 @@ export const Snapshot = Schema.Struct({
31
43
  patch: Schema.String,
32
44
  notes: Schema.Array(Note),
33
45
  version: Schema.String,
46
+ diffVersion: Schema.String,
34
47
  watching: Schema.Boolean,
35
48
  updatedAt: Schema.String,
36
49
  });
@@ -75,6 +88,27 @@ export const Utterance = Schema.Struct({
75
88
  at: Schema.String,
76
89
  handled: Schema.Boolean,
77
90
  });
91
+ export const ThreadEvent = Schema.Struct({
92
+ seq: Schema.Int,
93
+ kind: Schema.Literals(['thread', 'reply', 'resolved', 'reopened', 'converted']),
94
+ noteId: Schema.String,
95
+ filePath: Schema.String,
96
+ side: Side,
97
+ line: Line,
98
+ author: Schema.String,
99
+ body: Schema.String,
100
+ at: Schema.String,
101
+ });
102
+ export class NoteMissing extends Schema.TaggedError()('NoteMissing', { id: Schema.String }) {
103
+ get message() {
104
+ return `no note ${this.id}`;
105
+ }
106
+ }
107
+ export class NoteUnsaved extends Schema.TaggedError()('NoteUnsaved', { reason: Schema.String }) {
108
+ get message() {
109
+ return this.reason;
110
+ }
111
+ }
78
112
  export class FileMissing extends Schema.TaggedError()('FileMissing', {
79
113
  side: Schema.Literals(['old', 'new']),
80
114
  path: Schema.String,
@@ -88,4 +122,28 @@ export const SidediffRpcs = RpcGroup.make(Rpc.make('Snapshots', { success: Snaps
88
122
  payload: { after: Schema.optionalKey(Schema.Int) },
89
123
  success: Schema.Array(Utterance),
90
124
  stream: true,
125
+ }), Rpc.make('StartThread', {
126
+ payload: { filePath: Schema.NonEmptyString, side: Side, line: Line, body: Schema.NonEmptyString },
127
+ success: Note,
128
+ error: NoteUnsaved,
129
+ }), Rpc.make('Reply', {
130
+ payload: { noteId: Schema.NonEmptyString, body: Schema.NonEmptyString, author: Schema.optionalKey(Schema.String) },
131
+ success: Note,
132
+ error: Schema.Union([NoteMissing, NoteUnsaved]),
133
+ }), Rpc.make('ToComment', {
134
+ payload: { noteId: Schema.NonEmptyString },
135
+ success: Note,
136
+ error: Schema.Union([NoteMissing, NoteUnsaved]),
137
+ }), Rpc.make('Resolve', {
138
+ payload: { noteId: Schema.NonEmptyString, resolved: Schema.Boolean, author: Schema.optionalKey(Schema.String) },
139
+ success: Note,
140
+ error: Schema.Union([NoteMissing, NoteUnsaved]),
141
+ }), Rpc.make('Refresh', {}), Rpc.make('ListNotes', { success: Schema.Array(Note) }), Rpc.make('AddNotes', { payload: { notes: Schema.Array(NoteInput) }, success: Schema.Array(Note), error: NoteUnsaved }), Rpc.make('RemoveNote', { payload: { id: Schema.NonEmptyString }, error: NoteUnsaved }), Rpc.make('ClearNotes', {
142
+ payload: { filePath: Schema.optionalKey(Schema.String) },
143
+ success: Schema.Int,
144
+ error: NoteUnsaved,
145
+ }), Rpc.make('Threads', {
146
+ payload: { after: Schema.optionalKey(Schema.Int) },
147
+ success: Schema.Array(ThreadEvent),
148
+ stream: true,
91
149
  }));
package/dist/server.js CHANGED
@@ -8,7 +8,7 @@ import { HttpRouter, HttpServerResponse } from 'effect/http';
8
8
  import { RpcSerialization, RpcServer } from 'effect/rpc';
9
9
  import { Git } from './git.js';
10
10
  import { Notes } from './notes.js';
11
- import { FileMissing, SidediffRpcs } from './protocol.js';
11
+ import { FileMissing, NoteUnsaved, SidediffRpcs, } from './protocol.js';
12
12
  import { Repo } from './repo.js';
13
13
  export class ServeOptions extends Context.Service()('sidediff/ServeOptions') {
14
14
  }
@@ -31,26 +31,22 @@ export class Review extends Context.Service()('sidediff/Review') {
31
31
  const options = yield* ServeOptions;
32
32
  const fs = yield* FileSystem.FileSystem;
33
33
  const path = yield* Path.Path;
34
- const build = Effect.gen(function* () {
35
- const [patch, current, branch, now] = yield* Effect.all([git.diff(repo.root, options.range), notes.list, git.branch(repo.root), DateTime.now], { concurrency: 'unbounded' });
36
- return {
37
- repo: repo.root,
38
- branch,
39
- range: options.range,
40
- patch,
41
- notes: current,
42
- version: versionOf(patch, current),
43
- watching: options.watch,
44
- updatedAt: DateTime.formatIso(now),
45
- };
46
- });
47
- const snapshot = yield* SubscriptionRef.make(yield* build);
48
- const refresh = build.pipe(Effect.flatMap((next) => SubscriptionRef.update(snapshot, (current) => (current.version === next.version ? current : next))), Effect.catch((error) => Effect.logWarning('[refresh] diff failed', error)));
49
- yield* fs.makeDirectory(repo.notes, { recursive: true });
50
- const changes = Stream.merge(fs.watch(repo.notes), options.watch
51
- ? fs.watch(repo.root, { recursive: true }).pipe(Stream.filter((event) => isRelevant(event.path)))
52
- : Stream.empty);
53
- yield* changes.pipe(Stream.debounce(Duration.millis(250)), Stream.runForEach(() => refresh), Effect.catch((error) => Effect.logWarning('[watch] stopped', error)), Effect.forkScoped);
34
+ const readPatch = Effect.map(Effect.all([git.diff(repo.root, options.range), git.branch(repo.root)], { concurrency: 'unbounded' }), ([patch, branch]) => ({ patch, branch }));
35
+ const diff = yield* SubscriptionRef.make(yield* readPatch);
36
+ const refresh = readPatch.pipe(Effect.flatMap((next) => SubscriptionRef.update(diff, (current) => (current.patch === next.patch ? current : next))), Effect.catch((error) => Effect.logWarning('[refresh] diff failed', error)));
37
+ const snapshots = Stream.zipLatest(SubscriptionRef.changes(diff), notes.changes).pipe(Stream.mapEffect(([{ patch, branch }, current]) => Effect.map(DateTime.now, (now) => ({
38
+ repo: repo.root,
39
+ branch,
40
+ range: options.range,
41
+ patch,
42
+ notes: current,
43
+ version: versionOf(patch, current),
44
+ diffVersion: versionOf(patch, []),
45
+ watching: options.watch,
46
+ updatedAt: DateTime.formatIso(now),
47
+ }))));
48
+ if (options.watch)
49
+ yield* fs.watch(repo.root, { recursive: true }).pipe(Stream.filter((event) => isRelevant(event.path)), Stream.debounce(Duration.millis(250)), Stream.runForEach(() => refresh), Effect.catch((error) => Effect.logWarning('[watch] stopped', error)), Effect.forkScoped);
54
50
  const commands = yield* PubSub.unbounded();
55
51
  const watchers = yield* Ref.make(0);
56
52
  const views = yield* Ref.make(null);
@@ -84,8 +80,68 @@ export class Review extends Context.Service()('sidediff/Review') {
84
80
  const live = Stream.fromPubSub(spoken).pipe(Stream.filter((utterance) => utterance.id > since), Stream.map((utterance) => [utterance]));
85
81
  return pending.length > 0 ? Stream.concat(Stream.make(pending), live) : live;
86
82
  }));
83
+ const reviewer = yield* git.run(repo.root, ['config', 'user.name']).pipe(Effect.map((name) => name.trim()), Effect.map((name) => (name === '' ? 'reviewer' : name)), Effect.orElseSucceed(() => 'reviewer'));
84
+ const events = yield* Ref.make([]);
85
+ const threadEvents = yield* PubSub.unbounded();
86
+ const unsaved = (error) => new NoteUnsaved({ reason: error.message });
87
+ const record = Effect.fnUntraced(function* (kind, note, author, body) {
88
+ const now = yield* DateTime.now;
89
+ const event = yield* Ref.modify(events, (all) => {
90
+ const next = {
91
+ seq: all.length + 1,
92
+ kind,
93
+ noteId: note.id,
94
+ filePath: note.filePath,
95
+ side: note.side,
96
+ line: note.line,
97
+ author,
98
+ body,
99
+ at: DateTime.formatIso(now),
100
+ };
101
+ return [next, [...all, next]];
102
+ });
103
+ yield* PubSub.publish(threadEvents, event);
104
+ return note;
105
+ });
106
+ const startThread = Effect.fn('Review.startThread')(function* (input) {
107
+ const lineKey = input.side === 'deletions' ? { oldLine: input.line } : { newLine: input.line };
108
+ const [note] = yield* notes
109
+ .add([{ filePath: input.filePath, ...lineKey, summary: input.body, author: reviewer, kind: 'comment' }])
110
+ .pipe(Effect.mapError(unsaved));
111
+ if (note === undefined)
112
+ return yield* new NoteUnsaved({ reason: 'the thread was not created' });
113
+ return yield* record('thread', note, reviewer, input.body);
114
+ });
115
+ const reply = Effect.fn('Review.reply')(function* (id, body, author) {
116
+ const who = author ?? reviewer;
117
+ const note = yield* notes.reply(id, who, body).pipe(Effect.catchTag('NotesUnwritable', (error) => Effect.fail(unsaved(error))));
118
+ return yield* record('reply', note, who, body);
119
+ });
120
+ const resolve = Effect.fn('Review.resolve')(function* (id, resolved, author) {
121
+ const who = author ?? reviewer;
122
+ const note = yield* notes.resolve(id, resolved).pipe(Effect.catchTag('NotesUnwritable', (error) => Effect.fail(unsaved(error))));
123
+ return yield* record(resolved ? 'resolved' : 'reopened', note, who, '');
124
+ });
125
+ const toComment = Effect.fn('Review.toComment')(function* (id) {
126
+ const note = yield* notes
127
+ .toComment(id)
128
+ .pipe(Effect.catchTag('NotesUnwritable', (error) => Effect.fail(unsaved(error))));
129
+ return yield* record('converted', note, reviewer, note.summary);
130
+ });
131
+ const threads = (after) => Stream.unwrap(Effect.map(Ref.get(events), (all) => {
132
+ const since = after ?? all.length;
133
+ const pending = all.filter((event) => event.seq > since);
134
+ const live = Stream.fromPubSub(threadEvents).pipe(Stream.filter((event) => event.seq > since), Stream.map((event) => [event]));
135
+ return pending.length > 0 ? Stream.concat(Stream.make(pending), live) : live;
136
+ }));
87
137
  return {
88
- snapshots: SubscriptionRef.changes(snapshot),
138
+ toComment,
139
+ refresh,
140
+ startThread,
141
+ reply,
142
+ resolve,
143
+ threads,
144
+ snapshots,
89
145
  file,
90
146
  send: (command) => Effect.andThen(PubSub.publish(commands, command), Ref.get(watchers)),
91
147
  commands: Stream.unwrap(Effect.as(Ref.update(watchers, (count) => count + 1), Stream.fromPubSub(commands).pipe(Stream.ensuring(Ref.update(watchers, (count) => count - 1))))),
@@ -96,8 +152,10 @@ export class Review extends Context.Service()('sidediff/Review') {
96
152
  };
97
153
  }));
98
154
  }
155
+ const unsavedError = (error) => new NoteUnsaved({ reason: error.message });
99
156
  const RpcHandlers = SidediffRpcs.toLayer(Effect.gen(function* () {
100
157
  const review = yield* Review;
158
+ const notes = yield* Notes;
101
159
  return SidediffRpcs.of({
102
160
  Snapshots: () => review.snapshots,
103
161
  File: ({ side, path }) => review.file(side, path),
@@ -107,6 +165,16 @@ const RpcHandlers = SidediffRpcs.toLayer(Effect.gen(function* () {
107
165
  View: () => review.view,
108
166
  Utter: ({ text, handled }) => review.utter(text, handled),
109
167
  Listen: ({ after }) => review.listen(after),
168
+ StartThread: (input) => review.startThread(input),
169
+ Reply: ({ noteId, body, author }) => review.reply(noteId, body, author),
170
+ Resolve: ({ noteId, resolved, author }) => review.resolve(noteId, resolved, author),
171
+ ToComment: ({ noteId }) => review.toComment(noteId),
172
+ Threads: ({ after }) => review.threads(after),
173
+ Refresh: () => review.refresh,
174
+ ListNotes: () => notes.list,
175
+ AddNotes: ({ notes: inputs }) => notes.add(inputs).pipe(Effect.mapError(unsavedError)),
176
+ RemoveNote: ({ id }) => notes.remove(id).pipe(Effect.mapError(unsavedError)),
177
+ ClearNotes: ({ filePath }) => notes.clear(filePath).pipe(Effect.mapError(unsavedError)),
110
178
  });
111
179
  }));
112
180
  const webRoot = fileURLToPath(new URL('./web', import.meta.url));