sidediff 0.2.3 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -1
- package/dist/cli.js +59 -14
- package/dist/git.js +0 -1
- package/dist/notes.js +50 -18
- package/dist/protocol.js +58 -0
- package/dist/repo.js +5 -3
- package/dist/server.js +91 -23
- package/dist/web/assets/{angular-html-KvMUz706.js → angular-html-dpkqgbRV.js} +1 -1
- package/dist/web/assets/{angular-ts-NlM5GifH.js → angular-ts-CsmXxkwl.js} +1 -1
- package/dist/web/assets/{apl-BXWiWEVo.js → apl-Cb01Bf5P.js} +1 -1
- package/dist/web/assets/{astro-Bhx06L1l.js → astro-C66OEn5O.js} +1 -1
- package/dist/web/assets/{blade-BlUBx0PC.js → blade-El75k66R.js} +1 -1
- package/dist/web/assets/{c-C0eQNQNW.js → c-Bhdr_xah.js} +1 -1
- package/dist/web/assets/{chapel-CKFpmDLJ.js → chapel-C5KQsiHG.js} +1 -1
- package/dist/web/assets/{cobol-DwsyCr9b.js → cobol-BRb1H5HB.js} +1 -1
- package/dist/web/assets/{coffee-C4hkhraW.js → coffee-NR0si6Vq.js} +1 -1
- package/dist/web/assets/{cpp-Bs3Ktq9r.js → cpp-DVyXcCha.js} +1 -1
- package/dist/web/assets/{crystal-DMpaaMhJ.js → crystal-0irrWVMC.js} +1 -1
- package/dist/web/assets/{css-DtPqiQGm.js → css-B69tIC5-.js} +1 -1
- package/dist/web/assets/{edge-ZfatZ85-.js → edge-Dfies4ns.js} +1 -1
- package/dist/web/assets/{elixir-F3RMzoCL.js → elixir-CkHYLBMl.js} +1 -1
- package/dist/web/assets/{elm-BGJ0PgxU.js → elm-BM3XZF_H.js} +1 -1
- package/dist/web/assets/{erb-C7WsHgWS.js → erb-ibsMVJQV.js} +1 -1
- package/dist/web/assets/{git-rebase-hktnMhXh.js → git-rebase-v0T8i51i.js} +1 -1
- package/dist/web/assets/{glimmer-js-DajRjYwP.js → glimmer-js-DLkiF188.js} +1 -1
- package/dist/web/assets/{glimmer-ts-dnv4My2j.js → glimmer-ts-C1MfSiD_.js} +1 -1
- package/dist/web/assets/{glsl-BVmpNKLS.js → glsl-DmJpMzIS.js} +1 -1
- package/dist/web/assets/{graphql-DCDJyx6a.js → graphql-Bx_gWPNL.js} +1 -1
- package/dist/web/assets/{hack-bEVwbA5b.js → hack-zuzo4z8C.js} +1 -1
- package/dist/web/assets/{haml-CdgCZHNu.js → haml-2O-haJVu.js} +1 -1
- package/dist/web/assets/{handlebars-BgotjozU.js → handlebars-DW9-jkUL.js} +1 -1
- package/dist/web/assets/{html-DSG7GFxE.js → html-4b-UbCWm.js} +1 -1
- package/dist/web/assets/{html-derivative-CYDMDI-0.js → html-derivative-lCJLFQ95.js} +1 -1
- package/dist/web/assets/{http-XatvGKBy.js → http-CkqYUsMn.js} +1 -1
- package/dist/web/assets/{hurl-BCQmW8qD.js → hurl-BpcUpblR.js} +1 -1
- package/dist/web/assets/index-BxMmt431.js +1584 -0
- package/dist/web/assets/{index-BQY0pgMH.css → index-Vivty9_l.css} +1 -1
- package/dist/web/assets/{java-Dm2W5EfJ.js → java-p9UcnYwP.js} +1 -1
- package/dist/web/assets/{javascript-C2_AZCD8.js → javascript-Dlz1Uo-L.js} +1 -1
- package/dist/web/assets/{jinja-CwzMoVwz.js → jinja-Bpg33dsD.js} +1 -1
- package/dist/web/assets/{jison-DsBwpiNe.js → jison-By-VToHw.js} +1 -1
- package/dist/web/assets/{json-jdIt9h50.js → json-BxAha_TD.js} +1 -1
- package/dist/web/assets/{jsx-Btu-zt-Q.js → jsx-DSiXkHRj.js} +1 -1
- package/dist/web/assets/{julia-CZUYdDvB.js → julia-D0yc7GFR.js} +1 -1
- package/dist/web/assets/{just-D532zIY8.js → just-C2DI3pUP.js} +1 -1
- package/dist/web/assets/{latex-D2J5-TH0.js → latex-DMT_f-3C.js} +1 -1
- package/dist/web/assets/{liquid-WY7D0fMm.js → liquid-DiASchGu.js} +1 -1
- package/dist/web/assets/{lua-Baa_WEf0.js → lua-BafzqdQU.js} +1 -1
- package/dist/web/assets/{marko-DsygGl2f.js → marko-oJq8ByTy.js} +1 -1
- package/dist/web/assets/{mdc-BcGy04Fp.js → mdc-RuZg9ymz.js} +1 -1
- package/dist/web/assets/{nginx-B86ZxrAw.js → nginx-B4H34M9v.js} +1 -1
- package/dist/web/assets/{nim-BM686CfU.js → nim-Cw8zuJky.js} +1 -1
- package/dist/web/assets/{org-RvFkgp46.js → org-hVregGpI.js} +1 -1
- package/dist/web/assets/{perl-C6U9yGM0.js → perl-CwTUPbAz.js} +1 -1
- package/dist/web/assets/{php-uAYB-Mtd.js → php-gQC_Fmii.js} +1 -1
- package/dist/web/assets/{pug-6ncXSInX.js → pug-D6dZj3jJ.js} +1 -1
- package/dist/web/assets/{qml-BE1B7Quf.js → qml-E4rdecj4.js} +1 -1
- package/dist/web/assets/{r-BZJcu9wg.js → r-DR6ad8yZ.js} +1 -1
- package/dist/web/assets/{razor-BKwfOyyl.js → razor-D8M2A4M7.js} +1 -1
- package/dist/web/assets/{regexp-Dih_I1qs.js → regexp-DSjZ8XWG.js} +1 -1
- package/dist/web/assets/{rst-DHEdAr-V.js → rst-DQnNZTXl.js} +1 -1
- package/dist/web/assets/{ruby-BbWx5B9V.js → ruby-BJSAqM9X.js} +1 -1
- package/dist/web/assets/{sas-Df5EO-Nu.js → sas-cMF9FoL4.js} +1 -1
- package/dist/web/assets/{scss-XJX16h32.js → scss-D1ikNPGF.js} +1 -1
- package/dist/web/assets/{shellscript-Dxd4inRL.js → shellscript-D4nS65SB.js} +1 -1
- package/dist/web/assets/{shellsession-Df2uEcSZ.js → shellsession-BX-bfrSn.js} +1 -1
- package/dist/web/assets/{soy-B9Ytzufd.js → soy-B8DHGyol.js} +1 -1
- package/dist/web/assets/{sql-Eeo1bdO9.js → sql-BXnvc70Q.js} +1 -1
- package/dist/web/assets/{stata-DJp7s67I.js → stata-i4_4KZkj.js} +1 -1
- package/dist/web/assets/{surrealql-CDYi7ARY.js → surrealql-CJYktgMP.js} +1 -1
- package/dist/web/assets/{svelte-BjXPUNXf.js → svelte-DkTFRfzJ.js} +1 -1
- package/dist/web/assets/{templ-CfTV__LG.js → templ-DR-sPCuW.js} +1 -1
- package/dist/web/assets/{tex-yGzDqfiz.js → tex-Bm81cpcu.js} +1 -1
- package/dist/web/assets/{ts-tags-H73Fp8-B.js → ts-tags-8o0M5w05.js} +1 -1
- package/dist/web/assets/{tsx-D0NsN8tR.js → tsx-DPlNg7Ry.js} +1 -1
- package/dist/web/assets/{twig-BxcdexCK.js → twig-Cteaw1dL.js} +1 -1
- package/dist/web/assets/{typescript-CTohDgGQ.js → typescript-CZO0lWCV.js} +1 -1
- package/dist/web/assets/{typst-BHhe5E0i.js → typst-BFzy4EUn.js} +1 -1
- package/dist/web/assets/{vue-xxrWY3vq.js → vue-Cvhq1cTP.js} +1 -1
- package/dist/web/assets/{vue-html-DZJ0eUb2.js → vue-html-BTAnySQ7.js} +1 -1
- package/dist/web/assets/{vue-vine-b741IG4q.js → vue-vine-BLuytsGS.js} +1 -1
- package/dist/web/assets/{xml-CVHrHsRm.js → xml-HIAzbxU_.js} +1 -1
- package/dist/web/assets/{xsl-Bo14RTqx.js → xsl-ZYoNgca3.js} +1 -1
- package/dist/web/assets/{yaml-769gRZTS.js → yaml-Bc5rHCoM.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-C7UosTBH.js +0 -1584
package/README.md
CHANGED
|
@@ -49,7 +49,22 @@ sidediff note clear [--file src/app.ts]
|
|
|
49
49
|
|
|
50
50
|
`--new-line` targets the new side of the diff, `--old-line` the old side. The batch format matches Hunk's `session comment apply`, so existing agent tooling can target either.
|
|
51
51
|
|
|
52
|
-
Notes are stored one file per note
|
|
52
|
+
Notes, review threads and the running server's address are stored per worktree under `$XDG_STATE_HOME/sidediff/<worktree>/` (`~/.local/state/sidediff/<worktree>/` by default), one file per note, so concurrent writers never collide, nothing is committed to the repository, worktrees of the same repository never see each other's notes, and a review's annotations and threads come back when its server restarts. Versions before 0.3.1 kept them in the repository's shared `<git dir>/sidediff`; those files are left where they are.
|
|
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`.
|
|
53
68
|
|
|
54
69
|
## Guided tours and conversation
|
|
55
70
|
|
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
|
|
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
|
|
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
|
|
92
|
-
const shown =
|
|
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
|
-
|
|
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
|
|
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([
|
|
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
|
-
|
|
169
|
+
const { version } = createRequire(import.meta.url)('../package.json');
|
|
170
|
+
Command.run(sidediff, { version }).pipe(Effect.provide(AppLayer), NodeRuntime.runMain);
|
package/dist/git.js
CHANGED
|
@@ -32,7 +32,6 @@ export class Git extends Context.Service()('sidediff/Git') {
|
|
|
32
32
|
return {
|
|
33
33
|
run,
|
|
34
34
|
repoRoot: (cwd) => trimmed(cwd, ['rev-parse', '--show-toplevel']),
|
|
35
|
-
commonDir: (cwd) => trimmed(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir']),
|
|
36
35
|
branch: (cwd) => trimmed(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']).pipe(Effect.orElseSucceed(() => 'HEAD')),
|
|
37
36
|
diff: (cwd, range) => run(cwd, ['-c', 'core.quotepath=false', 'diff', '--no-color', '--no-ext-diff', '--find-renames', ...range]),
|
|
38
37
|
sides,
|
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
|
|
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
|
|
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*
|
|
56
|
-
yield*
|
|
59
|
+
yield* Effect.forEach(created, write, { concurrency: 16, discard: true });
|
|
60
|
+
yield* put(created);
|
|
57
61
|
return created;
|
|
58
62
|
});
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
yield*
|
|
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
|
-
|
|
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/repo.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
import { Context, Effect, Layer, Path } from 'effect';
|
|
1
|
+
import { Config, Context, Effect, Layer, Path } from 'effect';
|
|
2
2
|
import { Git } from './git.js';
|
|
3
|
+
const worktreeKey = (root) => root.replace(/^\/+/, '').replaceAll('/', '-');
|
|
3
4
|
export class Repo extends Context.Service()('sidediff/Repo') {
|
|
4
5
|
static layer = Layer.effect(Repo, Effect.gen(function* () {
|
|
5
6
|
const git = yield* Git;
|
|
6
7
|
const path = yield* Path.Path;
|
|
7
8
|
const root = yield* git.repoRoot(process.cwd());
|
|
8
|
-
const
|
|
9
|
-
const
|
|
9
|
+
const home = yield* Effect.orDie(Config.String('HOME'));
|
|
10
|
+
const stateHome = yield* Effect.orDie(Config.NonEmptyString('XDG_STATE_HOME').pipe(Config.withDefault(path.join(home, '.local', 'state'))));
|
|
11
|
+
const state = path.join(stateHome, 'sidediff', worktreeKey(root));
|
|
10
12
|
return { root, notes: path.join(state, 'notes'), state, serverFile: path.join(state, 'server.json') };
|
|
11
13
|
}));
|
|
12
14
|
}
|
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
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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));
|
|
@@ -130,7 +198,7 @@ const Announce = Layer.effectDiscard(Effect.gen(function* () {
|
|
|
130
198
|
const repo = yield* Repo;
|
|
131
199
|
const options = yield* ServeOptions;
|
|
132
200
|
const info = { port: options.port, host: options.host, pid: process.pid };
|
|
133
|
-
yield* Effect.acquireRelease(fs.writeFileString(repo.serverFile, JSON.stringify(Schema.encodeSync(ServerInfo)(info))), () => fs.readFileString(repo.serverFile).pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(ServerInfo))), Effect.flatMap((current) => current.pid === info.pid ? fs.remove(repo.serverFile, { force: true }) : Effect.void), Effect.ignore));
|
|
201
|
+
yield* Effect.acquireRelease(fs.makeDirectory(repo.state, { recursive: true }).pipe(Effect.andThen(fs.writeFileString(repo.serverFile, JSON.stringify(Schema.encodeSync(ServerInfo)(info))))), () => fs.readFileString(repo.serverFile).pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(ServerInfo))), Effect.flatMap((current) => current.pid === info.pid ? fs.remove(repo.serverFile, { force: true }) : Effect.void), Effect.ignore));
|
|
134
202
|
}));
|
|
135
203
|
export const serverLayer = (options) => HttpRouter.serve(Layer.mergeAll(RpcRoute, StaticFiles), { disableListenLog: true, disableLogger: true }).pipe(Layer.provide(Review.layer), Layer.merge(Announce), Layer.provide(NodeHttpServer.layer(createServer, {
|
|
136
204
|
port: options.port,
|