projectkeeper 0.0.0-stage → 1.0.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/CHANGELOG.md +56 -0
- package/LICENSE +21 -0
- package/README.md +283 -2
- package/README.zh-CN.md +285 -0
- package/THIRD_PARTY_NOTICES.md +53 -0
- package/bin/pk.mjs +6 -0
- package/bin/postinstall.mjs +5 -0
- package/bin/strip-types.mjs +28 -0
- package/package.json +68 -4
- package/patches/README.md +111 -0
- package/patches/pi-ai-0.85.0.ts +122 -0
- package/patches/pi-ai-0.87.1.ts +122 -0
- package/scripts/apply-pi-streaming-patch.ts +86 -0
- package/skills/clerk/coverage/SKILL.md +35 -0
- package/skills/clerk/cross-check/SKILL.md +118 -0
- package/skills/clerk/deepen-dispatch/SKILL.md +69 -0
- package/skills/clerk/lane-follow-up/SKILL.md +94 -0
- package/skills/clerk/lane-plan/SKILL.md +107 -0
- package/skills/clerk/lane-slot/SKILL.md +113 -0
- package/skills/clerk/lane-topic/SKILL.md +116 -0
- package/skills/clerk/orientation/SKILL.md +160 -0
- package/skills/clerk/reconcile/SKILL.md +77 -0
- package/skills/clerk/skeleton-dispatch/SKILL.md +43 -0
- package/skills/clerk/spot-check/SKILL.md +52 -0
- package/skills/clerk/synthesis/SKILL.md +65 -0
- package/skills/table-follow-up/SKILL.md +42 -0
- package/src/cli.ts +319 -0
- package/src/codemap/README.md +13 -0
- package/src/codemap/engines.ts +102 -0
- package/src/codemap/facts.ts +504 -0
- package/src/context/agent-entry.ts +232 -0
- package/src/context/ask.ts +97 -0
- package/src/context/assemble.ts +1058 -0
- package/src/context/code-source.ts +103 -0
- package/src/context/k-briefs.ts +114 -0
- package/src/context/lineage.ts +100 -0
- package/src/context/object-brief.ts +300 -0
- package/src/context/owner-words.ts +191 -0
- package/src/context/pack-fixture.ts +342 -0
- package/src/context/project-rules.ts +160 -0
- package/src/intake/intake.ts +392 -0
- package/src/intake/material-rules.ts +166 -0
- package/src/intake/removal.ts +316 -0
- package/src/intake/skipped.ts +72 -0
- package/src/keeper/adjustment.ts +565 -0
- package/src/keeper/bounds/boundary.ts +509 -0
- package/src/keeper/bounds/command.ts +1665 -0
- package/src/keeper/bounds/paths.ts +164 -0
- package/src/keeper/bounds/reads.ts +718 -0
- package/src/keeper/bounds/shell-write-guard.ts +521 -0
- package/src/keeper/capabilities.ts +136 -0
- package/src/keeper/clerk-steps.ts +151 -0
- package/src/keeper/clerk-tools.ts +1424 -0
- package/src/keeper/conversation.ts +253 -0
- package/src/keeper/evidence.ts +662 -0
- package/src/keeper/fake-provider.ts +132 -0
- package/src/keeper/held.ts +15 -0
- package/src/keeper/history-tools.ts +163 -0
- package/src/keeper/jobs/answering.ts +53 -0
- package/src/keeper/key-check.ts +105 -0
- package/src/keeper/ledger-tools.ts +295 -0
- package/src/keeper/link-check.ts +185 -0
- package/src/keeper/merge.ts +260 -0
- package/src/keeper/note-origin.ts +56 -0
- package/src/keeper/numbers-check.ts +102 -0
- package/src/keeper/open-in-pi.ts +57 -0
- package/src/keeper/organize/absence.ts +266 -0
- package/src/keeper/organize/carried-on.ts +214 -0
- package/src/keeper/organize/clerk-coverage.ts +293 -0
- package/src/keeper/organize/clerk-prompts.ts +132 -0
- package/src/keeper/organize/clerk.ts +1395 -0
- package/src/keeper/organize/coverage-gate.ts +19 -0
- package/src/keeper/organize/coverage-tools.ts +765 -0
- package/src/keeper/organize/entries.ts +153 -0
- package/src/keeper/organize/entry-gate.ts +289 -0
- package/src/keeper/organize/fill-tools.ts +718 -0
- package/src/keeper/organize/follow-up.ts +280 -0
- package/src/keeper/organize/generation-check.ts +504 -0
- package/src/keeper/organize/graph.ts +230 -0
- package/src/keeper/organize/lane-tools.ts +289 -0
- package/src/keeper/organize/looked-tools.ts +54 -0
- package/src/keeper/organize/materials.ts +243 -0
- package/src/keeper/organize/owner-lines.ts +493 -0
- package/src/keeper/organize/place-tools.ts +216 -0
- package/src/keeper/organize/placement-inference.ts +1045 -0
- package/src/keeper/organize/placement-time.ts +320 -0
- package/src/keeper/organize/placing.ts +821 -0
- package/src/keeper/organize/positioning.ts +33 -0
- package/src/keeper/organize/readiness.ts +47 -0
- package/src/keeper/organize/reading.ts +401 -0
- package/src/keeper/organize/round-blocks.ts +65 -0
- package/src/keeper/organize/round-news.ts +164 -0
- package/src/keeper/organize/round-open.ts +586 -0
- package/src/keeper/organize/schedule-state.ts +39 -0
- package/src/keeper/organize/schedule.ts +137 -0
- package/src/keeper/organize/service.ts +409 -0
- package/src/keeper/organize/skills.ts +116 -0
- package/src/keeper/organize/slots.ts +85 -0
- package/src/keeper/organize/stage-gate.ts +175 -0
- package/src/keeper/organize/stage-tools.ts +471 -0
- package/src/keeper/organize/standing-notes.ts +141 -0
- package/src/keeper/organize/table-follow-up.ts +330 -0
- package/src/keeper/organize/takeover-clerk.ts +832 -0
- package/src/keeper/organize/takeover-state.ts +103 -0
- package/src/keeper/organize/takeover.ts +216 -0
- package/src/keeper/organize/update-pending.ts +535 -0
- package/src/keeper/organize/workbench-placement.ts +92 -0
- package/src/keeper/own-keys.ts +114 -0
- package/src/keeper/owner-text.ts +165 -0
- package/src/keeper/project-folder.ts +334 -0
- package/src/keeper/prompts.ts +101 -0
- package/src/keeper/references.ts +177 -0
- package/src/keeper/relook/compose.ts +120 -0
- package/src/keeper/relook/prompts.ts +36 -0
- package/src/keeper/roles.ts +259 -0
- package/src/keeper/round-tools.ts +227 -0
- package/src/keeper/route.ts +148 -0
- package/src/keeper/rules.ts +97 -0
- package/src/keeper/runtime.ts +1858 -0
- package/src/keeper/scope-tools.ts +194 -0
- package/src/keeper/sendback-text.ts +83 -0
- package/src/keeper/step-timing.ts +207 -0
- package/src/keeper/testing/clerk-script.ts +212 -0
- package/src/keeper/testing/streaming-arguments.ts +57 -0
- package/src/keeper/tools.ts +2340 -0
- package/src/keeper/utterance-tools.ts +64 -0
- package/src/keeper/verbatim.ts +397 -0
- package/src/ledger/adapters.ts +186 -0
- package/src/ledger/arrangements.ts +257 -0
- package/src/ledger/blame.ts +58 -0
- package/src/ledger/code-engine-child.ts +69 -0
- package/src/ledger/code-engine.ts +548 -0
- package/src/ledger/code.ts +607 -0
- package/src/ledger/docs.ts +294 -0
- package/src/ledger/git-read.ts +321 -0
- package/src/ledger/index.ts +1850 -0
- package/src/ledger/item-range.ts +16 -0
- package/src/ledger/lines.ts +328 -0
- package/src/ledger/numbering.ts +269 -0
- package/src/ledger/ranges.ts +74 -0
- package/src/ledger/rebuild-worker.ts +15 -0
- package/src/ledger/rebuild.ts +387 -0
- package/src/ledger/repo-scan.ts +181 -0
- package/src/ledger/schema.ts +450 -0
- package/src/ledger/sessions.ts +134 -0
- package/src/ledger/text-scan.ts +466 -0
- package/src/ledger/time.ts +185 -0
- package/src/ledger/ts-symbols.ts +180 -0
- package/src/ledger/views.ts +377 -0
- package/src/mcp/server.ts +127 -0
- package/src/mcp/tools.ts +187 -0
- package/src/model/ids.ts +23 -0
- package/src/model/k-types.ts +912 -0
- package/src/model/time.ts +85 -0
- package/src/model/types.ts +1382 -0
- package/src/model/views-k.ts +707 -0
- package/src/model/vocab.ts +269 -0
- package/src/process/analysis.ts +79 -0
- package/src/process/breakpoint-candidates.ts +383 -0
- package/src/process/breakpoints.ts +746 -0
- package/src/process/claim-check.ts +295 -0
- package/src/process/delivery-links.ts +52 -0
- package/src/process/facts.ts +487 -0
- package/src/process/generations.ts +138 -0
- package/src/process/index.ts +243 -0
- package/src/process/integration.ts +128 -0
- package/src/process/placement.ts +107 -0
- package/src/process/plan.ts +276 -0
- package/src/process/sendbacks.ts +244 -0
- package/src/process/text.ts +185 -0
- package/src/process/units.ts +543 -0
- package/src/process/unnumbered.ts +431 -0
- package/src/process/work.ts +884 -0
- package/src/scope/candidates.ts +86 -0
- package/src/scope/decisions.ts +229 -0
- package/src/scope/discover.ts +741 -0
- package/src/scope/ignore.ts +126 -0
- package/src/scope/ignored-place.ts +93 -0
- package/src/scope/skip.ts +161 -0
- package/src/scope/toolchain.ts +180 -0
- package/src/scope/worktree.ts +187 -0
- package/src/server/api.ts +549 -0
- package/src/server/app.ts +595 -0
- package/src/server/drafts-api.ts +170 -0
- package/src/server/folders-api.ts +243 -0
- package/src/server/graph-view.ts +639 -0
- package/src/server/http.ts +224 -0
- package/src/server/k-agent-api.ts +56 -0
- package/src/server/k-rounds-d99.ts +91 -0
- package/src/server/k-views.ts +405 -0
- package/src/server/keeper-page.ts +446 -0
- package/src/server/keys-api.ts +239 -0
- package/src/server/project-folder-api.ts +104 -0
- package/src/server/vendor.ts +14 -0
- package/src/server/workbench-content.ts +561 -0
- package/src/sources/anchor.ts +129 -0
- package/src/sources/files.ts +267 -0
- package/src/sources/gitobs.ts +41 -0
- package/src/sources/history.ts +231 -0
- package/src/sources/scan-worker.ts +10 -0
- package/src/sources/sessions/locate.ts +292 -0
- package/src/sources/sessions/read.ts +346 -0
- package/src/sources/sessions/scope.ts +42 -0
- package/src/sources/sessions/utterances.ts +223 -0
- package/src/sources/watch.ts +215 -0
- package/src/store/json-file.ts +50 -0
- package/src/store/paths.ts +39 -0
- package/src/store/project-store.ts +295 -0
- package/src/store/versions.ts +172 -0
- package/src/store/workspace.ts +216 -0
- package/src/table/check.ts +333 -0
- package/src/table/follow-up-tools.ts +30 -0
- package/src/table/follow-up.ts +344 -0
- package/src/table/import.ts +610 -0
- package/src/table/project-home.ts +61 -0
- package/src/table/sessions.ts +168 -0
- package/src/table/takeover.ts +483 -0
- package/src/util/git-check.ts +29 -0
- package/src/util/git.ts +411 -0
- package/src/util/own-ts.ts +15 -0
- package/src/util/paths.ts +225 -0
- package/template/FILLING-IN.md +148 -0
- package/template/examples/papertrail.json +747 -0
- package/template/projectkeeper.schema.json +1001 -0
- package/ui/app.js +1135 -0
- package/ui/failures.js +82 -0
- package/ui/flyout.js +120 -0
- package/ui/folder-chooser.js +264 -0
- package/ui/glossary.js +156 -0
- package/ui/graph-fit.js +157 -0
- package/ui/graph-material.js +61 -0
- package/ui/graph-palette.js +109 -0
- package/ui/graph-tools.js +62 -0
- package/ui/graph.js +1712 -0
- package/ui/index.html +31 -0
- package/ui/k/code-view.js +230 -0
- package/ui/k/draft-view.js +93 -0
- package/ui/k/k-views.css +224 -0
- package/ui/k/round-tree.js +450 -0
- package/ui/k/scope-k.js +159 -0
- package/ui/k-data.js +118 -0
- package/ui/k-fold.js +106 -0
- package/ui/k-process.css +302 -0
- package/ui/k-process.js +934 -0
- package/ui/keeper-activity.js +175 -0
- package/ui/keeper-view.js +801 -0
- package/ui/keeper.css +101 -0
- package/ui/key-order.js +62 -0
- package/ui/list-fold.js +67 -0
- package/ui/markdown.d.ts +21 -0
- package/ui/markdown.js +472 -0
- package/ui/placement.d.ts +92 -0
- package/ui/placement.js +445 -0
- package/ui/popover-place.d.ts +16 -0
- package/ui/popover-place.js +89 -0
- package/ui/popover.js +198 -0
- package/ui/project-folder.js +37 -0
- package/ui/shell-state.js +40 -0
- package/ui/strip-rows.js +171 -0
- package/ui/styles.css +492 -0
- package/ui/theme-pick.js +36 -0
- package/ui/theme.js +74 -0
- package/ui/themes/LICENSE.md +13 -0
- package/ui/themes/README.md +44 -0
- package/ui/themes/TOKENS.md +291 -0
- package/ui/themes/a1/board.css +1557 -0
- package/ui/themes/a1/pk.css +392 -0
- package/ui/themes/b1/board.css +1163 -0
- package/ui/themes/b1/pk.css +218 -0
- package/ui/themes/d2/board.css +518 -0
- package/ui/themes/d2/pk.css +243 -0
- package/ui/themes/guard.css +73 -0
- package/ui/themes/i1/board.css +776 -0
- package/ui/themes/i1/pk.css +223 -0
- package/ui/themes/manifest.json +13 -0
- package/ui/views.js +2796 -0
package/ui/failures.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// What intake could not take in (CKC-07 AC-10), as the top bar counts it and its list shows it (owner 2026-09-30: "2
|
|
2
|
+
// failed" could not be opened, and it was one file recorded twice). No DOM here: tested with `node --test`
|
|
3
|
+
// (src/ui/failures.test.ts). The server keeps one entry per ref too (src/intake/intake.ts `latestFailures`); this also
|
|
4
|
+
// folds what an older home still holds, and the entries of several scopes.
|
|
5
|
+
//
|
|
6
|
+
// A file over the size intake reads of one file is not a failure (D105; owner: 「超过 2 MB 的上限 这个不要红色的」): it is
|
|
7
|
+
// `Skipped: too large`, listed in Project scope with its size and the limit, and the top bar neither counts it nor
|
|
8
|
+
// shows it in the colour of an error. The server records it apart (`scope.skipped`, src/intake/skipped.ts); a home
|
|
9
|
+
// written before that still holds it among its failures, and it is read as skipped here all the same.
|
|
10
|
+
|
|
11
|
+
const TOO_LARGE = /^larger than (\d+) bytes$/;
|
|
12
|
+
const tooLargeLimit = (reason) => { const m = TOO_LARGE.exec(String(reason ?? '').trim()); return m ? Number(m[1]) : null; };
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One entry per ref across the coverage's scopes: the latest (by `at`; the later listed on a tie), each with the label of
|
|
16
|
+
* the scope it was recorded under, newest first. Only what really failed: a file too large to read is skipped (`skippedOf`).
|
|
17
|
+
*/
|
|
18
|
+
export function failuresOf(coverage) {
|
|
19
|
+
const byRef = new Map();
|
|
20
|
+
for (const s of coverage?.scopes ?? []) {
|
|
21
|
+
for (const f of s.failed ?? []) {
|
|
22
|
+
const before = byRef.get(f.ref);
|
|
23
|
+
if (before && String(before.at) > String(f.at)) continue;
|
|
24
|
+
byRef.set(f.ref, { ...f, scope: s.label ?? s.id });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return [...byRef.values()].filter((f) => tooLargeLimit(f.reason) === null).sort((a, b) => String(b.at).localeCompare(String(a.at)));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The files skipped as too large across the coverage's scopes, one entry per file (the latest), newest first: each with
|
|
32
|
+
* its size when it was skipped (null when an older home did not record it) and the limit, in bytes.
|
|
33
|
+
*/
|
|
34
|
+
export function skippedOf(coverage) {
|
|
35
|
+
const byRef = new Map();
|
|
36
|
+
const take = (entry) => {
|
|
37
|
+
const before = byRef.get(entry.ref);
|
|
38
|
+
if (before && String(before.at) > String(entry.at)) return;
|
|
39
|
+
byRef.set(entry.ref, entry);
|
|
40
|
+
};
|
|
41
|
+
for (const s of coverage?.scopes ?? []) {
|
|
42
|
+
for (const k of s.skipped ?? []) take({ ref: k.ref, bytes: k.bytes ?? null, limit: k.limit, at: k.at, scope: s.label ?? s.id });
|
|
43
|
+
for (const f of s.failed ?? []) {
|
|
44
|
+
const limit = tooLargeLimit(f.reason);
|
|
45
|
+
if (limit !== null) take({ ref: f.ref, bytes: null, limit, at: f.at, scope: s.label ?? s.id });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return [...byRef.values()].sort((a, b) => String(b.at).localeCompare(String(a.at)));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A size in words: `2.4 MB (2,400,000 bytes)`; MB as intake counts its limit, a million bytes. */
|
|
52
|
+
export function sizeText(bytes) {
|
|
53
|
+
const n = Number(bytes);
|
|
54
|
+
if (bytes === null || bytes === undefined || !Number.isFinite(n)) return 'size not recorded';
|
|
55
|
+
const mb = n / 1_000_000;
|
|
56
|
+
return `${Number.isInteger(mb) ? mb : mb.toFixed(1)} MB (${n.toLocaleString('en-US')} bytes)`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** One skipped file in words: its size and the limit. */
|
|
60
|
+
export function skippedText(entry) {
|
|
61
|
+
return `${sizeText(entry.bytes)} · the most intake reads of one file is ${sizeText(entry.limit)}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The reason in words: a byte limit also in MB, the rest as recorded. */
|
|
65
|
+
export function reasonText(reason) {
|
|
66
|
+
const m = /^larger than (\d+) bytes$/.exec(String(reason ?? '').trim());
|
|
67
|
+
if (!m) return String(reason ?? '');
|
|
68
|
+
const n = Number(m[1]);
|
|
69
|
+
const mb = n / 1_000_000;
|
|
70
|
+
return `larger than ${Number.isInteger(mb) ? mb : mb.toFixed(1)} MB (${n.toLocaleString('en-US')} bytes), the most intake reads of one file`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The path shown under a project location it lies in (with forward slashes), else as recorded. */
|
|
74
|
+
export function shortRef(ref, locations = []) {
|
|
75
|
+
const norm = (p) => String(p).replace(/\\/g, '/').replace(/\/+$/, '');
|
|
76
|
+
const r = norm(ref);
|
|
77
|
+
for (const loc of locations) {
|
|
78
|
+
const l = norm(loc);
|
|
79
|
+
if (l && r.toLowerCase().startsWith(`${l.toLowerCase()}/`)) return r.slice(l.length + 1);
|
|
80
|
+
}
|
|
81
|
+
return String(ref);
|
|
82
|
+
}
|
package/ui/flyout.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// A floating panel that belongs to one button of a view's control row: the `Filter` expansion and the panel behind the
|
|
2
|
+
// readability mark (Spec §6.3 "控件放在哪", "可读性自检 · 在哪里说"; CKC-09 AC-37). It floats — nothing under it is pushed
|
|
3
|
+
// down — and it lies wholly inside the main view's visible area, which is narrower while the Keeper is docked: where it
|
|
4
|
+
// goes is worked out by the same rule as the details popover (popover-place.js), under its button when there is room.
|
|
5
|
+
// Esc or a press elsewhere closes it and focus goes back to the button; Tab walks its items and stays inside it. One
|
|
6
|
+
// panel is open at a time. What is shown inside comes from the caller.
|
|
7
|
+
import { placePopover } from './popover-place.js';
|
|
8
|
+
|
|
9
|
+
const FOCUSABLE = 'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), summary, [tabindex]:not([tabindex="-1"])';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {{ bounds: () => {left:number,top:number,right:number,bottom:number}, avoid?: () => Array<{left:number,top:number,right:number,bottom:number}> }} o
|
|
13
|
+
* `bounds` is the main view's visible area; `avoid` names rectangles not to lie on when that can be helped (the pill).
|
|
14
|
+
*/
|
|
15
|
+
export function createFlyout({ bounds, avoid = () => [] }) {
|
|
16
|
+
const el = document.createElement('section');
|
|
17
|
+
el.className = 'flyout';
|
|
18
|
+
el.hidden = true;
|
|
19
|
+
el.tabIndex = -1;
|
|
20
|
+
el.setAttribute('role', 'dialog');
|
|
21
|
+
document.body.append(el);
|
|
22
|
+
|
|
23
|
+
/** @type {null | { button: HTMLElement, render: (api: object) => Array<Node|null>, className: string }} */
|
|
24
|
+
let current = null;
|
|
25
|
+
let frame = 0;
|
|
26
|
+
let watch = null;
|
|
27
|
+
const focusables = () => [...el.querySelectorAll(FOCUSABLE)].filter((n) => n.getClientRects().length > 0);
|
|
28
|
+
|
|
29
|
+
function place() {
|
|
30
|
+
if (!current) return;
|
|
31
|
+
if (!current.button.isConnected) { close(); return; } // the row it belonged to was rebuilt
|
|
32
|
+
const scrollTop = el.scrollTop;
|
|
33
|
+
el.style.maxHeight = '';
|
|
34
|
+
el.style.width = '';
|
|
35
|
+
const size = { width: el.offsetWidth, height: el.offsetHeight };
|
|
36
|
+
const r = current.button.getBoundingClientRect();
|
|
37
|
+
const p = placePopover({ anchor: { left: r.left, top: r.top, right: r.right, bottom: r.bottom }, size, bounds: bounds(), gap: 6, prefer: ['bottom', 'top', 'right', 'left'], avoid: avoid() });
|
|
38
|
+
el.style.left = `${p.left}px`;
|
|
39
|
+
el.style.top = `${p.top}px`;
|
|
40
|
+
el.style.width = p.width < size.width ? `${p.width}px` : '';
|
|
41
|
+
el.style.maxHeight = p.height < size.height ? `${p.height}px` : '';
|
|
42
|
+
el.dataset.side = p.side;
|
|
43
|
+
el.scrollTop = scrollTop;
|
|
44
|
+
}
|
|
45
|
+
function reposition() {
|
|
46
|
+
if (!current || frame) return;
|
|
47
|
+
frame = requestAnimationFrame(() => { frame = 0; place(); });
|
|
48
|
+
}
|
|
49
|
+
function draw({ focus = false } = {}) {
|
|
50
|
+
if (!current) return;
|
|
51
|
+
const at = el.contains(document.activeElement) ? focusables().indexOf(document.activeElement) : -1;
|
|
52
|
+
const nodes = current.render(api).flat(Infinity).filter((n) => n !== null && n !== undefined && n !== false);
|
|
53
|
+
el.replaceChildren(...nodes);
|
|
54
|
+
el.hidden = false;
|
|
55
|
+
place();
|
|
56
|
+
if (focus) (focusables()[0] ?? el).focus({ preventScroll: true });
|
|
57
|
+
else if (at >= 0) (focusables()[at] ?? el).focus({ preventScroll: true });
|
|
58
|
+
}
|
|
59
|
+
function close({ focus = false } = {}) {
|
|
60
|
+
if (!current) return;
|
|
61
|
+
const was = current;
|
|
62
|
+
current = null;
|
|
63
|
+
watch?.disconnect();
|
|
64
|
+
watch = null;
|
|
65
|
+
el.hidden = true;
|
|
66
|
+
el.replaceChildren();
|
|
67
|
+
was.button.setAttribute('aria-expanded', 'false');
|
|
68
|
+
if (focus && was.button.isConnected) was.button.focus({ preventScroll: true });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const api = {
|
|
72
|
+
el,
|
|
73
|
+
isOpen: (button) => Boolean(current) && (button === undefined || current.button === button),
|
|
74
|
+
/** Open the panel of a button; pressed again while it is open, the button puts it away. */
|
|
75
|
+
toggle({ button, label, render, className = '' }) {
|
|
76
|
+
if (current?.button === button) { close({ focus: true }); return false; }
|
|
77
|
+
close();
|
|
78
|
+
current = { button, render, className };
|
|
79
|
+
el.className = `flyout ${className}`.trim();
|
|
80
|
+
el.setAttribute('aria-label', label);
|
|
81
|
+
button.setAttribute('aria-expanded', 'true');
|
|
82
|
+
// The main view changes width when the Keeper docks or is resized: the panel follows it.
|
|
83
|
+
const main = document.getElementById('main');
|
|
84
|
+
if (main && 'ResizeObserver' in window) { watch = new ResizeObserver(() => reposition()); watch.observe(main); }
|
|
85
|
+
draw({ focus: true });
|
|
86
|
+
return true;
|
|
87
|
+
},
|
|
88
|
+
/** What the panel shows changed (a filter was set, the check ran again): drawn again in place, focus where it was. */
|
|
89
|
+
refresh(button) { if (current && (button === undefined || current.button === button)) draw(); },
|
|
90
|
+
close,
|
|
91
|
+
reposition,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// One Escape closes one thing: a dialog on top takes it first; otherwise this panel, and nothing else hears it.
|
|
95
|
+
document.addEventListener('keydown', (e) => {
|
|
96
|
+
if (!current) return;
|
|
97
|
+
if (e.key === 'Escape') {
|
|
98
|
+
if (document.querySelector('dialog[open]')) return;
|
|
99
|
+
e.preventDefault();
|
|
100
|
+
e.stopPropagation();
|
|
101
|
+
close({ focus: true });
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (e.key !== 'Tab' || !el.contains(document.activeElement)) return;
|
|
105
|
+
const items = focusables();
|
|
106
|
+
if (!items.length) { e.preventDefault(); return; }
|
|
107
|
+
const at = items.indexOf(document.activeElement);
|
|
108
|
+
if (e.shiftKey && at <= 0) { e.preventDefault(); items[items.length - 1].focus(); }
|
|
109
|
+
else if (!e.shiftKey && at === items.length - 1) { e.preventDefault(); items[0].focus(); }
|
|
110
|
+
}, true);
|
|
111
|
+
// A press elsewhere closes it. A press on its own button is that button's business (it toggles).
|
|
112
|
+
document.addEventListener('pointerdown', (e) => {
|
|
113
|
+
if (!current || !(e.target instanceof Element)) return;
|
|
114
|
+
if (el.contains(e.target) || current.button.contains(e.target) || e.target.closest('dialog, #toast')) return;
|
|
115
|
+
close();
|
|
116
|
+
}, true);
|
|
117
|
+
addEventListener('resize', () => reposition());
|
|
118
|
+
|
|
119
|
+
return api;
|
|
120
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// The folder chooser: where a location is typed (Add project's `Locations`, a scope item's `Path`), `Browse…` opens this
|
|
2
|
+
// panel inside the dialog and the owner walks to the folder instead of typing its path (owner 2026-10-06:
|
|
3
|
+
// 「add这个locations帮我换成可以有对话框自己选一下」). The field it stands under stays as it was: typing and pasting a path
|
|
4
|
+
// work as before, and what is chosen here is written into that field as text.
|
|
5
|
+
//
|
|
6
|
+
// It is the program's own panel, not the system's dialog and not the browser's directory picker: a page cannot learn a
|
|
7
|
+
// folder's whole path from the browser, and a dialog opened by a local server comes up behind the browser. The folders
|
|
8
|
+
// come from `GET /api/folders` (src/server/folders-api.ts), which answers the workbench's own pages only.
|
|
9
|
+
//
|
|
10
|
+
// Nothing here knows the system it runs on: the separator, the quick starts (the home directory; the drives on Windows,
|
|
11
|
+
// `/` elsewhere) and every path are the server's. The first half of this file is what the panel says and decides, with
|
|
12
|
+
// no DOM in it (src/ui/folder-chooser.test.ts); the second half draws it with the page's own `h` and `api`.
|
|
13
|
+
|
|
14
|
+
/** The locations typed in a field, one a line: trimmed, empty lines dropped. */
|
|
15
|
+
export const locationLines = (text) => String(text ?? '').split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
16
|
+
|
|
17
|
+
/** `text` with `path` as a new last line; unchanged when a line already says exactly that. What was typed is kept as typed. */
|
|
18
|
+
export function withLocation(text, path) {
|
|
19
|
+
const now = String(text ?? '');
|
|
20
|
+
if (locationLines(now).includes(path)) return now;
|
|
21
|
+
const kept = now.replace(/\s+$/, '');
|
|
22
|
+
return kept ? `${kept}\n${path}` : path;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The request for one directory. `added` are the paths the field already holds (at most 50 are sent). */
|
|
26
|
+
export function foldersUrl({ path = '', hidden = false, added = [] } = {}) {
|
|
27
|
+
const q = new URLSearchParams();
|
|
28
|
+
if (path) q.set('path', path);
|
|
29
|
+
if (hidden) q.set('hidden', '1');
|
|
30
|
+
for (const a of added.slice(0, 50)) q.append('added', a);
|
|
31
|
+
const s = q.toString();
|
|
32
|
+
return s ? `/api/folders?${s}` : '/api/folders';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The marks beside a folder's name: `{ text, tone, title }`, in the order shown. */
|
|
36
|
+
export function folderMarks(folder, addedWord = 'added') {
|
|
37
|
+
const marks = [];
|
|
38
|
+
if (folder.repository) marks.push({ text: 'git', tone: 'blue', title: 'A git repository or a worktree of one' });
|
|
39
|
+
if (folder.link) marks.push({ text: 'link', tone: '', title: 'A link: it opens the folder it leads to' });
|
|
40
|
+
if (folder.added) marks.push({ text: addedWord, tone: 'green', title: `Already ${addedWord}` });
|
|
41
|
+
return marks;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The line under the list: how many folders are not shown, and how to reach one. Empty when all are shown. */
|
|
45
|
+
export function moreLine(listing) {
|
|
46
|
+
if (!listing || !listing.more) return '';
|
|
47
|
+
return `${listing.more.toLocaleString('en')} more ${listing.more === 1 ? 'folder is' : 'folders are'} not shown. Type a path above to go to one.`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** What the list says when it has no row. */
|
|
51
|
+
export function emptyLine(listing, showHidden) {
|
|
52
|
+
if (listing.error) return listing.error;
|
|
53
|
+
if (listing.hidden > 0 && !showHidden) return `No folders here, apart from ${listing.hidden} hidden.`;
|
|
54
|
+
return 'No folders here.';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The label of the hidden-folders switch. */
|
|
58
|
+
export const hiddenLabel = (listing) => (listing && listing.hidden > 0 ? `Show hidden (${listing.hidden})` : 'Show hidden');
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* What a key does in the panel, or null when it is left to the browser.
|
|
62
|
+
* Escape → 'close' (the panel, never the dialog it is in)
|
|
63
|
+
* Alt+ArrowUp → 'up' (anywhere in the panel)
|
|
64
|
+
* Backspace → 'up' (not while typing: in the path line it deletes a character)
|
|
65
|
+
* ArrowDown / ArrowUp → 'next' / 'previous' row (from the path line, ArrowDown enters the list)
|
|
66
|
+
* Home / End → 'first' / 'last' row (in the list)
|
|
67
|
+
* Enter needs no entry: on a row it is the button's own click (go in); in the path line it goes to the typed path.
|
|
68
|
+
* @param {{ key: string, altKey?: boolean, ctrlKey?: boolean, metaKey?: boolean }} e
|
|
69
|
+
* @param {'row' | 'path' | 'other'} where what has the focus
|
|
70
|
+
*/
|
|
71
|
+
export function chooserKey(e, where) {
|
|
72
|
+
if (e.ctrlKey || e.metaKey) return null;
|
|
73
|
+
if (e.key === 'Escape') return 'close';
|
|
74
|
+
if (e.key === 'ArrowUp' && e.altKey) return 'up';
|
|
75
|
+
if (e.altKey) return null;
|
|
76
|
+
if (e.key === 'Backspace') return where === 'path' ? null : 'up';
|
|
77
|
+
if (e.key === 'ArrowDown') return where === 'other' ? null : 'next';
|
|
78
|
+
if (e.key === 'ArrowUp') return where === 'row' ? 'previous' : null;
|
|
79
|
+
if (where === 'row' && e.key === 'Home') return 'first';
|
|
80
|
+
if (where === 'row' && e.key === 'End') return 'last';
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ───────────────────────── the panel ─────────────────────────
|
|
85
|
+
|
|
86
|
+
const FOLDER_ICON = "<svg viewBox='0 0 20 20' width='15' height='15' fill='none' stroke='currentColor' stroke-width='1.5' stroke-linejoin='round' aria-hidden='true'><path d='M2.5 5.5a1 1 0 0 1 1-1h4l1.6 1.8h7.4a1 1 0 0 1 1 1v7.7a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1z'/></svg>";
|
|
87
|
+
|
|
88
|
+
/** Where the last chooser was left, so the next one opens there: this page only, never stored. */
|
|
89
|
+
let lastPlace = '';
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @param {{
|
|
93
|
+
* h: Function, api: (path: string) => Promise<any>,
|
|
94
|
+
* added: () => string[], the paths the field holds now
|
|
95
|
+
* onAdd: (path: string) => void, write the chosen folder into the field
|
|
96
|
+
* start?: () => string, where to open; empty: where the last chooser was left, else the home directory
|
|
97
|
+
* addLabel?: string, the button that takes the folder shown ('Add this folder')
|
|
98
|
+
* addedWord?: string, the mark of a folder the field already holds ('added')
|
|
99
|
+
* single?: boolean, one folder is wanted: choosing it closes the panel
|
|
100
|
+
* chooseAdded?: boolean, a folder the field's owner already holds can be chosen again (it is only marked)
|
|
101
|
+
* }} o
|
|
102
|
+
* @returns {{ button: HTMLElement, panel: HTMLElement, open: () => Promise<void>, close: () => void, isOpen: () => boolean }}
|
|
103
|
+
*/
|
|
104
|
+
export function createFolderChooser(o) {
|
|
105
|
+
const { h, api } = o;
|
|
106
|
+
const addLabel = o.addLabel ?? 'Add this folder';
|
|
107
|
+
const addedWord = o.addedWord ?? 'added';
|
|
108
|
+
let listing = null; // the last answer shown
|
|
109
|
+
let showHidden = false;
|
|
110
|
+
let asked = 0; // the newest request; an older answer that arrives late is dropped
|
|
111
|
+
let host = null; // the dialog the panel is in, while it listens there
|
|
112
|
+
let byKeys = false; // the last thing done in the panel was a key, not the mouse: the folder in focus is shown as such
|
|
113
|
+
|
|
114
|
+
const path = h('input', { class: 'input mono fc-path', 'aria-label': 'Folder path', autocomplete: 'off', spellcheck: 'false', title: 'The folder shown. Type or paste a path and press Enter to go there.' });
|
|
115
|
+
const up = h('button', { class: 'btn small fc-up', type: 'button', title: 'Up one level (Alt+↑, or Backspace in the list)', onClick: () => goUp() }, '↑ Up');
|
|
116
|
+
const go = h('button', { class: 'btn small', type: 'button', title: 'Go to the path typed', onClick: () => load(path.value, { focus: 'list' }) }, 'Go');
|
|
117
|
+
const starts = h('div', { class: 'fc-starts' });
|
|
118
|
+
const hiddenBox = h('input', { type: 'checkbox', onChange: () => { showHidden = hiddenBox.checked; void load(listing?.path ?? path.value, { focus: 'keep' }); } });
|
|
119
|
+
const hiddenText = h('span', {}, 'Show hidden');
|
|
120
|
+
const list = h('div', { class: 'fc-list', role: 'group', 'aria-label': 'Folders' });
|
|
121
|
+
const more = h('small', { class: 'fc-more faint', 'aria-live': 'polite' });
|
|
122
|
+
const here = h('span', { class: 'fc-here muted' });
|
|
123
|
+
const add = h('button', { class: 'btn', type: 'button', onClick: () => choose() }, addLabel);
|
|
124
|
+
const done = h('button', { class: 'btn', type: 'button', title: 'Close the folder list (Esc)', onClick: () => close() }, o.single ? 'Cancel' : 'Done');
|
|
125
|
+
const panel = h('div', { class: 'fc', hidden: true },
|
|
126
|
+
h('div', { class: 'fc-bar' }, up, path, go),
|
|
127
|
+
h('div', { class: 'fc-bar fc-sub' }, starts, h('label', { class: 'fc-hidden' }, hiddenBox, hiddenText)),
|
|
128
|
+
list, more,
|
|
129
|
+
h('div', { class: 'fc-foot' }, here, add, done));
|
|
130
|
+
const button = h('button', { class: 'btn small fc-browse', type: 'button', 'aria-expanded': 'false', title: 'Choose the folder from a list instead of typing its path', onClick: () => (isOpen() ? close() : void open()) }, 'Browse…');
|
|
131
|
+
|
|
132
|
+
panel.addEventListener('pointerdown', () => { byKeys = false; }, true);
|
|
133
|
+
const rows = () => [...list.querySelectorAll('.fc-row')];
|
|
134
|
+
const isOpen = () => !panel.hidden;
|
|
135
|
+
|
|
136
|
+
function say(text) {
|
|
137
|
+
list.replaceChildren(h('div', { class: 'fc-empty muted' }, text));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function draw(focus) {
|
|
141
|
+
const l = listing;
|
|
142
|
+
path.value = l.path;
|
|
143
|
+
up.disabled = l.parent === null;
|
|
144
|
+
starts.replaceChildren(...l.starts.map((s) => h('button', { class: 'btn small', type: 'button', title: s.path, onClick: () => load(s.path, { focus: 'list' }) }, s.label)));
|
|
145
|
+
hiddenText.textContent = hiddenLabel(l);
|
|
146
|
+
if (l.folders.length === 0) say(emptyLine(l, showHidden));
|
|
147
|
+
else {
|
|
148
|
+
list.replaceChildren(...l.folders.map((f) => h('button', { class: `fc-row${f.hidden ? ' dim' : ''}`, type: 'button', dataset: { path: f.path }, title: f.path, onClick: () => load(f.path, { focus: 'list' }) },
|
|
149
|
+
h('span', { class: 'fc-icon', html: FOLDER_ICON }),
|
|
150
|
+
h('span', { class: 'fc-name' }, f.name),
|
|
151
|
+
...folderMarks(f, addedWord).map((m) => h('span', { class: `tag ${m.tone}`.trim(), title: m.title }, m.text)))));
|
|
152
|
+
}
|
|
153
|
+
list.scrollTop = 0;
|
|
154
|
+
more.textContent = moreLine(l);
|
|
155
|
+
const marks = folderMarks({ repository: l.repository, link: false, added: l.added }, addedWord);
|
|
156
|
+
here.replaceChildren(h('span', { class: 'fc-here-name', title: l.path }, l.name), ...marks.map((m) => h('span', { class: `tag ${m.tone}`.trim(), title: m.title }, m.text)));
|
|
157
|
+
const taken = l.added && !o.chooseAdded;
|
|
158
|
+
add.disabled = Boolean(l.error) || taken;
|
|
159
|
+
add.title = l.error ? 'This folder cannot be chosen' : taken ? `This folder is already ${addedWord}` : l.path;
|
|
160
|
+
if (focus === 'keep') return;
|
|
161
|
+
// Coming up, the folder that was left is the one in focus; going in, the first; with no row, the way on.
|
|
162
|
+
const from = typeof focus === 'object' && focus ? rows().find((r) => r.dataset.path === focus.from) : null;
|
|
163
|
+
const target = from ?? rows()[0] ?? (add.disabled ? (up.disabled ? path : up) : add);
|
|
164
|
+
// A key held with Alt does not make the browser draw the focus by itself, so it is asked to.
|
|
165
|
+
target.focus({ preventScroll: true, focusVisible: byKeys });
|
|
166
|
+
target.scrollIntoView?.({ block: 'nearest' });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function load(to, { focus = 'list' } = {}) {
|
|
170
|
+
const mine = ++asked;
|
|
171
|
+
const slow = setTimeout(() => { if (mine === asked) say('Reading…'); }, 250);
|
|
172
|
+
try {
|
|
173
|
+
const answer = await api(foldersUrl({ path: String(to ?? '').trim(), hidden: showHidden, added: o.added() }));
|
|
174
|
+
if (mine !== asked) return;
|
|
175
|
+
listing = answer;
|
|
176
|
+
lastPlace = answer.error ? lastPlace : answer.path;
|
|
177
|
+
draw(focus);
|
|
178
|
+
} catch (e) {
|
|
179
|
+
if (mine !== asked) return;
|
|
180
|
+
// Not a whole path, or the workbench did not answer: said where the list is; the path line keeps what was typed.
|
|
181
|
+
say(e.message);
|
|
182
|
+
more.textContent = '';
|
|
183
|
+
} finally { clearTimeout(slow); }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function goUp() {
|
|
187
|
+
if (!listing || listing.parent === null) return;
|
|
188
|
+
void load(listing.parent, { focus: { from: listing.path } });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function choose() {
|
|
192
|
+
if (!listing || listing.error || (listing.added && !o.chooseAdded)) return;
|
|
193
|
+
o.onAdd(listing.path);
|
|
194
|
+
if (o.single) { close(); return; }
|
|
195
|
+
// Still open: a project often has more than one location. The folder now shows as added, here and in the list above it.
|
|
196
|
+
void load(listing.path, { focus: 'keep' });
|
|
197
|
+
done.focus({ preventScroll: true });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function onKey(e) {
|
|
201
|
+
if (!panel.isConnected) { detach(); return; } // the dialog went on to other content with the panel open
|
|
202
|
+
if (!isOpen() || e.isComposing) return; // a key that belongs to an input method composing a name is not the panel's
|
|
203
|
+
byKeys = true;
|
|
204
|
+
const inPanel = panel.contains(e.target);
|
|
205
|
+
const where = e.target === path ? 'path' : inPanel && e.target.classList?.contains('fc-row') ? 'row' : 'other';
|
|
206
|
+
// Escape closes the panel wherever the focus is in the dialog; every other key is the panel's only inside it.
|
|
207
|
+
if (e.key === 'Enter' && where === 'path') { e.preventDefault(); void load(path.value, { focus: 'list' }); return; }
|
|
208
|
+
const action = chooserKey(e, where);
|
|
209
|
+
if (!action || (action !== 'close' && !inPanel)) return;
|
|
210
|
+
e.preventDefault();
|
|
211
|
+
e.stopPropagation();
|
|
212
|
+
if (action === 'close') { close(); return; }
|
|
213
|
+
if (action === 'up') { goUp(); return; }
|
|
214
|
+
const all = rows();
|
|
215
|
+
if (all.length === 0) return;
|
|
216
|
+
const at = all.indexOf(e.target);
|
|
217
|
+
const next = action === 'first' ? 0 : action === 'last' ? all.length - 1 : action === 'next' ? Math.min(all.length - 1, at + 1) : Math.max(0, at - 1);
|
|
218
|
+
all[next].focus({ preventScroll: true, focusVisible: true });
|
|
219
|
+
all[next].scrollIntoView({ block: 'nearest' });
|
|
220
|
+
}
|
|
221
|
+
/** The dialog's own answer to Escape (it would close): the panel closes instead. */
|
|
222
|
+
function onCancel(e) {
|
|
223
|
+
if (!panel.isConnected) { detach(); return; }
|
|
224
|
+
if (!isOpen()) return;
|
|
225
|
+
e.preventDefault();
|
|
226
|
+
e.stopImmediatePropagation();
|
|
227
|
+
close();
|
|
228
|
+
}
|
|
229
|
+
function detach() {
|
|
230
|
+
host?.removeEventListener('keydown', onKey, true);
|
|
231
|
+
host?.removeEventListener('cancel', onCancel, true);
|
|
232
|
+
host?.removeEventListener('close', detach);
|
|
233
|
+
host = null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function open() {
|
|
237
|
+
if (isOpen()) return;
|
|
238
|
+
panel.hidden = false;
|
|
239
|
+
button.setAttribute('aria-expanded', 'true');
|
|
240
|
+
button.classList.add('active');
|
|
241
|
+
host = panel.closest('dialog') ?? panel.ownerDocument;
|
|
242
|
+
host.addEventListener('keydown', onKey, true);
|
|
243
|
+
host.addEventListener('cancel', onCancel, true);
|
|
244
|
+
host.addEventListener('close', detach); // the dialog closed with the panel open: nothing of it stays behind
|
|
245
|
+
say('Reading…');
|
|
246
|
+
listing = null;
|
|
247
|
+
const first = o.start?.() || lastPlace || '';
|
|
248
|
+
await load(first, { focus: 'list' });
|
|
249
|
+
// What the field holds may be no whole path (a name half typed): the panel then opens at the home directory.
|
|
250
|
+
if (listing === null && first && isOpen()) await load('', { focus: 'list' });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function close() {
|
|
254
|
+
if (!isOpen()) return;
|
|
255
|
+
asked++;
|
|
256
|
+
panel.hidden = true;
|
|
257
|
+
button.setAttribute('aria-expanded', 'false');
|
|
258
|
+
button.classList.remove('active');
|
|
259
|
+
detach();
|
|
260
|
+
if (button.isConnected) button.focus({ preventScroll: true });
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return { button, panel, open, close, isOpen };
|
|
264
|
+
}
|