@north-light/crouter 0.3.242 → 0.3.244
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/clients/attach/viewer.js +366 -366
- package/dist/commands/sys/context/admin/detail-panel.d.ts +36 -13
- package/dist/commands/sys/context/admin/detail-panel.js +166 -129
- package/dist/commands/sys/context/admin/docs-panel.d.ts +17 -7
- package/dist/commands/sys/context/admin/docs-panel.js +43 -23
- package/dist/commands/sys/context/admin/list-view.d.ts +22 -11
- package/dist/commands/sys/context/admin/list-view.js +112 -53
- package/dist/commands/sys/context/admin/read-view.d.ts +27 -0
- package/dist/commands/sys/context/admin/read-view.js +152 -0
- package/dist/commands/sys/context/admin/shell.d.ts +15 -0
- package/dist/commands/sys/context/admin/shell.js +111 -36
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import
|
|
1
|
+
import type { SurfaceEvent } from '../../../../core/substrate/schema.js';
|
|
2
2
|
import type { DeliveryRecord } from '../../../../core/substrate/plan.js';
|
|
3
|
+
import { type CapOwner } from './read-view.js';
|
|
3
4
|
import type { DocRow } from './docs-panel.js';
|
|
5
|
+
export type { CapOwner } from './read-view.js';
|
|
4
6
|
/** A field of the focused document the shell can write. `entry` carries an
|
|
5
7
|
* index into the document's whole surfaces list, not the focused event's
|
|
6
8
|
* subset, because that list is what a write replaces. */
|
|
@@ -26,10 +28,6 @@ export type DetailRow = {
|
|
|
26
28
|
} | {
|
|
27
29
|
kind: 'move';
|
|
28
30
|
};
|
|
29
|
-
export interface CapOwner {
|
|
30
|
-
path: string;
|
|
31
|
-
memory: Rung;
|
|
32
|
-
}
|
|
33
31
|
export declare class ContextDetailPanel {
|
|
34
32
|
private row;
|
|
35
33
|
private event;
|
|
@@ -37,20 +35,45 @@ export declare class ContextDetailPanel {
|
|
|
37
35
|
private readOnly;
|
|
38
36
|
private rows;
|
|
39
37
|
private index;
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
private
|
|
38
|
+
private face;
|
|
39
|
+
private open;
|
|
40
|
+
private scroll;
|
|
41
|
+
/** Last render's geometry, so a half-pane jump and the end-of-content clamp
|
|
42
|
+
* measure against real content rather than a counter nobody can see. */
|
|
43
|
+
private lastLines;
|
|
44
|
+
private lastHeight;
|
|
43
45
|
setContext(row: DocRow | undefined, event: SurfaceEvent, capOwner: CapOwner | null, readOnly: string | null): void;
|
|
46
|
+
/** True while the field list owns the pane, so the shell routes the write
|
|
47
|
+
* letters and teaches them in the footer. */
|
|
48
|
+
get editing(): boolean;
|
|
49
|
+
get bodyOpen(): boolean;
|
|
44
50
|
get selected(): DetailRow | undefined;
|
|
45
51
|
/** The entry index a rung cycle or a removal would act on. */
|
|
46
52
|
get selectedEntry(): number | undefined;
|
|
53
|
+
/** `j`/`k`: a line of the read face, a field of the edit face. */
|
|
47
54
|
move(delta: number): void;
|
|
55
|
+
/** `ctrl-d`/`ctrl-u`: half a pane, so a long body is crossed in jumps a
|
|
56
|
+
* reader can still follow. */
|
|
57
|
+
page(direction: 1 | -1): void;
|
|
58
|
+
/** `g`/`G`. */
|
|
59
|
+
jump(to: 'top' | 'bottom'): void;
|
|
60
|
+
/** `enter`: the document's own text, in place. False when there is no body,
|
|
61
|
+
* so the shell leaves the key alone. */
|
|
62
|
+
openBody(): boolean;
|
|
63
|
+
/** `e`: the field list. */
|
|
64
|
+
edit(): void;
|
|
65
|
+
/** `esc`/`h`: leave the edit face, else collapse the body. False means the
|
|
66
|
+
* read face is already at rest and the shell should leave the dossier. */
|
|
67
|
+
back(): boolean;
|
|
68
|
+
private scrollBy;
|
|
69
|
+
private maxScroll;
|
|
70
|
+
private record;
|
|
71
|
+
private context;
|
|
48
72
|
render(width: number, height: number, focused: boolean): string[];
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
*
|
|
52
|
-
private
|
|
53
|
-
private acrossEvents;
|
|
73
|
+
/** Today's field list, under the identity and delivery it is being edited
|
|
74
|
+
* against. A row's clarifying tail renders only while that row is focused:
|
|
75
|
+
* unfocused, twelve explanations are twelve lines of noise. */
|
|
76
|
+
private editLines;
|
|
54
77
|
}
|
|
55
78
|
/** The editable rows for one document, in the order the panel draws them. */
|
|
56
79
|
export declare function buildDetailRows(record: DeliveryRecord): DetailRow[];
|
|
@@ -1,19 +1,14 @@
|
|
|
1
|
-
// The dossier: one document
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// The dossier: one memory document, read first.
|
|
2
|
+
//
|
|
3
|
+
// Two faces over one pane. The read face is the resting state — who this
|
|
4
|
+
// document is, when an agent reads it, what it says, and whether it loads on
|
|
5
|
+
// this event — and `enter` opens its body in place. The edit face is the field
|
|
6
|
+
// list every write still starts from, one `e` away. The panel owns which face
|
|
7
|
+
// is showing and how far it is scrolled; it owns no text layout.
|
|
5
8
|
import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
|
|
6
9
|
import { padAnsi, theme, wrapText } from '../../../../core/tui/panel.js';
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
const STATE_WORD = {
|
|
10
|
-
pending: 'planning…',
|
|
11
|
-
delivers: 'delivers',
|
|
12
|
-
capped: 'capped',
|
|
13
|
-
gated: 'gated out',
|
|
14
|
-
shadowed: 'shadowed',
|
|
15
|
-
silent: 'silent',
|
|
16
|
-
};
|
|
10
|
+
import { describeSurfaceEntry, plural } from './model.js';
|
|
11
|
+
import { bodyLineCount, renderDossierDelivery, renderDossierHeader, renderDossierRead, } from './read-view.js';
|
|
17
12
|
export class ContextDetailPanel {
|
|
18
13
|
row;
|
|
19
14
|
event = 'boot';
|
|
@@ -21,9 +16,13 @@ export class ContextDetailPanel {
|
|
|
21
16
|
readOnly = null;
|
|
22
17
|
rows = [];
|
|
23
18
|
index = 0;
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
19
|
+
face = 'read';
|
|
20
|
+
open = false;
|
|
21
|
+
scroll = 0;
|
|
22
|
+
/** Last render's geometry, so a half-pane jump and the end-of-content clamp
|
|
23
|
+
* measure against real content rather than a counter nobody can see. */
|
|
24
|
+
lastLines = 1;
|
|
25
|
+
lastHeight = 1;
|
|
27
26
|
setContext(row, event, capOwner, readOnly) {
|
|
28
27
|
const changed = row?.key !== this.row?.key;
|
|
29
28
|
this.row = row;
|
|
@@ -33,59 +32,160 @@ export class ContextDetailPanel {
|
|
|
33
32
|
this.rows = row === undefined ? [] : buildDetailRows(row.anchor);
|
|
34
33
|
if (changed) {
|
|
35
34
|
this.index = 0;
|
|
36
|
-
this.
|
|
35
|
+
this.scroll = 0;
|
|
36
|
+
this.open = false;
|
|
37
|
+
this.face = 'read';
|
|
37
38
|
}
|
|
38
39
|
this.index = Math.max(0, Math.min(this.index, this.rows.length - 1));
|
|
39
40
|
}
|
|
41
|
+
/** True while the field list owns the pane, so the shell routes the write
|
|
42
|
+
* letters and teaches them in the footer. */
|
|
43
|
+
get editing() {
|
|
44
|
+
return this.face === 'edit';
|
|
45
|
+
}
|
|
46
|
+
get bodyOpen() {
|
|
47
|
+
return this.open;
|
|
48
|
+
}
|
|
40
49
|
get selected() {
|
|
41
|
-
return this.rows[this.index];
|
|
50
|
+
return this.face === 'edit' ? this.rows[this.index] : undefined;
|
|
42
51
|
}
|
|
43
52
|
/** The entry index a rung cycle or a removal would act on. */
|
|
44
53
|
get selectedEntry() {
|
|
45
54
|
const row = this.selected;
|
|
46
55
|
return row?.kind === 'entry' ? row.at : undefined;
|
|
47
56
|
}
|
|
57
|
+
// navigation
|
|
58
|
+
/** `j`/`k`: a line of the read face, a field of the edit face. */
|
|
48
59
|
move(delta) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
this.tail = Math.max(0, this.tail + delta);
|
|
60
|
+
if (this.face === 'edit') {
|
|
61
|
+
this.index = Math.max(0, Math.min(this.rows.length - 1, this.index + delta));
|
|
52
62
|
return;
|
|
53
63
|
}
|
|
54
|
-
this.
|
|
55
|
-
this.tail = 0;
|
|
64
|
+
this.scrollBy(delta);
|
|
56
65
|
}
|
|
57
|
-
|
|
66
|
+
/** `ctrl-d`/`ctrl-u`: half a pane, so a long body is crossed in jumps a
|
|
67
|
+
* reader can still follow. */
|
|
68
|
+
page(direction) {
|
|
69
|
+
if (this.face === 'edit')
|
|
70
|
+
return;
|
|
71
|
+
this.scrollBy(direction * Math.max(1, Math.floor(this.lastHeight / 2)));
|
|
72
|
+
}
|
|
73
|
+
/** `g`/`G`. */
|
|
74
|
+
jump(to) {
|
|
75
|
+
if (this.face === 'edit')
|
|
76
|
+
return;
|
|
77
|
+
this.scroll = to === 'top' ? 0 : this.maxScroll();
|
|
78
|
+
}
|
|
79
|
+
/** `enter`: the document's own text, in place. False when there is no body,
|
|
80
|
+
* so the shell leaves the key alone. */
|
|
81
|
+
openBody() {
|
|
82
|
+
if (this.face === 'edit' || this.row === undefined)
|
|
83
|
+
return false;
|
|
84
|
+
if (bodyLineCount(this.record().doc.body) === 0)
|
|
85
|
+
return false;
|
|
86
|
+
this.open = true;
|
|
87
|
+
this.scroll = 0;
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
/** `e`: the field list. */
|
|
91
|
+
edit() {
|
|
92
|
+
if (this.row === undefined)
|
|
93
|
+
return;
|
|
94
|
+
this.face = 'edit';
|
|
95
|
+
this.scroll = 0;
|
|
96
|
+
}
|
|
97
|
+
/** `esc`/`h`: leave the edit face, else collapse the body. False means the
|
|
98
|
+
* read face is already at rest and the shell should leave the dossier. */
|
|
99
|
+
back() {
|
|
100
|
+
if (this.face === 'edit') {
|
|
101
|
+
this.face = 'read';
|
|
102
|
+
this.scroll = 0;
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
if (!this.open)
|
|
106
|
+
return false;
|
|
107
|
+
this.open = false;
|
|
108
|
+
this.scroll = 0;
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
scrollBy(delta) {
|
|
112
|
+
this.scroll = Math.max(0, Math.min(this.scroll + delta, this.maxScroll()));
|
|
113
|
+
}
|
|
114
|
+
maxScroll() {
|
|
115
|
+
return Math.max(0, this.lastLines - this.lastHeight);
|
|
116
|
+
}
|
|
117
|
+
record() {
|
|
58
118
|
const row = this.row;
|
|
59
|
-
|
|
119
|
+
return row.records.get(this.event) ?? row.anchor;
|
|
120
|
+
}
|
|
121
|
+
context() {
|
|
122
|
+
return {
|
|
123
|
+
event: this.event,
|
|
124
|
+
records: this.row?.records ?? new Map(),
|
|
125
|
+
capOwner: this.capOwner,
|
|
126
|
+
readOnly: this.readOnly !== null,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
// rendering
|
|
130
|
+
render(width, height, focused) {
|
|
131
|
+
if (this.row === undefined) {
|
|
132
|
+
this.lastLines = 1;
|
|
133
|
+
this.lastHeight = height;
|
|
60
134
|
return fit([theme.fg('dim', ' Select a document.')], height);
|
|
61
135
|
}
|
|
62
|
-
const record =
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
lines
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
136
|
+
const record = this.record();
|
|
137
|
+
const built = this.face === 'read'
|
|
138
|
+
? { lines: renderDossierRead(record, this.context(), width, this.open), cursor: undefined }
|
|
139
|
+
: this.editLines(record, width, focused);
|
|
140
|
+
const lines = built.lines;
|
|
141
|
+
// The badge is the pane's last row, so it costs a line of content only
|
|
142
|
+
// while there is content it cannot show anyway.
|
|
143
|
+
const overflows = lines.length > height;
|
|
144
|
+
const viewport = Math.max(1, overflows ? height - 1 : height);
|
|
145
|
+
this.lastLines = lines.length;
|
|
146
|
+
this.lastHeight = viewport;
|
|
147
|
+
let start;
|
|
148
|
+
if (built.cursor === undefined) {
|
|
149
|
+
this.scroll = Math.max(0, Math.min(this.scroll, Math.max(0, lines.length - viewport)));
|
|
150
|
+
start = this.scroll;
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
start = Math.min(Math.max(0, built.cursor - Math.floor(viewport / 3)), Math.max(0, lines.length - viewport));
|
|
154
|
+
}
|
|
155
|
+
const window = fit(lines.slice(start, start + viewport), viewport);
|
|
156
|
+
if (!overflows)
|
|
157
|
+
return window;
|
|
158
|
+
window.push(badge(start + 1, Math.min(lines.length, start + viewport), lines.length, width));
|
|
159
|
+
return window;
|
|
160
|
+
}
|
|
161
|
+
/** Today's field list, under the identity and delivery it is being edited
|
|
162
|
+
* against. A row's clarifying tail renders only while that row is focused:
|
|
163
|
+
* unfocused, twelve explanations are twelve lines of noise. */
|
|
164
|
+
editLines(record, width, focused) {
|
|
165
|
+
const context = this.context();
|
|
166
|
+
const lines = [...renderDossierHeader(record, context, width), ...renderDossierDelivery(record, context, width), ''];
|
|
167
|
+
let cursor = 0;
|
|
168
|
+
const push = (at, body, tail) => {
|
|
169
|
+
const own = [body, ...(at === this.index && tail !== undefined ? tailLines(tail, width) : [])];
|
|
170
|
+
if (at === this.index)
|
|
171
|
+
cursor = lines.length;
|
|
172
|
+
for (const line of own) {
|
|
173
|
+
lines.push(at === this.index && focused
|
|
174
|
+
? theme.bg('selectedBg', padAnsi(truncateToWidth(line, width), width))
|
|
175
|
+
: at === this.index
|
|
176
|
+
? `${theme.fg('accent', '\u258c')}${truncateToWidth(line, Math.max(0, width - 1))}`
|
|
177
|
+
: ` ${truncateToWidth(line, Math.max(0, width - 1))}`);
|
|
178
|
+
}
|
|
78
179
|
};
|
|
79
180
|
let at = 0;
|
|
80
181
|
lines.push(theme.fg('dim', ' SURFACES'));
|
|
81
182
|
const surfaces = record.doc.surfaces;
|
|
82
|
-
if (surfaces.length === 0)
|
|
83
|
-
push(
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
183
|
+
if (surfaces.length === 0) {
|
|
184
|
+
lines.push(theme.fg('dim', ' no entries — this document only appears in its listing'));
|
|
185
|
+
}
|
|
186
|
+
for (const entry of surfaces) {
|
|
187
|
+
const text = describeSurfaceEntry(entry);
|
|
188
|
+
push(at++, entry.on === this.event ? text : theme.fg('dim', text));
|
|
89
189
|
}
|
|
90
190
|
push(at++, theme.fg('success', '+ add a surface entry'));
|
|
91
191
|
lines.push('');
|
|
@@ -95,71 +195,13 @@ export class ContextDetailPanel {
|
|
|
95
195
|
push(at++, field('slash', record.doc.slash ? 'yes' : theme.fg('dim', 'no'), width));
|
|
96
196
|
push(at++, field('routing', record.doc.whenAndWhyToRead, width));
|
|
97
197
|
push(at++, field('short form', record.doc.shortForm === '' ? theme.fg('dim', 'none') : record.doc.shortForm, width));
|
|
98
|
-
push(at++, field('body', theme.fg('dim',
|
|
99
|
-
push(at++, field('profile cap', this.capOwner === null
|
|
100
|
-
?
|
|
101
|
-
:
|
|
198
|
+
push(at++, field('body', theme.fg('dim', plural(bodyLineCount(record.doc.body), 'line', 'lines')), width), 'opens in $EDITOR');
|
|
199
|
+
push(at++, field('profile cap', this.capOwner === null ? theme.fg('dim', 'uncapped') : `${this.capOwner.memory} ${theme.fg('dim', this.capOwner.path)}`, width), this.capOwner === null
|
|
200
|
+
? 'no profile project governs this store'
|
|
201
|
+
: 'the highest rung any document from this store can deliver at');
|
|
102
202
|
push(at++, field('canonical name', record.name, width));
|
|
103
|
-
lines.push('');
|
|
104
|
-
lines
|
|
105
|
-
lines.push(theme.fg('dim', ` VERDICT · ${this.event}`));
|
|
106
|
-
lines.push(...this.verdict(record, width));
|
|
107
|
-
lines.push('');
|
|
108
|
-
lines.push(theme.fg('dim', ' COST IF DELIVERED · approximate'));
|
|
109
|
-
lines.push(...this.costs(record, width));
|
|
110
|
-
lines.push('');
|
|
111
|
-
lines.push(theme.fg('dim', ' ACROSS EVENTS'));
|
|
112
|
-
lines.push(...this.acrossEvents(row, width));
|
|
113
|
-
lines.push('');
|
|
114
|
-
lines.push(theme.fg('dim', ' BODY'));
|
|
115
|
-
lines.push(...bodyPreview(record.doc.body, width, height));
|
|
116
|
-
const cursorLine = rowLines.get(this.index) ?? 0;
|
|
117
|
-
const maxStart = Math.max(0, lines.length - height);
|
|
118
|
-
let start = Math.min(Math.max(0, cursorLine - Math.floor(height / 3)), maxStart);
|
|
119
|
-
if (this.tail > 0)
|
|
120
|
-
start = Math.min(maxStart, start + this.tail);
|
|
121
|
-
return fit(lines.slice(start, start + height), height);
|
|
122
|
-
}
|
|
123
|
-
verdict(record, width) {
|
|
124
|
-
const out = [];
|
|
125
|
-
const cap = record.projectMemoryCap === null ? 'uncapped' : record.projectMemoryCap;
|
|
126
|
-
out.push(truncateToWidth(` authored ${theme.bold(record.authoredRung)} → cap ${cap} → ${theme.fg(record.finalRung === 'none' ? 'dim' : 'success', theme.bold(record.finalRung))}`, width));
|
|
127
|
-
if (!record.docGate.pass)
|
|
128
|
-
out.push(...wrapText(` document gate fails: ${record.docGate.reason}`, width).map((line) => theme.fg('warning', line)));
|
|
129
|
-
if (record.entryGate !== null && !record.entryGate.pass) {
|
|
130
|
-
out.push(...wrapText(` entry gate fails: ${record.entryGate.reason}`, width).map((line) => theme.fg('warning', line)));
|
|
131
|
-
}
|
|
132
|
-
if (record.matchedEntry === null && record.authoredEntries.length > 0) {
|
|
133
|
-
out.push(theme.fg('dim', ` ${plural(record.authoredEntries.length, 'entry', 'entries')} for this event, none matched`));
|
|
134
|
-
}
|
|
135
|
-
if (record.shadowedBy !== null) {
|
|
136
|
-
out.push(...wrapText(` shadowed by the ${record.shadowedBy.scope} copy at ${record.shadowedBy.path}`, width).map((line) => theme.fg('muted', line)));
|
|
137
|
-
}
|
|
138
|
-
else if (record.winner) {
|
|
139
|
-
out.push(theme.fg('dim', ' wins this canonical name'));
|
|
140
|
-
}
|
|
141
|
-
return out;
|
|
142
|
-
}
|
|
143
|
-
/** All three rungs, not just the current one, so "what would demoting this
|
|
144
|
-
* save" is read rather than computed. */
|
|
145
|
-
costs(record, width) {
|
|
146
|
-
const costs = rungCostsOf(record);
|
|
147
|
-
const cells = ['content', 'preview', 'name'].map((rung) => {
|
|
148
|
-
const text = `${padAnsi(rung, 8)}${padAnsi(formatCost(costs[rung]) || '~0', 8)}`;
|
|
149
|
-
return rung === record.finalRung ? theme.fg('success', text) : theme.fg('dim', text);
|
|
150
|
-
});
|
|
151
|
-
return [truncateToWidth(` ${cells.join(' ')}`, width)];
|
|
152
|
-
}
|
|
153
|
-
acrossEvents(row, width) {
|
|
154
|
-
return SURFACE_EVENTS.map((event) => {
|
|
155
|
-
const record = row.records.get(event);
|
|
156
|
-
const state = record === undefined ? 'silent' : cellStateOf(record);
|
|
157
|
-
const rung = record === undefined ? 'none' : record.finalRung;
|
|
158
|
-
const label = padAnsi(` ${EVENT_INITIALS[event]} ${event}`, 20);
|
|
159
|
-
const word = STATE_WORD[state] ?? state;
|
|
160
|
-
const body = `${label} ${padAnsi(word, 11)} ${rung}`;
|
|
161
|
-
return truncateToWidth(state === 'delivers' || state === 'capped' ? body : theme.fg('dim', body), width);
|
|
162
|
-
});
|
|
203
|
+
lines.push(` ${field('store path', theme.fg('dim', record.storePath), Math.max(0, width - 1))}`);
|
|
204
|
+
return { lines, cursor };
|
|
163
205
|
}
|
|
164
206
|
}
|
|
165
207
|
/** The editable rows for one document, in the order the panel draws them. */
|
|
@@ -178,27 +220,22 @@ export function buildDetailRows(record) {
|
|
|
178
220
|
{ kind: 'move' },
|
|
179
221
|
];
|
|
180
222
|
}
|
|
181
|
-
function bodyLines(body) {
|
|
182
|
-
const trimmed = body.replace(/\n+$/, '');
|
|
183
|
-
return trimmed === '' ? 0 : trimmed.split('\n').length;
|
|
184
|
-
}
|
|
185
|
-
/** The document's opening lines, filling the rows the dossier would otherwise
|
|
186
|
-
* leave blank. Bounded by the pane height: past that the reader is scrolling,
|
|
187
|
-
* and the body opens in $EDITOR. */
|
|
188
|
-
function bodyPreview(body, width, height) {
|
|
189
|
-
const trimmed = body.trim();
|
|
190
|
-
if (trimmed === '')
|
|
191
|
-
return [theme.fg('dim', ' (empty)')];
|
|
192
|
-
return trimmed
|
|
193
|
-
.split('\n')
|
|
194
|
-
.slice(0, Math.max(1, height))
|
|
195
|
-
.map((line) => theme.fg('dim', truncateToWidth(` ${line}`, width)));
|
|
196
|
-
}
|
|
197
223
|
const FIELD_WIDTH = 15;
|
|
198
224
|
function field(label, value, width) {
|
|
199
225
|
const room = Math.max(1, width - FIELD_WIDTH - 2);
|
|
200
226
|
return `${padAnsi(theme.fg('dim', label), FIELD_WIDTH)} ${truncateToWidth(value, room)}`;
|
|
201
227
|
}
|
|
228
|
+
/** A focused row's explanation, hanging under its value rather than its label. */
|
|
229
|
+
function tailLines(text, width) {
|
|
230
|
+
const room = Math.max(1, width - FIELD_WIDTH - 2);
|
|
231
|
+
return wrapText(`\u2014 ${text}`, room).map((line) => `${' '.repeat(FIELD_WIDTH + 1)}${theme.fg('dim', line)}`);
|
|
232
|
+
}
|
|
233
|
+
/** Where the visible window sits in the whole document, drawn only while there
|
|
234
|
+
* is something outside it. */
|
|
235
|
+
function badge(from, to, total, width) {
|
|
236
|
+
const text = `${from}\u2013${to} / ${total}`;
|
|
237
|
+
return theme.fg('dim', `${' '.repeat(Math.max(0, width - text.length))}${text}`);
|
|
238
|
+
}
|
|
202
239
|
function fit(lines, height) {
|
|
203
240
|
const out = [...lines];
|
|
204
241
|
while (out.length < height)
|
|
@@ -26,27 +26,37 @@ export declare class ContextDocsPanel {
|
|
|
26
26
|
private event;
|
|
27
27
|
private lines;
|
|
28
28
|
private index;
|
|
29
|
-
/**
|
|
30
|
-
* the page opens: ~25 rows that name the whole
|
|
29
|
+
/** Directories the user opened, by full name prefix. Empty means every level
|
|
30
|
+
* is collapsed, which is how the page opens: ~25 rows that name the whole
|
|
31
|
+
* corpus. */
|
|
31
32
|
private readonly expanded;
|
|
32
33
|
constructor(onChange: () => void);
|
|
33
34
|
/** The document the dossier describes: the focused row, or the first member
|
|
34
35
|
* of the focused group so the dossier is never blank. */
|
|
35
36
|
get selected(): DocRow | undefined;
|
|
36
|
-
/** Documents the current question selects — counted from the
|
|
37
|
-
* so a fully collapsed list still
|
|
37
|
+
/** Documents the current question selects — counted from the top-level
|
|
38
|
+
* rollups, which partition the corpus, so a fully collapsed list still
|
|
39
|
+
* reports its size and a nested rollup is never counted twice. */
|
|
38
40
|
get visibleCount(): number;
|
|
39
41
|
setRows(rows: DocRow[], event: SurfaceEvent, selectedKey?: string): void;
|
|
40
42
|
setFilters(filters: Filters): void;
|
|
41
43
|
move(delta: number): void;
|
|
42
|
-
/** True when the focused line
|
|
43
|
-
* descend opens a group or the dossier. */
|
|
44
|
+
/** True when the focused line can be opened — a directory header. */
|
|
44
45
|
get onGroup(): boolean;
|
|
46
|
+
/** True when the focused line IS a document rather than merely standing for
|
|
47
|
+
* one. A directory that is also a document is both, which is why the two
|
|
48
|
+
* gestures split there: space opens it, enter edits it. */
|
|
49
|
+
get onDocument(): boolean;
|
|
45
50
|
toggleGroup(): void;
|
|
46
51
|
/** Rebuild the drawn lines, keeping the cursor on the same document (or the
|
|
47
|
-
* same
|
|
52
|
+
* same directory), and clamping when what it was on is gone. A document that
|
|
53
|
+
* owns a directory is drawn as that directory's header, so a held document
|
|
54
|
+
* key matches there too. */
|
|
48
55
|
private relayout;
|
|
49
56
|
render(width: number, height: number, focused: boolean, plans: PlanSet): string[];
|
|
57
|
+
/** A directory header. Its stats and cost are the whole subtree's; the matrix
|
|
58
|
+
* is drawn only when the directory is itself a document, which is what marks
|
|
59
|
+
* the one row as both a document and a directory. */
|
|
50
60
|
private groupLine;
|
|
51
61
|
private docLine;
|
|
52
62
|
/** Pad rather than trim: this column sets the length of the divider drawn
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// The middle column: every document the snapshot can see, grouped
|
|
2
|
-
//
|
|
3
|
-
// row is one physical document, so two stores answering
|
|
4
|
-
// name stay two rows and the shadowed one is visible.
|
|
1
|
+
// The middle column: every document the snapshot can see, grouped recursively
|
|
2
|
+
// by the directories its canonical name names, and each carrying its own
|
|
3
|
+
// six-event matrix. One row is one physical document, so two stores answering
|
|
4
|
+
// to the same canonical name stay two rows and the shadowed one is visible.
|
|
5
5
|
import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
|
|
6
6
|
import { padAnsi, theme, visibleRange } from '../../../../core/tui/panel.js';
|
|
7
7
|
import { SURFACE_EVENTS } from '../../../../core/substrate/schema.js';
|
|
@@ -50,6 +50,9 @@ const COST_WIDTH = 6;
|
|
|
50
50
|
/** The glyphs a group header counts its members with — the matrix vocabulary,
|
|
51
51
|
* so a rung reads the same in both columns. */
|
|
52
52
|
const RUNG_MARK = { content: '█', preview: '▄', name: '▁' };
|
|
53
|
+
/** Columns one nesting level spends, so a doc sits under its directory's label
|
|
54
|
+
* and the truncator sees the indent as part of the name it is eliding. */
|
|
55
|
+
const INDENT = 2;
|
|
53
56
|
export class ContextDocsPanel {
|
|
54
57
|
onChange;
|
|
55
58
|
rows = [];
|
|
@@ -57,8 +60,9 @@ export class ContextDocsPanel {
|
|
|
57
60
|
event = 'boot';
|
|
58
61
|
lines = [];
|
|
59
62
|
index = 0;
|
|
60
|
-
/**
|
|
61
|
-
* the page opens: ~25 rows that name the whole
|
|
63
|
+
/** Directories the user opened, by full name prefix. Empty means every level
|
|
64
|
+
* is collapsed, which is how the page opens: ~25 rows that name the whole
|
|
65
|
+
* corpus. */
|
|
62
66
|
expanded = new Set();
|
|
63
67
|
constructor(onChange) {
|
|
64
68
|
this.onChange = onChange;
|
|
@@ -71,10 +75,11 @@ export class ContextDocsPanel {
|
|
|
71
75
|
return undefined;
|
|
72
76
|
return line.kind === 'doc' ? line.row : line.first;
|
|
73
77
|
}
|
|
74
|
-
/** Documents the current question selects — counted from the
|
|
75
|
-
* so a fully collapsed list still
|
|
78
|
+
/** Documents the current question selects — counted from the top-level
|
|
79
|
+
* rollups, which partition the corpus, so a fully collapsed list still
|
|
80
|
+
* reports its size and a nested rollup is never counted twice. */
|
|
76
81
|
get visibleCount() {
|
|
77
|
-
return this.lines.reduce((count, line) => count + (line.kind === 'group' ? line.docs : 0), 0);
|
|
82
|
+
return this.lines.reduce((count, line) => count + (line.kind === 'group' && line.depth === 0 ? line.docs : 0), 0);
|
|
78
83
|
}
|
|
79
84
|
setRows(rows, event, selectedKey) {
|
|
80
85
|
this.rows = rows;
|
|
@@ -95,11 +100,17 @@ export class ContextDocsPanel {
|
|
|
95
100
|
this.index = Math.max(0, Math.min(this.lines.length - 1, this.index + delta));
|
|
96
101
|
this.onChange();
|
|
97
102
|
}
|
|
98
|
-
/** True when the focused line
|
|
99
|
-
* descend opens a group or the dossier. */
|
|
103
|
+
/** True when the focused line can be opened — a directory header. */
|
|
100
104
|
get onGroup() {
|
|
101
105
|
return this.lines[this.index]?.kind === 'group';
|
|
102
106
|
}
|
|
107
|
+
/** True when the focused line IS a document rather than merely standing for
|
|
108
|
+
* one. A directory that is also a document is both, which is why the two
|
|
109
|
+
* gestures split there: space opens it, enter edits it. */
|
|
110
|
+
get onDocument() {
|
|
111
|
+
const line = this.lines[this.index];
|
|
112
|
+
return line !== undefined && (line.kind === 'doc' || line.isDoc);
|
|
113
|
+
}
|
|
103
114
|
toggleGroup() {
|
|
104
115
|
const line = this.lines[this.index];
|
|
105
116
|
if (line?.kind !== 'group')
|
|
@@ -112,7 +123,9 @@ export class ContextDocsPanel {
|
|
|
112
123
|
this.onChange();
|
|
113
124
|
}
|
|
114
125
|
/** Rebuild the drawn lines, keeping the cursor on the same document (or the
|
|
115
|
-
* same
|
|
126
|
+
* same directory), and clamping when what it was on is gone. A document that
|
|
127
|
+
* owns a directory is drawn as that directory's header, so a held document
|
|
128
|
+
* key matches there too. */
|
|
116
129
|
relayout(selectedKey, groupKey) {
|
|
117
130
|
const held = selectedKey ?? (this.lines[this.index]?.kind === 'doc' ? this.lines[this.index].key : undefined);
|
|
118
131
|
this.lines = buildListView(this.rows, this.filters, this.expanded, this.event);
|
|
@@ -120,7 +133,7 @@ export class ContextDocsPanel {
|
|
|
120
133
|
? this.lines.findIndex((line) => line.kind === 'group' && line.key === groupKey)
|
|
121
134
|
: held === undefined
|
|
122
135
|
? -1
|
|
123
|
-
: this.lines.findIndex((line) => line.kind === 'doc' && line.key === held);
|
|
136
|
+
: this.lines.findIndex((line) => (line.kind === 'doc' && line.key === held) || (line.kind === 'group' && line.isDoc && line.first.key === held));
|
|
124
137
|
this.index = wanted >= 0 ? wanted : Math.max(0, Math.min(this.index, this.lines.length - 1));
|
|
125
138
|
}
|
|
126
139
|
render(width, height, focused, plans) {
|
|
@@ -140,7 +153,7 @@ export class ContextDocsPanel {
|
|
|
140
153
|
const range = visibleRange(this.lines.length, this.index, capacity);
|
|
141
154
|
for (let at = range.start; at < range.end; at += 1) {
|
|
142
155
|
const line = this.lines[at];
|
|
143
|
-
const body = line.kind === 'group' ? this.groupLine(line, nameRoom) : this.docLine(line, nameRoom, plans);
|
|
156
|
+
const body = line.kind === 'group' ? this.groupLine(line, nameRoom, plans) : this.docLine(line, nameRoom, plans);
|
|
144
157
|
out.push(at === this.index && focused
|
|
145
158
|
? theme.bg('selectedBg', padAnsi(body, width))
|
|
146
159
|
: at === this.index
|
|
@@ -149,9 +162,12 @@ export class ContextDocsPanel {
|
|
|
149
162
|
}
|
|
150
163
|
return this.fit(out, height);
|
|
151
164
|
}
|
|
152
|
-
|
|
165
|
+
/** A directory header. Its stats and cost are the whole subtree's; the matrix
|
|
166
|
+
* is drawn only when the directory is itself a document, which is what marks
|
|
167
|
+
* the one row as both a document and a directory. */
|
|
168
|
+
groupLine(line, nameRoom, plans) {
|
|
153
169
|
const marker = line.expanded ? '▾' : '▸';
|
|
154
|
-
const label = `${marker} ${theme.bold(line.label)}`;
|
|
170
|
+
const label = `${' '.repeat(line.depth * INDENT)}${marker} ${theme.bold(line.label)}`;
|
|
155
171
|
const marks = ['content', 'preview', 'name']
|
|
156
172
|
.filter((rung) => line.rungs[rung] > 0)
|
|
157
173
|
.map((rung) => `${line.rungs[rung]}${RUNG_MARK[rung]}`)
|
|
@@ -159,15 +175,12 @@ export class ContextDocsPanel {
|
|
|
159
175
|
const stats = theme.fg('dim', marks === '' ? plural(line.docs, 'doc', 'docs') : `${line.docs} · ${marks}`);
|
|
160
176
|
const room = Math.max(1, nameRoom - visibleWidth(stats) - 1);
|
|
161
177
|
const field = `${padAnsi(truncateToWidth(label, room), room)} ${stats}`;
|
|
162
|
-
|
|
178
|
+
const matrix = line.isDoc ? matrixOf(line.first, plans) : ' '.repeat(MATRIX_WIDTH);
|
|
179
|
+
return ` ${padAnsi(field, nameRoom)} ${cost(line.cost)} ${matrix}`;
|
|
163
180
|
}
|
|
164
181
|
docLine(line, nameRoom, plans) {
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
return cellGlyph(line.row.records.get(event), state);
|
|
168
|
-
}).join(' ');
|
|
169
|
-
const label = truncateToWidth(` ${line.label}`, nameRoom);
|
|
170
|
-
return ` ${padAnsi(label, nameRoom)} ${cost(line.cost)} ${matrix}`;
|
|
182
|
+
const label = truncateToWidth(`${' '.repeat(line.depth * INDENT)}${line.label}`, nameRoom);
|
|
183
|
+
return ` ${padAnsi(label, nameRoom)} ${cost(line.cost)} ${matrixOf(line.row, plans)}`;
|
|
171
184
|
}
|
|
172
185
|
/** Pad rather than trim: this column sets the length of the divider drawn
|
|
173
186
|
* beside it. */
|
|
@@ -177,6 +190,13 @@ export class ContextDocsPanel {
|
|
|
177
190
|
return lines.slice(0, height).map((line) => (visibleWidth(line) === 0 ? '' : line));
|
|
178
191
|
}
|
|
179
192
|
}
|
|
193
|
+
/** One document's six events, in the order the heading names them. */
|
|
194
|
+
function matrixOf(row, plans) {
|
|
195
|
+
return SURFACE_EVENTS.map((event) => {
|
|
196
|
+
const state = plans.peek(event) === undefined ? 'pending' : cellStateOf(row.records.get(event));
|
|
197
|
+
return cellGlyph(row.records.get(event), state);
|
|
198
|
+
}).join(' ');
|
|
199
|
+
}
|
|
180
200
|
/** Right-flushed into the cost column, dim because it is an estimate. */
|
|
181
201
|
function cost(count) {
|
|
182
202
|
const text = formatCost(count);
|