@north-light/crouter 0.3.242 → 0.3.243
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/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/shell.d.ts +3 -0
- package/dist/commands/sys/context/admin/shell.js +11 -2
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -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);
|
|
@@ -17,34 +17,45 @@ export interface Filters {
|
|
|
17
17
|
query: string;
|
|
18
18
|
}
|
|
19
19
|
export declare const NO_FILTERS: Filters;
|
|
20
|
-
/** One drawn line. A group carries what its
|
|
21
|
-
* list still states where the corpus and its cost sit.
|
|
20
|
+
/** One drawn line. A group carries what its whole subtree adds up to, so a
|
|
21
|
+
* collapsed list still states where the corpus and its cost sit. `depth` is
|
|
22
|
+
* how many name segments sit above the line, which the renderer spends on
|
|
23
|
+
* indent. */
|
|
22
24
|
export type ListLine = {
|
|
23
25
|
kind: 'group';
|
|
24
26
|
key: string;
|
|
25
27
|
label: string;
|
|
28
|
+
depth: number;
|
|
26
29
|
docs: number;
|
|
27
30
|
rungs: Record<Exclude<Rung, 'none'>, number>;
|
|
28
31
|
cost: number;
|
|
29
32
|
expanded: boolean;
|
|
30
|
-
/** The
|
|
31
|
-
*
|
|
33
|
+
/** The document the dossier describes while the cursor rests on the
|
|
34
|
+
* header: this directory's own document when it has one, else the first
|
|
35
|
+
* document in its subtree. */
|
|
32
36
|
first: DocRow;
|
|
37
|
+
/** True when the directory is itself a document — `first` is that
|
|
38
|
+
* document, and the one row is both selectable and expandable. */
|
|
39
|
+
isDoc: boolean;
|
|
33
40
|
} | {
|
|
34
41
|
kind: 'doc';
|
|
35
42
|
key: string;
|
|
36
43
|
row: DocRow;
|
|
37
44
|
label: string;
|
|
45
|
+
depth: number;
|
|
38
46
|
cost: number;
|
|
39
47
|
};
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
* re-plan removed instead of accumulating them.
|
|
48
|
+
/** Every group key the rows can produce — one per proper name prefix, since a
|
|
49
|
+
* prefix exists only because something sits under it — so collapse state can
|
|
50
|
+
* drop the keys a re-plan removed instead of accumulating them. The leading
|
|
51
|
+
* section is a key only when a name with no `/` owns no directory, which is
|
|
52
|
+
* exactly when it is not itself one of those prefixes. */
|
|
43
53
|
export declare function groupKeysOf(rows: readonly DocRow[]): Set<string>;
|
|
44
|
-
/** The lines to draw, in corpus order: the leading section, then each
|
|
45
|
-
* order of first appearance
|
|
46
|
-
*
|
|
47
|
-
*
|
|
54
|
+
/** The lines to draw, in corpus order: the leading section, then each directory
|
|
55
|
+
* in order of first appearance, each recursing into its own members only when
|
|
56
|
+
* it is open. A live query force-expands every directory, which is exactly the
|
|
57
|
+
* ancestor chain of every surviving match, without touching `expanded` — so
|
|
58
|
+
* clearing the query restores what the user collapsed. */
|
|
48
59
|
export declare function buildListView(rows: readonly DocRow[], filters: Filters, expanded: ReadonlySet<string>, event: SurfaceEvent): ListLine[];
|
|
49
60
|
/** What the whole filtered set costs, split by the rung it arrives at. */
|
|
50
61
|
export declare function costTotals(rows: readonly DocRow[], event: SurfaceEvent): {
|
|
@@ -4,78 +4,137 @@
|
|
|
4
4
|
import { fuzzyMatch } from '../../../../core/tui/fuzzy.js';
|
|
5
5
|
import { deliveredCost, rungCostsOf } from './model.js';
|
|
6
6
|
export const NO_FILTERS = { rung: 'any', origin: 'any', kind: 'any', state: 'any', query: '' };
|
|
7
|
-
/** Documents whose canonical name carries no `/`
|
|
8
|
-
* rather than becoming a crowd of one-member groups. */
|
|
7
|
+
/** Documents whose canonical name carries no `/` and owns no directory share
|
|
8
|
+
* one leading section rather than becoming a crowd of one-member groups. */
|
|
9
9
|
const LEADING_KEY = '';
|
|
10
10
|
const LEADING_LABEL = 'top level';
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
* re-plan removed instead of accumulating them. */
|
|
11
|
+
/** Every group key the rows can produce — one per proper name prefix, since a
|
|
12
|
+
* prefix exists only because something sits under it — so collapse state can
|
|
13
|
+
* drop the keys a re-plan removed instead of accumulating them. The leading
|
|
14
|
+
* section is a key only when a name with no `/` owns no directory, which is
|
|
15
|
+
* exactly when it is not itself one of those prefixes. */
|
|
17
16
|
export function groupKeysOf(rows) {
|
|
18
|
-
|
|
17
|
+
const keys = new Set();
|
|
18
|
+
const unslashed = [];
|
|
19
|
+
for (const row of rows) {
|
|
20
|
+
if (row.name.includes('/')) {
|
|
21
|
+
for (let cut = row.name.indexOf('/'); cut >= 0; cut = row.name.indexOf('/', cut + 1)) {
|
|
22
|
+
keys.add(row.name.slice(0, cut));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
else
|
|
26
|
+
unslashed.push(row.name);
|
|
27
|
+
}
|
|
28
|
+
if (unslashed.some((name) => !keys.has(name)))
|
|
29
|
+
keys.add(LEADING_KEY);
|
|
30
|
+
return keys;
|
|
19
31
|
}
|
|
20
|
-
/** The lines to draw, in corpus order: the leading section, then each
|
|
21
|
-
* order of first appearance
|
|
22
|
-
*
|
|
23
|
-
*
|
|
32
|
+
/** The lines to draw, in corpus order: the leading section, then each directory
|
|
33
|
+
* in order of first appearance, each recursing into its own members only when
|
|
34
|
+
* it is open. A live query force-expands every directory, which is exactly the
|
|
35
|
+
* ancestor chain of every surviving match, without touching `expanded` — so
|
|
36
|
+
* clearing the query restores what the user collapsed. */
|
|
24
37
|
export function buildListView(rows, filters, expanded, event) {
|
|
25
|
-
const
|
|
26
|
-
const order = [];
|
|
38
|
+
const root = { key: '', segment: '', depth: -1, children: new Map(), selves: [] };
|
|
27
39
|
for (const row of rows) {
|
|
28
40
|
if (!passes(row, row.records.get(event), filters))
|
|
29
41
|
continue;
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
42
|
+
let dir = root;
|
|
43
|
+
for (const segment of row.name.split('/')) {
|
|
44
|
+
let child = dir.children.get(segment);
|
|
45
|
+
if (child === undefined) {
|
|
46
|
+
child = {
|
|
47
|
+
key: dir.depth < 0 ? segment : `${dir.key}/${segment}`,
|
|
48
|
+
segment,
|
|
49
|
+
depth: dir.depth + 1,
|
|
50
|
+
children: new Map(),
|
|
51
|
+
selves: [],
|
|
52
|
+
};
|
|
53
|
+
dir.children.set(segment, child);
|
|
54
|
+
}
|
|
55
|
+
dir = child;
|
|
35
56
|
}
|
|
36
|
-
|
|
37
|
-
held.push(row);
|
|
57
|
+
dir.selves.push(row);
|
|
38
58
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
59
|
+
const leading = [];
|
|
60
|
+
const dirs = [];
|
|
61
|
+
for (const child of root.children.values()) {
|
|
62
|
+
if (child.children.size === 0)
|
|
63
|
+
leading.push(...child.selves);
|
|
64
|
+
else
|
|
65
|
+
dirs.push(child);
|
|
42
66
|
}
|
|
43
67
|
const lines = [];
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
const rungs = { name: 0, preview: 0, content: 0 };
|
|
47
|
-
let cost = 0;
|
|
48
|
-
for (const row of members) {
|
|
49
|
-
const record = row.records.get(event);
|
|
50
|
-
if (record !== undefined && record.finalRung !== 'none')
|
|
51
|
-
rungs[record.finalRung] += 1;
|
|
52
|
-
cost += deliveredCost(record);
|
|
53
|
-
}
|
|
54
|
-
const open = filters.query !== '' || expanded.has(key);
|
|
68
|
+
if (leading.length > 0) {
|
|
69
|
+
const open = filters.query !== '' || expanded.has(LEADING_KEY);
|
|
55
70
|
lines.push({
|
|
56
71
|
kind: 'group',
|
|
57
|
-
key,
|
|
58
|
-
label:
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
cost,
|
|
72
|
+
key: LEADING_KEY,
|
|
73
|
+
label: LEADING_LABEL,
|
|
74
|
+
depth: 0,
|
|
75
|
+
...rollup(leading, event),
|
|
62
76
|
expanded: open,
|
|
63
|
-
first:
|
|
77
|
+
first: leading[0],
|
|
78
|
+
isDoc: false,
|
|
64
79
|
});
|
|
65
|
-
if (
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
lines.push({
|
|
69
|
-
kind: 'doc',
|
|
70
|
-
key: row.key,
|
|
71
|
-
row,
|
|
72
|
-
label: key === LEADING_KEY ? row.name : row.name.slice(key.length + 1),
|
|
73
|
-
cost: deliveredCost(row.records.get(event)),
|
|
74
|
-
});
|
|
75
|
-
}
|
|
80
|
+
if (open)
|
|
81
|
+
for (const row of leading)
|
|
82
|
+
lines.push(docLine(row, row.name, 1, event));
|
|
76
83
|
}
|
|
84
|
+
for (const dir of dirs)
|
|
85
|
+
emit(dir, filters, expanded, event, lines);
|
|
77
86
|
return lines;
|
|
78
87
|
}
|
|
88
|
+
/** A directory's rows in drawn order: its own document first, then each child
|
|
89
|
+
* segment's subtree — so the head of the list is also the document the header
|
|
90
|
+
* hands the dossier. */
|
|
91
|
+
function subtree(dir, out = []) {
|
|
92
|
+
out.push(...dir.selves);
|
|
93
|
+
for (const child of dir.children.values())
|
|
94
|
+
subtree(child, out);
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
function emit(dir, filters, expanded, event, lines) {
|
|
98
|
+
if (dir.children.size === 0) {
|
|
99
|
+
for (const row of dir.selves)
|
|
100
|
+
lines.push(docLine(row, dir.segment, dir.depth, event));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const members = subtree(dir);
|
|
104
|
+
const open = filters.query !== '' || expanded.has(dir.key);
|
|
105
|
+
lines.push({
|
|
106
|
+
kind: 'group',
|
|
107
|
+
key: dir.key,
|
|
108
|
+
label: `${dir.segment}/`,
|
|
109
|
+
depth: dir.depth,
|
|
110
|
+
...rollup(members, event),
|
|
111
|
+
expanded: open,
|
|
112
|
+
first: members[0],
|
|
113
|
+
isDoc: dir.selves.length > 0,
|
|
114
|
+
});
|
|
115
|
+
if (!open)
|
|
116
|
+
return;
|
|
117
|
+
// The header already stands for the first document named exactly this path;
|
|
118
|
+
// a second store answering the same name still needs its own row.
|
|
119
|
+
for (const shadowed of dir.selves.slice(1))
|
|
120
|
+
lines.push(docLine(shadowed, dir.segment, dir.depth + 1, event));
|
|
121
|
+
for (const child of dir.children.values())
|
|
122
|
+
emit(child, filters, expanded, event, lines);
|
|
123
|
+
}
|
|
124
|
+
function docLine(row, label, depth, event) {
|
|
125
|
+
return { kind: 'doc', key: row.key, row, label, depth, cost: deliveredCost(row.records.get(event)) };
|
|
126
|
+
}
|
|
127
|
+
function rollup(members, event) {
|
|
128
|
+
const rungs = { name: 0, preview: 0, content: 0 };
|
|
129
|
+
let cost = 0;
|
|
130
|
+
for (const row of members) {
|
|
131
|
+
const record = row.records.get(event);
|
|
132
|
+
if (record !== undefined && record.finalRung !== 'none')
|
|
133
|
+
rungs[record.finalRung] += 1;
|
|
134
|
+
cost += deliveredCost(record);
|
|
135
|
+
}
|
|
136
|
+
return { docs: members.length, rungs, cost };
|
|
137
|
+
}
|
|
79
138
|
/** What the whole filtered set costs, split by the rung it arrives at. */
|
|
80
139
|
export function costTotals(rows, event) {
|
|
81
140
|
const byRung = { name: 0, preview: 0, content: 0 };
|
|
@@ -90,6 +90,9 @@ export declare class ContextAdminShell implements Component, Focusable {
|
|
|
90
90
|
private handleModal;
|
|
91
91
|
private renderModal;
|
|
92
92
|
private footer;
|
|
93
|
+
/** What the focused row does when opened. A directory answers to space, a
|
|
94
|
+
* document to enter, and a directory that is also a document to both. */
|
|
95
|
+
private docsGesture;
|
|
93
96
|
render(width: number): string[];
|
|
94
97
|
/** What the delivering documents spend on this event. An estimate, and a
|
|
95
98
|
* floor on the rendered prompt: boot wraps these documents in its own intro
|
|
@@ -228,7 +228,9 @@ export class ContextAdminShell {
|
|
|
228
228
|
this.syncDetail();
|
|
229
229
|
}
|
|
230
230
|
else if (this.isDescend(data)) {
|
|
231
|
-
|
|
231
|
+
// A directory that is also a document keeps both gestures: space opens it,
|
|
232
|
+
// and descending goes to its dossier like any other document.
|
|
233
|
+
if (this.docs.onGroup && !this.docs.onDocument) {
|
|
232
234
|
this.docs.toggleGroup();
|
|
233
235
|
this.syncDetail();
|
|
234
236
|
}
|
|
@@ -648,7 +650,7 @@ export class ContextAdminShell {
|
|
|
648
650
|
const left = this.zone === 'rail'
|
|
649
651
|
? `${move} row · space change · enter documents`
|
|
650
652
|
: this.zone === 'docs'
|
|
651
|
-
? `${move} row ·
|
|
653
|
+
? `${move} row · ${this.docsGesture()} · / search`
|
|
652
654
|
: `${move} field · enter edit · e add · x remove · g gate · b body · u unlisted · s slash · m cap · r rename`;
|
|
653
655
|
const right = `${this.label('crtr.setup.tab-next', 'tab')} event · ${this.label('crtr.setup.cancel', 'q')} close`;
|
|
654
656
|
const gap = width - visibleWidth(left) - visibleWidth(right);
|
|
@@ -656,6 +658,13 @@ export class ContextAdminShell {
|
|
|
656
658
|
? `${theme.fg('dim', left)}${' '.repeat(gap)}${theme.fg('dim', right)}`
|
|
657
659
|
: theme.fg('dim', truncateToWidth(`${left} · ${this.label('crtr.setup.cancel', 'q')} close`, width));
|
|
658
660
|
}
|
|
661
|
+
/** What the focused row does when opened. A directory answers to space, a
|
|
662
|
+
* document to enter, and a directory that is also a document to both. */
|
|
663
|
+
docsGesture() {
|
|
664
|
+
if (!this.docs.onGroup)
|
|
665
|
+
return 'enter dossier';
|
|
666
|
+
return this.docs.onDocument ? 'space open · enter dossier' : 'space/enter open';
|
|
667
|
+
}
|
|
659
668
|
render(width) {
|
|
660
669
|
const height = this.terminalRows();
|
|
661
670
|
const bodyHeight = Math.max(1, height - PAGE_CHROME_ROWS);
|
package/package.json
CHANGED
package/runtime.lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/crouter",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.243",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@north-light/crouter",
|
|
9
|
-
"version": "0.3.
|
|
9
|
+
"version": "0.3.243",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"dependencies": {
|