pi-weave 0.1.7 → 0.1.8
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 +114 -34
- package/package.json +16 -4
- package/src/core/cache/workspace.ts +466 -0
- package/src/core/frontmatter.ts +217 -23
- package/src/core/git.ts +19 -0
- package/src/core/graph/build.ts +37 -4
- package/src/core/graph/current.ts +41 -28
- package/src/core/graph/mentions.ts +170 -0
- package/src/core/graph/model.ts +24 -0
- package/src/core/index.ts +12 -0
- package/src/core/openInEditor.ts +69 -0
- package/src/core/types.ts +40 -0
- package/src/core/vault.ts +477 -43
- package/src/core/view/cluster.ts +262 -0
- package/src/core/view/detail.ts +118 -0
- package/src/core/view/focus.ts +109 -0
- package/src/core/view/health.ts +156 -0
- package/src/core/view/index.ts +15 -0
- package/src/core/view/links.ts +105 -0
- package/src/core/view/time.ts +47 -0
- package/src/core/view/tree.ts +269 -0
- package/src/core/view/types.ts +39 -0
- package/src/pi/index.ts +104 -11
- package/src/pi/viewer/tui/explorer.ts +4 -2
- package/src/pi/viewer/tui/model.ts +46 -667
- package/src/pi/viewer/tui/openNote.ts +7 -56
- package/src/pi/viewer/tui/surface/explore.ts +4 -2
- package/src/pi/viewer/web/run.ts +331 -0
- package/src/web/client/api.dom.ts +40 -0
- package/src/web/client/api.ts +472 -0
- package/src/web/client/bootstrap.ts +58 -0
- package/src/web/client/context/context.model.ts +313 -0
- package/src/web/client/dist/app.js +751 -0
- package/src/web/client/graph/Graph.tsx +158 -0
- package/src/web/client/graph/column.model.ts +431 -0
- package/src/web/client/graph/graph.model.ts +538 -0
- package/src/web/client/graph/positions.ts +339 -0
- package/src/web/client/graph/project.ts +153 -0
- package/src/web/client/graph/renderer.dom.ts +52 -0
- package/src/web/client/graph/renderer.ts +279 -0
- package/src/web/client/graph/scheme.ts +44 -0
- package/src/web/client/live.model.ts +275 -0
- package/src/web/client/live.ts +151 -0
- package/src/web/client/main.tsx +27 -0
- package/src/web/client/note/Editor.tsx +102 -0
- package/src/web/client/note/Note.tsx +113 -0
- package/src/web/client/note/editor.controller.ts +151 -0
- package/src/web/client/note/editor.model.ts +636 -0
- package/src/web/client/note/note.model.ts +738 -0
- package/src/web/client/search/SearchPalette.tsx +105 -0
- package/src/web/client/search/search.model.ts +588 -0
- package/src/web/client/search/search.ts +107 -0
- package/src/web/client/shell/Columns.tsx +161 -0
- package/src/web/client/shell/ContextRail.tsx +87 -0
- package/src/web/client/shell/Divider.tsx +44 -0
- package/src/web/client/shell/FocusTrap.tsx +56 -0
- package/src/web/client/shell/Header.tsx +54 -0
- package/src/web/client/shell/HelpOverlay.tsx +70 -0
- package/src/web/client/shell/Shell.tsx +193 -0
- package/src/web/client/shell/StatusBar.tsx +28 -0
- package/src/web/client/shell/cssvars.ts +70 -0
- package/src/web/client/shell/drag.model.ts +170 -0
- package/src/web/client/shell/focus.model.ts +100 -0
- package/src/web/client/shell/keys.model.ts +453 -0
- package/src/web/client/shell/keys.ts +59 -0
- package/src/web/client/shell/layout.model.ts +526 -0
- package/src/web/client/shell/shell.model.ts +333 -0
- package/src/web/client/shell/theme.ts +477 -0
- package/src/web/client/shell/viewport.ts +29 -0
- package/src/web/client/state.ts +78 -0
- package/src/web/client/tree/Tree.tsx +138 -0
- package/src/web/client/tree/tree.model.ts +674 -0
- package/src/web/client/workspace.ts +214 -0
- package/src/web/server/page.ts +256 -0
- package/src/web/server/routes.ts +975 -0
- package/src/web/server/security.ts +361 -0
- package/src/web/server/server.ts +275 -0
- package/src/web/server/sse.ts +321 -0
- package/src/web/server/watcher.ts +507 -0
- package/src/web/shared/graph.ts +206 -0
- package/src/web/shared/layout.ts +497 -0
- package/src/web/shared/metrics.ts +136 -0
- package/src/web/shared/view.ts +200 -0
- package/src/web/shared/wire.ts +358 -0
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The filesystem watcher — the "something changed" half of liveness
|
|
3
|
+
* (weave-workspace §6).
|
|
4
|
+
*
|
|
5
|
+
* ```text
|
|
6
|
+
* fs.watch(vault, {recursive}) ─┐
|
|
7
|
+
* fs.watch(repo/.okf, {recursive}) ─┼─▶ debounce 80ms ─▶ onPath (cache.invalidate)
|
|
8
|
+
* git HEAD/index poll (2s) ─┘ ─▶ onChange(scopes) ─▶ SSE
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* ## Events are hints, never deltas
|
|
12
|
+
*
|
|
13
|
+
* macOS coalesces `fs.watch` events and can drop them under a rapid burst,
|
|
14
|
+
* and recursive watches on every platform report renames and content writes
|
|
15
|
+
* with the same `"rename" | "change"` vocabulary. So a change here means
|
|
16
|
+
* "something in this scope moved, re-read it" and nothing more. Everything
|
|
17
|
+
* downstream — the cache, the SSE frame, the client's refetch — is built on
|
|
18
|
+
* that weaker promise, which is why a missed event costs latency rather than
|
|
19
|
+
* correctness.
|
|
20
|
+
*
|
|
21
|
+
* ## Coalescing into a scope set, not a path list
|
|
22
|
+
*
|
|
23
|
+
* A `git checkout` or a `/weave-scan` touches hundreds of files. The
|
|
24
|
+
* debounce collects them into a {@link ChangeScope} *set*, so one window
|
|
25
|
+
* emits at most three frames (`vault`, `repo`, `git`) no matter how many
|
|
26
|
+
* paths took part. Per-path work still happens — {@link WatcherOptions.onPath}
|
|
27
|
+
* fires immediately for each accepted path so the cache can evict precisely —
|
|
28
|
+
* but the broadcast is per scope.
|
|
29
|
+
*
|
|
30
|
+
* The window is **leading-edge opening, trailing-edge firing**: the first
|
|
31
|
+
* accepted path opens an 80 ms window and every later path joins it without
|
|
32
|
+
* restarting it. A restart-on-each-event debounce starves under a sustained
|
|
33
|
+
* write stream (a large checkout would emit nothing until it finished);
|
|
34
|
+
* this variant bounds notification latency at `debounceMs` regardless of
|
|
35
|
+
* event rate, which is the property that matters for a live UI.
|
|
36
|
+
*
|
|
37
|
+
* ## Why git is polled rather than watched
|
|
38
|
+
*
|
|
39
|
+
* `<repo>/.git` is not under either watched root, and watching it directly
|
|
40
|
+
* would mean absorbing the object database's write traffic to learn one bit.
|
|
41
|
+
* A 2 s fingerprint of `HEAD`, the loose ref it points at, and `index`
|
|
42
|
+
* catches branch switches, commits and staging with three `stat`/`read`
|
|
43
|
+
* calls and no subprocess.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { promises as fs, watch, type FSWatcher } from "node:fs";
|
|
47
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
48
|
+
import { classifyPath } from "../../core/cache/workspace";
|
|
49
|
+
import { repoIndexDir } from "../../core/paths";
|
|
50
|
+
import { CHANGE_SCOPES, type ChangeScope } from "../shared/wire";
|
|
51
|
+
|
|
52
|
+
// --- injectable timers --------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The two timing primitives this module needs, injected so tests never sleep.
|
|
56
|
+
*
|
|
57
|
+
* Cancellation is a returned closure rather than an opaque handle: it keeps
|
|
58
|
+
* the interface free of `NodeJS.Timeout` (which a fake would have to forge)
|
|
59
|
+
* and makes "cancel exactly the thing I scheduled" the only expressible
|
|
60
|
+
* operation.
|
|
61
|
+
*/
|
|
62
|
+
export interface Scheduler {
|
|
63
|
+
/** Run `fn` once after `ms`. Returns a cancel function; cancelling twice is safe. */
|
|
64
|
+
delay(fn: () => void, ms: number): () => void;
|
|
65
|
+
/** Run `fn` every `ms`. Returns a cancel function; cancelling twice is safe. */
|
|
66
|
+
repeat(fn: () => void, ms: number): () => void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Real timers, `unref`'d so a watcher nobody closed cannot by itself keep the
|
|
71
|
+
* pi session's process alive after shutdown.
|
|
72
|
+
*/
|
|
73
|
+
export const realScheduler: Scheduler = {
|
|
74
|
+
delay(fn, ms) {
|
|
75
|
+
const handle = setTimeout(fn, ms);
|
|
76
|
+
handle.unref?.();
|
|
77
|
+
return () => clearTimeout(handle);
|
|
78
|
+
},
|
|
79
|
+
repeat(fn, ms) {
|
|
80
|
+
const handle = setInterval(fn, ms);
|
|
81
|
+
handle.unref?.();
|
|
82
|
+
return () => clearInterval(handle);
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// --- the ignore list ----------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
/** §6 defaults: 80 ms debounce, 2 s git poll, 200 ms self-write suppression. */
|
|
89
|
+
export const DEFAULT_DEBOUNCE_MS = 80;
|
|
90
|
+
export const DEFAULT_GIT_POLL_MS = 2_000;
|
|
91
|
+
export const DEFAULT_SUPPRESS_MS = 200;
|
|
92
|
+
|
|
93
|
+
/** Split a path on either separator, dropping empty and `.` segments. */
|
|
94
|
+
function segments(path: string): string[] {
|
|
95
|
+
return path.split(/[\\/]/).filter((part) => part.length > 0 && part !== ".");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* True when a watch event for this path should be discarded outright.
|
|
100
|
+
*
|
|
101
|
+
* Takes the path **relative to the watched root** — that is what `fs.watch`
|
|
102
|
+
* hands us, and it is also the only form in which a `.git` segment is
|
|
103
|
+
* unambiguous. An absolute path could contain a directory literally named
|
|
104
|
+
* `.git` somewhere above the root and would then be misread.
|
|
105
|
+
*
|
|
106
|
+
* The list, and why each entry is on it:
|
|
107
|
+
*
|
|
108
|
+
* | Pattern | Source |
|
|
109
|
+
* | ----------- | ------ |
|
|
110
|
+
* | `.git/**` | The object database writes constantly and means nothing to us. `HEAD` and `index` are the two exceptions — they are the branch-switch and staging signals, and are classified as the `git` scope. |
|
|
111
|
+
* | `*.swp` | vim's swap file, rewritten on every keystroke. |
|
|
112
|
+
* | `*~` | emacs/gedit backup on save — arrives paired with the real write. |
|
|
113
|
+
* | `.#*` | emacs lock symlink, created on first edit and removed on close. |
|
|
114
|
+
* | `4913` | vim's writability probe: created, `stat`ed and deleted before every single save. Named for the port-number-looking constant in `vim/src/fileio.c`. |
|
|
115
|
+
* | `.DS_Store` | Finder rewrites it merely for opening the folder. |
|
|
116
|
+
*/
|
|
117
|
+
export function isIgnoredPath(path: string): boolean {
|
|
118
|
+
const parts = segments(path);
|
|
119
|
+
const gitAt = parts.indexOf(".git");
|
|
120
|
+
if (gitAt >= 0) {
|
|
121
|
+
// Everything under `.git` is noise except the two files §6 names, and
|
|
122
|
+
// those only when they sit directly in the git directory.
|
|
123
|
+
const rest = parts.slice(gitAt + 1);
|
|
124
|
+
return !(rest.length === 1 && (rest[0] === "HEAD" || rest[0] === "index"));
|
|
125
|
+
}
|
|
126
|
+
const base = parts[parts.length - 1] ?? "";
|
|
127
|
+
if (base === ".DS_Store" || base === "4913") return true;
|
|
128
|
+
if (base.endsWith(".swp") || base.endsWith("~")) return true;
|
|
129
|
+
return base.startsWith(".#");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The scope a *non-ignored* event path belongs to, or `null` for "not our
|
|
134
|
+
* business".
|
|
135
|
+
*
|
|
136
|
+
* `relPath` is relative to `root`; `root` is one of the watched roots.
|
|
137
|
+
* Classification of everything outside `.git` is delegated to
|
|
138
|
+
* {@link classifyPath} in `src/core/cache/workspace` — the same function the
|
|
139
|
+
* cache uses to decide what an invalidation affects. Two implementations of
|
|
140
|
+
* "is this a note?" would eventually disagree, and the disagreement would
|
|
141
|
+
* present as a note that silently stops updating.
|
|
142
|
+
*/
|
|
143
|
+
export function inferScope(
|
|
144
|
+
root: string,
|
|
145
|
+
relPath: string,
|
|
146
|
+
opts: { cwd: string; vaultRoot: string },
|
|
147
|
+
): ChangeScope | null {
|
|
148
|
+
if (isIgnoredPath(relPath)) return null;
|
|
149
|
+
// Survived the ignore list with a `.git` segment ⇒ it is HEAD or index.
|
|
150
|
+
if (segments(relPath).includes(".git")) return "git";
|
|
151
|
+
const scope = classifyPath(resolve(root, relPath), opts);
|
|
152
|
+
return scope === "none" ? null : scope;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// --- watch handles ------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
/** The slice of `FSWatcher` this module uses. */
|
|
158
|
+
export interface WatchHandle {
|
|
159
|
+
close(): void;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Opens one recursive watch. Injected so tests can make it throw (the
|
|
164
|
+
* network-filesystem case, §6 / §14) without mocking `node:fs` globally.
|
|
165
|
+
*
|
|
166
|
+
* `onEvent` receives the path relative to `root`, or `null` when the platform
|
|
167
|
+
* declined to name the file — which is a legitimate outcome and means "assume
|
|
168
|
+
* the whole root moved".
|
|
169
|
+
*/
|
|
170
|
+
export type OpenWatch = (
|
|
171
|
+
root: string,
|
|
172
|
+
onEvent: (relPath: string | null) => void,
|
|
173
|
+
onError: (error: Error) => void,
|
|
174
|
+
) => WatchHandle;
|
|
175
|
+
|
|
176
|
+
/** `fs.watch` with the options §6 requires. Recursive is safe on `engines >= 20.13.0`. */
|
|
177
|
+
export const realOpenWatch: OpenWatch = (root, onEvent, onError) => {
|
|
178
|
+
const watcher: FSWatcher = watch(root, { recursive: true, persistent: false }, (_type, filename) => {
|
|
179
|
+
onEvent(typeof filename === "string" ? filename : null);
|
|
180
|
+
});
|
|
181
|
+
watcher.on("error", onError);
|
|
182
|
+
return watcher;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
// --- status -------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
/** One root that `fs.watch` refused, with the reason. */
|
|
188
|
+
export interface WatchFailure {
|
|
189
|
+
root: string;
|
|
190
|
+
error: string;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* What {@link Watcher.start} achieved.
|
|
195
|
+
*
|
|
196
|
+
* `available === false` is the signal §14 calls for: the caller falls back to
|
|
197
|
+
* polling `/api/graph` for a changed stamp instead of assuming liveness it is
|
|
198
|
+
* not getting. It is never an exception — a container or network filesystem
|
|
199
|
+
* without `inotify` must degrade the workspace, not fail to open it.
|
|
200
|
+
*/
|
|
201
|
+
export interface WatcherStatus {
|
|
202
|
+
/** Roots currently under an open watch. */
|
|
203
|
+
watching: string[];
|
|
204
|
+
/** Roots that could not be watched, and why. */
|
|
205
|
+
failed: WatchFailure[];
|
|
206
|
+
/** True while at least one root is watched. */
|
|
207
|
+
available: boolean;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface WatcherOptions {
|
|
211
|
+
/** Repository/working directory. `<cwd>/.okf` is the second watched root. */
|
|
212
|
+
cwd: string;
|
|
213
|
+
/** Vault root (`~/.okf` by default). The first watched root. */
|
|
214
|
+
vaultRoot: string;
|
|
215
|
+
/**
|
|
216
|
+
* One call per debounce window, with the scopes that changed, in
|
|
217
|
+
* {@link CHANGE_SCOPES} order. Never called with an empty list.
|
|
218
|
+
*/
|
|
219
|
+
onChange: (scopes: readonly ChangeScope[]) => void;
|
|
220
|
+
/**
|
|
221
|
+
* One call per accepted path, immediately — not debounced. This is
|
|
222
|
+
* `cache.invalidate(path)` in the §6 diagram: eviction wants every path,
|
|
223
|
+
* the broadcast wants one frame per scope.
|
|
224
|
+
*/
|
|
225
|
+
onPath?: (absPath: string, scope: ChangeScope) => void;
|
|
226
|
+
/** Injected clock in epoch ms, for suppression windows. */
|
|
227
|
+
now?: () => number;
|
|
228
|
+
debounceMs?: number;
|
|
229
|
+
gitPollMs?: number;
|
|
230
|
+
scheduler?: Scheduler;
|
|
231
|
+
openWatch?: OpenWatch;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Fingerprint components of the git directory. Empty string = absent. */
|
|
235
|
+
interface GitFingerprint {
|
|
236
|
+
head: string;
|
|
237
|
+
ref: string;
|
|
238
|
+
index: string;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function sameFingerprint(a: GitFingerprint, b: GitFingerprint): boolean {
|
|
242
|
+
return a.head === b.head && a.ref === b.ref && a.index === b.index;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Watches the vault and the repository index, and polls git.
|
|
247
|
+
*
|
|
248
|
+
* A class for the same reason {@link classifyPath}'s owner is: this has a
|
|
249
|
+
* lifetime, open OS handles and mutable state that a caller must be able to
|
|
250
|
+
* `close()`. Everything decision-shaped is a pure exported function above, so
|
|
251
|
+
* the class holds wiring only.
|
|
252
|
+
*/
|
|
253
|
+
export class Watcher {
|
|
254
|
+
private readonly cwd: string;
|
|
255
|
+
private readonly vaultRoot: string;
|
|
256
|
+
private readonly onChange: (scopes: readonly ChangeScope[]) => void;
|
|
257
|
+
private readonly onPath: ((absPath: string, scope: ChangeScope) => void) | undefined;
|
|
258
|
+
private readonly now: () => number;
|
|
259
|
+
private readonly debounceMs: number;
|
|
260
|
+
private readonly gitPollMs: number;
|
|
261
|
+
private readonly scheduler: Scheduler;
|
|
262
|
+
private readonly openWatch: OpenWatch;
|
|
263
|
+
|
|
264
|
+
/** Watched root → its open handle. */
|
|
265
|
+
private readonly handles = new Map<string, WatchHandle>();
|
|
266
|
+
private readonly failures: WatchFailure[] = [];
|
|
267
|
+
|
|
268
|
+
/** Scopes accumulated by the currently open debounce window. */
|
|
269
|
+
private readonly pending = new Set<ChangeScope>();
|
|
270
|
+
private cancelDebounce: (() => void) | null = null;
|
|
271
|
+
private cancelPoll: (() => void) | null = null;
|
|
272
|
+
|
|
273
|
+
/** Absolute path → epoch ms at which its self-write suppression expires. */
|
|
274
|
+
private readonly suppressed = new Map<string, number>();
|
|
275
|
+
|
|
276
|
+
/** `null` until the first poll establishes a baseline to compare against. */
|
|
277
|
+
private gitSeen: GitFingerprint | null = null;
|
|
278
|
+
|
|
279
|
+
private started = false;
|
|
280
|
+
private closed = false;
|
|
281
|
+
|
|
282
|
+
constructor(opts: WatcherOptions) {
|
|
283
|
+
this.cwd = opts.cwd;
|
|
284
|
+
this.vaultRoot = opts.vaultRoot;
|
|
285
|
+
this.onChange = opts.onChange;
|
|
286
|
+
this.onPath = opts.onPath;
|
|
287
|
+
this.now = opts.now ?? (() => Date.now());
|
|
288
|
+
this.debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
|
|
289
|
+
this.gitPollMs = opts.gitPollMs ?? DEFAULT_GIT_POLL_MS;
|
|
290
|
+
this.scheduler = opts.scheduler ?? realScheduler;
|
|
291
|
+
this.openWatch = opts.openWatch ?? realOpenWatch;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Open the watches and start the git poll.
|
|
296
|
+
*
|
|
297
|
+
* Never throws. A root that cannot be watched is recorded in
|
|
298
|
+
* {@link WatcherStatus.failed} and the others still run; if every root
|
|
299
|
+
* fails, `available` is false and the caller falls back to stamp polling.
|
|
300
|
+
*/
|
|
301
|
+
start(): WatcherStatus {
|
|
302
|
+
if (this.started) return this.status();
|
|
303
|
+
this.started = true;
|
|
304
|
+
for (const root of [this.vaultRoot, repoIndexDir(this.cwd)]) {
|
|
305
|
+
this.open(root);
|
|
306
|
+
}
|
|
307
|
+
this.cancelPoll = this.scheduler.repeat(() => void this.pollGitOnce(), this.gitPollMs);
|
|
308
|
+
return this.status();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Current watch health. Reflects roots lost to a runtime error, not just startup. */
|
|
312
|
+
status(): WatcherStatus {
|
|
313
|
+
return {
|
|
314
|
+
watching: [...this.handles.keys()],
|
|
315
|
+
failed: this.failures.map((f) => ({ ...f })),
|
|
316
|
+
available: this.handles.size > 0,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Ignore changes to `absPath` for the next `ms`.
|
|
322
|
+
*
|
|
323
|
+
* `POST /api/open` (and, at P5, a browser save) writes through the same
|
|
324
|
+
* filesystem the watcher is watching, so without this the write comes
|
|
325
|
+
* straight back as a change event, which broadcasts, which makes the client
|
|
326
|
+
* refetch what it just caused. A window rather than a one-shot flag because
|
|
327
|
+
* a single logical save can produce several events — the editor's temp
|
|
328
|
+
* file, the rename, the mtime touch.
|
|
329
|
+
*/
|
|
330
|
+
suppress(absPath: string, ms: number = DEFAULT_SUPPRESS_MS): void {
|
|
331
|
+
this.suppressed.set(resolve(absPath), this.now() + ms);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Stop everything: close the OS handles, cancel the debounce window and the
|
|
336
|
+
* poll. Idempotent, because both `session_shutdown` and an explicit stop can
|
|
337
|
+
* plausibly arrive.
|
|
338
|
+
*/
|
|
339
|
+
close(): void {
|
|
340
|
+
if (this.closed) return;
|
|
341
|
+
this.closed = true;
|
|
342
|
+
for (const handle of this.handles.values()) safeClose(handle);
|
|
343
|
+
this.handles.clear();
|
|
344
|
+
this.cancelDebounce?.();
|
|
345
|
+
this.cancelDebounce = null;
|
|
346
|
+
this.cancelPoll?.();
|
|
347
|
+
this.cancelPoll = null;
|
|
348
|
+
this.pending.clear();
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* One git fingerprint comparison. The body of the poll, exposed so tests
|
|
353
|
+
* drive it directly instead of waiting 2 s.
|
|
354
|
+
*
|
|
355
|
+
* The first call only establishes a baseline: at startup nothing has
|
|
356
|
+
* changed yet, and emitting a `git` frame for the state the client already
|
|
357
|
+
* fetched would be a spurious refetch on every boot.
|
|
358
|
+
*/
|
|
359
|
+
async pollGitOnce(): Promise<void> {
|
|
360
|
+
if (this.closed) return;
|
|
361
|
+
const next = await readGitFingerprint(this.cwd);
|
|
362
|
+
const previous = this.gitSeen;
|
|
363
|
+
this.gitSeen = next;
|
|
364
|
+
if (previous === null || sameFingerprint(previous, next)) return;
|
|
365
|
+
this.enqueue("git");
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// --- internals --------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
private open(root: string): void {
|
|
371
|
+
try {
|
|
372
|
+
const handle = this.openWatch(
|
|
373
|
+
root,
|
|
374
|
+
(relPath) => this.onEvent(root, relPath),
|
|
375
|
+
(error) => this.onWatchError(root, error),
|
|
376
|
+
);
|
|
377
|
+
this.handles.set(root, handle);
|
|
378
|
+
} catch (error) {
|
|
379
|
+
this.failures.push({ root, error: messageOf(error) });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* A watch that dies mid-session (the directory was deleted, inotify ran out
|
|
385
|
+
* of handles) is demoted to a failure rather than left in `watching`, so
|
|
386
|
+
* `available` stays an honest answer to "is liveness working right now?".
|
|
387
|
+
*/
|
|
388
|
+
private onWatchError(root: string, error: Error): void {
|
|
389
|
+
const handle = this.handles.get(root);
|
|
390
|
+
if (handle === undefined) return;
|
|
391
|
+
safeClose(handle);
|
|
392
|
+
this.handles.delete(root);
|
|
393
|
+
this.failures.push({ root, error: messageOf(error) });
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
private onEvent(root: string, relPath: string | null): void {
|
|
397
|
+
if (this.closed) return;
|
|
398
|
+
if (relPath === null) {
|
|
399
|
+
// The platform declined to name the file. Treat the whole root as dirty
|
|
400
|
+
// rather than guessing — this is the "hint, not delta" contract paying
|
|
401
|
+
// for itself.
|
|
402
|
+
this.enqueue(rootScope(root, this.vaultRoot));
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const scope = inferScope(root, relPath, { cwd: this.cwd, vaultRoot: this.vaultRoot });
|
|
406
|
+
if (scope === null) return;
|
|
407
|
+
const abs = resolve(root, relPath);
|
|
408
|
+
if (this.isSuppressed(abs)) return;
|
|
409
|
+
this.onPath?.(abs, scope);
|
|
410
|
+
this.enqueue(scope);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** True while `abs` is inside its self-write window. Expired entries are reaped. */
|
|
414
|
+
private isSuppressed(abs: string): boolean {
|
|
415
|
+
const until = this.suppressed.get(abs);
|
|
416
|
+
if (until === undefined) return false;
|
|
417
|
+
if (this.now() < until) return true;
|
|
418
|
+
this.suppressed.delete(abs);
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Add a scope to the open window, opening one if there is none. The window
|
|
424
|
+
* is deliberately *not* restarted by later events — see the module header.
|
|
425
|
+
*/
|
|
426
|
+
private enqueue(scope: ChangeScope): void {
|
|
427
|
+
this.pending.add(scope);
|
|
428
|
+
if (this.cancelDebounce !== null) return;
|
|
429
|
+
this.cancelDebounce = this.scheduler.delay(() => this.flush(), this.debounceMs);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
private flush(): void {
|
|
433
|
+
this.cancelDebounce = null;
|
|
434
|
+
if (this.closed || this.pending.size === 0) return;
|
|
435
|
+
const scopes = CHANGE_SCOPES.filter((scope) => this.pending.has(scope));
|
|
436
|
+
this.pending.clear();
|
|
437
|
+
this.onChange(scopes);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Which scope an unnamed event on a watched root implies. */
|
|
442
|
+
function rootScope(root: string, vaultRoot: string): ChangeScope {
|
|
443
|
+
return resolve(root) === resolve(vaultRoot) ? "vault" : "repo";
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function safeClose(handle: WatchHandle): void {
|
|
447
|
+
try {
|
|
448
|
+
handle.close();
|
|
449
|
+
} catch {
|
|
450
|
+
// Already closed, or the handle died with its directory. Either way there
|
|
451
|
+
// is nothing left to release and shutdown must not fail because of it.
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function messageOf(error: unknown): string {
|
|
456
|
+
return error instanceof Error ? error.message : String(error);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// --- git fingerprint ----------------------------------------------------------
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Resolve the real git directory for `cwd`.
|
|
463
|
+
*
|
|
464
|
+
* Usually `<cwd>/.git`, but in a linked worktree or a submodule `.git` is a
|
|
465
|
+
* *file* containing `gitdir: <path>`. Following it costs four lines and is the
|
|
466
|
+
* difference between working and silently never reporting a branch switch for
|
|
467
|
+
* everyone who uses `git worktree`.
|
|
468
|
+
*/
|
|
469
|
+
async function resolveGitDir(cwd: string): Promise<string> {
|
|
470
|
+
const dotGit = join(cwd, ".git");
|
|
471
|
+
const stat = await fs.stat(dotGit).catch(() => null);
|
|
472
|
+
if (stat === null || stat.isDirectory()) return dotGit;
|
|
473
|
+
const text = (await fs.readFile(dotGit, "utf8").catch(() => "")).trim();
|
|
474
|
+
const match = /^gitdir:\s*(.+)$/.exec(text);
|
|
475
|
+
if (match === null) return dotGit;
|
|
476
|
+
const target = match[1]!.trim();
|
|
477
|
+
return isAbsolute(target) ? target : resolve(cwd, target);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* A cheap, total fingerprint of "which commit are we on, and what is staged".
|
|
482
|
+
*
|
|
483
|
+
* - `HEAD` text: `ref: refs/heads/main` or a bare sha when detached. Catches
|
|
484
|
+
* every branch switch.
|
|
485
|
+
* - the loose ref file `HEAD` names: catches commits, which move the ref but
|
|
486
|
+
* leave `HEAD` byte-identical. A commit always writes a *loose* ref, so not
|
|
487
|
+
* consulting `packed-refs` costs nothing — a packed ref is by definition one
|
|
488
|
+
* that has not moved since the pack.
|
|
489
|
+
* - `index` size and mtime: catches staging without reading a file that is
|
|
490
|
+
* routinely megabytes.
|
|
491
|
+
*
|
|
492
|
+
* Every read is best-effort. Outside a repository all three parts stay empty,
|
|
493
|
+
* the fingerprint never changes, and the poll is a no-op forever.
|
|
494
|
+
*/
|
|
495
|
+
async function readGitFingerprint(cwd: string): Promise<GitFingerprint> {
|
|
496
|
+
const gitDir = await resolveGitDir(cwd);
|
|
497
|
+
const head = (await fs.readFile(join(gitDir, "HEAD"), "utf8").catch(() => "")).trim();
|
|
498
|
+
const refMatch = /^ref:\s*(.+)$/.exec(head);
|
|
499
|
+
const ref =
|
|
500
|
+
refMatch === null
|
|
501
|
+
? ""
|
|
502
|
+
: (await fs.readFile(join(gitDir, refMatch[1]!.trim()), "utf8").catch(() => "")).trim();
|
|
503
|
+
const indexStat = await fs.stat(join(gitDir, "index")).catch(() => null);
|
|
504
|
+
const index = indexStat === null ? "" : `${indexStat.size}:${indexStat.mtimeMs}`;
|
|
505
|
+
return { head, ref, index };
|
|
506
|
+
}
|
|
507
|
+
|