claude-code-runrate 0.2.4 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +134 -2
- package/bin/ccr.js +235 -22
- package/package.json +4 -2
- package/scripts/launch.sh +121 -16
- package/sidecar/ccr.tmux.conf +12 -4
- package/src/account-limits.js +21 -13
- package/src/burn.js +7 -3
- package/src/cycle-view.js +78 -0
- package/src/doctor.js +15 -2
- package/src/economy-model.js +3 -0
- package/src/git-history.js +273 -0
- package/src/git-ignore.js +118 -0
- package/src/git-index.js +167 -0
- package/src/git-objects.js +448 -0
- package/src/git-repo.js +294 -0
- package/src/git-working-tree.js +266 -0
- package/src/instance-name.js +182 -0
- package/src/instance-resolve.js +116 -0
- package/src/instance-slot.js +433 -0
- package/src/launch-vscode.js +65 -6
- package/src/launch-win.js +40 -9
- package/src/liveness.js +18 -1
- package/src/migrate.js +155 -0
- package/src/normalize.js +24 -5
- package/src/pane-blob.js +249 -0
- package/src/pane-config.js +109 -0
- package/src/rate-limits.js +12 -2
- package/src/render/economy.js +10 -2
- package/src/render/git-pane.js +345 -0
- package/src/render/pane.js +186 -0
- package/src/render/shared.js +115 -11
- package/src/render/statusline.js +45 -4
- package/src/safe-read.js +82 -0
- package/src/sanitize.js +45 -4
- package/src/session-log.js +116 -0
- package/src/sidecar-keys.js +154 -0
- package/src/sidecar.js +277 -23
- package/src/state-dir.js +44 -1
- package/src/transcripts.js +31 -15
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
// src/pane-config.js — where the list of pane blob paths comes from.
|
|
4
|
+
//
|
|
5
|
+
// RULED 2026-08-02 (the question left open since session e2994e0d):
|
|
6
|
+
//
|
|
7
|
+
// Location: $XDG_CONFIG_HOME/ccr/config.json, defaulting to
|
|
8
|
+
// ~/.config/ccr/config.json. Overridable by CCR_CONFIG for tests and for
|
|
9
|
+
// users who keep dotfiles elsewhere.
|
|
10
|
+
//
|
|
11
|
+
// NOT ccr's state dir (~/.ccr): that holds state ccr writes, and mixing
|
|
12
|
+
// user-authored configuration into a directory the program rewrites invites
|
|
13
|
+
// exactly one accident — clobbering it. NOT repo-local, ever: a config file
|
|
14
|
+
// discovered by walking up from the working directory would let anyone who
|
|
15
|
+
// can land a PR add a pane path to a teammate's sidecar. That is the same
|
|
16
|
+
// reasoning that removed configurable prompt files (see the contract's
|
|
17
|
+
// ruling log); config is the user's, and only the user's.
|
|
18
|
+
//
|
|
19
|
+
// Format: JSON. ccr already parses JSON at every ingestion point, so this
|
|
20
|
+
// adds no new parser and no new attack surface, and the verifier discipline
|
|
21
|
+
// (whitelist-construct, types checked not coerced, total function) applies
|
|
22
|
+
// here unchanged. A bespoke line format would need all of that written again.
|
|
23
|
+
//
|
|
24
|
+
// Shape (v1):
|
|
25
|
+
// { "panes": [ { "path": "~/code/app/.gherkin-trace/sidecar.json" } ] }
|
|
26
|
+
//
|
|
27
|
+
// Entries are OBJECTS rather than bare strings so a later optional key is an
|
|
28
|
+
// additive change rather than a format break. Order is significant (it is the
|
|
29
|
+
// cycle order). Two entries naming the same path are two panes, per the
|
|
30
|
+
// contract — this never de-duplicates.
|
|
31
|
+
//
|
|
32
|
+
// Config is trusted more than a blob (the user wrote it) but is still parsed
|
|
33
|
+
// defensively: a malformed config yields NO panes rather than throwing into
|
|
34
|
+
// the draw loop. The sidecar's own panel must survive a typo in a config file.
|
|
35
|
+
|
|
36
|
+
const path = require('node:path');
|
|
37
|
+
const os = require('node:os');
|
|
38
|
+
const { readTextCapped } = require('./safe-read');
|
|
39
|
+
|
|
40
|
+
/** Config is small; this is a sanity bound, not a policy. */
|
|
41
|
+
const MAX_CONFIG_BYTES = 64 * 1024;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The config file path, without touching the filesystem.
|
|
45
|
+
* @param {Record<string, string|undefined>} [env]
|
|
46
|
+
* @returns {string}
|
|
47
|
+
*/
|
|
48
|
+
function configPath(env) {
|
|
49
|
+
const e = env || process.env;
|
|
50
|
+
if (e.CCR_CONFIG) return e.CCR_CONFIG;
|
|
51
|
+
const xdg = e.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
|
52
|
+
return path.join(xdg, 'ccr', 'config.json');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Expand a leading `~`, then resolve relative paths against `baseDir` — the
|
|
57
|
+
* config file's own directory, per the contract ("a relative path resolves
|
|
58
|
+
* against the config file's directory"). Resolving against the CWD instead
|
|
59
|
+
* would make a pane's identity depend on where the sidecar happened to start.
|
|
60
|
+
* @param {string} p
|
|
61
|
+
* @param {string} baseDir
|
|
62
|
+
* @param {string} home
|
|
63
|
+
* @returns {string}
|
|
64
|
+
*/
|
|
65
|
+
function resolvePanePath(p, baseDir, home) {
|
|
66
|
+
let out = p;
|
|
67
|
+
if (out === '~') out = home;
|
|
68
|
+
else if (out.startsWith('~/')) out = path.join(home, out.slice(2));
|
|
69
|
+
return path.resolve(baseDir, out);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Load the configured pane list. Never throws: a missing, unreadable, or
|
|
74
|
+
* malformed config is "no panes configured", which renders as the plain
|
|
75
|
+
* economy sidebar exactly as before this feature existed.
|
|
76
|
+
*
|
|
77
|
+
* @param {{ env?: Record<string, string|undefined>, home?: string }} [opts]
|
|
78
|
+
* @returns {{ panes: Array<{ path: string, source: string }>, configPath: string }}
|
|
79
|
+
* `path` is absolute and ready to read; `source` is the string the user wrote
|
|
80
|
+
* (what error states name, so the message matches their config, not ours).
|
|
81
|
+
*/
|
|
82
|
+
function loadPaneConfig(opts = {}) {
|
|
83
|
+
const env = opts.env || process.env;
|
|
84
|
+
const home = opts.home || os.homedir();
|
|
85
|
+
const file = configPath(env);
|
|
86
|
+
const empty = { panes: [], configPath: file };
|
|
87
|
+
|
|
88
|
+
const raw = readTextCapped(file, MAX_CONFIG_BYTES);
|
|
89
|
+
if (raw == null || !raw.trim()) return empty;
|
|
90
|
+
|
|
91
|
+
let parsed;
|
|
92
|
+
try { parsed = JSON.parse(raw); } catch { return empty; }
|
|
93
|
+
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.panes)) return empty;
|
|
94
|
+
|
|
95
|
+
const baseDir = path.dirname(file);
|
|
96
|
+
/** @type {Array<{ path: string, source: string }>} */
|
|
97
|
+
const panes = [];
|
|
98
|
+
for (const entry of parsed.panes) {
|
|
99
|
+
// Whitelist-construct: read the one field v1 names, off a fresh object.
|
|
100
|
+
// Never spread the entry — same rule the blob verifier follows.
|
|
101
|
+
if (!entry || typeof entry !== 'object' || typeof entry.path !== 'string') continue;
|
|
102
|
+
const source = entry.path;
|
|
103
|
+
if (!source.trim()) continue;
|
|
104
|
+
panes.push({ path: resolvePanePath(source, baseDir, home), source });
|
|
105
|
+
}
|
|
106
|
+
return { panes, configPath: file };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = { loadPaneConfig, configPath, resolvePanePath, MAX_CONFIG_BYTES };
|
package/src/rate-limits.js
CHANGED
|
@@ -16,10 +16,20 @@ const { stripControl } = require('./sanitize');
|
|
|
16
16
|
const FIVE = 300, WEEK = 10080, MONTH = 43200;
|
|
17
17
|
|
|
18
18
|
// Known keys get exact labels/windows; everything else falls back to heuristics.
|
|
19
|
-
|
|
19
|
+
//
|
|
20
|
+
// NULL PROTOTYPE, deliberately: bucket names arrive from the status JSON, so a
|
|
21
|
+
// bucket named `toString`, `constructor`, or `__proto__` must MISS this table
|
|
22
|
+
// and fall through to the heuristics below. A plain object literal inherits
|
|
23
|
+
// those names from Object.prototype and hands back a function, whose `.label`
|
|
24
|
+
// is undefined — so labelFor would return undefined while its own contract
|
|
25
|
+
// promises a string. That only stayed harmless because every consumer happens
|
|
26
|
+
// to write `wd.label || wd.key`; a consumer trusting the declared type would
|
|
27
|
+
// break. Structural fix rather than a guard at each lookup.
|
|
28
|
+
/** @type {Record<string, { label: string, windowMinutes: number }>} */
|
|
29
|
+
const KNOWN = Object.assign(Object.create(null), {
|
|
20
30
|
five_hour: { label: '5h', windowMinutes: FIVE },
|
|
21
31
|
seven_day: { label: 'weekly', windowMinutes: WEEK },
|
|
22
|
-
};
|
|
32
|
+
});
|
|
23
33
|
|
|
24
34
|
/** @param {string} key → 'Sonnet' | 'Opus' | 'Haiku' | null */
|
|
25
35
|
function modelScope(key) {
|
package/src/render/economy.js
CHANGED
|
@@ -28,7 +28,9 @@ function wallRow(/** @type {any} */ row, /** @type {any} */ L, /** @type {boolea
|
|
|
28
28
|
const dotColor = row.resetsFirst ? green : bandColor[b];
|
|
29
29
|
const dot = (row.binding && b === 'imminent') ? flash(tick, '●') : dotColor('●');
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
// Truncate as well as pad: labelW is capped, so a longer label must be cut to
|
|
32
|
+
// the column rather than pushing every sibling row out of alignment.
|
|
33
|
+
const labelTxt = (row.label.length > labelW ? row.label.slice(0, labelW - 1) + '…' : row.label).padEnd(labelW);
|
|
32
34
|
const label = row.binding ? bold(bandColor[b](labelTxt)) : dim(labelTxt);
|
|
33
35
|
// Time-to-exhaust carries no word: the sibling "resets …" is self-labelling,
|
|
34
36
|
// so a bare "~8h43m" reads unambiguously as remaining budget.
|
|
@@ -59,7 +61,13 @@ function renderEconomy(view, opts = {}) {
|
|
|
59
61
|
const out = [bold('economy') + dim(' ' + (view.model || '')), ''];
|
|
60
62
|
|
|
61
63
|
const { rows, next } = classifyWindows(view);
|
|
62
|
-
|
|
64
|
+
// Cap the label column. `labelW` multiplies: every row pads to it, so cost is
|
|
65
|
+
// rows × longest-label, and BOTH come from the snapshot's rate_limits keys. A
|
|
66
|
+
// planted file with many buckets and one very long key built a string large
|
|
67
|
+
// enough to throw RangeError and blank the panel — an amplifier, not a leak,
|
|
68
|
+
// but it costs the whole display. 18 columns fits every real bucket name.
|
|
69
|
+
const LABEL_MAX = 18;
|
|
70
|
+
const labelW = Math.min(LABEL_MAX, Math.max(8, ...rows.map((/** @type {any} */ r) => r.label.length)));
|
|
63
71
|
|
|
64
72
|
// HERO
|
|
65
73
|
if (!rows.length) {
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
// src/render/git-pane.js — draw the git pane from a model src/git-repo.js built.
|
|
4
|
+
//
|
|
5
|
+
// Same split as src/pane-blob.js → src/render/pane.js: the reader is the choke
|
|
6
|
+
// point that sanitizes and caps, so this file does no validation and no
|
|
7
|
+
// stripping. Every string arriving here is already printable. What it does own
|
|
8
|
+
// is LAYOUT, and the one layout rule the contract actually pins is that a value
|
|
9
|
+
// too long for the pane is shortened rather than wrapped — a wrapped line
|
|
10
|
+
// corrupts the sidecar's cursor-home redraw (the reason clampVisible exists).
|
|
11
|
+
//
|
|
12
|
+
// The identity line is the pane's whole point: features/git-repo-identity.feature
|
|
13
|
+
// exists because six tabs each labelled with an instance name say nothing about
|
|
14
|
+
// which repo they sit in. So it is the first row, it is always exactly one row,
|
|
15
|
+
// and it budgets its own space rather than letting a long branch name push the
|
|
16
|
+
// repo name off the end.
|
|
17
|
+
|
|
18
|
+
const {
|
|
19
|
+
dim, bold, cyan, green, red, yellow, clampVisible, visibleWidth, ellipsize, charWidth,
|
|
20
|
+
} = require('./shared');
|
|
21
|
+
|
|
22
|
+
// Left margin, matching every other ccr surface.
|
|
23
|
+
const INDENT = ' ';
|
|
24
|
+
|
|
25
|
+
// Fallback width when the caller knows nothing (non-TTY, `ccr sidecar` piped).
|
|
26
|
+
// Same default the external pane renderer uses.
|
|
27
|
+
const DEFAULT_WIDTH = 48;
|
|
28
|
+
|
|
29
|
+
// The launch repo gets its OWN ROW, and this is a contract detail rather than a
|
|
30
|
+
// layout preference. The option the visionary chose at scoping was "Follows,
|
|
31
|
+
// with the launch repo pinned — shows the current repo, but ALWAYS keeps the
|
|
32
|
+
// launch repo visible AS A SECOND LINE, so the tab keeps a stable identity while
|
|
33
|
+
// the pane tracks the work" (features/OUT-OF-SCOPE.md, Roads not taken).
|
|
34
|
+
//
|
|
35
|
+
// An earlier build put it inline as "launch › current" and had to invent a rule
|
|
36
|
+
// for dropping it when the row got tight — which meant a repo with a long branch
|
|
37
|
+
// name silently lost the pinned identity the option promises to keep. A second
|
|
38
|
+
// row cannot be crowded out by a branch name.
|
|
39
|
+
const LAUNCH_PREFIX = 'launched in ';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Lay two values out on one line: `left` at the margin, `right` flushed to the
|
|
43
|
+
* end, at least `gap` columns between them. When they do not both fit, each
|
|
44
|
+
* gets what it needs up to a fair half and the remainder goes to the other, so
|
|
45
|
+
* a short name never costs a long one room it could have used.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} left
|
|
48
|
+
* @param {string} right
|
|
49
|
+
* @param {number} avail Columns available to left + gap + right.
|
|
50
|
+
* @param {number} gap
|
|
51
|
+
* @returns {{ left: string, right: string, pad: number }}
|
|
52
|
+
*/
|
|
53
|
+
function fitPair(left, right, avail, gap) {
|
|
54
|
+
const room = Math.max(0, avail - gap);
|
|
55
|
+
const wl = visibleWidth(left);
|
|
56
|
+
const wr = visibleWidth(right);
|
|
57
|
+
if (wl + wr <= room) return { left, right, pad: room - wl - wr + gap };
|
|
58
|
+
const half = Math.floor(room / 2);
|
|
59
|
+
let bl;
|
|
60
|
+
let br;
|
|
61
|
+
if (wl <= half) { bl = wl; br = room - wl; } else if (wr <= half) { br = wr; bl = room - wr; } else { bl = room - half; br = half; }
|
|
62
|
+
return { left: ellipsize(left, bl), right: ellipsize(right, br), pad: gap };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The identity row — one row, always, whatever the model says.
|
|
67
|
+
*
|
|
68
|
+
* The position marker arrives as PLAIN text and is coloured here, deliberately.
|
|
69
|
+
* Passing it in pre-coloured is the obvious shape and it is wrong: visibleWidth
|
|
70
|
+
* counts display characters and knows nothing about SGR, so a dimmed " 2/2"
|
|
71
|
+
* measures 13 columns instead of 5 and quietly steals eight from the names.
|
|
72
|
+
* That version rendered fine at 48 columns and collapsed to "c… …" at 20 —
|
|
73
|
+
* a layout bug that only appears off the demo path.
|
|
74
|
+
*
|
|
75
|
+
* @param {import('../git-repo').RepoIdentity} id
|
|
76
|
+
* @param {number} width Total columns the row may occupy, marker included.
|
|
77
|
+
* @param {string} position Plain cycle position, e.g. "2/3" (may be '').
|
|
78
|
+
* @returns {string}
|
|
79
|
+
*/
|
|
80
|
+
function identityLine(id, width, position) {
|
|
81
|
+
const markerText = position ? ' ' + position : '';
|
|
82
|
+
const marker = markerText ? dim(markerText) : '';
|
|
83
|
+
const avail = Math.max(1, width - visibleWidth(INDENT) - visibleWidth(markerText));
|
|
84
|
+
|
|
85
|
+
// ONE layout for every state. The right-hand slot holds the branch when there
|
|
86
|
+
// is one and the state's own sentence when there is not, so a repository whose
|
|
87
|
+
// HEAD cannot be read still gets NAMED on the left — the pane's entire job.
|
|
88
|
+
// An earlier version early-returned on any non-ok state and threw the name
|
|
89
|
+
// away, though the model had it.
|
|
90
|
+
//
|
|
91
|
+
// The two failure sentences stay distinct: "not a git repository" is a fact
|
|
92
|
+
// about the directory, and features/git-pane-safety.feature separately
|
|
93
|
+
// requires that a repo whose data cannot be READ says so instead. Conflating
|
|
94
|
+
// them would report a broken clone as a scratch directory.
|
|
95
|
+
const right = id.state === 'unreadable'
|
|
96
|
+
? { text: 'git data unavailable', paint: yellow }
|
|
97
|
+
: id.state !== 'ok'
|
|
98
|
+
? { text: 'not a git repository', paint: dim }
|
|
99
|
+
// Before `detached`, because it is the larger fact: a bare repository has
|
|
100
|
+
// no working tree, so there is no checkout for a branch name to describe
|
|
101
|
+
// and nothing for the working-tree section to ever show. "bare", not
|
|
102
|
+
// "empty" — `git init --bare` then push a thousand commits and it is
|
|
103
|
+
// still bare, so "empty" would be a different claim, and a false one.
|
|
104
|
+
: id.bare
|
|
105
|
+
? { text: 'bare repository', paint: yellow }
|
|
106
|
+
: id.detached
|
|
107
|
+
? { text: 'detached', paint: yellow }
|
|
108
|
+
// The `|| ''` is a type guard, not a case: readHead returns either a
|
|
109
|
+
// non-empty branch or detached: true, so an ok state with a null branch
|
|
110
|
+
// cannot occur. It stays because `branch` is nullable in the model and
|
|
111
|
+
// strict mode is right to insist the reader handle that.
|
|
112
|
+
: { text: id.branch || '', paint: cyan };
|
|
113
|
+
|
|
114
|
+
// Nothing to name on the left — the row is the sentence alone, which is what
|
|
115
|
+
// "the pane shows no branch name" pins for a plain scratch directory. The
|
|
116
|
+
// launch repo, if there is one, is a separate row and does not appear here.
|
|
117
|
+
if (!id.name) return INDENT + right.paint(right.text) + marker;
|
|
118
|
+
|
|
119
|
+
const leftPlain = id.name;
|
|
120
|
+
const leftColored = bold(id.name);
|
|
121
|
+
const fit = fitPair(leftPlain, right.text, avail, 2);
|
|
122
|
+
// Re-apply colour only when the plain text survived intact; a shortened value
|
|
123
|
+
// is rebuilt from the fitted string, so the ellipsis lands inside the colour
|
|
124
|
+
// run rather than after it.
|
|
125
|
+
const leftOut = fit.left === leftPlain ? leftColored : bold(fit.left);
|
|
126
|
+
const rightOut = fit.right === right.text ? right.paint(right.text) : right.paint(fit.right);
|
|
127
|
+
return INDENT + leftOut + ' '.repeat(Math.max(1, fit.pad)) + rightOut + marker;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── The working-tree section ────────────────────────────────────────────────
|
|
131
|
+
//
|
|
132
|
+
// Sits between the identity rows and the commit graph, which is why its row
|
|
133
|
+
// budget is a stated formula rather than "whatever fits": the flat file list
|
|
134
|
+
// and the graph compete for the same rows (features/git-working-tree.feature's
|
|
135
|
+
// header says so), and the cap is the contract's answer to that competition.
|
|
136
|
+
|
|
137
|
+
// Rows always reserved for the commit graph below the list, so a long file
|
|
138
|
+
// list can never starve history off the pane entirely.
|
|
139
|
+
const GRAPH_RESERVE = 8;
|
|
140
|
+
|
|
141
|
+
// The section's own chrome: the counts row, the possible rebase row, the
|
|
142
|
+
// possible "N more" row, and the blank line above the section.
|
|
143
|
+
const WT_CHROME = 4;
|
|
144
|
+
|
|
145
|
+
// A ceiling regardless of pane height: past this many file rows the list stops
|
|
146
|
+
// informing and starts scrolling the reader.
|
|
147
|
+
const WT_MAX_FILE_ROWS = 16;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* How many file rows the working-tree list may use in a pane `rows` tall.
|
|
151
|
+
* Exported because it IS the contract the long-list scenario names ("the pane
|
|
152
|
+
* has room for 8 file rows") — the steps derive the pane height from this
|
|
153
|
+
* formula rather than duplicating the arithmetic.
|
|
154
|
+
* @param {number} rows
|
|
155
|
+
* @returns {number}
|
|
156
|
+
*/
|
|
157
|
+
function fileRowBudget(rows) {
|
|
158
|
+
return Math.max(2, Math.min(WT_MAX_FILE_ROWS, Math.trunc(rows) - GRAPH_RESERVE - WT_CHROME));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Shorten from the FRONT, keeping the tail — the working-tree list's rule,
|
|
163
|
+
* because the tail is the file name and the file name is the answer ("A path
|
|
164
|
+
* too long for the pane keeps its file name"). The mirror of ellipsize.
|
|
165
|
+
* @param {string} s
|
|
166
|
+
* @param {number} cols
|
|
167
|
+
* @returns {string}
|
|
168
|
+
*/
|
|
169
|
+
function ellipsizeStart(s, cols) {
|
|
170
|
+
if (cols <= 0) return '';
|
|
171
|
+
if (visibleWidth(s) <= cols) return s;
|
|
172
|
+
if (cols === 1) return '…';
|
|
173
|
+
const cps = [...s];
|
|
174
|
+
let used = 1; // the ellipsis
|
|
175
|
+
let start = cps.length;
|
|
176
|
+
while (start > 0) {
|
|
177
|
+
const w = charWidth(/** @type {number} */(cps[start - 1].codePointAt(0)));
|
|
178
|
+
if (used + w > cols) break;
|
|
179
|
+
used += w;
|
|
180
|
+
start -= 1;
|
|
181
|
+
}
|
|
182
|
+
return '…' + cps.slice(start).join('');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** @type {Record<import('../git-working-tree').ChangeMark, (s: string) => string>} */
|
|
186
|
+
const MARK_PAINT = { '!': red, '+': green, M: yellow, '?': dim };
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The working-tree rows: counts, the capped file list, the remainder.
|
|
190
|
+
*
|
|
191
|
+
* @param {import('../git-working-tree').WorkingTree} wt
|
|
192
|
+
* @param {{ width: number, rows: number }} opts
|
|
193
|
+
* @returns {string[]}
|
|
194
|
+
*/
|
|
195
|
+
function workingTreeLines(wt, opts) {
|
|
196
|
+
const { width } = opts;
|
|
197
|
+
if (wt.state !== 'ok') return [INDENT + yellow('git data unavailable')];
|
|
198
|
+
|
|
199
|
+
/** @type {string[]} */
|
|
200
|
+
const lines = [];
|
|
201
|
+
// The rebase banner leads: it is the state that explains every "!" below it.
|
|
202
|
+
if (wt.rebase) lines.push(INDENT + yellow('rebase in progress'));
|
|
203
|
+
|
|
204
|
+
const total = wt.entries.length;
|
|
205
|
+
if (total === 0) {
|
|
206
|
+
if (!wt.rebase) lines.push(INDENT + dim('clean'));
|
|
207
|
+
return lines;
|
|
208
|
+
}
|
|
209
|
+
// `truncated` means the untracked walk hit its visit budget, so `total` is a
|
|
210
|
+
// floor rather than the count; the "+" keeps the headline honest.
|
|
211
|
+
lines.push(INDENT + (total === 1 && !wt.truncated ? '1 change' : `${total}${wt.truncated ? '+' : ''} changes`));
|
|
212
|
+
|
|
213
|
+
const budget = fileRowBudget(opts.rows);
|
|
214
|
+
const listed = wt.entries.slice(0, budget);
|
|
215
|
+
// Columns for the path: margin, one mark column, one space.
|
|
216
|
+
const pathCols = Math.max(1, width - visibleWidth(INDENT) - 2);
|
|
217
|
+
for (const e of listed) {
|
|
218
|
+
lines.push(INDENT + MARK_PAINT[e.mark](e.mark) + ' ' + ellipsizeStart(e.path, pathCols));
|
|
219
|
+
}
|
|
220
|
+
const rest = total - listed.length;
|
|
221
|
+
if (rest > 0) lines.push(INDENT + dim(`${rest}${wt.truncated ? '+' : ''} more`));
|
|
222
|
+
return lines;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── The commit graph ────────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
// One column per lane; the rest of a graph row is margin, hash, subject, age.
|
|
228
|
+
// Reserving this much keeps a readable subject at every lane count the budget
|
|
229
|
+
// can return.
|
|
230
|
+
const LANE_RESERVE = 26;
|
|
231
|
+
const MAX_LANES = 6;
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* How many lanes a pane `width` columns wide may draw. Exported for the same
|
|
235
|
+
* reason as fileRowBudget: "the pane has room for 3 lanes" is a fact about
|
|
236
|
+
* THIS formula, and the steps derive the width from it.
|
|
237
|
+
* @param {number} width
|
|
238
|
+
* @returns {number}
|
|
239
|
+
*/
|
|
240
|
+
function laneBudget(width) {
|
|
241
|
+
return Math.max(1, Math.min(MAX_LANES, Math.trunc(width) - LANE_RESERVE));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* A relative age: "now", then minutes, hours, days. Coarse on purpose — the
|
|
246
|
+
* scenario pins that an age is SHOWN, and a graph is not a clock.
|
|
247
|
+
* @param {number} whenSec
|
|
248
|
+
* @param {number} nowMs
|
|
249
|
+
* @returns {string}
|
|
250
|
+
*/
|
|
251
|
+
function fmtAge(whenSec, nowMs) {
|
|
252
|
+
const s = Math.max(0, Math.floor(nowMs / 1000) - whenSec);
|
|
253
|
+
if (s < 90) return 'now';
|
|
254
|
+
if (s < 90 * 60) return Math.round(s / 60) + 'm';
|
|
255
|
+
if (s < 36 * 3600) return Math.round(s / 3600) + 'h';
|
|
256
|
+
return Math.round(s / 86400) + 'd';
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The graph rows: lane cells, short hash, subject, age — plus the overflow
|
|
261
|
+
* count when branches outnumber lanes.
|
|
262
|
+
*
|
|
263
|
+
* @param {import('../git-history').History} history
|
|
264
|
+
* @param {{ width: number, maxRows: number, now: number }} opts
|
|
265
|
+
* @returns {string[]}
|
|
266
|
+
*/
|
|
267
|
+
function commitGraphLines(history, opts) {
|
|
268
|
+
const { width } = opts;
|
|
269
|
+
if (history.state === 'unavailable') return [INDENT + yellow('git data unavailable')];
|
|
270
|
+
if (history.state === 'empty') return [INDENT + dim('no commits yet')];
|
|
271
|
+
|
|
272
|
+
/** @type {string[]} */
|
|
273
|
+
const lines = [];
|
|
274
|
+
const span = Math.max(1, history.laneCount);
|
|
275
|
+
for (const row of history.rows.slice(0, Math.max(1, opts.maxRows))) {
|
|
276
|
+
let cells = '';
|
|
277
|
+
for (let i = 0; i < span; i += 1) {
|
|
278
|
+
if (i === row.lane) cells += '●';
|
|
279
|
+
else if (row.joinLanes && row.joinLanes.includes(i)) cells += '╮';
|
|
280
|
+
else cells += (row.activeMask && row.activeMask[i]) ? '│' : ' ';
|
|
281
|
+
}
|
|
282
|
+
const age = fmtAge(row.when, opts.now);
|
|
283
|
+
// Margin + cells + space + hash + space + subject + gap + age = width.
|
|
284
|
+
const subjCols = Math.max(1,
|
|
285
|
+
width - visibleWidth(INDENT) - span - 1 - row.shortHash.length - 1 - 2 - visibleWidth(age));
|
|
286
|
+
const subject = ellipsize(row.subject, subjCols);
|
|
287
|
+
lines.push(INDENT + cyan(cells) + ' ' + dim(row.shortHash) + ' ' + subject
|
|
288
|
+
+ ' ' + dim(age));
|
|
289
|
+
}
|
|
290
|
+
if (history.droppedBranches > 0) {
|
|
291
|
+
lines.push(INDENT + dim(`${history.droppedBranches} more branches`));
|
|
292
|
+
}
|
|
293
|
+
return lines;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Render the whole git pane.
|
|
298
|
+
*
|
|
299
|
+
* @param {{ identity: import('../git-repo').RepoIdentity,
|
|
300
|
+
* workingTree?: import('../git-working-tree').WorkingTree,
|
|
301
|
+
* history?: import('../git-history').History }} model
|
|
302
|
+
* @param {{ width?: number, position?: string, rows?: number, now?: number }} [opts]
|
|
303
|
+
* @returns {string}
|
|
304
|
+
*/
|
|
305
|
+
function renderGitPane(model, opts = {}) {
|
|
306
|
+
const width = opts.width && opts.width > 0 ? opts.width : DEFAULT_WIDTH;
|
|
307
|
+
const rows = opts.rows && opts.rows > 0 ? opts.rows : 24;
|
|
308
|
+
const id = model.identity;
|
|
309
|
+
const lines = [identityLine(id, width, opts.position || '')];
|
|
310
|
+
// The pinned launch repo: its own row, shown only when it differs from the
|
|
311
|
+
// repo the session is in — a tab that names the same repo twice has told the
|
|
312
|
+
// reader nothing, and the row costs vertical space the graph wants.
|
|
313
|
+
if (id.launchName) {
|
|
314
|
+
lines.push(INDENT + dim(ellipsize(LAUNCH_PREFIX + id.launchName, Math.max(1, width - visibleWidth(INDENT)))));
|
|
315
|
+
}
|
|
316
|
+
// The body sections render only where a working tree can exist: a located,
|
|
317
|
+
// readable, non-bare repository. Everywhere else the identity row already
|
|
318
|
+
// carries the pane's whole sentence.
|
|
319
|
+
const bodied = id.state === 'ok' && !id.bare;
|
|
320
|
+
if (model.workingTree && bodied) {
|
|
321
|
+
lines.push('');
|
|
322
|
+
lines.push(...workingTreeLines(model.workingTree, { width, rows }));
|
|
323
|
+
}
|
|
324
|
+
// The graph sits below the list — and is skipped when the working tree
|
|
325
|
+
// already degraded, so "git data unavailable" is said once, not twice from
|
|
326
|
+
// two sections that failed to read the same store.
|
|
327
|
+
if (model.history && bodied && (!model.workingTree || model.workingTree.state === 'ok')) {
|
|
328
|
+
lines.push('');
|
|
329
|
+
lines.push(...commitGraphLines(model.history, {
|
|
330
|
+
width,
|
|
331
|
+
maxRows: Math.max(3, rows - lines.length - 1),
|
|
332
|
+
now: opts.now != null ? opts.now : 0,
|
|
333
|
+
}));
|
|
334
|
+
}
|
|
335
|
+
// clampVisible is the net, not the mechanism: identityLine already budgets to
|
|
336
|
+
// `width`. It stays because a layout bug must cost a truncated line, never
|
|
337
|
+
// the wrap that corrupts the redraw.
|
|
338
|
+
return lines.map((l) => clampVisible(l, width)).join('\n');
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
module.exports = {
|
|
342
|
+
renderGitPane, identityLine, fitPair, workingTreeLines, commitGraphLines,
|
|
343
|
+
fileRowBudget, laneBudget, ellipsizeStart, fmtAge,
|
|
344
|
+
GRAPH_RESERVE, WT_MAX_FILE_ROWS, MAX_LANES,
|
|
345
|
+
};
|