@gavin-hu/my-pi-agent 0.15.0 → 0.16.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 +12 -11
- package/README.zh-CN.md +9 -7
- package/extensions/bytefly/README.md +98 -116
- package/extensions/bytefly/notice/component.ts +6 -4
- package/extensions/bytefly/notice/config.ts +16 -2
- package/extensions/bytefly/panel/format.ts +3 -8
- package/extensions/bytefly/panel/join.ts +1 -6
- package/extensions/bytefly/panel/settings.ts +74 -377
- package/extensions/bytefly/panel/tui/entries.ts +16 -14
- package/extensions/bytefly/panel/tui/extensions-view.ts +3 -0
- package/extensions/bytefly/panel/tui/frame.ts +2 -2
- package/extensions/bytefly/panel/tui/settings-view.ts +8 -12
- package/extensions/bytefly/panel/tui.ts +4 -6
- package/extensions/bytefly/picker/resources.ts +7 -3
- package/extensions/bytefly/working/register.ts +2 -8
- package/extensions/check/tools.ts +2 -11
- package/extensions/context/README.md +162 -0
- package/extensions/context/collect.ts +53 -0
- package/extensions/context/commands.ts +100 -0
- package/extensions/context/format.ts +82 -0
- package/extensions/context/index.ts +16 -0
- package/extensions/context/report.ts +319 -0
- package/extensions/context/tui.ts +268 -0
- package/extensions/context/types.ts +122 -0
- package/extensions/file-browse/README.md +83 -8
- package/extensions/file-browse/client.ts +3 -1
- package/extensions/file-browse/config.ts +23 -0
- package/extensions/file-browse/files.ts +183 -48
- package/extensions/file-browse/fuzzy.ts +65 -11
- package/extensions/file-browse/html.ts +78 -133
- package/extensions/file-browse/index.ts +17 -2
- package/extensions/file-browse/live-client.ts +158 -0
- package/extensions/file-browse/live.ts +256 -0
- package/extensions/file-browse/markdown.ts +3 -10
- package/extensions/file-browse/raw-html.ts +3 -10
- package/extensions/file-browse/router.ts +33 -8
- package/extensions/file-browse/server.ts +78 -13
- package/extensions/file-browse/styles.ts +124 -0
- package/extensions/file-open/README.md +1 -2
- package/extensions/{document → file-read}/README.md +72 -56
- package/extensions/{document → file-read}/config.ts +12 -9
- package/extensions/{document → file-read}/extract/doc.ts +2 -2
- package/extensions/{document → file-read}/extract/docx.ts +3 -3
- package/extensions/{document → file-read}/extract/file.ts +9 -9
- package/extensions/{document → file-read}/extract/odt.ts +3 -3
- package/extensions/{document → file-read}/extract/pdf.ts +3 -3
- package/extensions/{document → file-read}/extract/rtf.ts +3 -3
- package/extensions/{document → file-read}/extract/xlsx.ts +4 -4
- package/extensions/{document → file-read}/formats.ts +11 -11
- package/extensions/file-read/index.ts +17 -0
- package/extensions/{document → file-read}/paging.ts +15 -13
- package/extensions/{document → file-read}/render.ts +17 -13
- package/extensions/{document → file-read}/schema.ts +7 -7
- package/extensions/{document → file-read}/tool.ts +36 -28
- package/extensions/goal/tools.ts +10 -14
- package/extensions/job/commands.ts +32 -46
- package/extensions/job/runtime.ts +6 -8
- package/extensions/job/tools.ts +7 -26
- package/extensions/memory/commands.ts +30 -44
- package/extensions/memory/tools.ts +6 -14
- package/extensions/plan/tools/enter.ts +6 -7
- package/extensions/plan/tools/exit.ts +6 -7
- package/extensions/plan/tools/steps.ts +6 -7
- package/extensions/plan/tools/write.ts +8 -9
- package/extensions/plan/web-router.ts +2 -12
- package/extensions/plan/web.ts +30 -15
- package/extensions/question/tools.ts +5 -4
- package/extensions/rewind/checklist.ts +10 -3
- package/extensions/rewind/git.ts +27 -14
- package/extensions/rewind/hunk-picker.ts +1 -1
- package/extensions/rewind/path-picker.ts +1 -1
- package/extensions/rewind/review-tui.ts +3 -3
- package/extensions/rewind/snapshot.ts +13 -4
- package/extensions/subagent/background-tui.ts +1 -1
- package/extensions/subagent/background.ts +1 -7
- package/extensions/todo/tools.ts +10 -14
- package/extensions/watch/README.md +5 -5
- package/extensions/watch/commands.ts +33 -47
- package/extensions/watch/runtime.ts +5 -7
- package/extensions/watch/tools.ts +7 -18
- package/extensions/web-access/README.md +3 -3
- package/extensions/web-access/search/search.ts +9 -18
- package/extensions/wechat/timer.ts +9 -30
- package/extensions/wechat/tools.ts +4 -7
- package/extensions/worktree/status.ts +14 -9
- package/lib/README.md +12 -8
- package/lib/escape.ts +15 -2
- package/lib/git/read.ts +20 -0
- package/lib/http/headers.ts +20 -0
- package/lib/http/server.ts +22 -4
- package/lib/pdf.ts +2 -2
- package/lib/pool.ts +62 -0
- package/lib/requirements.ts +2 -2
- package/lib/screen-session.ts +85 -0
- package/lib/timer.ts +61 -0
- package/lib/tui/README.md +3 -3
- package/lib/tui/columns.ts +33 -8
- package/lib/tui/dock-screen.ts +12 -4
- package/lib/tui/rows.ts +15 -6
- package/lib/tui/rules.ts +4 -8
- package/lib/tui/tool.ts +55 -2
- package/package.json +8 -4
- package/skills/changelog/SKILL.md +136 -0
- package/extensions/document/index.ts +0 -17
- /package/extensions/{document → file-read}/deadline.ts +0 -0
- /package/extensions/{document → file-read}/extract/vendor.d.ts +0 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live status for the browse pages.
|
|
3
|
+
*
|
|
4
|
+
* A page that stays open holds one event stream; this module builds the snapshot
|
|
5
|
+
* it is sent, the frame that carries it, and the channel that fans it out on a
|
|
6
|
+
* tick. The git and diff providers already memoize, so a tick re-reads at most
|
|
7
|
+
* one `git status`, and every read failure degrades to `null`: a status feed
|
|
8
|
+
* must never be able to fail a page or the server that serves it.
|
|
9
|
+
*
|
|
10
|
+
* The channel opens no socket of its own — the caller hands it a `write`/`end`
|
|
11
|
+
* pair — so the whole feed is tested without one.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { defaultInterval, defaultTimer } from "../../lib/timer.ts";
|
|
15
|
+
import { changeDirs } from "./diff.ts";
|
|
16
|
+
import type { DiffList, GitDiffProvider, GitStatus, GitStatusProvider } from "./git.ts";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The changed-path index the pages render from: the totals the changes chip
|
|
20
|
+
* prints and the paths the tree and listing mark.
|
|
21
|
+
*/
|
|
22
|
+
export interface ChangeIndex {
|
|
23
|
+
/** Changed paths in the scope. */
|
|
24
|
+
count: number;
|
|
25
|
+
/** Added lines in the scope. */
|
|
26
|
+
insertions: number;
|
|
27
|
+
/** Removed lines in the scope. */
|
|
28
|
+
deletions: number;
|
|
29
|
+
/** Changed files, root-relative. */
|
|
30
|
+
files: ReadonlySet<string>;
|
|
31
|
+
/** Directories that contain a changed file. */
|
|
32
|
+
dirs: ReadonlySet<string>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The marker/totals index a change list yields. */
|
|
36
|
+
export function changeIndexOf(list: DiffList): ChangeIndex {
|
|
37
|
+
return {
|
|
38
|
+
count: list.files.length,
|
|
39
|
+
insertions: list.insertions,
|
|
40
|
+
deletions: list.deletions,
|
|
41
|
+
files: new Set(list.files.map((file) => file.rel)),
|
|
42
|
+
dirs: changeDirs(list.files),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A change index as it goes over the wire: sets become ordered lists. */
|
|
47
|
+
export interface LiveChanges {
|
|
48
|
+
count: number;
|
|
49
|
+
insertions: number;
|
|
50
|
+
deletions: number;
|
|
51
|
+
files: string[];
|
|
52
|
+
dirs: string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One status frame's payload. */
|
|
56
|
+
export interface LiveSnapshot {
|
|
57
|
+
/** Server wall clock when the snapshot was taken, in ms. */
|
|
58
|
+
at: number;
|
|
59
|
+
/** How long the server has been listening, in ms. */
|
|
60
|
+
uptimeMs: number;
|
|
61
|
+
/** Requests the server has answered so far, this one included. */
|
|
62
|
+
requests: number;
|
|
63
|
+
/** Branch or detached head, or `null` outside a repository. */
|
|
64
|
+
git: GitStatus | null;
|
|
65
|
+
/** Uncommitted changes against `HEAD`, or `null` when unavailable. */
|
|
66
|
+
changes: LiveChanges | null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The snapshot a streamed frame is built from. */
|
|
70
|
+
export interface LiveStatusSource {
|
|
71
|
+
snapshot(): Promise<LiveSnapshot>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface LiveStatusOptions {
|
|
75
|
+
/** Read-only git context; omitted when the page has none. */
|
|
76
|
+
git?: GitStatusProvider;
|
|
77
|
+
/** Read-only diff provider; omitted when the diff view is off. */
|
|
78
|
+
diff?: GitDiffProvider;
|
|
79
|
+
/** When the server started listening, in ms. */
|
|
80
|
+
startedAt: number;
|
|
81
|
+
/** Requests answered so far, read per snapshot. */
|
|
82
|
+
requests: () => number;
|
|
83
|
+
/** Wall-clock seam; defaults to the real clock. */
|
|
84
|
+
now?: () => number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Read a provider, turning a rejection or an absent value into `null`. */
|
|
88
|
+
async function settled<T>(read: () => Promise<T | undefined>): Promise<T | null> {
|
|
89
|
+
try {
|
|
90
|
+
return (await read()) ?? null;
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Build the status source for one server. `snapshot` never rejects: a
|
|
98
|
+
* non-repository, a failed git call, or a timeout simply leaves that field
|
|
99
|
+
* `null`, and the page keeps whatever it last showed.
|
|
100
|
+
*/
|
|
101
|
+
export function createLiveStatus(options: LiveStatusOptions): LiveStatusSource {
|
|
102
|
+
const now = options.now ?? (() => Date.now());
|
|
103
|
+
const diff = options.diff;
|
|
104
|
+
return {
|
|
105
|
+
async snapshot() {
|
|
106
|
+
const at = now();
|
|
107
|
+
const git = options.git ? await settled(options.git) : null;
|
|
108
|
+
const list = diff ? await settled(() => diff.list("head")) : null;
|
|
109
|
+
const index = list ? changeIndexOf(list) : null;
|
|
110
|
+
return {
|
|
111
|
+
at,
|
|
112
|
+
uptimeMs: Math.max(0, at - options.startedAt),
|
|
113
|
+
requests: options.requests(),
|
|
114
|
+
git,
|
|
115
|
+
changes: index
|
|
116
|
+
? {
|
|
117
|
+
count: index.count,
|
|
118
|
+
insertions: index.insertions,
|
|
119
|
+
deletions: index.deletions,
|
|
120
|
+
files: [...index.files].sort(),
|
|
121
|
+
dirs: [...index.dirs].sort(),
|
|
122
|
+
}
|
|
123
|
+
: null,
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** One server-sent event carrying a snapshot. */
|
|
130
|
+
export function frameOf(snapshot: LiveSnapshot): string {
|
|
131
|
+
return `event: status\ndata: ${JSON.stringify(snapshot)}\n\n`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** A connected page: the two things a server can do to its stream. */
|
|
135
|
+
export interface LiveClient {
|
|
136
|
+
/** Write one frame. */
|
|
137
|
+
write(frame: string): void;
|
|
138
|
+
/** End the stream. */
|
|
139
|
+
end(): void;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Starts a repeating tick and returns its cancel. */
|
|
143
|
+
export type LiveTimer = (tick: () => void, intervalMs: number) => () => void;
|
|
144
|
+
|
|
145
|
+
/** Starts a one-shot delay and returns its cancel. */
|
|
146
|
+
export type LiveTimeout = (run: () => void, delayMs: number) => () => void;
|
|
147
|
+
|
|
148
|
+
/** The default ticker: a repeating timer that cannot hold the process open. */
|
|
149
|
+
const defaultTicker: LiveTimer = (tick, intervalMs) => defaultInterval(intervalMs, tick);
|
|
150
|
+
|
|
151
|
+
/** The default grace timer: one shot, and it cannot hold the process open either. */
|
|
152
|
+
const defaultTimeout: LiveTimeout = (run, delayMs) => defaultTimer(delayMs, run);
|
|
153
|
+
|
|
154
|
+
export interface LiveChannelOptions {
|
|
155
|
+
/** What each frame carries. */
|
|
156
|
+
status: LiveStatusSource;
|
|
157
|
+
/** How far apart the frames are, in ms. */
|
|
158
|
+
intervalMs: number;
|
|
159
|
+
/** How long the last page's departure is held before `onIdle` is called, in ms. */
|
|
160
|
+
idleMs: number;
|
|
161
|
+
/** Called once the last page has been gone for `idleMs`; omitted, the channel only idles. */
|
|
162
|
+
onIdle?: () => void;
|
|
163
|
+
/** Timer seam; defaults to an unref'd interval. */
|
|
164
|
+
timer?: LiveTimer;
|
|
165
|
+
/** Grace seam; defaults to an unref'd timeout. */
|
|
166
|
+
timeout?: LiveTimeout;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The fan-out for one server: pages attach, ticks publish, close ends them. */
|
|
170
|
+
export interface LiveChannel {
|
|
171
|
+
/** Add a connected page, send it the current status, and return its detach. */
|
|
172
|
+
attach(client: LiveClient): () => void;
|
|
173
|
+
/** End every connected page, stop the ticker, and drop a pending idle stop. */
|
|
174
|
+
close(): void;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Build a channel for one server. The ticker runs only while at least one page
|
|
179
|
+
* is attached, so an idle server does no status work at all; the idle stop is one
|
|
180
|
+
* timer armed when the last page leaves and cancelled by the next page, so a
|
|
181
|
+
* reload keeps the server and a closed window releases it.
|
|
182
|
+
*/
|
|
183
|
+
export function createLiveChannel(options: LiveChannelOptions): LiveChannel {
|
|
184
|
+
const timer = options.timer ?? defaultTicker;
|
|
185
|
+
const timeout = options.timeout ?? defaultTimeout;
|
|
186
|
+
const clients = new Set<LiveClient>();
|
|
187
|
+
let cancel: (() => void) | undefined;
|
|
188
|
+
let cancelIdle: (() => void) | undefined;
|
|
189
|
+
let inFlight = false;
|
|
190
|
+
|
|
191
|
+
const stop = (): void => {
|
|
192
|
+
cancel?.();
|
|
193
|
+
cancel = undefined;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const cancelIdleStop = (): void => {
|
|
197
|
+
cancelIdle?.();
|
|
198
|
+
cancelIdle = undefined;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const armIdleStop = (): void => {
|
|
202
|
+
if (!options.onIdle || cancelIdle) return;
|
|
203
|
+
cancelIdle = timeout(() => {
|
|
204
|
+
cancelIdle = undefined;
|
|
205
|
+
options.onIdle?.();
|
|
206
|
+
}, options.idleMs);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/** Nobody is reading a frame any more: park the ticker and start the grace. */
|
|
210
|
+
const pageLeft = (): void => {
|
|
211
|
+
stop();
|
|
212
|
+
armIdleStop();
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const send = (frame: string): void => {
|
|
216
|
+
for (const client of clients) {
|
|
217
|
+
try {
|
|
218
|
+
client.write(frame);
|
|
219
|
+
} catch {
|
|
220
|
+
clients.delete(client);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (clients.size === 0) pageLeft();
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
// One snapshot serves every attached page, so a tick is one git read and one
|
|
227
|
+
// serialization however many tabs are open.
|
|
228
|
+
const publish = async (): Promise<void> => {
|
|
229
|
+
if (inFlight) return;
|
|
230
|
+
inFlight = true;
|
|
231
|
+
try {
|
|
232
|
+
send(frameOf(await options.status.snapshot()));
|
|
233
|
+
} finally {
|
|
234
|
+
inFlight = false;
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
attach(client) {
|
|
240
|
+
cancelIdleStop();
|
|
241
|
+
clients.add(client);
|
|
242
|
+
cancel ??= timer(() => void publish(), options.intervalMs);
|
|
243
|
+
void publish();
|
|
244
|
+
return () => {
|
|
245
|
+
if (!clients.delete(client)) return;
|
|
246
|
+
if (clients.size === 0) pageLeft();
|
|
247
|
+
};
|
|
248
|
+
},
|
|
249
|
+
close() {
|
|
250
|
+
cancelIdleStop();
|
|
251
|
+
stop();
|
|
252
|
+
for (const client of clients) client.end();
|
|
253
|
+
clients.clear();
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* policy and raw HTML do not.
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
import { escapeHtml } from "../../lib/escape.ts";
|
|
28
|
+
import { decodeCodePoint, escapeHtml } from "../../lib/escape.ts";
|
|
29
29
|
import { fenceLanguage, highlightLines } from "../../lib/highlight.ts";
|
|
30
30
|
import {
|
|
31
31
|
closesSuppressed,
|
|
@@ -739,13 +739,6 @@ interface Entity {
|
|
|
739
739
|
length: number;
|
|
740
740
|
}
|
|
741
741
|
|
|
742
|
-
/** A code point, or U+FFFD when the reference is a surrogate or out of range. */
|
|
743
|
-
function codePoint(value: number): string {
|
|
744
|
-
if (!Number.isFinite(value) || value <= 0 || value > 0x10ffff || (value >= 0xd800 && value <= 0xdfff))
|
|
745
|
-
return "\uFFFD";
|
|
746
|
-
return String.fromCodePoint(value);
|
|
747
|
-
}
|
|
748
|
-
|
|
749
742
|
/**
|
|
750
743
|
* Match a numeric or named character reference at `start`. Decoding happens in
|
|
751
744
|
* the inline scanner rather than a pre-pass so an entity cannot become Markdown
|
|
@@ -755,8 +748,8 @@ function matchEntity(text: string, start: number): Entity | undefined {
|
|
|
755
748
|
const match = /^&(?:#([0-9]{1,7})|#[xX]([0-9a-fA-F]{1,6})|([a-zA-Z][a-zA-Z0-9]{1,31}));/.exec(text.slice(start));
|
|
756
749
|
if (!match) return undefined;
|
|
757
750
|
let value: string | undefined;
|
|
758
|
-
if (match[1] !== undefined) value =
|
|
759
|
-
else if (match[2] !== undefined) value =
|
|
751
|
+
if (match[1] !== undefined) value = decodeCodePoint(Number(match[1]));
|
|
752
|
+
else if (match[2] !== undefined) value = decodeCodePoint(Number.parseInt(match[2], 16));
|
|
760
753
|
else if (match[3] !== undefined && Object.hasOwn(NAMED_ENTITIES, match[3])) value = NAMED_ENTITIES[match[3]];
|
|
761
754
|
if (value === undefined) return undefined;
|
|
762
755
|
return { value, length: match[0].length };
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* how `<details>` wraps Markdown.
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
import { escapeHtml } from "../../lib/escape.ts";
|
|
28
|
+
import { decodeCodePoint, escapeHtml } from "../../lib/escape.ts";
|
|
29
29
|
import { resolveImage, resolveLink } from "./urls.ts";
|
|
30
30
|
|
|
31
31
|
/** Elements re-emitted with sanitized attributes. */
|
|
@@ -353,21 +353,14 @@ function decodeAttribute(value: string): string {
|
|
|
353
353
|
return value.replace(
|
|
354
354
|
ATTRIBUTE_ENTITY,
|
|
355
355
|
(match, decimal: string | undefined, hex: string | undefined, named: string | undefined) => {
|
|
356
|
-
if (decimal !== undefined) return
|
|
357
|
-
if (hex !== undefined) return
|
|
356
|
+
if (decimal !== undefined) return decodeCodePoint(Number(decimal));
|
|
357
|
+
if (hex !== undefined) return decodeCodePoint(Number.parseInt(hex, 16));
|
|
358
358
|
if (named !== undefined && Object.hasOwn(ATTRIBUTE_ENTITIES, named)) return ATTRIBUTE_ENTITIES[named];
|
|
359
359
|
return match;
|
|
360
360
|
},
|
|
361
361
|
);
|
|
362
362
|
}
|
|
363
363
|
|
|
364
|
-
/** A code point, or U+FFFD when the reference is a surrogate or out of range. */
|
|
365
|
-
function codePoint(value: number): string {
|
|
366
|
-
if (!Number.isFinite(value) || value <= 0 || value > 0x10ffff || (value >= 0xd800 && value <= 0xdfff))
|
|
367
|
-
return "\uFFFD";
|
|
368
|
-
return String.fromCodePoint(value);
|
|
369
|
-
}
|
|
370
|
-
|
|
371
364
|
/** The first value for `name`, decoded, so a duplicate attribute cannot override it. */
|
|
372
365
|
function attribute(tag: RawTag, name: string): string | undefined {
|
|
373
366
|
const found = tag.attrs.find((candidate) => candidate.name === name);
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import type { Stats } from "node:fs";
|
|
11
11
|
import { stat } from "node:fs/promises";
|
|
12
12
|
import {
|
|
13
|
+
eventHeaders,
|
|
13
14
|
htmlResponse,
|
|
14
15
|
isLocalHostHeader,
|
|
15
16
|
jsonError,
|
|
@@ -22,12 +23,14 @@ import {
|
|
|
22
23
|
import { THEME_JS } from "../../lib/web/index.ts";
|
|
23
24
|
import { CLIENT_JS } from "./client.ts";
|
|
24
25
|
import type { ServeConfig } from "./config.ts";
|
|
25
|
-
import {
|
|
26
|
+
import { isDiffMode, type DiffMode } from "./diff.ts";
|
|
26
27
|
import type { DiffList, GitDiffProvider, GitStatus, GitStatusProvider } from "./git.ts";
|
|
28
|
+
import { changeIndexOf, type LiveStatusSource } from "./live.ts";
|
|
27
29
|
import {
|
|
28
30
|
buildFileIndex,
|
|
29
31
|
buildTree,
|
|
30
32
|
contentTypeFor,
|
|
33
|
+
directoryView,
|
|
31
34
|
isImageName,
|
|
32
35
|
listDirectory,
|
|
33
36
|
readFileView,
|
|
@@ -50,6 +53,8 @@ export interface ServeContext {
|
|
|
50
53
|
diff?: GitDiffProvider;
|
|
51
54
|
/** Cached quick-open file index; built on demand when omitted. */
|
|
52
55
|
index?: FileIndexProvider;
|
|
56
|
+
/** Snapshot source for the live status channel; omitted when live status is off. */
|
|
57
|
+
live?: LiveStatusSource;
|
|
53
58
|
}
|
|
54
59
|
|
|
55
60
|
/** Most palette rows the client will render for one query. */
|
|
@@ -93,15 +98,21 @@ async function diffFor(ctx: ServeContext, mode: DiffMode) {
|
|
|
93
98
|
|
|
94
99
|
/** The marker/totals index derived from a change list. */
|
|
95
100
|
function summaryOf(list: DiffList): ChangeSummary {
|
|
101
|
+
const index = changeIndexOf(list);
|
|
96
102
|
return {
|
|
97
|
-
count:
|
|
98
|
-
insertions:
|
|
99
|
-
deletions:
|
|
100
|
-
files:
|
|
101
|
-
dirs:
|
|
103
|
+
count: index.count,
|
|
104
|
+
insertions: index.insertions,
|
|
105
|
+
deletions: index.deletions,
|
|
106
|
+
files: index.files,
|
|
107
|
+
dirs: index.dirs,
|
|
102
108
|
};
|
|
103
109
|
}
|
|
104
110
|
|
|
111
|
+
/** The live chrome a page renders, or `undefined` when live status is off. */
|
|
112
|
+
function liveOf(ctx: ServeContext): { intervalMs: number } | undefined {
|
|
113
|
+
return ctx.config.live && ctx.live ? { intervalMs: ctx.config.liveIntervalMs } : undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
105
116
|
/** The `head` change summary, for the header chip, tree markers, and file action. */
|
|
106
117
|
async function changed(ctx: ServeContext): Promise<ChangeSummary | undefined> {
|
|
107
118
|
const list = await diffFor(ctx, "head");
|
|
@@ -135,6 +146,7 @@ async function errorPage(
|
|
|
135
146
|
message,
|
|
136
147
|
tree,
|
|
137
148
|
git: await gitFor(ctx),
|
|
149
|
+
live: liveOf(ctx),
|
|
138
150
|
});
|
|
139
151
|
return htmlResponse(status, body, extra);
|
|
140
152
|
}
|
|
@@ -158,8 +170,7 @@ async function view(ctx: ServeContext, rawRel: string, raw: boolean): Promise<Se
|
|
|
158
170
|
}
|
|
159
171
|
|
|
160
172
|
async function directoryResponse(ctx: ServeContext, rel: string): Promise<ServeResponse> {
|
|
161
|
-
const listing = await
|
|
162
|
-
const tree = await treeFor(ctx, rel);
|
|
173
|
+
const { listing, tree } = await directoryView(ctx.root, rel, ctx.config.maxDirEntries);
|
|
163
174
|
const git = await gitFor(ctx);
|
|
164
175
|
const changes = await changed(ctx);
|
|
165
176
|
return htmlResponse(
|
|
@@ -173,6 +184,7 @@ async function directoryResponse(ctx: ServeContext, rel: string): Promise<ServeR
|
|
|
173
184
|
maxThumbBytes: ctx.config.maxThumbBytes,
|
|
174
185
|
git,
|
|
175
186
|
summary: changes,
|
|
187
|
+
live: liveOf(ctx),
|
|
176
188
|
}),
|
|
177
189
|
);
|
|
178
190
|
}
|
|
@@ -195,6 +207,7 @@ async function fileResponse(ctx: ServeContext, rel: string, raw: boolean): Promi
|
|
|
195
207
|
markdownHtml: ctx.config.markdownHtml,
|
|
196
208
|
changed: changes?.files.has(rel),
|
|
197
209
|
summary: changes,
|
|
210
|
+
live: liveOf(ctx),
|
|
198
211
|
}),
|
|
199
212
|
);
|
|
200
213
|
}
|
|
@@ -215,6 +228,7 @@ async function diffOverview(ctx: ServeContext, mode: DiffMode): Promise<ServeRes
|
|
|
215
228
|
tree,
|
|
216
229
|
git,
|
|
217
230
|
summary: list ? summaryOf(list) : undefined,
|
|
231
|
+
live: liveOf(ctx),
|
|
218
232
|
}),
|
|
219
233
|
);
|
|
220
234
|
}
|
|
@@ -246,6 +260,7 @@ async function diffFile(ctx: ServeContext, rawRel: string, mode: DiffMode): Prom
|
|
|
246
260
|
tree,
|
|
247
261
|
git,
|
|
248
262
|
summary: changes,
|
|
263
|
+
live: liveOf(ctx),
|
|
249
264
|
}),
|
|
250
265
|
);
|
|
251
266
|
}
|
|
@@ -304,6 +319,15 @@ async function filesApi(ctx: ServeContext, query: string): Promise<ServeResponse
|
|
|
304
319
|
return jsonResponse(200, { query, total: index.files.length, truncated: index.truncated, results });
|
|
305
320
|
}
|
|
306
321
|
|
|
322
|
+
/**
|
|
323
|
+
* The live status stream: a marker response the server in `server.ts` keeps open
|
|
324
|
+
* and feeds from the same source.
|
|
325
|
+
*/
|
|
326
|
+
function liveEvents(ctx: ServeContext): ServeResponse {
|
|
327
|
+
if (!ctx.config.live || !ctx.live) throw new HttpError(404, "Live status is not available.");
|
|
328
|
+
return { status: 200, headers: eventHeaders(), stream: true };
|
|
329
|
+
}
|
|
330
|
+
|
|
307
331
|
/**
|
|
308
332
|
* Handle one request. `method` and `headers` are the raw HTTP values; `rawUrl`
|
|
309
333
|
* is the request target (path plus optional query).
|
|
@@ -332,6 +356,7 @@ export async function handleRequest(
|
|
|
332
356
|
if (pathname === "/favicon.ico") return { status: 204, headers: pageHeaders(), body: "" };
|
|
333
357
|
if (pathname === "/api/tree") return await treeApi(ctx, search.get("path") ?? "");
|
|
334
358
|
if (pathname === "/api/files") return await filesApi(ctx, search.get("q") ?? "");
|
|
359
|
+
if (pathname === "/events") return liveEvents(ctx);
|
|
335
360
|
const rawMode = search.get("mode");
|
|
336
361
|
if (rawMode !== null && !isDiffMode(rawMode)) return errorPage(ctx, 400, "Unknown diff mode.");
|
|
337
362
|
const mode: DiffMode = isDiffMode(rawMode) ? rawMode : "head";
|
|
@@ -2,21 +2,30 @@
|
|
|
2
2
|
* The HTTP server for the browse extension.
|
|
3
3
|
*
|
|
4
4
|
* Wraps `node:http` around the pure `handleRequest`: it resolves the root once,
|
|
5
|
-
* binds `127.0.0.1`, streams `/raw` responses from disk,
|
|
6
|
-
* idempotent `close()`. The request handler is injectable
|
|
7
|
-
* socket when they only need routing.
|
|
5
|
+
* binds `127.0.0.1`, streams `/raw` responses from disk, feeds the live status
|
|
6
|
+
* channel, and exposes an idempotent `close()`. The request handler is injectable
|
|
7
|
+
* so tests can avoid a socket when they only need routing.
|
|
8
8
|
*
|
|
9
|
-
* The bind, the response writer, and the close come from
|
|
10
|
-
* shares; what stays here is this server's own request
|
|
11
|
-
* turns a thrown handler into a
|
|
9
|
+
* The bind, the response writer, the event-stream head, and the close come from
|
|
10
|
+
* `lib/http`, which `plan` shares; what stays here is this server's own request
|
|
11
|
+
* handler, its request count, and the catch that turns a thrown handler into a
|
|
12
|
+
* plain-text 500.
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
15
|
import { realpathSync } from "node:fs";
|
|
15
16
|
import { createServer } from "node:http";
|
|
16
|
-
import {
|
|
17
|
-
|
|
17
|
+
import {
|
|
18
|
+
bindLoopback,
|
|
19
|
+
createCloser,
|
|
20
|
+
openEventStream,
|
|
21
|
+
respond,
|
|
22
|
+
textResponse,
|
|
23
|
+
type ServeResponse,
|
|
24
|
+
} from "../../lib/http/index.ts";
|
|
25
|
+
import { IDLE_STOP_MS, type ServeConfig } from "./config.ts";
|
|
18
26
|
import { createFileIndex } from "./files.ts";
|
|
19
27
|
import type { GitDiffProvider, GitStatusProvider } from "./git.ts";
|
|
28
|
+
import { createLiveChannel, createLiveStatus } from "./live.ts";
|
|
20
29
|
import { handleRequest, type ServeContext } from "./router.ts";
|
|
21
30
|
|
|
22
31
|
export interface FileServer {
|
|
@@ -37,6 +46,10 @@ export interface FileServerOptions {
|
|
|
37
46
|
git?: GitStatusProvider;
|
|
38
47
|
/** Read-only diff provider for the change and diff views; omitted when unavailable. */
|
|
39
48
|
diff?: GitDiffProvider;
|
|
49
|
+
/** Called once the last page has been gone for `idleStopMs`; omitted, the server waits for `close()`. */
|
|
50
|
+
onIdle?: () => void;
|
|
51
|
+
/** Grace before `onIdle`, in ms; defaults to `IDLE_STOP_MS`. */
|
|
52
|
+
idleStopMs?: number;
|
|
40
53
|
/** Override the request handler (tests). */
|
|
41
54
|
handler?: typeof handleRequest;
|
|
42
55
|
}
|
|
@@ -44,16 +57,38 @@ export interface FileServerOptions {
|
|
|
44
57
|
/** Start a read-only server rooted at `options.root` on 127.0.0.1. */
|
|
45
58
|
export async function createFileServer(options: FileServerOptions): Promise<FileServer> {
|
|
46
59
|
const root = realpathSync.native(options.root);
|
|
60
|
+
const { config } = options;
|
|
61
|
+
// The status feed's two server-owned facts: how long it has been up, and how
|
|
62
|
+
// many requests it has answered. Both are read per frame, never cached.
|
|
63
|
+
let requests = 0;
|
|
64
|
+
const status = config.live
|
|
65
|
+
? createLiveStatus({
|
|
66
|
+
git: options.git,
|
|
67
|
+
diff: config.gitDiff ? options.diff : undefined,
|
|
68
|
+
startedAt: Date.now(),
|
|
69
|
+
requests: () => requests,
|
|
70
|
+
})
|
|
71
|
+
: undefined;
|
|
72
|
+
const live = status
|
|
73
|
+
? createLiveChannel({
|
|
74
|
+
status,
|
|
75
|
+
intervalMs: config.liveIntervalMs,
|
|
76
|
+
idleMs: options.idleStopMs ?? IDLE_STOP_MS,
|
|
77
|
+
onIdle: options.onIdle,
|
|
78
|
+
})
|
|
79
|
+
: undefined;
|
|
47
80
|
const context: ServeContext = {
|
|
48
81
|
root,
|
|
49
|
-
config
|
|
82
|
+
config,
|
|
50
83
|
git: options.git,
|
|
51
84
|
diff: options.diff,
|
|
52
|
-
index: createFileIndex(root,
|
|
85
|
+
index: createFileIndex(root, config.maxIndexEntries),
|
|
86
|
+
live: status,
|
|
53
87
|
};
|
|
54
88
|
const handler = options.handler ?? handleRequest;
|
|
55
89
|
|
|
56
90
|
const server = createServer((request, response) => {
|
|
91
|
+
requests += 1;
|
|
57
92
|
const answer = async (): Promise<ServeResponse> => {
|
|
58
93
|
try {
|
|
59
94
|
return await handler(context, request.method ?? "GET", request.url ?? "/", request.headers);
|
|
@@ -61,9 +96,39 @@ export async function createFileServer(options: FileServerOptions): Promise<File
|
|
|
61
96
|
return textResponse(500, "Internal error");
|
|
62
97
|
}
|
|
63
98
|
};
|
|
64
|
-
void answer().then((result) =>
|
|
99
|
+
void answer().then((result) => {
|
|
100
|
+
// A HEAD of the stream is answered as a plain response: there is no body to
|
|
101
|
+
// hold open, and the client would otherwise wait on frames it asked not to get.
|
|
102
|
+
if (!result.stream || !live || (request.method ?? "GET").toUpperCase() === "HEAD") {
|
|
103
|
+
respond(request, response, result);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
openEventStream(response, result);
|
|
107
|
+
const detach = live.attach({
|
|
108
|
+
write: (frame) => {
|
|
109
|
+
response.write(frame);
|
|
110
|
+
},
|
|
111
|
+
end: () => {
|
|
112
|
+
if (!response.writableEnded) response.end();
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
request.on("close", () => {
|
|
116
|
+
detach();
|
|
117
|
+
if (!response.writableEnded) response.end();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
65
120
|
});
|
|
66
121
|
|
|
67
|
-
const port = await bindLoopback(server,
|
|
68
|
-
|
|
122
|
+
const port = await bindLoopback(server, config.port);
|
|
123
|
+
const closeServer = createCloser(server);
|
|
124
|
+
return {
|
|
125
|
+
url: `http://127.0.0.1:${port}/`,
|
|
126
|
+
port,
|
|
127
|
+
root,
|
|
128
|
+
close: async () => {
|
|
129
|
+
// End the streams first, so a connected page does not hold the close open.
|
|
130
|
+
live?.close();
|
|
131
|
+
await closeServer();
|
|
132
|
+
},
|
|
133
|
+
};
|
|
69
134
|
}
|