@gavin-hu/my-pi-agent 0.19.0 → 0.21.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/README.md +19 -12
- package/README.zh-CN.md +15 -9
- package/docs/extensions/README.md +2 -1
- package/docs/extensions/board.md +329 -151
- package/docs/extensions/bytefly.md +427 -273
- package/docs/extensions/file-browse.md +46 -44
- package/docs/extensions/job.md +1 -2
- package/docs/extensions/plan.md +86 -24
- package/docs/extensions/serve.md +129 -0
- package/docs/extensions/session-bus.md +23 -10
- package/docs/extensions/team.md +56 -28
- package/docs/extensions/wechat.md +2 -1
- package/docs/extensions/worktree.md +30 -15
- package/extensions/board/commands.ts +48 -17
- package/extensions/board/format.ts +107 -9
- package/extensions/board/index.ts +14 -2
- package/extensions/board/join.ts +31 -10
- package/extensions/board/page.ts +202 -156
- package/extensions/board/result.ts +10 -24
- package/extensions/board/rows.ts +1 -1
- package/extensions/board/schema.ts +31 -10
- package/extensions/board/seat.ts +9 -10
- package/extensions/board/snapshot.ts +7 -5
- package/extensions/board/tools.ts +211 -34
- package/extensions/board/transport/router.ts +27 -110
- package/extensions/board/transport/server.ts +76 -56
- package/extensions/board/types.ts +57 -0
- package/extensions/board/view/client.ts +24 -13
- package/extensions/board/view/dialog.ts +43 -0
- package/extensions/board/view/keys.ts +28 -24
- package/extensions/board/view/live.ts +6 -178
- package/extensions/board/view/names.ts +91 -0
- package/extensions/board/view/page.ts +60 -89
- package/extensions/board/view/render.ts +228 -200
- package/extensions/board/view/styles.ts +72 -49
- package/extensions/board/view/team.ts +63 -41
- package/extensions/bytefly/footer/budget.ts +6 -5
- package/extensions/bytefly/footer/collect/entries.ts +12 -4
- package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
- package/extensions/bytefly/footer/config.ts +19 -11
- package/extensions/bytefly/footer/price/price.ts +30 -8
- package/extensions/bytefly/footer/register.ts +5 -5
- package/extensions/bytefly/footer/render/footer.ts +18 -4
- package/extensions/bytefly/footer/render/format.ts +18 -0
- package/extensions/bytefly/footer/render/key.ts +46 -0
- package/extensions/bytefly/footer/render/layout.ts +13 -3
- package/extensions/bytefly/footer/render/lines.ts +23 -7
- package/extensions/bytefly/footer/render/segments.ts +93 -31
- package/extensions/bytefly/footer/types.ts +29 -5
- package/extensions/bytefly/footer/unpriced.ts +42 -17
- package/extensions/bytefly/header/component.ts +4 -20
- package/extensions/bytefly/panel/commands.ts +28 -1
- package/extensions/bytefly/panel/filter.ts +68 -0
- package/extensions/bytefly/panel/format.ts +16 -15
- package/extensions/bytefly/panel/join.ts +56 -13
- package/extensions/bytefly/panel/prices.ts +43 -0
- package/extensions/bytefly/panel/settings.ts +72 -23
- package/extensions/bytefly/panel/tui/entries.ts +26 -8
- package/extensions/bytefly/panel/tui/frame.ts +130 -93
- package/extensions/bytefly/panel/tui/keys.ts +352 -0
- package/extensions/bytefly/panel/tui/list-view.ts +25 -46
- package/extensions/bytefly/panel/tui/panes.ts +76 -0
- package/extensions/bytefly/panel/tui/section-view.ts +126 -0
- package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
- package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
- package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
- package/extensions/bytefly/panel/tui.ts +306 -259
- package/extensions/check/commands.ts +2 -1
- package/extensions/check/render.ts +10 -1
- package/extensions/file-browse/config/config.ts +4 -19
- package/extensions/file-browse/files/classify.ts +136 -0
- package/extensions/file-browse/files/files.ts +8 -547
- package/extensions/file-browse/files/index.ts +90 -0
- package/extensions/file-browse/files/list.ts +261 -0
- package/extensions/file-browse/files/tree.ts +101 -0
- package/extensions/file-browse/index.ts +27 -48
- package/extensions/file-browse/live/live.ts +1 -1
- package/extensions/file-browse/transport/router.ts +12 -6
- package/extensions/file-browse/transport/server.ts +61 -28
- package/extensions/file-browse/view/chrome.ts +4 -4
- package/extensions/file-browse/view/client.ts +3 -1
- package/extensions/file-browse/view/emit.ts +51 -0
- package/extensions/file-browse/view/html.ts +78 -46
- package/extensions/file-browse/view/live-client.ts +2 -2
- package/extensions/file-browse/view/markdown.ts +23 -26
- package/extensions/file-browse/view/palette.ts +2 -2
- package/extensions/file-browse/view/raw-html.ts +27 -22
- package/extensions/file-browse/view/tree.ts +1 -1
- package/extensions/file-browse/view/urls.ts +20 -6
- package/extensions/goal/tui.ts +12 -2
- package/extensions/job/runtime.ts +4 -4
- package/extensions/job/tools.ts +19 -7
- package/extensions/job/tui.ts +2 -28
- package/extensions/plan/commands.ts +2 -2
- package/extensions/plan/feedback.ts +88 -18
- package/extensions/plan/format.ts +10 -3
- package/extensions/plan/index.ts +24 -2
- package/extensions/plan/plan-files.ts +88 -0
- package/extensions/plan/plan-text.ts +114 -0
- package/extensions/plan/plans.ts +19 -23
- package/extensions/plan/policy.ts +10 -5
- package/extensions/plan/runtime.ts +43 -4
- package/extensions/plan/todos.ts +51 -3
- package/extensions/plan/tools/exit.ts +10 -1
- package/extensions/plan/tools/todo-seed.ts +4 -2
- package/extensions/plan/tools/write.ts +12 -1
- package/extensions/plan/tools.ts +2 -2
- package/extensions/plan/types.ts +15 -0
- package/extensions/plan/web-blocks.ts +160 -0
- package/extensions/plan/web-chrome.ts +7 -5
- package/extensions/plan/web-client.ts +271 -325
- package/extensions/plan/web-collab.ts +7 -7
- package/extensions/plan/web-comments.ts +57 -16
- package/extensions/plan/web-composer.ts +91 -0
- package/extensions/plan/web-draft.ts +40 -0
- package/extensions/plan/web-keys.ts +258 -0
- package/extensions/plan/web-markdown.ts +1 -1
- package/extensions/plan/web-outline.ts +212 -0
- package/extensions/plan/web-page.ts +59 -86
- package/extensions/plan/web-payload.ts +188 -0
- package/extensions/plan/web-plan.ts +160 -0
- package/extensions/plan/web-router.ts +0 -1
- package/extensions/plan/web-settle.ts +201 -0
- package/extensions/plan/web-share-session.ts +24 -55
- package/extensions/plan/web-share.ts +19 -37
- package/extensions/plan/web-write.ts +112 -0
- package/extensions/plan/web.ts +77 -130
- package/extensions/rewind/commands/review.ts +2 -1
- package/extensions/serve/config.ts +37 -0
- package/extensions/serve/index.ts +71 -0
- package/extensions/serve/registry.ts +251 -0
- package/extensions/session-bus/join-request.ts +16 -9
- package/extensions/session-bus/result.ts +1 -27
- package/extensions/session-bus/rows.ts +7 -3
- package/extensions/session-bus/runtime.ts +100 -38
- package/extensions/session-bus/store.ts +14 -2
- package/extensions/session-bus/tools.ts +5 -4
- package/extensions/session-bus/tui.ts +1 -1
- package/extensions/session-bus/types.ts +10 -1
- package/extensions/subagent/commands.ts +2 -1
- package/extensions/team/commands.ts +44 -9
- package/extensions/team/format.ts +14 -0
- package/extensions/team/index.ts +2 -1
- package/extensions/team/tools.ts +30 -9
- package/extensions/team/types.ts +17 -0
- package/extensions/todo/index.ts +4 -1
- package/extensions/todo/nudge.ts +8 -2
- package/extensions/todo/tui.ts +21 -3
- package/extensions/wechat/bridge.ts +43 -172
- package/extensions/wechat/intake.ts +245 -0
- package/extensions/worktree/commands.ts +24 -17
- package/extensions/worktree/exit.ts +12 -6
- package/extensions/worktree/git.ts +22 -14
- package/extensions/worktree/guard.ts +15 -285
- package/extensions/worktree/handoff.ts +10 -2
- package/extensions/worktree/index.ts +2 -1
- package/extensions/worktree/lifecycle.ts +2 -0
- package/extensions/worktree/registry.ts +20 -11
- package/extensions/worktree/root-tools.ts +13 -3
- package/extensions/worktree/runtime.ts +8 -2
- package/extensions/worktree/screen.ts +15 -8
- package/extensions/worktree/shell.ts +303 -0
- package/extensions/worktree/state.ts +1 -1
- package/extensions/worktree/status.ts +42 -9
- package/extensions/worktree/tools.ts +19 -13
- package/lib/README.md +9 -7
- package/lib/board/card.ts +192 -3
- package/lib/board/links.ts +26 -2
- package/lib/board/page-pointer.ts +111 -32
- package/lib/board/round-text.ts +11 -2
- package/lib/board/round.ts +25 -20
- package/lib/board/rules.ts +144 -11
- package/lib/board/session.ts +84 -18
- package/lib/board/store.ts +268 -55
- package/lib/board/view.ts +114 -24
- package/lib/bus-request.ts +83 -0
- package/lib/format.ts +22 -0
- package/lib/http/index.ts +11 -7
- package/lib/http/mount.ts +142 -0
- package/lib/http/port.ts +25 -0
- package/lib/http/serve.ts +170 -43
- package/lib/http/server.ts +3 -3
- package/lib/http/sink.ts +34 -9
- package/lib/plan-entry.ts +20 -13
- package/lib/policy.ts +14 -1
- package/lib/requirements.ts +2 -2
- package/lib/session-label.ts +23 -7
- package/lib/session-roster.ts +74 -3
- package/lib/session-tool.ts +37 -21
- package/lib/spawn.ts +37 -3
- package/lib/stray-fields.ts +45 -0
- package/lib/tool-names.ts +13 -0
- package/lib/tui/README.md +6 -3
- package/lib/tui/glyphs.ts +3 -6
- package/lib/tui/render-cache.ts +29 -0
- package/lib/tui/tool.ts +30 -1
- package/lib/web/README.md +52 -2
- package/lib/web/client-runtime.ts +149 -0
- package/lib/web/index.ts +4 -2
- package/lib/web/markup.ts +106 -2
- package/lib/web/names.ts +42 -0
- package/package.json +3 -2
- package/extensions/board/config.ts +0 -36
- package/extensions/board/view/actions.ts +0 -306
- package/extensions/board/writes.ts +0 -99
- package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
- package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
- package/extensions/job/process.ts +0 -35
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The review page's plan renderer: the DOM a frame draws, thrown away and rebuilt.
|
|
3
|
+
*
|
|
4
|
+
* The seventh capability out of the review client's template literal, and the one with the
|
|
5
|
+
* most to read: it builds every block, its comment rail, and the cards under it, then puts
|
|
6
|
+
* the reader back where they were. Reads arrive through getters and mutations leave through
|
|
7
|
+
* callbacks, so the client keeps every variable it owns — `activeIndex`, the quote a click
|
|
8
|
+
* is waiting for, the observer — and this function holds only the shape of one frame.
|
|
9
|
+
*
|
|
10
|
+
* Two rules it owns, both about a repaint not stealing the reader's place:
|
|
11
|
+
*
|
|
12
|
+
* - focus is restored only when it was inside the plan to begin with, so a frame the stream
|
|
13
|
+
* caused cannot pull the keyboard out of the page the reviewer is working in;
|
|
14
|
+
* - the active block's class is put back in place rather than by scrolling, so the arrow
|
|
15
|
+
* keys resume where they were instead of jumping to the top.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ClientNode } from "../../lib/web/client-runtime.ts";
|
|
19
|
+
import type { ComposerDeps } from "./web-composer.ts";
|
|
20
|
+
import { blockElements, type IslandBlock, setActive } from "./web-blocks.ts";
|
|
21
|
+
import type { SectionWatcher } from "./web-outline.ts";
|
|
22
|
+
|
|
23
|
+
/** What `el` has to answer for a frame. Spliced beside it; declared for the checker. */
|
|
24
|
+
type Build = (tag: string, className?: string, text?: string | null) => ClientNode;
|
|
25
|
+
declare const el: Build;
|
|
26
|
+
|
|
27
|
+
/** The quote a click on a block's control is waiting for: the reader's selection. */
|
|
28
|
+
export interface PendingQuote {
|
|
29
|
+
wrap: ClientNode;
|
|
30
|
+
quote: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** What a repaint reads, and the callbacks it changes the client's state through. */
|
|
34
|
+
export interface PlanRenderDeps {
|
|
35
|
+
planEl: ClientNode | null;
|
|
36
|
+
outlineList: ClientNode | null;
|
|
37
|
+
/** The element that had focus before this frame, or null when nothing did. */
|
|
38
|
+
focused: ClientNode | null;
|
|
39
|
+
blocks(): readonly IslandBlock[];
|
|
40
|
+
/** Whether this block is new or edited since the previous review; absent means no marks. */
|
|
41
|
+
changed?(line: number): boolean;
|
|
42
|
+
annotations(): readonly { anchor: number }[];
|
|
43
|
+
activeIndex(): number;
|
|
44
|
+
setActiveIndex(index: number): void;
|
|
45
|
+
pendingQuote(): PendingQuote | null;
|
|
46
|
+
clearPendingQuote(): void;
|
|
47
|
+
/** Whether the review has ended, so the rail offers no new comment. */
|
|
48
|
+
settled?(): boolean;
|
|
49
|
+
/** The composer lifecycle, for the close a repaint begins with. */
|
|
50
|
+
composer(): ComposerDeps;
|
|
51
|
+
/** The block's content, from the Markdown half. */
|
|
52
|
+
renderBlock(wrap: ClientNode, block: IslandBlock): void;
|
|
53
|
+
/** The cards under one block, and the loose ones that lost their anchor. */
|
|
54
|
+
renderComments(scope: ClientNode, line: number): void;
|
|
55
|
+
renderCommentCards(scope: ClientNode, list: readonly unknown[]): void;
|
|
56
|
+
/** The text a reader selected inside a block, used as the next comment's quote. */
|
|
57
|
+
selectionIn(wrap: ClientNode): string;
|
|
58
|
+
open(wrap: ClientNode, block: IslandBlock, quote: string): void;
|
|
59
|
+
/** The facts and the header chip, repainted after the blocks. */
|
|
60
|
+
facts(): void;
|
|
61
|
+
updateState(): void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Draw the plan, and put the reader back. Returns the section watcher it installed.
|
|
66
|
+
*
|
|
67
|
+
* The client's own zero-argument entry is `renderAll()`: it builds the frame's deps and
|
|
68
|
+
* hands them here, which is how the fragments keep calling a name that takes nothing.
|
|
69
|
+
*/
|
|
70
|
+
export function renderPlanFrame(
|
|
71
|
+
deps: PlanRenderDeps,
|
|
72
|
+
closePlan: () => void,
|
|
73
|
+
observe: (
|
|
74
|
+
planEl: ClientNode | null,
|
|
75
|
+
list: ClientNode | null,
|
|
76
|
+
previous: SectionWatcher | null,
|
|
77
|
+
) => SectionWatcher | null,
|
|
78
|
+
previousObserver: SectionWatcher | null,
|
|
79
|
+
): SectionWatcher | null {
|
|
80
|
+
const { planEl } = deps;
|
|
81
|
+
if (!planEl) return previousObserver;
|
|
82
|
+
// Focus is only put back when it was inside this document to begin with.
|
|
83
|
+
const keepFocus = !!(deps.focused && deps.focused !== planEl && planEl.contains(deps.focused));
|
|
84
|
+
closePlan();
|
|
85
|
+
const scrollTop = planEl.scrollTop;
|
|
86
|
+
planEl.replaceChildren();
|
|
87
|
+
const anchored: Record<number, boolean> = {};
|
|
88
|
+
for (const block of deps.blocks()) {
|
|
89
|
+
if (block.kind === "blank") continue;
|
|
90
|
+
anchored[block.start] = true;
|
|
91
|
+
const wrap = el("div", "bf-block");
|
|
92
|
+
wrap.setAttribute("data-line", String(block.start));
|
|
93
|
+
wrap.setAttribute("data-kind", String(block.kind));
|
|
94
|
+
// The one mark that says "read this line again": a block the last review did not have.
|
|
95
|
+
if (deps.changed?.(block.start) === true) wrap.setAttribute("data-changed", "1");
|
|
96
|
+
if (block.kind === "heading") wrap.id = "s-" + block.start;
|
|
97
|
+
deps.renderBlock(wrap, block);
|
|
98
|
+
const mine = deps.annotations().filter((annotation) => annotation.anchor === block.start);
|
|
99
|
+
if (mine.length > 0) wrap.classList.add("bf-block--commented");
|
|
100
|
+
const rail = el("div", "bf-block__rail");
|
|
101
|
+
// A settled review takes no new comment: this control would open a composer whose
|
|
102
|
+
// note could never be saved, so it is not drawn rather than drawn and refused.
|
|
103
|
+
if (deps.settled?.() !== true) {
|
|
104
|
+
const add = el("button", "bf-block__add");
|
|
105
|
+
add.type = "button";
|
|
106
|
+
add.textContent = "+";
|
|
107
|
+
add.setAttribute("aria-label", "Comment on this block");
|
|
108
|
+
add.addEventListener("click", function () {
|
|
109
|
+
// Read at click time, not at render time: a selection made after this frame is
|
|
110
|
+
// the quote the reader means.
|
|
111
|
+
const waiting = deps.pendingQuote();
|
|
112
|
+
const quoted = waiting && waiting.wrap === wrap ? waiting.quote : deps.selectionIn(wrap);
|
|
113
|
+
deps.clearPendingQuote();
|
|
114
|
+
deps.open(wrap, block, quoted);
|
|
115
|
+
});
|
|
116
|
+
rail.appendChild(add);
|
|
117
|
+
}
|
|
118
|
+
if (mine.length > 0) {
|
|
119
|
+
const badge = el("span", "bf-block__count");
|
|
120
|
+
badge.textContent = String(mine.length);
|
|
121
|
+
badge.title = mine.length === 1 ? "1 review comment" : mine.length + " review comments";
|
|
122
|
+
rail.appendChild(badge);
|
|
123
|
+
}
|
|
124
|
+
wrap.appendChild(rail);
|
|
125
|
+
wrap.addEventListener("focusin", function () {
|
|
126
|
+
const next = setActive(planEl, blockElements(planEl).indexOf(wrap));
|
|
127
|
+
if (next !== null) deps.setActiveIndex(next);
|
|
128
|
+
});
|
|
129
|
+
planEl.appendChild(wrap);
|
|
130
|
+
deps.renderComments(planEl, block.start);
|
|
131
|
+
}
|
|
132
|
+
const orphans = deps.annotations().filter((annotation) => !anchored[annotation.anchor]);
|
|
133
|
+
if (orphans.length > 0) {
|
|
134
|
+
const box = el("div", "bf-comments bf-comments--orphans");
|
|
135
|
+
const note = el("p");
|
|
136
|
+
note.textContent = "Comments not attached to a current block";
|
|
137
|
+
box.appendChild(note);
|
|
138
|
+
deps.renderCommentCards(box, orphans);
|
|
139
|
+
planEl.appendChild(box);
|
|
140
|
+
}
|
|
141
|
+
planEl.scrollTop = scrollTop;
|
|
142
|
+
// The DOM was rebuilt, so the active block's class is gone; restore it in place rather
|
|
143
|
+
// than scrolling, so the keyboard resumes where the reviewer was.
|
|
144
|
+
const activeIndex = deps.activeIndex();
|
|
145
|
+
const active = activeIndex >= 0 ? blockElements(planEl)[activeIndex] : null;
|
|
146
|
+
if (active) active.classList.add("bf-block--active");
|
|
147
|
+
// The focused element went with the old DOM, so put the tab stop back on the block's
|
|
148
|
+
// comment control: that is where adding, editing, or deleting left it.
|
|
149
|
+
if (keepFocus && active) {
|
|
150
|
+
const stop = active.querySelector(".bf-block__add");
|
|
151
|
+
if (stop) stop.focus();
|
|
152
|
+
}
|
|
153
|
+
deps.facts();
|
|
154
|
+
const observer = observe(planEl, deps.outlineList, previousObserver);
|
|
155
|
+
deps.updateState();
|
|
156
|
+
return observer;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The block renderer as source, for the client bundle. */
|
|
160
|
+
export const WEB_PLAN_JS = String(renderPlanFrame);
|
|
@@ -122,7 +122,6 @@ export async function handleReviewRequest(
|
|
|
122
122
|
if (!parsed) return jsonError(400, "Malformed request URL.");
|
|
123
123
|
const { pathname, search } = parsed;
|
|
124
124
|
|
|
125
|
-
if (pathname === "/favicon.ico") return { status: 204, headers: { "Cache-Control": "no-store" } };
|
|
126
125
|
if (pathname === THEME_JS_PATH) return { status: 200, headers: scriptHeaders(), body: THEME_JS };
|
|
127
126
|
if (pathname === "/client.js") return { status: 200, headers: scriptHeaders(), body: REVIEW_CLIENT_JS };
|
|
128
127
|
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The review page's settlement: the four ways a review ends, and the one POST they share.
|
|
3
|
+
*
|
|
4
|
+
* The eighth capability out of the review client's template literal. Every write has the
|
|
5
|
+
* same shape — POST the annotations, read the answer, report it in the status line — and
|
|
6
|
+
* what differs is whether the review settles: the owner's save and approve finish it, a
|
|
7
|
+
* reviewer's post does not (the server keeps the review open so she can keep editing), and
|
|
8
|
+
* cancel is the owner's own decision and carries no body at all.
|
|
9
|
+
*
|
|
10
|
+
* The page owns its flags, its buttons, and its stream; this module owns the rules about
|
|
11
|
+
* them, so it reads through getters and reports changes through the callbacks it is given.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** What a settlement reads of the page, and the changes it reports. */
|
|
15
|
+
export interface SettleDeps {
|
|
16
|
+
/** The path a write goes to, without its query string. */
|
|
17
|
+
token(): string;
|
|
18
|
+
/** The review's mount on the session's server, e.g. `/plan/3f9c21aa`. */
|
|
19
|
+
base(): string;
|
|
20
|
+
role(): string;
|
|
21
|
+
/** The comments this writer may write: her own when she is a reviewer, all when owner. */
|
|
22
|
+
mine(): readonly { author?: string }[];
|
|
23
|
+
annotations(): readonly { author?: string }[];
|
|
24
|
+
/** Whether the review has already ended, by any of the three routes. */
|
|
25
|
+
settled(): boolean;
|
|
26
|
+
setStatus(text: string, kind?: string): void;
|
|
27
|
+
/** The three buttons a conflict disables, and the fourth a close does. */
|
|
28
|
+
buttons(): {
|
|
29
|
+
approve: { disabled: boolean } | null;
|
|
30
|
+
save: { disabled: boolean } | null;
|
|
31
|
+
post: { disabled: boolean } | null;
|
|
32
|
+
};
|
|
33
|
+
/** The draft this tab's work lives in: cleared once the server has it. */
|
|
34
|
+
clearDraft(): void;
|
|
35
|
+
/** The page is no longer dirty: the server has everything this tab wrote. */
|
|
36
|
+
markClean(): void;
|
|
37
|
+
/** The server bound this reviewer's name to her link: adopt it. */
|
|
38
|
+
adoptName(name: string): void;
|
|
39
|
+
/** Put a reload button beside the controls: the page cannot recover on its own. */
|
|
40
|
+
offerReload(): void;
|
|
41
|
+
/** Record how the review ended, stop following it, and stop offering to change it. */
|
|
42
|
+
markSettled(kind: "saved" | "approved" | "closed"): void;
|
|
43
|
+
settleStream(): void;
|
|
44
|
+
lockActions(): void;
|
|
45
|
+
/** Repaint the header chip and the facts. */
|
|
46
|
+
updateState(): void;
|
|
47
|
+
/** Refuse to leave a half-written comment, saying why. */
|
|
48
|
+
confirmNoDraft(): boolean;
|
|
49
|
+
post(url: string, body: unknown): Promise<{ ok: boolean; status: number; body: unknown }>;
|
|
50
|
+
/** A body-less POST, for the close that carries nothing. */
|
|
51
|
+
send(url: string): Promise<unknown>;
|
|
52
|
+
/** What a body-less conflict says, when the server gave no sentence of its own. */
|
|
53
|
+
planMoved: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** What a settlement reports back: the flag it set, and whether the page is finished. */
|
|
57
|
+
export interface SettleResult {
|
|
58
|
+
outcome: "ok" | "conflict" | "failed";
|
|
59
|
+
reason?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* POST the annotations, and read the answer on both branches.
|
|
64
|
+
*
|
|
65
|
+
* A 409 disables the three write buttons and offers a reload, because the page's island is
|
|
66
|
+
* out of date and no retry from here can fix that; anything else surfaces the server's own
|
|
67
|
+
* sentence, which is why the body is parsed on both branches rather than only on success.
|
|
68
|
+
*/
|
|
69
|
+
export function submitAnnotations(
|
|
70
|
+
deps: SettleDeps,
|
|
71
|
+
url: string,
|
|
72
|
+
okText: string,
|
|
73
|
+
failText: string,
|
|
74
|
+
onOk: (() => void) | null,
|
|
75
|
+
): Promise<SettleResult> {
|
|
76
|
+
const payload = deps.role() === "reviewer" ? deps.mine() : deps.annotations();
|
|
77
|
+
return deps
|
|
78
|
+
.post(url + "?token=" + encodeURIComponent(deps.token()), { annotations: payload })
|
|
79
|
+
.then((result) => {
|
|
80
|
+
if (result.ok) {
|
|
81
|
+
deps.markClean();
|
|
82
|
+
deps.clearDraft();
|
|
83
|
+
deps.setStatus(okText, "saved");
|
|
84
|
+
if (onOk) onOk();
|
|
85
|
+
deps.updateState();
|
|
86
|
+
// The server binds a reviewer's name to her link on the first post, so a
|
|
87
|
+
// second tab of the same link posts under the same name — and it may normalise
|
|
88
|
+
// what she typed (`@alice` is stored as `alice`). The comments she just posted
|
|
89
|
+
// are in the file under the bound name, so the page's copy has to say that too:
|
|
90
|
+
// otherwise they stop being hers to edit, and her next post would replace
|
|
91
|
+
// markers it never sent.
|
|
92
|
+
const body = result.body as { author?: unknown } | null;
|
|
93
|
+
if (deps.role() === "reviewer" && body && typeof body.author === "string" && body.author) {
|
|
94
|
+
for (const annotation of payload) if (annotation.author !== undefined) annotation.author = body.author;
|
|
95
|
+
deps.adoptName(body.author);
|
|
96
|
+
}
|
|
97
|
+
return { outcome: "ok" as const };
|
|
98
|
+
}
|
|
99
|
+
if (result.status === 409) {
|
|
100
|
+
const buttons = deps.buttons();
|
|
101
|
+
if (buttons.approve) buttons.approve.disabled = true;
|
|
102
|
+
if (buttons.save) buttons.save.disabled = true;
|
|
103
|
+
if (buttons.post) buttons.post.disabled = true;
|
|
104
|
+
deps.offerReload();
|
|
105
|
+
const body = result.body as { error?: unknown } | null;
|
|
106
|
+
const reason = body && typeof body.error === "string" ? body.error : deps.planMoved;
|
|
107
|
+
deps.setStatus(reason, "error");
|
|
108
|
+
return { outcome: "conflict" as const, reason };
|
|
109
|
+
}
|
|
110
|
+
const body = result.body as { error?: unknown } | null;
|
|
111
|
+
throw new Error(body && typeof body.error === "string" ? body.error : "server returned " + result.status);
|
|
112
|
+
})
|
|
113
|
+
.catch((error: { message?: string }) => {
|
|
114
|
+
const reason = failText + (error && error.message ? error.message : "");
|
|
115
|
+
deps.setStatus(reason, "error");
|
|
116
|
+
return { outcome: "failed" as const, reason };
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Save the annotations and finish the review: the owner's way to close an approved plan. */
|
|
121
|
+
export function saveReview(deps: SettleDeps): Promise<SettleResult> | null {
|
|
122
|
+
if (deps.settled()) return null;
|
|
123
|
+
if (deps.confirmNoDraft()) return null;
|
|
124
|
+
deps.setStatus("Saving\u2026");
|
|
125
|
+
return submitAnnotations(
|
|
126
|
+
deps,
|
|
127
|
+
deps.base() + "/api/feedback",
|
|
128
|
+
"Saved \u2014 return to Pi to continue.",
|
|
129
|
+
"Could not save: ",
|
|
130
|
+
function () {
|
|
131
|
+
deps.markSettled("saved");
|
|
132
|
+
deps.settleStream();
|
|
133
|
+
deps.lockActions();
|
|
134
|
+
},
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Post a reviewer's comments and keep the review open.
|
|
140
|
+
*
|
|
141
|
+
* A reviewer's write never settles the page: the server keeps the review open so she can
|
|
142
|
+
* keep editing her own comments, and closing the stream here would strand her.
|
|
143
|
+
*/
|
|
144
|
+
export function postReviewComments(deps: SettleDeps): Promise<SettleResult> | null {
|
|
145
|
+
if (deps.settled()) return null;
|
|
146
|
+
if (deps.confirmNoDraft()) return null;
|
|
147
|
+
deps.setStatus("Posting\u2026");
|
|
148
|
+
return submitAnnotations(
|
|
149
|
+
deps,
|
|
150
|
+
deps.base() + "/api/comment",
|
|
151
|
+
"Comments posted \u2014 the owner sees them now.",
|
|
152
|
+
"Could not post: ",
|
|
153
|
+
null,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Approve the plan, which finishes the review. */
|
|
158
|
+
export function approveReview(deps: SettleDeps): Promise<SettleResult> | null {
|
|
159
|
+
if (deps.settled()) return null;
|
|
160
|
+
deps.setStatus("Approving\u2026");
|
|
161
|
+
return submitAnnotations(
|
|
162
|
+
deps,
|
|
163
|
+
deps.base() + "/api/approve",
|
|
164
|
+
"Approved \u2014 return to Pi to continue.",
|
|
165
|
+
"Could not approve: ",
|
|
166
|
+
function () {
|
|
167
|
+
deps.markSettled("approved");
|
|
168
|
+
deps.settleStream();
|
|
169
|
+
deps.lockActions();
|
|
170
|
+
},
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Close the review without saving: only the owner may, and the page says so while it asks.
|
|
176
|
+
*
|
|
177
|
+
* The close carries no body, so it is the one write that does not go through
|
|
178
|
+
* `submitAnnotations`; its answer is a status line either way, because a tab that closed the
|
|
179
|
+
* review has nothing left to do with a failure.
|
|
180
|
+
*/
|
|
181
|
+
export function cancelReview(deps: SettleDeps): Promise<void> | null {
|
|
182
|
+
if (deps.role() !== "owner" || deps.settled()) return null;
|
|
183
|
+
if (deps.confirmNoDraft()) return null;
|
|
184
|
+
deps.markSettled("closed");
|
|
185
|
+
deps.settleStream();
|
|
186
|
+
deps.lockActions();
|
|
187
|
+
deps.setStatus("Closing\u2026");
|
|
188
|
+
deps.updateState();
|
|
189
|
+
return deps
|
|
190
|
+
.send(deps.base() + "/api/cancel?token=" + encodeURIComponent(deps.token()))
|
|
191
|
+
.catch(function () {})
|
|
192
|
+
.then(function () {
|
|
193
|
+
deps.setStatus("Closed \u2014 return to Pi.");
|
|
194
|
+
deps.updateState();
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The settlement rules as source, for the client bundle. */
|
|
199
|
+
export const WEB_SETTLE_JS = [submitAnnotations, saveReview, postReviewComments, approveReview, cancelReview]
|
|
200
|
+
.map(String)
|
|
201
|
+
.join("\n\n");
|
|
@@ -8,31 +8,25 @@
|
|
|
8
8
|
* hooks, and the frame hooks, so it never reaches into the session's maps.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import { type HttpMount, LOOPBACK_HOSTS, type SharedListener } from "../../lib/http/index.ts";
|
|
12
12
|
import { qrSvg } from "../../lib/qr.ts";
|
|
13
13
|
import type { ReviewFrame, ReviewRole } from "./types.ts";
|
|
14
14
|
import {
|
|
15
15
|
AT_CAP_REASON,
|
|
16
16
|
createLinkTable,
|
|
17
|
-
lanAddresses,
|
|
18
17
|
NO_SHARE_REASON,
|
|
19
18
|
shareUrl,
|
|
20
19
|
type LinkTable,
|
|
21
|
-
type NetworkInterfaces,
|
|
22
20
|
type ShareResult,
|
|
23
21
|
type ShareState,
|
|
24
22
|
} from "./web-share.ts";
|
|
25
23
|
|
|
26
24
|
/** What the share listener needs from the review session it belongs to. */
|
|
27
25
|
export interface ShareSessionDeps {
|
|
28
|
-
/** The
|
|
29
|
-
|
|
30
|
-
/** The
|
|
31
|
-
|
|
32
|
-
/** The owner's bound port, reused for the link when it is free. */
|
|
33
|
-
ownerPort: () => number;
|
|
34
|
-
/** The machine's interfaces, or a snapshot a test injected. */
|
|
35
|
-
networkInterfaces: () => NetworkInterfaces;
|
|
26
|
+
/** The review's mount on the session's server; sharing borrows its LAN listener. */
|
|
27
|
+
mount: HttpMount;
|
|
28
|
+
/** The mount's prefix, so a reviewer's link names the same page the owner has. */
|
|
29
|
+
base: string;
|
|
36
30
|
/** Add a reviewer token to the session's role map. */
|
|
37
31
|
mintReviewer: () => string;
|
|
38
32
|
/** Remove a reviewer token from the session's role map. */
|
|
@@ -64,16 +58,14 @@ export interface ShareSession {
|
|
|
64
58
|
}
|
|
65
59
|
|
|
66
60
|
export function createShareSession(deps: ShareSessionDeps): ShareSession {
|
|
67
|
-
let
|
|
68
|
-
let port = 0;
|
|
69
|
-
let server: LoopbackServer | undefined;
|
|
61
|
+
let shared: SharedListener | undefined;
|
|
70
62
|
const links: LinkTable = createLinkTable({
|
|
71
63
|
mint: deps.mintReviewer,
|
|
72
64
|
revoke: deps.revokeReviewer,
|
|
73
65
|
nameOf: deps.nameOf,
|
|
74
66
|
});
|
|
75
67
|
let reason: string | undefined;
|
|
76
|
-
const hosts = (): readonly string[] => (
|
|
68
|
+
const hosts = (): readonly string[] => (shared ? [...LOOPBACK_HOSTS, shared.address] : [...LOOPBACK_HOSTS]);
|
|
77
69
|
|
|
78
70
|
/**
|
|
79
71
|
* The share surface as it is now, read rather than cached: a link's bound name
|
|
@@ -81,42 +73,22 @@ export function createShareSession(deps: ShareSessionDeps): ShareSession {
|
|
|
81
73
|
* another share action.
|
|
82
74
|
*/
|
|
83
75
|
const snapshot = (): ShareState => {
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const bind = async (chosen: string): Promise<void> => {
|
|
90
|
-
try {
|
|
91
|
-
const bound = await serveLoopback({
|
|
92
|
-
host: chosen,
|
|
93
|
-
ports: [deps.ownerPort(), 0],
|
|
94
|
-
maxBodyBytes: deps.maxBodyBytes,
|
|
95
|
-
handler: deps.handleRequest,
|
|
96
|
-
});
|
|
97
|
-
server = bound;
|
|
98
|
-
address = chosen;
|
|
99
|
-
port = bound.port;
|
|
100
|
-
} catch {
|
|
101
|
-
// Neither the owner's port nor a free one answered: the caller reports it.
|
|
102
|
-
server = undefined;
|
|
103
|
-
}
|
|
76
|
+
const state: ShareState =
|
|
77
|
+
shared === undefined
|
|
78
|
+
? { sharing: false, origin: "", links: [] }
|
|
79
|
+
: links.state(deps.base, shared.origin, shared.address);
|
|
80
|
+
return reason === undefined ? state : { ...state, reason };
|
|
104
81
|
};
|
|
105
82
|
|
|
106
83
|
/**
|
|
107
|
-
*
|
|
108
|
-
* there is nothing to bind to. Every link answers on this same listener, so
|
|
109
|
-
* runs once per share and the rest is bookkeeping.
|
|
84
|
+
* Ask the session's server for the LAN listener the first time a link needs it, or
|
|
85
|
+
* report why there is nothing to bind to. Every link answers on this same listener, so
|
|
86
|
+
* this runs once per share and the rest is bookkeeping.
|
|
110
87
|
*/
|
|
111
88
|
const ensureListener = async (): Promise<boolean> => {
|
|
112
|
-
if (
|
|
113
|
-
|
|
114
|
-
if (
|
|
115
|
-
reason = NO_SHARE_REASON;
|
|
116
|
-
return false;
|
|
117
|
-
}
|
|
118
|
-
await bind(chosen);
|
|
119
|
-
if (server === undefined) {
|
|
89
|
+
if (shared !== undefined) return true;
|
|
90
|
+
shared = await deps.mount.share();
|
|
91
|
+
if (shared === undefined) {
|
|
120
92
|
reason = NO_SHARE_REASON;
|
|
121
93
|
return false;
|
|
122
94
|
}
|
|
@@ -129,7 +101,7 @@ export function createShareSession(deps: ShareSessionDeps): ShareSession {
|
|
|
129
101
|
deps.broadcast("share", "owner");
|
|
130
102
|
const state = snapshot();
|
|
131
103
|
if (minted === undefined) return { state };
|
|
132
|
-
return { state, svg: await qrSvg(shareUrl(
|
|
104
|
+
return { state, svg: await qrSvg(shareUrl(deps.base, shared?.origin ?? "", minted)) };
|
|
133
105
|
};
|
|
134
106
|
|
|
135
107
|
const share = async (): Promise<ShareResult> => {
|
|
@@ -172,19 +144,16 @@ export function createShareSession(deps: ShareSessionDeps): ShareSession {
|
|
|
172
144
|
// The reviewers are told before their listener goes away; the review itself
|
|
173
145
|
// stays open for its owner.
|
|
174
146
|
deps.endStreams("revoked", { role: "reviewer" });
|
|
175
|
-
|
|
176
|
-
server = undefined;
|
|
177
|
-
address = "";
|
|
178
|
-
port = 0;
|
|
147
|
+
shared = undefined;
|
|
179
148
|
reason = undefined;
|
|
180
|
-
|
|
149
|
+
await deps.mount.unshare();
|
|
181
150
|
return settle();
|
|
182
151
|
};
|
|
183
152
|
|
|
184
153
|
const close = async (): Promise<void> => {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
154
|
+
if (shared === undefined) return;
|
|
155
|
+
shared = undefined;
|
|
156
|
+
await deps.mount.unshare();
|
|
188
157
|
};
|
|
189
158
|
|
|
190
159
|
return { share, invite, revoke, stop, state: snapshot, hosts, close };
|
|
@@ -12,10 +12,12 @@
|
|
|
12
12
|
* failed. Everything else would print a link that cannot work.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import type
|
|
15
|
+
import { lanAddresses, type NetworkInterfaces } from "../../lib/http/index.ts";
|
|
16
16
|
|
|
17
|
-
/** The interfaces a review may be shared to, by name. */
|
|
18
|
-
export type NetworkInterfaces
|
|
17
|
+
/** The interfaces a review may be shared to, by name; `lib/http` owns the rule. */
|
|
18
|
+
export type { NetworkInterfaces };
|
|
19
|
+
|
|
20
|
+
export { lanAddresses };
|
|
19
21
|
|
|
20
22
|
/** One address a reviewer can open the review at. */
|
|
21
23
|
export interface ShareLink {
|
|
@@ -33,8 +35,12 @@ export interface ShareLink {
|
|
|
33
35
|
export interface ShareState {
|
|
34
36
|
/** True while reviewer tokens exist and an address is bound. */
|
|
35
37
|
sharing: boolean;
|
|
36
|
-
/**
|
|
37
|
-
|
|
38
|
+
/**
|
|
39
|
+
* The origin reviewers connect to, `http://192.168.1.24:4780`, or `""` when nothing is
|
|
40
|
+
* shared. The port is the listener's own, which is why it is reported with its address
|
|
41
|
+
* rather than as a number the review could have guessed.
|
|
42
|
+
*/
|
|
43
|
+
origin: string;
|
|
38
44
|
/** Every shared link, oldest first. */
|
|
39
45
|
links: ShareLink[];
|
|
40
46
|
/** Why sharing is unavailable, when it is. */
|
|
@@ -53,35 +59,11 @@ export const NO_SHARE_REASON =
|
|
|
53
59
|
"This machine has no network address to share on. Reviewers can still use a loopback link on this computer.";
|
|
54
60
|
|
|
55
61
|
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
62
|
+
* The tokenized link for one reviewer: the origin the page answers on, its mount inside
|
|
63
|
+
* that origin, and the token the link carries.
|
|
58
64
|
*/
|
|
59
|
-
export function
|
|
60
|
-
|
|
61
|
-
for (const entries of Object.values(interfaces)) {
|
|
62
|
-
for (const entry of entries ?? []) {
|
|
63
|
-
if (entry.family !== "IPv4" || entry.internal) continue;
|
|
64
|
-
if (entry.address === "0.0.0.0" || entry.address.startsWith("169.254.")) continue;
|
|
65
|
-
addresses.add(entry.address);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
return [...addresses].sort(byAddress);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** Numeric IPv4 comparison, so `…1.9` sorts before `…1.24`. */
|
|
72
|
-
function byAddress(a: string, b: string): number {
|
|
73
|
-
const left = a.split(".").map(Number);
|
|
74
|
-
const right = b.split(".").map(Number);
|
|
75
|
-
for (let index = 0; index < 4; index++) {
|
|
76
|
-
const difference = (left[index] ?? 0) - (right[index] ?? 0);
|
|
77
|
-
if (difference !== 0) return difference;
|
|
78
|
-
}
|
|
79
|
-
return 0;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** The tokenized link for one address. */
|
|
83
|
-
export function shareUrl(address: string, port: number, token: string): string {
|
|
84
|
-
return `http://${address}:${port}/?token=${token}`;
|
|
65
|
+
export function shareUrl(base: string, origin: string, token: string): string {
|
|
66
|
+
return `${origin}${base}/?token=${token}`;
|
|
85
67
|
}
|
|
86
68
|
|
|
87
69
|
/**
|
|
@@ -129,7 +111,7 @@ export interface LinkTable {
|
|
|
129
111
|
/** The links currently shared, oldest first. */
|
|
130
112
|
list(): readonly SharedLink[];
|
|
131
113
|
/** The state the owner's page and the terminal read. */
|
|
132
|
-
state(
|
|
114
|
+
state(base: string, origin: string, address: string): ShareState;
|
|
133
115
|
}
|
|
134
116
|
|
|
135
117
|
export function createLinkTable(deps: LinkTableDeps): LinkTable {
|
|
@@ -152,15 +134,15 @@ export function createLinkTable(deps: LinkTableDeps): LinkTable {
|
|
|
152
134
|
return true;
|
|
153
135
|
},
|
|
154
136
|
list: () => [...links],
|
|
155
|
-
state: (
|
|
137
|
+
state: (base, origin, address) => ({
|
|
156
138
|
sharing: links.length > 0,
|
|
157
|
-
|
|
139
|
+
origin,
|
|
158
140
|
links: links.map((link) => {
|
|
159
141
|
const name = deps.nameOf(link.token);
|
|
160
142
|
return {
|
|
161
143
|
id: link.id,
|
|
162
144
|
address,
|
|
163
|
-
url: shareUrl(
|
|
145
|
+
url: shareUrl(base, origin, link.token),
|
|
164
146
|
...(name === undefined ? {} : { name }),
|
|
165
147
|
};
|
|
166
148
|
}),
|