claude-code-runrate 0.3.0 → 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 +86 -10
- package/bin/ccr.js +223 -26
- package/package.json +4 -2
- package/scripts/launch.sh +34 -5
- package/src/account-limits.js +21 -13
- package/src/doctor.js +13 -2
- 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/migrate.js +155 -0
- package/src/render/git-pane.js +345 -0
- package/src/render/shared.js +49 -1
- package/src/render/statusline.js +42 -4
- package/src/safe-read.js +18 -2
- package/src/session-log.js +116 -0
- package/src/sidecar-keys.js +154 -0
- package/src/sidecar.js +145 -20
- package/src/state-dir.js +44 -1
package/src/launch-win.js
CHANGED
|
@@ -82,11 +82,15 @@ function resolveProfileState(profile, opts = {}) {
|
|
|
82
82
|
const env = opts.env || process.env;
|
|
83
83
|
const home = opts.home || os.homedir();
|
|
84
84
|
|
|
85
|
+
// The stateDir fallbacks are reachable only when called with neither a slot's
|
|
86
|
+
// env nor the user's own override — never from the launchers, which refuse an
|
|
87
|
+
// exhausted allocation before this. They point at slot 1's member dir so no
|
|
88
|
+
// path here can ever name the container itself as a state dir.
|
|
85
89
|
if (profile) {
|
|
86
90
|
return {
|
|
87
91
|
ccCmd: `ccs ${profile}`,
|
|
88
|
-
session: env.CCR_SESSION ||
|
|
89
|
-
stateDir: env.CCR_STATE_DIR || path.join(home, '.ccr',
|
|
92
|
+
session: env.CCR_SESSION || 'ccr',
|
|
93
|
+
stateDir: env.CCR_STATE_DIR || path.join(home, '.ccr', 'instances', '1'),
|
|
90
94
|
instanceDir: path.join(home, '.ccs', 'instances', profile),
|
|
91
95
|
usesCcs: true,
|
|
92
96
|
};
|
|
@@ -94,7 +98,7 @@ function resolveProfileState(profile, opts = {}) {
|
|
|
94
98
|
return {
|
|
95
99
|
ccCmd: env.CC_BIN || 'claude',
|
|
96
100
|
session: env.CCR_SESSION || 'ccr',
|
|
97
|
-
stateDir: env.CCR_STATE_DIR || path.join(home, '.ccr'),
|
|
101
|
+
stateDir: env.CCR_STATE_DIR || path.join(home, '.ccr', 'instances', '1'),
|
|
98
102
|
instanceDir: null,
|
|
99
103
|
usesCcs: false,
|
|
100
104
|
};
|
|
@@ -179,7 +183,7 @@ function sidecarCols(termCols, fracNum, splitFlag) {
|
|
|
179
183
|
*
|
|
180
184
|
* @param {{ ccCmd: string, settingsFile: string, stateDir: string,
|
|
181
185
|
* node: string, ccrJs: string, sidebarPct?: number, sidebarSide?: string,
|
|
182
|
-
* termCols?: number }} o
|
|
186
|
+
* termCols?: number, title?: string }} o
|
|
183
187
|
* @returns {string[]}
|
|
184
188
|
*/
|
|
185
189
|
function buildWtArgs(o) {
|
|
@@ -200,6 +204,9 @@ function buildWtArgs(o) {
|
|
|
200
204
|
}
|
|
201
205
|
const frac = sidebarFraction(o.sidebarPct);
|
|
202
206
|
const splitFlag = sidebarSplitFlag(o.sidebarSide);
|
|
207
|
+
// The tab's ADDRESS ("[profile / ]name") when the instance has one — both
|
|
208
|
+
// halves allow-listed, so it is wt-arg-safe by construction.
|
|
209
|
+
const title = o.title || 'Claude';
|
|
203
210
|
const exited = path.win32.join(stateDir, 'exited');
|
|
204
211
|
|
|
205
212
|
// After Claude exits we drop the sentinel + clean the settings file, then idle
|
|
@@ -224,7 +231,7 @@ function buildWtArgs(o) {
|
|
|
224
231
|
const pane1 = paneCommand(stateDir, sidecarBody);
|
|
225
232
|
|
|
226
233
|
return [
|
|
227
|
-
'-w', '0', 'new-tab', '--title',
|
|
234
|
+
'-w', '0', 'new-tab', '--title', title, 'cmd', '/c', pane0,
|
|
228
235
|
';',
|
|
229
236
|
'split-pane', splitFlag, '-s', frac, 'cmd', '/c', pane1,
|
|
230
237
|
];
|
|
@@ -255,7 +262,8 @@ function defaultWhere(name) {
|
|
|
255
262
|
}
|
|
256
263
|
|
|
257
264
|
const inject = require('./settings-inject');
|
|
258
|
-
const
|
|
265
|
+
const slots = require('./instance-slot');
|
|
266
|
+
const { ensureSecureDir, recordLaunchDir } = require('./state-dir');
|
|
259
267
|
|
|
260
268
|
/**
|
|
261
269
|
* Fill in real-environment implementations for anything the caller didn't
|
|
@@ -283,6 +291,11 @@ function withDefaults(deps) {
|
|
|
283
291
|
existsDir: deps.existsDir || defaultExistsDir,
|
|
284
292
|
listDir: deps.listDir || defaultListDir,
|
|
285
293
|
ensureDir: deps.ensureDir || ensureSecureDir,
|
|
294
|
+
recordLaunchDir: deps.recordLaunchDir || recordLaunchDir,
|
|
295
|
+
allocateSlot: deps.allocateSlot || ((o) => slots.allocateSlot(o)),
|
|
296
|
+
prepareInstance: deps.prepareInstance
|
|
297
|
+
|| ((/** @type {{slot:number,stateDir:string}} */ s, /** @type {any} */ o) =>
|
|
298
|
+
require('./instance-name').prepareInstance(s, { ...o, home: deps.home || os.homedir() })),
|
|
286
299
|
removeExited: deps.removeExited || defaultRemoveExited,
|
|
287
300
|
writeSettings: deps.writeSettings || ((s) => inject.writeSettingsFile(s)),
|
|
288
301
|
cleanup: deps.cleanup || ((f) => inject.cleanupSettingsFile(f)),
|
|
@@ -347,9 +360,10 @@ function fallbackNoWt(d) {
|
|
|
347
360
|
*
|
|
348
361
|
* @param {string} [profile]
|
|
349
362
|
* @param {Partial<Deps>} [deps]
|
|
363
|
+
* @param {{ name?: string|null }} [opts] explicit --name, already validated
|
|
350
364
|
* @returns {number}
|
|
351
365
|
*/
|
|
352
|
-
function run(profile, deps = {}) {
|
|
366
|
+
function run(profile, deps = {}, opts = {}) {
|
|
353
367
|
const d = withDefaults(deps);
|
|
354
368
|
|
|
355
369
|
// 1. Validate the profile (it lands in paths and a spawned command).
|
|
@@ -362,8 +376,18 @@ function run(profile, deps = {}) {
|
|
|
362
376
|
const wt = d.findWt();
|
|
363
377
|
if (!wt) return fallbackNoWt(d);
|
|
364
378
|
|
|
365
|
-
// 3. Resolve profile state + required binaries.
|
|
366
|
-
|
|
379
|
+
// 3. Resolve profile state + required binaries. Every launch first claims a
|
|
380
|
+
// free instance slot (src/instance-slot.js) so a second window never shares
|
|
381
|
+
// another's state dir; only an explicit override skips it.
|
|
382
|
+
const slot = d.allocateSlot({ profile, env: d.env, home: d.home });
|
|
383
|
+
if (slot && 'exhausted' in slot) {
|
|
384
|
+
d.err(`ccr: every slot is in use (${slots.MAX_SLOTS} live instances) — close one first\n`);
|
|
385
|
+
return 1;
|
|
386
|
+
}
|
|
387
|
+
// The instance's name, profile record and title — same on every platform.
|
|
388
|
+
const inst = slot && !('exhausted' in slot)
|
|
389
|
+
? d.prepareInstance(slot, { profile, name: opts.name }) : null;
|
|
390
|
+
const st = resolveProfileState(profile, { env: slots.applySlotEnv(d.env, slot), home: d.home });
|
|
367
391
|
if (st.usesCcs) {
|
|
368
392
|
if (!d.which('ccs')) {
|
|
369
393
|
d.err("ccr: 'ccs' not found on PATH — pass a profile only if CCS is installed.\n");
|
|
@@ -385,6 +409,9 @@ function run(profile, deps = {}) {
|
|
|
385
409
|
|
|
386
410
|
// 4. Prepare the per-profile state dir; clear a stale sentinel.
|
|
387
411
|
try { d.ensureDir(st.stateDir); } catch { /* best effort */ }
|
|
412
|
+
// The tab's stable identity for the git pane. Recorded here because only the
|
|
413
|
+
// launcher knows where ccr was started (src/state-dir.js).
|
|
414
|
+
try { d.recordLaunchDir(st.stateDir, process.cwd()); } catch { /* best effort */ }
|
|
388
415
|
d.removeExited(st.stateDir);
|
|
389
416
|
|
|
390
417
|
// 5. Inject statusLine via a temp settings FILE (avoids CLI JSON quoting).
|
|
@@ -404,6 +431,7 @@ function run(profile, deps = {}) {
|
|
|
404
431
|
sidebarPct: Number.isFinite(pct) ? pct : DEFAULT_SIDEBAR_PCT,
|
|
405
432
|
sidebarSide: d.env.CCR_SIDEBAR_SIDE || DEFAULT_SIDEBAR_SIDE,
|
|
406
433
|
termCols: d.cols,
|
|
434
|
+
title: inst ? inst.title : undefined,
|
|
407
435
|
});
|
|
408
436
|
} catch (e) {
|
|
409
437
|
d.err(`ccr: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
@@ -434,6 +462,9 @@ function run(profile, deps = {}) {
|
|
|
434
462
|
* @property {(dir: string) => boolean} existsDir
|
|
435
463
|
* @property {(dir: string) => string[]} listDir
|
|
436
464
|
* @property {(dir: string) => void} ensureDir
|
|
465
|
+
* @property {(dir: string, cwd: string) => void} recordLaunchDir
|
|
466
|
+
* @property {(o: {profile?: string, env: NodeJS.ProcessEnv, home: string}) => ({slot: number, session: string, stateDir: string, attached: boolean}|{exhausted: true}|null)} allocateSlot
|
|
467
|
+
* @property {(slot: {slot: number, stateDir: string}, o: {profile?: string, name?: string|null}) => {name: string, title: string}} prepareInstance
|
|
437
468
|
* @property {(dir: string) => void} removeExited
|
|
438
469
|
* @property {(settings: object) => string} writeSettings
|
|
439
470
|
* @property {(file: string) => void} cleanup
|
package/src/migrate.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
// src/migrate.js — move a 0.3 ccr home to the 0.4 container layout, once,
|
|
4
|
+
// safely (features/instance-migration.feature).
|
|
5
|
+
//
|
|
6
|
+
// There is nothing to relocate but HISTORY: the only persistent content the
|
|
7
|
+
// 0.3 layout ever held was account burn history, misplaced inside profile
|
|
8
|
+
// dirs because the logger wrote to whatever the state dir was. So migration
|
|
9
|
+
// HARVESTS burnlogs to the container's top level, SWEEPS the profile dirs and
|
|
10
|
+
// the loose ephemeral droppings (a dead session's captured status, heartbeat,
|
|
11
|
+
// sentinel), and writes the ".layout" marker LAST — an interrupted migration
|
|
12
|
+
// is indistinguishable from one that has not started, and every move is
|
|
13
|
+
// move-if-present, so the next launch simply completes it.
|
|
14
|
+
//
|
|
15
|
+
// THE GENERAL SAFETY PROPERTY, ruled: if the source is not what migration
|
|
16
|
+
// expects, it STOPS AND CHANGES NOTHING. That is also the entire handling of
|
|
17
|
+
// an entry named "instances" or "profiles" (reserved by the new layout) and
|
|
18
|
+
// of a CCS profile literally named either — declined as real scope ("no other
|
|
19
|
+
// users will have this problem"); the stop makes it fail loudly for free.
|
|
20
|
+
//
|
|
21
|
+
// Runs AT LAUNCH ONLY. `ccr statusline` — invoked headlessly by Claude
|
|
22
|
+
// mid-session — never calls this. Removed at 1.0.0.
|
|
23
|
+
|
|
24
|
+
const fs = require('node:fs');
|
|
25
|
+
const path = require('node:path');
|
|
26
|
+
const os = require('node:os');
|
|
27
|
+
|
|
28
|
+
const MARKER = '.layout';
|
|
29
|
+
|
|
30
|
+
// The 0.3 layout's loose per-session droppings — dead state the ephemeral
|
|
31
|
+
// rule says dies. Everything here may appear at the container root (bare
|
|
32
|
+
// launches) or inside a profile dir.
|
|
33
|
+
const DROPPINGS = ['last-status.json', 'launch-cwd', 'exited', 'sidecar-alive', 'slot-owner', 'view-request'];
|
|
34
|
+
|
|
35
|
+
const BURNLOG_RE = /^burnlog-[A-Za-z0-9_-]+\.jsonl$/;
|
|
36
|
+
// 0.4's own session join key (src/session-log.js). An upgrade while a session
|
|
37
|
+
// is open leaves the new statusline ticking against an unmigrated home, so
|
|
38
|
+
// these appear at the root BEFORE any launch migrates it — already at their
|
|
39
|
+
// final location, they are kept, not a surprise.
|
|
40
|
+
const SESSION_RE = /^session-[A-Za-z0-9_-]+\.jsonl$/;
|
|
41
|
+
// Same allow-list the launcher enforces for profile names.
|
|
42
|
+
const PROFILE_DIR_RE = /^[A-Za-z0-9._-]+$/;
|
|
43
|
+
// Names the new layout owns; their presence in an unmigrated home is exactly
|
|
44
|
+
// the surprise the safety property exists for.
|
|
45
|
+
const RESERVED = new Set(['instances', 'profiles']);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Is a 0.3 session still running in this dir, as far as 0.3 state can say?
|
|
49
|
+
* The published 0.3 recorded no owning pid, so the freshest signal it left is
|
|
50
|
+
* the sidecar heartbeat. Refusing on a live heartbeat is the safe direction:
|
|
51
|
+
* a false "live" delays migration by seconds; a false "dead" would harvest
|
|
52
|
+
* burnlogs out from under a writing session.
|
|
53
|
+
* @param {string} dir
|
|
54
|
+
*/
|
|
55
|
+
function legacyLive(dir) {
|
|
56
|
+
try {
|
|
57
|
+
if (fs.existsSync(path.join(dir, 'exited'))) return false;
|
|
58
|
+
return require('./sidecar').sidecarAlive(dir);
|
|
59
|
+
} catch { return false; }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Move a burnlog to the container root, first-wins on a name collision (the
|
|
64
|
+
* name carries the Claude session id, so a collision is the same session's
|
|
65
|
+
* data twice — the copy already at the root is kept).
|
|
66
|
+
* @param {string} from @param {string} rootDir @param {string} name
|
|
67
|
+
*/
|
|
68
|
+
function harvestBurnlog(from, rootDir, name) {
|
|
69
|
+
const dest = path.join(rootDir, name);
|
|
70
|
+
try {
|
|
71
|
+
if (fs.existsSync(dest)) { fs.rmSync(from, { force: true }); return; }
|
|
72
|
+
fs.renameSync(from, dest);
|
|
73
|
+
} catch { /* best effort — a later launch completes it */ }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Bring <home>/.ccr to the 0.4 layout. Total: never throws.
|
|
78
|
+
*
|
|
79
|
+
* @param {{ home?: string }} [opts]
|
|
80
|
+
* @returns {{ ok: true } | { ok: false, error: string }}
|
|
81
|
+
*/
|
|
82
|
+
function ensureLayout(opts = {}) {
|
|
83
|
+
const home = opts.home || os.homedir();
|
|
84
|
+
const root = path.join(home, '.ccr');
|
|
85
|
+
const marker = path.join(root, MARKER);
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
if (!fs.existsSync(root)) {
|
|
89
|
+
// Fresh install: create the container already migrated.
|
|
90
|
+
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
91
|
+
fs.writeFileSync(marker, '1\n', { mode: 0o600 });
|
|
92
|
+
return { ok: true };
|
|
93
|
+
}
|
|
94
|
+
if (fs.existsSync(marker)) {
|
|
95
|
+
sweepLegacyDroppings(root);
|
|
96
|
+
return { ok: true };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---- Classify EVERYTHING before touching ANYTHING. ----
|
|
100
|
+
/** @type {{ dir: string, name: string }[]} */
|
|
101
|
+
const profileDirs = [];
|
|
102
|
+
/** @type {string[]} */
|
|
103
|
+
const looseBurnlogs = [];
|
|
104
|
+
let rootHasDroppings = false;
|
|
105
|
+
for (const name of fs.readdirSync(root)) {
|
|
106
|
+
const p = path.join(root, name);
|
|
107
|
+
if (RESERVED.has(name)) return { ok: false, error: `ccr: cannot migrate ~/.ccr — unexpected entry '${name}' (reserved by the new layout); move it aside and relaunch` };
|
|
108
|
+
if (name.startsWith('.')) continue; // dotted container entries are left alone
|
|
109
|
+
if (BURNLOG_RE.test(name)) { looseBurnlogs.push(name); continue; }
|
|
110
|
+
if (SESSION_RE.test(name)) continue; // 0.4 join key — already at its final home
|
|
111
|
+
if (DROPPINGS.includes(name)) { rootHasDroppings = true; continue; }
|
|
112
|
+
let st; try { st = fs.lstatSync(p); } catch { continue; }
|
|
113
|
+
if (st.isDirectory() && PROFILE_DIR_RE.test(name)) { profileDirs.push({ dir: p, name }); continue; }
|
|
114
|
+
return { ok: false, error: `ccr: cannot migrate ~/.ccr — unexpected entry '${name}'; move it aside and relaunch` };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// A live 0.3 session anywhere refuses the whole migration, by name.
|
|
118
|
+
if (legacyLive(root)) return { ok: false, error: 'ccr: cannot migrate ~/.ccr while a session is running there — close it first' };
|
|
119
|
+
for (const { dir, name } of profileDirs) {
|
|
120
|
+
if (legacyLive(dir)) return { ok: false, error: `ccr: cannot migrate ~/.ccr while an instance is running — close '${name}' first` };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---- Harvest, then sweep, then mark. ----
|
|
124
|
+
for (const { dir } of profileDirs) {
|
|
125
|
+
let names; try { names = fs.readdirSync(dir); } catch { continue; }
|
|
126
|
+
for (const n of names) if (BURNLOG_RE.test(n)) harvestBurnlog(path.join(dir, n), root, n);
|
|
127
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* next launch retries */ }
|
|
128
|
+
}
|
|
129
|
+
if (rootHasDroppings) for (const n of DROPPINGS) { try { fs.rmSync(path.join(root, n), { force: true }); } catch { /* best effort */ } }
|
|
130
|
+
try { fs.chmodSync(root, 0o700); } catch { /* best effort */ }
|
|
131
|
+
fs.writeFileSync(marker, '1\n', { mode: 0o600 });
|
|
132
|
+
return { ok: true };
|
|
133
|
+
} catch (e) {
|
|
134
|
+
return { ok: false, error: `ccr: migration failed: ${e instanceof Error ? e.message : String(e)}` };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A DOWNGRADE-then-re-upgrade leaves a migrated container plus a 0.3 session's
|
|
140
|
+
* loose droppings at the root — a dead instance's live state, which the
|
|
141
|
+
* ephemeral rule says dies. Its burnlogs already live where the pool is, so
|
|
142
|
+
* the day's history merges for free. Swept only once that session shows no
|
|
143
|
+
* heartbeat (the safe direction, same as everywhere else).
|
|
144
|
+
* @param {string} root
|
|
145
|
+
*/
|
|
146
|
+
function sweepLegacyDroppings(root) {
|
|
147
|
+
try {
|
|
148
|
+
if (!fs.existsSync(path.join(root, 'last-status.json'))
|
|
149
|
+
&& !fs.existsSync(path.join(root, 'sidecar-alive'))) return;
|
|
150
|
+
if (legacyLive(root)) return;
|
|
151
|
+
for (const n of DROPPINGS) { try { fs.rmSync(path.join(root, n), { force: true }); } catch { /* best effort */ } }
|
|
152
|
+
} catch { /* best effort */ }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = { ensureLayout, MARKER };
|
|
@@ -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
|
+
};
|