@crouton-kit/crouter 0.3.28 → 0.3.29
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/dist/build-root.js +4 -2
- package/dist/builtin-memory/crouter-development/marketplaces.md +7 -6
- package/dist/builtin-memory/crouter-development/personas/base-prompt.md +1 -1
- package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +1 -1
- package/dist/builtin-memory/crouter-development/personas.md +1 -1
- package/dist/builtin-memory/crouter-development/plugins.md +20 -18
- package/dist/builtin-memory/design.md +1 -1
- package/dist/builtin-memory/development.md +1 -1
- package/dist/builtin-memory/internal/INDEX.md +1 -1
- package/dist/builtin-memory/internal/examples/INDEX.md +1 -1
- package/dist/builtin-memory/internal/examples/imessage-assistant.md +2 -2
- package/dist/builtin-memory/internal/nodes-and-canvas.md +1 -1
- package/dist/builtin-memory/internal/storage-tiers.md +1 -1
- package/dist/builtin-memory/planning.md +1 -1
- package/dist/builtin-memory/spec.md +6 -4
- package/dist/builtin-personas/orchestration-kernel.md +4 -5
- package/dist/builtin-personas/runtime-base.md +5 -4
- package/dist/builtin-personas/spec/PERSONA.md +8 -4
- package/dist/builtin-personas/spec/orchestrator.md +5 -3
- package/dist/builtin-personas/spec/requirements/PERSONA.md +10 -0
- package/dist/builtin-views/canvas/core.mjs +586 -0
- package/dist/builtin-views/canvas/text.mjs +58 -0
- package/dist/builtin-views/canvas/tui.mjs +165 -0
- package/dist/builtin-views/canvas/web.jsx +120 -0
- package/dist/builtin-views/git-pr/core.mjs +673 -0
- package/dist/builtin-views/git-pr/text.mjs +84 -0
- package/dist/builtin-views/git-pr/tui.mjs +302 -0
- package/dist/builtin-views/git-pr/web.jsx +216 -0
- package/dist/builtin-views/inbox/_lib/render.mjs +1 -3
- package/dist/builtin-views/inbox/core.mjs +1273 -0
- package/dist/builtin-views/inbox/text.mjs +73 -0
- package/dist/builtin-views/inbox/tui.mjs +312 -0
- package/dist/builtin-views/inbox/web.jsx +188 -0
- package/dist/builtin-views/linkedin/core.mjs +906 -0
- package/dist/builtin-views/linkedin/text.mjs +69 -0
- package/dist/builtin-views/linkedin/tui.mjs +427 -0
- package/dist/builtin-views/linkedin/web.jsx +206 -0
- package/dist/builtin-views/workspace-sidebar/core.mjs +621 -0
- package/dist/builtin-views/workspace-sidebar/text.mjs +53 -0
- package/dist/builtin-views/workspace-sidebar/tui.mjs +142 -0
- package/dist/builtin-views/workspace-sidebar/web.jsx +109 -0
- package/dist/cli.js +3 -3
- package/dist/clients/attach/__tests__/action-parity.test.js +48 -0
- package/dist/clients/attach/__tests__/clipboard-image.test.js +76 -0
- package/dist/clients/attach/__tests__/editor-newline.test.js +57 -0
- package/dist/clients/attach/__tests__/git-info.test.js +31 -0
- package/dist/clients/attach/__tests__/onrequest-wired.test.js +20 -0
- package/dist/clients/attach/__tests__/slash-quit-copy.test.js +42 -0
- package/dist/clients/attach/__tests__/titled-editor.test.js +39 -0
- package/dist/clients/attach/attach-cmd.js +282 -46
- package/dist/clients/attach/auth-pickers.d.ts +9 -0
- package/dist/clients/attach/auth-pickers.js +194 -0
- package/dist/clients/attach/canvas-panels.d.ts +4 -2
- package/dist/clients/attach/canvas-panels.js +45 -17
- package/dist/clients/attach/chat-view.d.ts +27 -2
- package/dist/clients/attach/chat-view.js +98 -9
- package/dist/clients/attach/clipboard-image.d.ts +47 -8
- package/dist/clients/attach/clipboard-image.js +150 -26
- package/dist/clients/attach/clipboard-text.d.ts +3 -0
- package/dist/clients/attach/clipboard-text.js +39 -0
- package/dist/clients/attach/config-load.d.ts +54 -7
- package/dist/clients/attach/config-load.js +99 -3
- package/dist/clients/attach/context-message.d.ts +12 -0
- package/dist/clients/attach/context-message.js +72 -0
- package/dist/clients/attach/git-info.d.ts +20 -0
- package/dist/clients/attach/git-info.js +44 -0
- package/dist/clients/attach/graph-overlay.d.ts +13 -4
- package/dist/clients/attach/graph-overlay.js +59 -19
- package/dist/clients/attach/input-controller.d.ts +75 -13
- package/dist/clients/attach/input-controller.js +305 -67
- package/dist/clients/attach/pickers.d.ts +58 -0
- package/dist/clients/attach/pickers.js +171 -0
- package/dist/clients/attach/slash-commands.d.ts +22 -0
- package/dist/clients/attach/slash-commands.js +91 -25
- package/dist/clients/attach/titled-editor.d.ts +33 -0
- package/dist/clients/attach/titled-editor.js +84 -0
- package/dist/clients/attach/view-socket.d.ts +18 -1
- package/dist/clients/attach/view-socket.js +70 -1
- package/dist/clients/web/dev-server.d.ts +7 -0
- package/dist/clients/web/dev-server.js +59 -0
- package/dist/clients/web/events.d.ts +14 -0
- package/dist/clients/web/events.js +151 -0
- package/dist/clients/web/server.d.ts +18 -0
- package/dist/clients/web/server.js +450 -0
- package/dist/clients/web/web-cmd.d.ts +2 -0
- package/dist/clients/web/web-cmd.js +120 -0
- package/dist/commands/canvas.js +1 -2
- package/dist/commands/chord.js +1 -1
- package/dist/commands/dashboard.js +5 -1
- package/dist/commands/memory/__tests__/lint-schema.test.js +6 -6
- package/dist/commands/memory/lint.js +14 -2
- package/dist/commands/memory/read.js +2 -2
- package/dist/commands/memory/shared.d.ts +7 -1
- package/dist/commands/memory/shared.js +18 -3
- package/dist/commands/memory/write.js +8 -4
- package/dist/commands/memory.js +4 -4
- package/dist/commands/node.d.ts +1 -1
- package/dist/commands/node.js +64 -65
- package/dist/commands/pkg.js +1 -2
- package/dist/commands/revive.js +11 -3
- package/dist/commands/sys/sync.d.ts +1 -0
- package/dist/commands/sys/sync.js +187 -0
- package/dist/commands/sys.js +3 -2
- package/dist/commands/view-cycle.js +2 -2
- package/dist/commands/view-list.js +8 -8
- package/dist/commands/view-new.js +21 -17
- package/dist/commands/view-pick.js +1 -1
- package/dist/commands/view-run.js +35 -14
- package/dist/commands/view.js +8 -6
- package/dist/commands/workspace.d.ts +2 -0
- package/dist/commands/workspace.js +161 -0
- package/dist/core/__tests__/broker-double-spawn.test.d.ts +1 -0
- package/dist/core/__tests__/broker-double-spawn.test.js +143 -0
- package/dist/core/__tests__/broker-fork-seam.test.d.ts +1 -0
- package/dist/core/__tests__/broker-fork-seam.test.js +102 -0
- package/dist/core/__tests__/broker-sdk-wiring.test.js +2 -0
- package/dist/core/__tests__/child-death-wake.test.js +56 -111
- package/dist/core/__tests__/close.test.js +14 -20
- package/dist/core/__tests__/context-intro.test.js +19 -19
- package/dist/core/__tests__/daemon-boot.test.js +30 -23
- package/dist/core/__tests__/draw-style.test.js +1 -1
- package/dist/core/__tests__/error-stall-recycle.test.d.ts +1 -0
- package/dist/core/__tests__/error-stall-recycle.test.js +141 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +17 -1
- package/dist/core/__tests__/fixtures/fake-engine.js +22 -2
- package/dist/core/__tests__/fixtures/fake-pi-host.js +2 -2
- package/dist/core/__tests__/focuses.test.js +25 -13
- package/dist/core/__tests__/fork.test.js +22 -5
- package/dist/core/__tests__/full/broker-control-preempt.test.d.ts +1 -0
- package/dist/core/__tests__/full/broker-control-preempt.test.js +61 -0
- package/dist/core/__tests__/full/broker-model-changed-broadcast.test.d.ts +1 -0
- package/dist/core/__tests__/full/broker-model-changed-broadcast.test.js +57 -0
- package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.d.ts +1 -0
- package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.js +80 -0
- package/dist/core/__tests__/full/broker-pane-resolution.test.js +90 -0
- package/dist/core/__tests__/full/cascade-close.test.js +22 -8
- package/dist/core/__tests__/full/detach-focus.test.js +66 -169
- package/dist/core/__tests__/helpers/harness.d.ts +6 -9
- package/dist/core/__tests__/helpers/harness.js +19 -36
- package/dist/core/__tests__/human-node-not-supervised.test.d.ts +1 -0
- package/dist/core/__tests__/human-node-not-supervised.test.js +83 -0
- package/dist/core/__tests__/lifecycle.test.js +1 -19
- package/dist/core/__tests__/live-mutation-verbs.test.js +5 -5
- package/dist/core/__tests__/memory-resolver.test.js +1 -1
- package/dist/core/__tests__/on-read-dedup-resume.test.js +2 -2
- package/dist/core/__tests__/refresh-stall-recycle.test.d.ts +1 -0
- package/dist/core/__tests__/refresh-stall-recycle.test.js +88 -0
- package/dist/core/__tests__/relaunch-root.test.d.ts +1 -0
- package/dist/core/__tests__/relaunch-root.test.js +114 -0
- package/dist/core/__tests__/reset.test.js +19 -79
- package/dist/core/__tests__/steer-note.test.js +9 -7
- package/dist/core/__tests__/tmux-surface.test.js +13 -7
- package/dist/core/bootstrap.d.ts +0 -3
- package/dist/core/bootstrap.js +1 -143
- package/dist/core/canvas/browse/__tests__/model.test.js +86 -1
- package/dist/core/canvas/browse/__tests__/render.test.js +2 -1
- package/dist/core/canvas/browse/app.js +131 -8
- package/dist/core/canvas/browse/model.d.ts +23 -5
- package/dist/core/canvas/browse/model.js +68 -13
- package/dist/core/canvas/browse/render.d.ts +7 -1
- package/dist/core/canvas/browse/render.js +178 -47
- package/dist/core/canvas/canvas.js +1 -1
- package/dist/core/canvas/focuses.d.ts +4 -4
- package/dist/core/canvas/focuses.js +7 -7
- package/dist/core/canvas/nav-model.js +8 -7
- package/dist/core/canvas/render.d.ts +68 -9
- package/dist/core/canvas/render.js +195 -61
- package/dist/core/canvas/types.d.ts +16 -23
- package/dist/core/config.js +1 -4
- package/dist/core/memory-resolver.js +43 -5
- package/dist/core/runtime/bearings.d.ts +1 -1
- package/dist/core/runtime/bearings.js +8 -8
- package/dist/core/runtime/branded-host.d.ts +16 -0
- package/dist/core/runtime/branded-host.js +127 -0
- package/dist/core/runtime/broker-protocol.d.ts +212 -6
- package/dist/core/runtime/broker.d.ts +4 -1
- package/dist/core/runtime/broker.js +494 -58
- package/dist/core/runtime/close.d.ts +12 -2
- package/dist/core/runtime/close.js +37 -16
- package/dist/core/runtime/front-door.js +6 -14
- package/dist/core/runtime/host.d.ts +14 -34
- package/dist/core/runtime/host.js +14 -50
- package/dist/core/runtime/launch.d.ts +12 -9
- package/dist/core/runtime/launch.js +27 -15
- package/dist/core/runtime/lifecycle.d.ts +1 -1
- package/dist/core/runtime/lifecycle.js +15 -19
- package/dist/core/runtime/nodes.d.ts +0 -55
- package/dist/core/runtime/nodes.js +6 -74
- package/dist/core/runtime/placement.d.ts +91 -335
- package/dist/core/runtime/placement.js +262 -840
- package/dist/core/runtime/promote.d.ts +2 -0
- package/dist/core/runtime/promote.js +24 -6
- package/dist/core/runtime/recap.d.ts +8 -0
- package/dist/core/runtime/recap.js +107 -0
- package/dist/core/runtime/recycle.js +25 -61
- package/dist/core/runtime/reset.d.ts +43 -37
- package/dist/core/runtime/reset.js +131 -218
- package/dist/core/runtime/revive.d.ts +9 -29
- package/dist/core/runtime/revive.js +40 -139
- package/dist/core/runtime/spawn.d.ts +15 -16
- package/dist/core/runtime/spawn.js +165 -194
- package/dist/core/runtime/surface-bg.d.ts +11 -0
- package/dist/core/runtime/surface-bg.js +68 -0
- package/dist/core/runtime/tmux.d.ts +13 -50
- package/dist/core/runtime/tmux.js +33 -87
- package/dist/core/scope.d.ts +1 -2
- package/dist/core/scope.js +1 -5
- package/dist/core/skill-sync/__tests__/dry-run-wrote-count.test.d.ts +1 -0
- package/dist/core/skill-sync/__tests__/dry-run-wrote-count.test.js +57 -0
- package/dist/core/skill-sync/builtins.d.ts +42 -0
- package/dist/core/skill-sync/builtins.js +112 -0
- package/dist/core/skill-sync/claude-plugins.d.ts +23 -0
- package/dist/core/skill-sync/claude-plugins.js +71 -0
- package/dist/core/skill-sync/engine.d.ts +42 -0
- package/dist/core/skill-sync/engine.js +633 -0
- package/dist/core/skill-sync/export.d.ts +23 -0
- package/dist/core/skill-sync/export.js +86 -0
- package/dist/core/skill-sync/manifest.d.ts +64 -0
- package/dist/core/skill-sync/manifest.js +181 -0
- package/dist/core/skill-sync/profile.d.ts +76 -0
- package/dist/core/skill-sync/profile.js +173 -0
- package/dist/core/skill-sync/snapshot.d.ts +57 -0
- package/dist/core/skill-sync/snapshot.js +120 -0
- package/dist/core/substrate/index.d.ts +1 -1
- package/dist/core/substrate/index.js +1 -1
- package/dist/core/substrate/injected-store.js +3 -3
- package/dist/core/substrate/on-read.js +68 -6
- package/dist/core/substrate/render.d.ts +8 -11
- package/dist/core/substrate/render.js +29 -43
- package/dist/core/substrate/schema.d.ts +10 -3
- package/dist/core/substrate/schema.js +6 -3
- package/dist/core/tui/host.d.ts +12 -4
- package/dist/core/tui/host.js +280 -149
- package/dist/core/view/bridge.d.ts +10 -0
- package/dist/core/view/bridge.js +31 -0
- package/dist/core/view/chrome.d.ts +9 -0
- package/dist/core/view/chrome.js +22 -0
- package/dist/core/view/contract.d.ts +171 -0
- package/dist/core/view/contract.js +23 -0
- package/dist/core/view/loader.d.ts +31 -0
- package/dist/core/view/loader.js +188 -0
- package/dist/core/view/transport-local.d.ts +7 -0
- package/dist/core/view/transport-local.js +70 -0
- package/dist/core/view/transport.d.ts +4 -0
- package/dist/core/view/transport.js +15 -0
- package/dist/daemon/crtrd.d.ts +38 -27
- package/dist/daemon/crtrd.js +303 -376
- package/dist/daemon/manage.js +6 -1
- package/dist/index.js +1 -1
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +28 -20
- package/dist/pi-extensions/canvas-context-intro.js +4 -4
- package/dist/pi-extensions/canvas-doc-substrate.js +16 -16
- package/dist/pi-extensions/canvas-nav.js +5 -0
- package/dist/pi-extensions/canvas-recap.d.ts +37 -0
- package/dist/pi-extensions/canvas-recap.js +236 -0
- package/dist/pi-extensions/canvas-resume.js +2 -1
- package/dist/pi-extensions/canvas-stophook.d.ts +14 -12
- package/dist/pi-extensions/canvas-stophook.js +87 -101
- package/dist/pi-extensions/canvas-view.js +2 -1
- package/dist/pi-extensions/widget-order-bus.d.ts +6 -0
- package/dist/pi-extensions/widget-order-bus.js +34 -0
- package/dist/prompts/view.d.ts +2 -2
- package/dist/prompts/view.js +145 -73
- package/dist/types.d.ts +1 -6
- package/dist/types.js +1 -3
- package/dist/web/ViewChrome.d.ts +7 -0
- package/dist/web/ViewChrome.js +28 -0
- package/dist/web/ViewPane.d.ts +39 -0
- package/dist/web/ViewPane.js +48 -0
- package/dist/web/index.d.ts +6 -0
- package/dist/web/index.js +16 -0
- package/dist/web/runtime.d.ts +39 -0
- package/dist/web/runtime.js +133 -0
- package/dist/web/states.d.ts +24 -0
- package/dist/web/states.js +24 -0
- package/dist/web/transport-http.d.ts +5 -0
- package/dist/web/transport-http.js +28 -0
- package/dist/web-client/assets/index-BUvQb4hR.css +2 -0
- package/dist/web-client/assets/index-ClLQXYAE.js +10 -0
- package/dist/web-client/index.html +13 -0
- package/package.json +20 -6
- package/dist/builtin-views/canvas/client.mjs +0 -303
- package/dist/builtin-views/canvas/view.mjs +0 -576
- package/dist/builtin-views/git-pr/client.mjs +0 -440
- package/dist/builtin-views/git-pr/view.mjs +0 -675
- package/dist/builtin-views/inbox/sources/gmail.mjs +0 -965
- package/dist/builtin-views/inbox/sources/linkedin.mjs +0 -427
- package/dist/builtin-views/inbox/view.mjs +0 -889
- package/dist/builtin-views/linkedin/client.mjs +0 -610
- package/dist/builtin-views/linkedin/view.mjs +0 -1171
- package/dist/commands/pkg/bridge.d.ts +0 -1
- package/dist/commands/pkg/bridge.js +0 -137
- package/dist/commands/skill/author.d.ts +0 -3
- package/dist/commands/skill/author.js +0 -140
- package/dist/commands/skill/shared.d.ts +0 -3
- package/dist/commands/skill/shared.js +0 -19
- package/dist/commands/skill.d.ts +0 -2
- package/dist/commands/skill.js +0 -21
- package/dist/commands/tmux-spread.d.ts +0 -2
- package/dist/commands/tmux-spread.js +0 -144
- package/dist/core/__tests__/full/daemon-liveness-pane.full.test.js +0 -292
- package/dist/core/__tests__/full/placement-focus.test.js +0 -309
- package/dist/core/__tests__/full/placement-reconcile.test.js +0 -212
- package/dist/core/__tests__/full/placement-revive.test.js +0 -238
- package/dist/core/__tests__/full/placement-teardown.test.js +0 -297
- package/dist/core/__tests__/home-session.test.js +0 -186
- package/dist/core/__tests__/relaunch.test.js +0 -335
- package/dist/core/bridge-map.d.ts +0 -19
- package/dist/core/bridge-map.js +0 -73
- package/dist/core/tui/contract.d.ts +0 -83
- package/dist/core/tui/contract.js +0 -8
- package/dist/core/tui/loader.d.ts +0 -16
- package/dist/core/tui/loader.js +0 -94
- package/dist/prompts/skill.d.ts +0 -2
- package/dist/prompts/skill.js +0 -650
- /package/dist/{core/__tests__/full/daemon-liveness-pane.full.test.d.ts → clients/attach/__tests__/action-parity.test.d.ts} +0 -0
- /package/dist/{core/__tests__/full/placement-focus.test.d.ts → clients/attach/__tests__/clipboard-image.test.d.ts} +0 -0
- /package/dist/{core/__tests__/full/placement-reconcile.test.d.ts → clients/attach/__tests__/editor-newline.test.d.ts} +0 -0
- /package/dist/{core/__tests__/full/placement-revive.test.d.ts → clients/attach/__tests__/git-info.test.d.ts} +0 -0
- /package/dist/{core/__tests__/full/placement-teardown.test.d.ts → clients/attach/__tests__/onrequest-wired.test.d.ts} +0 -0
- /package/dist/{core/__tests__/home-session.test.d.ts → clients/attach/__tests__/slash-quit-copy.test.d.ts} +0 -0
- /package/dist/{core/__tests__/relaunch.test.d.ts → clients/attach/__tests__/titled-editor.test.d.ts} +0 -0
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Git / PR board — the PORTABLE CORE of the crtr `git-pr` view (manifest · init ·
|
|
4
|
+
* sources · intents). The reference dual-target view: this one core renders in
|
|
5
|
+
* BOTH targets — the tmux TUI (`crtr view run git-pr`, via `tui.mjs`) and the
|
|
6
|
+
* React+Tailwind web page (`crtr view serve git-pr`, via `web.jsx`).
|
|
7
|
+
*
|
|
8
|
+
* Runs in BOTH Node and the browser, so it imports NOTHING — no `node:*`, no
|
|
9
|
+
* crtr. The data layer is expressed as transport-agnostic `Source` descriptors:
|
|
10
|
+
* the core describes WHAT to run (`request()` → a SourceRequest for `git`/`gh`),
|
|
11
|
+
* the host's Transport runs it (local `execFile` for the TUI, the HTTP bridge
|
|
12
|
+
* for web), and the pure `parse()` turns bytes → typed data. The porcelain/JSON
|
|
13
|
+
* parsing (parseStatus / rollupCi / relAge / classifyGh …) is pure string work
|
|
14
|
+
* that runs anywhere.
|
|
15
|
+
*
|
|
16
|
+
* NOTHING throws. Sources return a `Result<T>` (typed `SourceError` on failure);
|
|
17
|
+
* the `refresh` intent maps a blocking git error to a guided takeover and keeps
|
|
18
|
+
* the last-known board on a transient failure (graceful partial failure).
|
|
19
|
+
*
|
|
20
|
+
* @module git-pr/core
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {import('../../core/view/contract.js').Source<any, any>} AnySource
|
|
25
|
+
* @typedef {import('../../core/view/contract.js').SourceError} SourceError
|
|
26
|
+
* @typedef {import('../../core/view/contract.js').RawResponse} RawResponse
|
|
27
|
+
* @typedef {import('../../core/view/contract.js').IntentCtx<GitPrState>} Ctx
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* One changed file, flattened from `git status --porcelain=v1 -z`.
|
|
32
|
+
* @typedef {Object} ChangedFile
|
|
33
|
+
* @property {string} path
|
|
34
|
+
* @property {string} xy
|
|
35
|
+
* @property {'staged'|'modified'|'untracked'|'conflict'} cls
|
|
36
|
+
* @property {number} add
|
|
37
|
+
* @property {number} del
|
|
38
|
+
*/
|
|
39
|
+
/**
|
|
40
|
+
* Local git state. `lastCommit` / `upstream` are null on an empty repo / a branch
|
|
41
|
+
* with no upstream — degraded fields, not errors.
|
|
42
|
+
* @typedef {Object} GitState
|
|
43
|
+
* @property {string} branch
|
|
44
|
+
* @property {boolean} detached
|
|
45
|
+
* @property {string|null} upstream
|
|
46
|
+
* @property {number} ahead
|
|
47
|
+
* @property {number} behind
|
|
48
|
+
* @property {{sha:string, subject:string, when:string}|null} lastCommit
|
|
49
|
+
* @property {ChangedFile[]} files
|
|
50
|
+
* @property {{staged:number, modified:number, untracked:number, conflict:number}} counts
|
|
51
|
+
*/
|
|
52
|
+
/**
|
|
53
|
+
* One open pull request, flattened from `gh pr list --json …`.
|
|
54
|
+
* @typedef {Object} Pr
|
|
55
|
+
* @property {number} number
|
|
56
|
+
* @property {string} title
|
|
57
|
+
* @property {string} headRefName
|
|
58
|
+
* @property {boolean} isDraft
|
|
59
|
+
* @property {boolean} current
|
|
60
|
+
* @property {'approved'|'changes'|'review'} review
|
|
61
|
+
* @property {'pass'|'fail'|'pending'|'none'} ci
|
|
62
|
+
* @property {string} updatedAt
|
|
63
|
+
*/
|
|
64
|
+
/**
|
|
65
|
+
* One logical board row (built once per refresh; re-rendered on resize without a
|
|
66
|
+
* re-fetch). The cursor moves over all of them — label/spacer rows are inert.
|
|
67
|
+
* @typedef {{kind:'label', text:string}
|
|
68
|
+
* | {kind:'file', file:ChangedFile}
|
|
69
|
+
* | {kind:'clean'}
|
|
70
|
+
* | {kind:'spacer'}
|
|
71
|
+
* | {kind:'pr', pr:Pr}
|
|
72
|
+
* | {kind:'note', text:string}} BoardRow
|
|
73
|
+
*/
|
|
74
|
+
/**
|
|
75
|
+
* The view's immutable state (the core owns it; intents replace it via ctx.set).
|
|
76
|
+
* @typedef {Object} GitPrState
|
|
77
|
+
* @property {GitState|null} git
|
|
78
|
+
* @property {SourceError|null} gitErr Typed git failure; presenters render its `display` VERBATIM.
|
|
79
|
+
* @property {Pr[]} prs
|
|
80
|
+
* @property {string|null} prNote
|
|
81
|
+
* @property {BoardRow[]} board
|
|
82
|
+
* @property {number} cursor
|
|
83
|
+
* @property {number} scroll
|
|
84
|
+
* @property {number} lastFetch
|
|
85
|
+
*/
|
|
86
|
+
|
|
87
|
+
// ── Result helpers (inlined — the core imports nothing) ───────────────────────
|
|
88
|
+
|
|
89
|
+
/** @template T @param {T} data @returns {{ok:true, data:T}} */
|
|
90
|
+
function ok(data) {
|
|
91
|
+
return { ok: true, data };
|
|
92
|
+
}
|
|
93
|
+
/** @param {SourceError} error @returns {{ok:false, error:SourceError}} */
|
|
94
|
+
function fail(error) {
|
|
95
|
+
return { ok: false, error };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Tiny pure utilities ───────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
/** @param {string} s @returns {string} */
|
|
101
|
+
function firstLine(s) {
|
|
102
|
+
const lines = String(s || '')
|
|
103
|
+
.split(/\r?\n/)
|
|
104
|
+
.map((l) => l.trim())
|
|
105
|
+
.filter(Boolean);
|
|
106
|
+
return lines.length ? lines[0] : '';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** @param {string|number} v @returns {number} */
|
|
110
|
+
function toInt(v) {
|
|
111
|
+
const n = parseInt(String(v), 10);
|
|
112
|
+
return Number.isFinite(n) ? n : 0;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** @param {number} n @param {string} w @returns {string} */
|
|
116
|
+
export function plural(n, w) {
|
|
117
|
+
return `${n} ${w}${n === 1 ? '' : 's'}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Relative-age ladder (design §5): `now` (<60s), `{m}m` (<60m), `{h}h` (<24h),
|
|
124
|
+
* `{d}d` (<7d), else `Mon D` (`Mar 4`), prior-year `Mon ʼYY`. Max ~5 cols.
|
|
125
|
+
* Shared by the TUI render + the text dump (both import it from here).
|
|
126
|
+
* @param {string} iso @param {number} now @returns {string}
|
|
127
|
+
*/
|
|
128
|
+
export function relAge(iso, now) {
|
|
129
|
+
const t = Date.parse(iso);
|
|
130
|
+
if (Number.isNaN(t)) return '';
|
|
131
|
+
const s = Math.max(0, Math.floor((now - t) / 1000));
|
|
132
|
+
if (s < 60) return 'now';
|
|
133
|
+
const m = Math.floor(s / 60);
|
|
134
|
+
if (m < 60) return `${m}m`;
|
|
135
|
+
const h = Math.floor(m / 60);
|
|
136
|
+
if (h < 24) return `${h}h`;
|
|
137
|
+
const d = Math.floor(h / 24);
|
|
138
|
+
if (d < 7) return `${d}d`;
|
|
139
|
+
const date = new Date(t);
|
|
140
|
+
const mon = MONTHS[date.getMonth()] || '?';
|
|
141
|
+
if (date.getFullYear() === new Date(now).getFullYear()) return `${mon} ${date.getDate()}`;
|
|
142
|
+
return `${mon} ʼ${String(date.getFullYear()).slice(-2)}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── git: status / churn parsing ──────────────────────────────────
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Classify a porcelain XY pair into the file's primary class.
|
|
149
|
+
* @param {string} x @param {string} y
|
|
150
|
+
* @returns {'staged'|'modified'|'untracked'|'conflict'}
|
|
151
|
+
*/
|
|
152
|
+
function classify(x, y) {
|
|
153
|
+
if (x === 'U' || y === 'U' || (x === 'A' && y === 'A') || (x === 'D' && y === 'D')) return 'conflict';
|
|
154
|
+
if (x === '?' || y === '?') return 'untracked';
|
|
155
|
+
if (y !== ' ' && y !== '') return 'modified';
|
|
156
|
+
if (x !== ' ' && x !== '') return 'staged';
|
|
157
|
+
return 'modified';
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Parse `git status --porcelain=v1 -z -uall` into changed files + column tallies.
|
|
162
|
+
* @param {string} stdout
|
|
163
|
+
* @returns {{files: ChangedFile[], counts: GitState['counts']}}
|
|
164
|
+
*/
|
|
165
|
+
function parseStatus(stdout) {
|
|
166
|
+
const parts = String(stdout || '').split('\0');
|
|
167
|
+
/** @type {ChangedFile[]} */
|
|
168
|
+
const files = [];
|
|
169
|
+
const counts = { staged: 0, modified: 0, untracked: 0, conflict: 0 };
|
|
170
|
+
for (let i = 0; i < parts.length; i++) {
|
|
171
|
+
const entry = parts[i];
|
|
172
|
+
if (!entry || entry.length < 3) continue;
|
|
173
|
+
const x = entry[0];
|
|
174
|
+
const y = entry[1];
|
|
175
|
+
const path = entry.slice(3);
|
|
176
|
+
if (x === 'R' || x === 'C' || y === 'R' || y === 'C') i++;
|
|
177
|
+
const cls = classify(x, y);
|
|
178
|
+
if (cls === 'conflict') counts.conflict++;
|
|
179
|
+
else if (cls === 'untracked') counts.untracked++;
|
|
180
|
+
else {
|
|
181
|
+
if (x !== ' ' && x !== '?' && x !== '') counts.staged++;
|
|
182
|
+
if (y !== ' ' && y !== '?' && y !== '') counts.modified++;
|
|
183
|
+
}
|
|
184
|
+
files.push({ path, xy: `${x}${y}`, cls, add: 0, del: 0 });
|
|
185
|
+
}
|
|
186
|
+
return { files, counts };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Parse `git diff [--cached] --numstat` into a path→churn map. Binary files
|
|
191
|
+
* (`-\t-`) and renames (`old => new`) are skipped — churn is best-effort.
|
|
192
|
+
* @param {string} stdout
|
|
193
|
+
* @returns {Array<{path:string, add:number, del:number}>}
|
|
194
|
+
*/
|
|
195
|
+
function parseNumstat(stdout) {
|
|
196
|
+
/** @type {Array<{path:string, add:number, del:number}>} */
|
|
197
|
+
const out = [];
|
|
198
|
+
for (const line of String(stdout || '').split(/\r?\n/)) {
|
|
199
|
+
if (!line.trim()) continue;
|
|
200
|
+
const m = line.split('\t');
|
|
201
|
+
if (m.length < 3) continue;
|
|
202
|
+
const add = m[0];
|
|
203
|
+
const del = m[1];
|
|
204
|
+
const path = m.slice(2).join('\t');
|
|
205
|
+
if (add === '-' || del === '-' || path.includes(' => ')) continue;
|
|
206
|
+
out.push({ path, add: toInt(add), del: toInt(del) });
|
|
207
|
+
}
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const CLASS_RANK = { conflict: 0, staged: 1, modified: 2, untracked: 3 };
|
|
212
|
+
|
|
213
|
+
// ── gh: rollups + classification ────────────────────────────────
|
|
214
|
+
|
|
215
|
+
const CI_BAD = new Set(['FAILURE', 'ERROR', 'CANCELLED', 'TIMED_OUT', 'ACTION_REQUIRED', 'STARTUP_FAILURE']);
|
|
216
|
+
const CI_GOOD = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED']);
|
|
217
|
+
|
|
218
|
+
/** @param {any[]|undefined} items @returns {'pass'|'fail'|'pending'|'none'} */
|
|
219
|
+
function rollupCi(items) {
|
|
220
|
+
if (!Array.isArray(items) || items.length === 0) return 'none';
|
|
221
|
+
let pending = false;
|
|
222
|
+
for (const it of items) {
|
|
223
|
+
const concl = String((it && it.conclusion) || '').toUpperCase();
|
|
224
|
+
const state = String((it && it.state) || '').toUpperCase();
|
|
225
|
+
const status = String((it && it.status) || '').toUpperCase();
|
|
226
|
+
if (CI_BAD.has(concl) || CI_BAD.has(state)) return 'fail';
|
|
227
|
+
if (status && status !== 'COMPLETED') pending = true;
|
|
228
|
+
else if (state && !CI_GOOD.has(state) && !CI_BAD.has(state)) pending = true;
|
|
229
|
+
else if (!concl && !state && !status) pending = true;
|
|
230
|
+
}
|
|
231
|
+
return pending ? 'pending' : 'pass';
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** @param {string} d @returns {'approved'|'changes'|'review'} */
|
|
235
|
+
function normReview(d) {
|
|
236
|
+
const s = String(d || '').toUpperCase();
|
|
237
|
+
if (s === 'APPROVED') return 'approved';
|
|
238
|
+
if (s === 'CHANGES_REQUESTED') return 'changes';
|
|
239
|
+
return 'review';
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── Typed SourceError displays (the `display`/`kind` split; presenters render
|
|
243
|
+
// `display` verbatim and never branch on `kind`) ────────────────────────────
|
|
244
|
+
|
|
245
|
+
/** @type {SourceError} */
|
|
246
|
+
const GIT_MISSING = {
|
|
247
|
+
kind: 'git-missing',
|
|
248
|
+
display: {
|
|
249
|
+
headline: 'git not found',
|
|
250
|
+
explanation: 'crtr could not find the git binary on PATH.',
|
|
251
|
+
nextStep: 'Install git, then press g.',
|
|
252
|
+
level: 'error',
|
|
253
|
+
blocking: true,
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
/** @type {SourceError} */
|
|
257
|
+
const NOT_A_REPO = {
|
|
258
|
+
kind: 'not-a-repo',
|
|
259
|
+
display: {
|
|
260
|
+
headline: 'Not a git repository',
|
|
261
|
+
explanation: 'This view monitors a git repo, and the current directory is not one.',
|
|
262
|
+
nextStep: 'cd into a repository (or run `git init`), then press g.',
|
|
263
|
+
level: 'action',
|
|
264
|
+
blocking: true,
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
/** @param {string} msg @returns {SourceError} */
|
|
268
|
+
function gitFailed(msg) {
|
|
269
|
+
return {
|
|
270
|
+
kind: 'git-failed',
|
|
271
|
+
display: {
|
|
272
|
+
headline: 'Git unavailable',
|
|
273
|
+
explanation: msg || 'A git command failed.',
|
|
274
|
+
nextStep: 'Press g to retry.',
|
|
275
|
+
level: 'error',
|
|
276
|
+
blocking: false,
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
/** @param {string} kind @param {string} headline @returns {SourceError} */
|
|
281
|
+
function ghError(kind, headline) {
|
|
282
|
+
return { kind, display: { headline, explanation: '', nextStep: '', level: 'info', blocking: false } };
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Classify a failed `gh` invocation into a guided PR-section note (auth first,
|
|
286
|
+
* then network, then a missing remote, else generic).
|
|
287
|
+
* @param {string} stderr @returns {SourceError}
|
|
288
|
+
*/
|
|
289
|
+
function classifyGh(stderr) {
|
|
290
|
+
const s = String(stderr || '').toLowerCase();
|
|
291
|
+
if (/auth|logged in|gh auth login|authentication|not logged/.test(s)) {
|
|
292
|
+
return ghError('gh-unauthed', 'gh is not authenticated — run `gh auth login`.');
|
|
293
|
+
}
|
|
294
|
+
if (/could not resolve host|dial tcp|network is unreachable|no such host|timeout|temporary failure|connection refused/.test(s)) {
|
|
295
|
+
return ghError('gh-network', 'Cannot reach GitHub (offline?).');
|
|
296
|
+
}
|
|
297
|
+
if (/could not resolve to a repository|no git remote|none of the git remotes|no default remote|not a github|head branch could not/.test(s)) {
|
|
298
|
+
return ghError('gh-no-remote', 'No GitHub remote for this repository.');
|
|
299
|
+
}
|
|
300
|
+
return ghError('gh-failed', firstLine(stderr) || 'gh command failed.');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ── Sources (reads): a request descriptor + a pure parse. The host's transport
|
|
304
|
+
// runs the request (local execFile for TUI, the HTTP bridge for web). ───────
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* PRIMARY git instrument — working-tree status. Its parse owns the three hard
|
|
308
|
+
* git states (missing binary / not-a-repo / a failing git command); empty-repo
|
|
309
|
+
* and no-upstream degrade to null fields on the secondary reads, not errors.
|
|
310
|
+
* @type {import('../../core/view/contract.js').Source<{files:ChangedFile[], counts:GitState['counts']}>}
|
|
311
|
+
*/
|
|
312
|
+
export const gitStatusSource = {
|
|
313
|
+
id: 'git-status',
|
|
314
|
+
request: () => ({ kind: 'exec', bin: 'git', args: ['status', '--porcelain=v1', '-z', '--untracked-files=all'] }),
|
|
315
|
+
parse: (raw) => {
|
|
316
|
+
if (!raw.ok) return fail(GIT_MISSING);
|
|
317
|
+
if (raw.exitCode !== 0) {
|
|
318
|
+
const s = String(raw.stderr || '').toLowerCase();
|
|
319
|
+
if (/not a git repository/.test(s)) return fail(NOT_A_REPO);
|
|
320
|
+
return fail(gitFailed(firstLine(raw.stderr)));
|
|
321
|
+
}
|
|
322
|
+
return ok(parseStatus(raw.stdout));
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
/** Current branch name ('' ⇒ detached HEAD; resolve via gitHeadSource). */
|
|
327
|
+
export const gitBranchSource = {
|
|
328
|
+
id: 'git-branch',
|
|
329
|
+
request: () => ({ kind: 'exec', bin: 'git', args: ['branch', '--show-current'] }),
|
|
330
|
+
parse: (raw) => ok(raw.ok && raw.exitCode === 0 ? raw.stdout.trim() : ''),
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
/** Short HEAD sha — used to label a detached HEAD. */
|
|
334
|
+
export const gitHeadSource = {
|
|
335
|
+
id: 'git-head',
|
|
336
|
+
request: () => ({ kind: 'exec', bin: 'git', args: ['rev-parse', '--short', 'HEAD'] }),
|
|
337
|
+
parse: (raw) => ok(raw.ok && raw.exitCode === 0 ? raw.stdout.trim() : ''),
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
/** Tracking ref (e.g. "origin/main") or null when no upstream is set. */
|
|
341
|
+
export const gitUpstreamSource = {
|
|
342
|
+
id: 'git-upstream',
|
|
343
|
+
request: () => ({ kind: 'exec', bin: 'git', args: ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'] }),
|
|
344
|
+
parse: (raw) => ok(raw.ok && raw.exitCode === 0 ? raw.stdout.trim() : null),
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
/** Ahead/behind vs upstream (only meaningful when an upstream exists). */
|
|
348
|
+
export const gitAheadBehindSource = {
|
|
349
|
+
id: 'git-ahead-behind',
|
|
350
|
+
request: () => ({ kind: 'exec', bin: 'git', args: ['rev-list', '--left-right', '--count', '@{upstream}...HEAD'] }),
|
|
351
|
+
parse: (raw) => {
|
|
352
|
+
if (!raw.ok || raw.exitCode !== 0) return ok({ ahead: 0, behind: 0 });
|
|
353
|
+
const nums = raw.stdout.trim().split(/\s+/);
|
|
354
|
+
return ok({ behind: toInt(nums[0]), ahead: toInt(nums[1]) });
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
/** Last commit (null on an empty repo). */
|
|
359
|
+
export const gitLogSource = {
|
|
360
|
+
id: 'git-log',
|
|
361
|
+
request: () => ({ kind: 'exec', bin: 'git', args: ['log', '-1', '--pretty=%h%x1f%s%x1f%cI'] }),
|
|
362
|
+
parse: (raw) => {
|
|
363
|
+
if (!raw.ok || raw.exitCode !== 0 || raw.stdout.trim() === '') return ok(null);
|
|
364
|
+
const [sha, subject, when] = raw.stdout.replace(/\n$/, '').split('\x1f');
|
|
365
|
+
return ok({ sha: sha || '', subject: subject || '', when: when || '' });
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
/** Unstaged churn (best-effort; never fatal). */
|
|
370
|
+
export const gitDiffSource = {
|
|
371
|
+
id: 'git-diff',
|
|
372
|
+
request: () => ({ kind: 'exec', bin: 'git', args: ['diff', '--numstat'] }),
|
|
373
|
+
parse: (raw) => ok(raw.ok && raw.exitCode === 0 ? parseNumstat(raw.stdout) : []),
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
/** Staged churn (best-effort; never fatal). */
|
|
377
|
+
export const gitDiffCachedSource = {
|
|
378
|
+
id: 'git-diff-cached',
|
|
379
|
+
request: () => ({ kind: 'exec', bin: 'git', args: ['diff', '--cached', '--numstat'] }),
|
|
380
|
+
parse: (raw) => ok(raw.ok && raw.exitCode === 0 ? parseNumstat(raw.stdout) : []),
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Open PRs for this repo. BEST-EFFORT: a typed error becomes the PR section's
|
|
385
|
+
* guided note (the git section is unaffected). `current`/sort-by-current is
|
|
386
|
+
* applied in `refresh` (parse has no access to the branch).
|
|
387
|
+
* @type {import('../../core/view/contract.js').Source<Pr[]>}
|
|
388
|
+
*/
|
|
389
|
+
export const prsSource = {
|
|
390
|
+
id: 'prs',
|
|
391
|
+
request: () => ({
|
|
392
|
+
kind: 'exec', bin: 'gh',
|
|
393
|
+
args: ['pr', 'list', '--state', 'open', '--limit', '30', '--json',
|
|
394
|
+
'number,title,headRefName,reviewDecision,statusCheckRollup,updatedAt,isDraft'],
|
|
395
|
+
}),
|
|
396
|
+
parse: (raw) => {
|
|
397
|
+
if (!raw.ok) return fail(ghError('gh-missing', 'gh (GitHub CLI) not found — install it to see PRs.'));
|
|
398
|
+
if (raw.exitCode !== 0) return fail(classifyGh(raw.stderr || raw.stdout));
|
|
399
|
+
let arr;
|
|
400
|
+
try {
|
|
401
|
+
arr = JSON.parse(raw.stdout.trim() || '[]');
|
|
402
|
+
} catch {
|
|
403
|
+
return fail(ghError('gh-failed', 'could not parse gh output as JSON.'));
|
|
404
|
+
}
|
|
405
|
+
if (!Array.isArray(arr)) arr = [];
|
|
406
|
+
/** @type {Pr[]} */
|
|
407
|
+
const prs = arr.map((p) => {
|
|
408
|
+
const o = p || {};
|
|
409
|
+
const head = String(o.headRefName || '');
|
|
410
|
+
return {
|
|
411
|
+
number: typeof o.number === 'number' ? o.number : toInt(o.number),
|
|
412
|
+
title: String(o.title || '(untitled)'),
|
|
413
|
+
headRefName: head,
|
|
414
|
+
isDraft: !!o.isDraft,
|
|
415
|
+
current: false,
|
|
416
|
+
review: normReview(o.reviewDecision),
|
|
417
|
+
ci: rollupCi(o.statusCheckRollup),
|
|
418
|
+
updatedAt: String(o.updatedAt || ''),
|
|
419
|
+
};
|
|
420
|
+
});
|
|
421
|
+
return ok(prs);
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
// ── Board model + chrome copy (shared by render + dump; pure) ──────────────────
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Flatten git + PR state into the ordered board rows. Each section degrades
|
|
429
|
+
* inline (a clean reward row / a guided gh note / a "no open PRs" note).
|
|
430
|
+
* @param {GitPrState} state @returns {BoardRow[]}
|
|
431
|
+
*/
|
|
432
|
+
export function buildBoard(state) {
|
|
433
|
+
/** @type {BoardRow[]} */
|
|
434
|
+
const rows = [];
|
|
435
|
+
const g = state.git;
|
|
436
|
+
rows.push({ kind: 'label', text: 'Working tree' });
|
|
437
|
+
if (!g || g.files.length === 0) rows.push({ kind: 'clean' });
|
|
438
|
+
else for (const f of g.files) rows.push({ kind: 'file', file: f });
|
|
439
|
+
|
|
440
|
+
rows.push({ kind: 'spacer' });
|
|
441
|
+
rows.push({ kind: 'label', text: 'Pull requests' });
|
|
442
|
+
if (state.prNote) rows.push({ kind: 'note', text: state.prNote });
|
|
443
|
+
else if (state.prs.length === 0) rows.push({ kind: 'note', text: 'No open pull requests.' });
|
|
444
|
+
else for (const pr of state.prs) rows.push({ kind: 'pr', pr });
|
|
445
|
+
return rows;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Working-tree state as one short phrase, for the subtitle + dump. @param {GitState} g */
|
|
449
|
+
export function treePhrase(g) {
|
|
450
|
+
const c = g.counts;
|
|
451
|
+
if (g.files.length === 0) return 'clean';
|
|
452
|
+
/** @type {string[]} */
|
|
453
|
+
const parts = [];
|
|
454
|
+
if (c.conflict) parts.push(`${c.conflict} conflict`);
|
|
455
|
+
if (c.staged) parts.push(`${c.staged} staged`);
|
|
456
|
+
if (c.modified) parts.push(`${c.modified} modified`);
|
|
457
|
+
if (c.untracked) parts.push(`${c.untracked} untracked`);
|
|
458
|
+
return parts.join(' · ') || plural(g.files.length, 'change');
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Live title subtitle — branch + tracking delta + tree state. @param {{git:GitState|null}} state */
|
|
462
|
+
function subtitleFor(state) {
|
|
463
|
+
const g = state.git;
|
|
464
|
+
if (!g) return null;
|
|
465
|
+
let s = g.branch;
|
|
466
|
+
if (g.ahead) s += ` ↑${g.ahead}`;
|
|
467
|
+
if (g.behind) s += ` ↓${g.behind}`;
|
|
468
|
+
s += ` · ${treePhrase(g)}`;
|
|
469
|
+
return s;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Footer status (left, transient). @param {{git:GitState|null, prs:Pr[], prNote:string|null}} state */
|
|
473
|
+
function footerSummary(state) {
|
|
474
|
+
const g = state.git;
|
|
475
|
+
if (!g) return null;
|
|
476
|
+
const files = g.files.length === 0 ? 'clean' : plural(g.files.length, 'change');
|
|
477
|
+
const prs = state.prNote ? 'PRs n/a' : plural(state.prs.length, 'PR');
|
|
478
|
+
return `${files} · ${prs}`;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** The one thing that wants a human's eyes → an ACTION banner. @param {{git:GitState|null, prs:Pr[], prNote:string|null}} state */
|
|
482
|
+
function attentionFor(state) {
|
|
483
|
+
const g = state.git;
|
|
484
|
+
/** @type {string[]} */
|
|
485
|
+
const parts = [];
|
|
486
|
+
if (g && g.counts.conflict) parts.push(`${plural(g.counts.conflict, 'conflict')}`);
|
|
487
|
+
if (!state.prNote) {
|
|
488
|
+
const failing = state.prs.filter((p) => p.ci === 'fail').length;
|
|
489
|
+
const changes = state.prs.filter((p) => p.review === 'changes').length;
|
|
490
|
+
if (failing) parts.push(`${failing} PR${failing === 1 ? '' : 's'} failing CI`);
|
|
491
|
+
if (changes) parts.push(`${changes} PR${changes === 1 ? '' : 's'} need changes`);
|
|
492
|
+
}
|
|
493
|
+
return parts.length ? `${parts.join(' · ')} — needs attention` : null;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ── The portable core ──────────────────────────────────────────────────────────
|
|
497
|
+
|
|
498
|
+
/** @type {import('../../core/view/contract.js').ViewCore<GitPrState>} */
|
|
499
|
+
const core = {
|
|
500
|
+
manifest: {
|
|
501
|
+
id: 'git-pr',
|
|
502
|
+
title: 'Git / PR',
|
|
503
|
+
description: 'Local git state + GitHub PR/CI status for the repo at this cwd',
|
|
504
|
+
refreshMs: 5000,
|
|
505
|
+
},
|
|
506
|
+
|
|
507
|
+
/** Cheap + synchronous initial state — NO fetch (the host paints a loading
|
|
508
|
+
* frame, then dispatches the first 'refresh'). @returns {GitPrState} */
|
|
509
|
+
init() {
|
|
510
|
+
return {
|
|
511
|
+
git: null,
|
|
512
|
+
gitErr: null,
|
|
513
|
+
prs: [],
|
|
514
|
+
prNote: null,
|
|
515
|
+
board: [],
|
|
516
|
+
cursor: 0,
|
|
517
|
+
scroll: 0,
|
|
518
|
+
lastFetch: 0,
|
|
519
|
+
};
|
|
520
|
+
},
|
|
521
|
+
|
|
522
|
+
sources: {
|
|
523
|
+
gitStatusSource,
|
|
524
|
+
gitBranchSource,
|
|
525
|
+
gitHeadSource,
|
|
526
|
+
gitUpstreamSource,
|
|
527
|
+
gitAheadBehindSource,
|
|
528
|
+
gitLogSource,
|
|
529
|
+
gitDiffSource,
|
|
530
|
+
gitDiffCachedSource,
|
|
531
|
+
prsSource,
|
|
532
|
+
},
|
|
533
|
+
|
|
534
|
+
intents: {
|
|
535
|
+
/**
|
|
536
|
+
* Read git + PR state, rebuild the board. Runs in the host's single-flight
|
|
537
|
+
* lane. A BLOCKING git error (no binary / not a repo) drops to a guided
|
|
538
|
+
* takeover; a transient git-failed KEEPS the last-known board + a banner. PR
|
|
539
|
+
* failures degrade the PR section only (an inline note).
|
|
540
|
+
* @param {Ctx} ctx
|
|
541
|
+
*/
|
|
542
|
+
async refresh(ctx) {
|
|
543
|
+
ctx.signal.setStatus('Reading git…');
|
|
544
|
+
|
|
545
|
+
const st = await ctx.resolve(gitStatusSource);
|
|
546
|
+
if (!st.ok) {
|
|
547
|
+
const err = st.error;
|
|
548
|
+
const hadGit = ctx.state.git != null;
|
|
549
|
+
// git-failed WITH a last-known board ⇒ keep it (transient). Hard cases
|
|
550
|
+
// (not-a-repo / git-missing) and a first-load git-failed ⇒ takeover.
|
|
551
|
+
const keep = !err.display.blocking && hadGit;
|
|
552
|
+
ctx.set((s) => {
|
|
553
|
+
/** @type {GitPrState} */
|
|
554
|
+
const next = { ...s, gitErr: err };
|
|
555
|
+
if (!keep) {
|
|
556
|
+
next.git = null;
|
|
557
|
+
next.prs = [];
|
|
558
|
+
next.prNote = null;
|
|
559
|
+
next.board = buildBoard(next);
|
|
560
|
+
}
|
|
561
|
+
next.lastFetch = Date.now();
|
|
562
|
+
return next;
|
|
563
|
+
});
|
|
564
|
+
// A takeover owns the whole rect and already names cause + next step —
|
|
565
|
+
// don't stack a banner under it. A kept board raises the cause instead.
|
|
566
|
+
if (keep) ctx.signal.setBanner(err.display.explanation, err.display.level);
|
|
567
|
+
else ctx.signal.clearBanner();
|
|
568
|
+
ctx.signal.setStatus(null);
|
|
569
|
+
ctx.signal.setSubtitle(keep ? subtitleFor(ctx.state) : null);
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Secondary git reads — independent, best-effort, resolved concurrently.
|
|
574
|
+
const [brR, headR, upR, abR, logR, diffR, diffCR] = await Promise.all([
|
|
575
|
+
ctx.resolve(gitBranchSource),
|
|
576
|
+
ctx.resolve(gitHeadSource),
|
|
577
|
+
ctx.resolve(gitUpstreamSource),
|
|
578
|
+
ctx.resolve(gitAheadBehindSource),
|
|
579
|
+
ctx.resolve(gitLogSource),
|
|
580
|
+
ctx.resolve(gitDiffSource),
|
|
581
|
+
ctx.resolve(gitDiffCachedSource),
|
|
582
|
+
]);
|
|
583
|
+
|
|
584
|
+
let branch = brR.ok ? brR.data : '';
|
|
585
|
+
let detached = false;
|
|
586
|
+
if (branch === '') {
|
|
587
|
+
detached = true;
|
|
588
|
+
branch = headR.ok && headR.data ? `(detached ${headR.data})` : '(no branch)';
|
|
589
|
+
}
|
|
590
|
+
const upstream = upR.ok ? upR.data : null;
|
|
591
|
+
let ahead = 0;
|
|
592
|
+
let behind = 0;
|
|
593
|
+
if (upstream && abR.ok) {
|
|
594
|
+
ahead = abR.data.ahead;
|
|
595
|
+
behind = abR.data.behind;
|
|
596
|
+
}
|
|
597
|
+
const lastCommit = logR.ok ? logR.data : null;
|
|
598
|
+
|
|
599
|
+
// Churn (best-effort) → apply to the status files, then problems-first sort.
|
|
600
|
+
/** @type {Map<string,{add:number,del:number}>} */
|
|
601
|
+
const churn = new Map();
|
|
602
|
+
const accChurn = (rows) => {
|
|
603
|
+
for (const c of rows) {
|
|
604
|
+
const prev = churn.get(c.path) || { add: 0, del: 0 };
|
|
605
|
+
prev.add += c.add;
|
|
606
|
+
prev.del += c.del;
|
|
607
|
+
churn.set(c.path, prev);
|
|
608
|
+
}
|
|
609
|
+
};
|
|
610
|
+
if (diffR.ok) accChurn(diffR.data);
|
|
611
|
+
if (diffCR.ok) accChurn(diffCR.data);
|
|
612
|
+
|
|
613
|
+
const { files, counts } = st.data;
|
|
614
|
+
for (const f of files) {
|
|
615
|
+
const c = churn.get(f.path);
|
|
616
|
+
if (c) {
|
|
617
|
+
f.add = c.add;
|
|
618
|
+
f.del = c.del;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
files.sort((a, b) => {
|
|
622
|
+
const r = CLASS_RANK[a.cls] - CLASS_RANK[b.cls];
|
|
623
|
+
return r !== 0 ? r : a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
/** @type {GitState} */
|
|
627
|
+
const git = { branch, detached, upstream, ahead, behind, lastCommit, files, counts };
|
|
628
|
+
|
|
629
|
+
// PRs — best-effort. Mark the current-branch PR + sort it first.
|
|
630
|
+
const p = await ctx.resolve(prsSource);
|
|
631
|
+
/** @type {Pr[]} */
|
|
632
|
+
let prs = [];
|
|
633
|
+
/** @type {string|null} */
|
|
634
|
+
let prNote = null;
|
|
635
|
+
if (p.ok) {
|
|
636
|
+
prs = p.data.map((pr) => ({ ...pr, current: pr.headRefName !== '' && pr.headRefName === branch }));
|
|
637
|
+
prs.sort((a, b) => {
|
|
638
|
+
if (a.current !== b.current) return a.current ? -1 : 1;
|
|
639
|
+
return a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : 0;
|
|
640
|
+
});
|
|
641
|
+
} else {
|
|
642
|
+
prNote = p.error.display.headline;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
ctx.set((s) => {
|
|
646
|
+
/** @type {GitPrState} */
|
|
647
|
+
const next = { ...s, git, gitErr: null, prs, prNote };
|
|
648
|
+
next.board = buildBoard(next);
|
|
649
|
+
if (next.cursor >= next.board.length) next.cursor = Math.max(0, next.board.length - 1);
|
|
650
|
+
next.lastFetch = Date.now();
|
|
651
|
+
return next;
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
const live = { git, prs, prNote };
|
|
655
|
+
ctx.signal.setSubtitle(subtitleFor(live));
|
|
656
|
+
const attn = attentionFor(live);
|
|
657
|
+
if (attn) ctx.signal.setBanner(attn, 'action');
|
|
658
|
+
else ctx.signal.clearBanner();
|
|
659
|
+
ctx.signal.setStatus(footerSummary(live));
|
|
660
|
+
},
|
|
661
|
+
|
|
662
|
+
/** @param {Ctx} ctx */
|
|
663
|
+
cursorDown: (ctx) => ctx.set((s) => ({ ...s, cursor: s.board.length ? Math.min(s.board.length - 1, s.cursor + 1) : 0 })),
|
|
664
|
+
/** @param {Ctx} ctx */
|
|
665
|
+
cursorUp: (ctx) => ctx.set((s) => ({ ...s, cursor: Math.max(0, s.cursor - 1) })),
|
|
666
|
+
/** @param {Ctx} ctx @param {number} [i] */
|
|
667
|
+
select: (ctx, i) => ctx.set((s) => ({ ...s, cursor: typeof i === 'number' ? Math.max(0, Math.min(s.board.length - 1, i)) : s.cursor })),
|
|
668
|
+
/** @param {Ctx} ctx */
|
|
669
|
+
quit: (ctx) => ctx.signal.quit(),
|
|
670
|
+
},
|
|
671
|
+
};
|
|
672
|
+
|
|
673
|
+
export default core;
|