lognal 0.1.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/LICENSE +21 -0
- package/README.md +99 -0
- package/dist/core/filter.d.ts +29 -0
- package/dist/core/filter.js +82 -0
- package/dist/core/layout/entry-lines.d.ts +16 -0
- package/dist/core/layout/entry-lines.js +120 -0
- package/dist/core/layout/layout.d.ts +152 -0
- package/dist/core/layout/layout.js +784 -0
- package/dist/core/layout/row-index.d.ts +33 -0
- package/dist/core/layout/row-index.js +99 -0
- package/dist/core/layout/types.d.ts +128 -0
- package/dist/core/layout/types.js +8 -0
- package/dist/core/store.d.ts +96 -0
- package/dist/core/store.js +204 -0
- package/dist/core/text/ansi.d.ts +20 -0
- package/dist/core/text/ansi.js +187 -0
- package/dist/core/text/graphemes.d.ts +17 -0
- package/dist/core/text/graphemes.js +70 -0
- package/dist/core/text/line-splitter.d.ts +18 -0
- package/dist/core/text/line-splitter.js +56 -0
- package/dist/core/text/measure.d.ts +10 -0
- package/dist/core/text/measure.js +38 -0
- package/dist/core/text/shape.d.ts +24 -0
- package/dist/core/text/shape.js +220 -0
- package/dist/core/text/unicode-width-data.d.ts +54 -0
- package/dist/core/text/unicode-width-data.js +227 -0
- package/dist/core/text/width.d.ts +20 -0
- package/dist/core/text/width.js +157 -0
- package/dist/core/text/wrap.d.ts +14 -0
- package/dist/core/text/wrap.js +94 -0
- package/dist/core/time.d.ts +10 -0
- package/dist/core/time.js +24 -0
- package/dist/core/types.d.ts +131 -0
- package/dist/core/types.js +9 -0
- package/dist/core/value/preview.d.ts +16 -0
- package/dist/core/value/preview.js +207 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +24 -0
- package/dist/lognal.css +589 -0
- package/dist/react/LogViewer.d.ts +32 -0
- package/dist/react/LogViewer.js +183 -0
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.js +2 -0
- package/dist/renderer/canvas/canvas-renderer.d.ts +45 -0
- package/dist/renderer/canvas/canvas-renderer.js +464 -0
- package/dist/renderer/canvas/palette.d.ts +6 -0
- package/dist/renderer/canvas/palette.js +25 -0
- package/dist/renderer/theme.d.ts +3 -0
- package/dist/renderer/theme.js +52 -0
- package/dist/renderer/types.d.ts +82 -0
- package/dist/renderer/types.js +1 -0
- package/dist/sources/console/format.d.ts +32 -0
- package/dist/sources/console/format.js +189 -0
- package/dist/sources/console/hook.d.ts +27 -0
- package/dist/sources/console/hook.js +94 -0
- package/dist/sources/console/recorder.d.ts +41 -0
- package/dist/sources/console/recorder.js +239 -0
- package/dist/sources/console/snapshot.d.ts +22 -0
- package/dist/sources/console/snapshot.js +547 -0
- package/dist/sources/console/table.d.ts +9 -0
- package/dist/sources/console/table.js +87 -0
- package/dist/sources/text/encoding.d.ts +11 -0
- package/dist/sources/text/encoding.js +59 -0
- package/dist/sources/text/follow-file.d.ts +49 -0
- package/dist/sources/text/follow-file.js +109 -0
- package/dist/sources/text/read-file.d.ts +63 -0
- package/dist/sources/text/read-file.js +82 -0
- package/dist/viewer/icons.d.ts +14 -0
- package/dist/viewer/icons.js +30 -0
- package/dist/viewer/input-line.d.ts +46 -0
- package/dist/viewer/input-line.js +144 -0
- package/dist/viewer/labels.d.ts +35 -0
- package/dist/viewer/labels.js +61 -0
- package/dist/viewer/scrollbar.d.ts +27 -0
- package/dist/viewer/scrollbar.js +143 -0
- package/dist/viewer/theme.d.ts +15 -0
- package/dist/viewer/theme.js +105 -0
- package/dist/viewer/viewer.d.ts +217 -0
- package/dist/viewer/viewer.js +1201 -0
- package/package.json +92 -0
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
import { compileFilter } from '../filter.js';
|
|
2
|
+
import { DEFAULT_SHAPE_OPTIONS, shapeLine, textBetween, widthAt } from '../text/shape.js';
|
|
3
|
+
import { wrapLine } from '../text/wrap.js';
|
|
4
|
+
import { buildEntryLines, INDENT_CELLS, isExpandedByDefault } from './entry-lines.js';
|
|
5
|
+
import { RowIndex } from './row-index.js';
|
|
6
|
+
export const DEFAULT_LAYOUT_OPTIONS = {
|
|
7
|
+
...DEFAULT_SHAPE_OPTIONS,
|
|
8
|
+
wrap: 'word'
|
|
9
|
+
};
|
|
10
|
+
/** The fewest columns a line wraps into, however deeply it is indented. */
|
|
11
|
+
const MIN_WRAP_COLUMNS = 16;
|
|
12
|
+
/** How many entries keep their shaped lines in memory. */
|
|
13
|
+
const LAYOUT_CACHE_SIZE = 4000;
|
|
14
|
+
const PRINTABLE_ASCII = /^[\x20-\x7e]*$/;
|
|
15
|
+
const WORD_CHARACTER = /^[\p{L}\p{N}\p{M}_$]/u;
|
|
16
|
+
const cellsBetween = (line, start, end) => {
|
|
17
|
+
if (!line.widths) {
|
|
18
|
+
return end - start;
|
|
19
|
+
}
|
|
20
|
+
let cells = 0;
|
|
21
|
+
for (let index = start; index < end; index++) {
|
|
22
|
+
cells += line.widths[index];
|
|
23
|
+
}
|
|
24
|
+
return cells;
|
|
25
|
+
};
|
|
26
|
+
/** Returns the index of the span that holds a cluster. */
|
|
27
|
+
const spanAt = (line, cluster) => {
|
|
28
|
+
const starts = line.spanStarts;
|
|
29
|
+
let low = 0;
|
|
30
|
+
let high = starts.length - 1;
|
|
31
|
+
while (low < high) {
|
|
32
|
+
const middle = (low + high + 1) >> 1;
|
|
33
|
+
if (starts[middle] <= cluster) {
|
|
34
|
+
low = middle;
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
high = middle - 1;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return low;
|
|
41
|
+
};
|
|
42
|
+
const comparePositions = (a, b) => {
|
|
43
|
+
return a.entryId - b.entryId || a.line - b.line || a.cell - b.cell;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Turns the entries of a store into rows for a viewer of a given width.
|
|
47
|
+
*
|
|
48
|
+
* The layout decides which entries are visible (the filter and collapsed groups), how each
|
|
49
|
+
* entry breaks into lines and rows, and which values are expanded. It works from the store's
|
|
50
|
+
* change notifications, and does the pending work when `sync` is called, so any number of
|
|
51
|
+
* messages between two frames costs one update.
|
|
52
|
+
*/
|
|
53
|
+
export class LogLayout {
|
|
54
|
+
store;
|
|
55
|
+
options;
|
|
56
|
+
optionsVersion = 0;
|
|
57
|
+
layoutVersion = 0;
|
|
58
|
+
columns = 80;
|
|
59
|
+
filter = compileFilter(null);
|
|
60
|
+
visible = [];
|
|
61
|
+
visibleStart = 0;
|
|
62
|
+
rowIndex = new RowIndex();
|
|
63
|
+
layouts = new Map();
|
|
64
|
+
rowCounts = new Map();
|
|
65
|
+
expansions = new Map();
|
|
66
|
+
/** 1 where the row count of the visible entry at the same position is only an estimate. */
|
|
67
|
+
stale = [];
|
|
68
|
+
staleCount = 0;
|
|
69
|
+
positions = 0;
|
|
70
|
+
lastSyncedId = 0;
|
|
71
|
+
needsRebuild = true;
|
|
72
|
+
dirty = true;
|
|
73
|
+
widest = 0;
|
|
74
|
+
unsubscribe;
|
|
75
|
+
constructor(store, options = {}) {
|
|
76
|
+
this.store = store;
|
|
77
|
+
this.options = { ...DEFAULT_LAYOUT_OPTIONS, ...options };
|
|
78
|
+
this.unsubscribe = store.subscribe((change) => this.onStoreChange(change));
|
|
79
|
+
}
|
|
80
|
+
/** The number of rows of all visible entries. Call `sync` first. */
|
|
81
|
+
get rowCount() {
|
|
82
|
+
return this.rowIndex.total;
|
|
83
|
+
}
|
|
84
|
+
/** The number of visible entries. Call `sync` first. */
|
|
85
|
+
get visibleCount() {
|
|
86
|
+
return this.visible.length - this.visibleStart;
|
|
87
|
+
}
|
|
88
|
+
/** The widest row seen, in cells, including indentation. */
|
|
89
|
+
get maxCells() {
|
|
90
|
+
return this.widest;
|
|
91
|
+
}
|
|
92
|
+
/** The number of visible entries whose row count is an estimate. See `measurePending`. */
|
|
93
|
+
get pendingCount() {
|
|
94
|
+
return this.staleCount;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Increases whenever the first row of an entry that was already visible may have moved: after
|
|
98
|
+
* a rebuild, when entries are dropped from the front, and when a row count changes. Appending
|
|
99
|
+
* entries at the end does not change it. A viewer compares it between frames to keep the same
|
|
100
|
+
* entry in view.
|
|
101
|
+
*/
|
|
102
|
+
get positionsVersion() {
|
|
103
|
+
return this.positions;
|
|
104
|
+
}
|
|
105
|
+
/** Whether the store changed since the last `sync`. */
|
|
106
|
+
get isDirty() {
|
|
107
|
+
return this.dirty;
|
|
108
|
+
}
|
|
109
|
+
getOptions() {
|
|
110
|
+
return this.options;
|
|
111
|
+
}
|
|
112
|
+
setOptions(options) {
|
|
113
|
+
const next = { ...this.options, ...options };
|
|
114
|
+
const shapeChanged = next.tabSize !== this.options.tabSize ||
|
|
115
|
+
next.ambiguousWidth !== this.options.ambiguousWidth ||
|
|
116
|
+
next.maxClusters !== this.options.maxClusters;
|
|
117
|
+
if (!shapeChanged && next.wrap === this.options.wrap) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
this.options = next;
|
|
121
|
+
if (shapeChanged) {
|
|
122
|
+
this.optionsVersion++;
|
|
123
|
+
this.layouts.clear();
|
|
124
|
+
}
|
|
125
|
+
this.invalidateRows();
|
|
126
|
+
}
|
|
127
|
+
/** Sets the number of columns rows wrap into. */
|
|
128
|
+
setColumns(columns) {
|
|
129
|
+
const next = Math.max(1, Math.floor(columns));
|
|
130
|
+
if (next === this.columns) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
this.columns = next;
|
|
134
|
+
if (this.options.wrap !== 'none') {
|
|
135
|
+
this.invalidateRows();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/** Sets which entries are visible. Returns the compiled filter, which reports a bad pattern. */
|
|
139
|
+
setFilter(filter) {
|
|
140
|
+
this.filter = compileFilter(filter);
|
|
141
|
+
this.needsRebuild = true;
|
|
142
|
+
this.dirty = true;
|
|
143
|
+
return this.filter;
|
|
144
|
+
}
|
|
145
|
+
/** The compiled filter in use. */
|
|
146
|
+
getFilter() {
|
|
147
|
+
return this.filter;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Applies pending store changes. Returns whether anything changed.
|
|
151
|
+
*
|
|
152
|
+
* `budget` limits how many entries are laid out exactly. Past it, an entry gets an estimated
|
|
153
|
+
* row count, based on its previous layout when it had one, and is left for `measureAround`
|
|
154
|
+
* and `measurePending`. The default lays out every entry exactly.
|
|
155
|
+
*/
|
|
156
|
+
sync(budget = Number.POSITIVE_INFINITY) {
|
|
157
|
+
if (!this.dirty) {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
this.dirty = false;
|
|
161
|
+
const measureBudget = { remaining: budget };
|
|
162
|
+
if (this.needsRebuild) {
|
|
163
|
+
this.rebuild(measureBudget);
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
const firstId = this.store.firstId;
|
|
167
|
+
let trimmed = 0;
|
|
168
|
+
while (this.visibleStart + trimmed < this.visible.length &&
|
|
169
|
+
this.visible[this.visibleStart + trimmed].id < firstId) {
|
|
170
|
+
const position = this.visibleStart + trimmed;
|
|
171
|
+
this.forget(this.visible[position].id);
|
|
172
|
+
this.staleCount -= this.stale[position];
|
|
173
|
+
trimmed++;
|
|
174
|
+
}
|
|
175
|
+
if (trimmed > 0) {
|
|
176
|
+
this.visibleStart += trimmed;
|
|
177
|
+
this.rowIndex.shift(trimmed);
|
|
178
|
+
this.positions++;
|
|
179
|
+
this.compactVisible();
|
|
180
|
+
}
|
|
181
|
+
const lastId = this.store.lastId;
|
|
182
|
+
for (let id = Math.max(this.lastSyncedId + 1, firstId); id <= lastId; id++) {
|
|
183
|
+
const entry = this.store.get(id);
|
|
184
|
+
if (entry && this.isVisible(entry)) {
|
|
185
|
+
this.pushVisible(entry, measureBudget);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
this.lastSyncedId = lastId;
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Lays out exactly the entries around one entry: `rowsBefore` rows of the entries before it,
|
|
193
|
+
* and `rowsAfter` rows starting with it. Pass `null` to start from the last visible entry.
|
|
194
|
+
* Call it for the part of the log on screen before reading its rows. Returns whether a row
|
|
195
|
+
* count changed.
|
|
196
|
+
*/
|
|
197
|
+
measureAround(entryId, rowsBefore, rowsAfter) {
|
|
198
|
+
if (this.staleCount === 0 || this.visibleCount === 0) {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
const index = entryId === null ? this.visibleCount - 1 : this.indexOf(entryId);
|
|
202
|
+
if (index < 0) {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
const positions = this.positions;
|
|
206
|
+
let rows = 0;
|
|
207
|
+
for (let current = index; current < this.visibleCount && rows < rowsAfter; current++) {
|
|
208
|
+
rows += this.measureIndex(current);
|
|
209
|
+
}
|
|
210
|
+
rows = 0;
|
|
211
|
+
for (let current = index - 1; current >= 0 && rows < rowsBefore; current--) {
|
|
212
|
+
rows += this.measureIndex(current);
|
|
213
|
+
}
|
|
214
|
+
return this.positions !== positions;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Lays out exactly up to `budget` entries whose row count is still an estimate, nearest to an
|
|
218
|
+
* entry first, or to the end of the log when `entryId` is `null`. Call it in small pieces of
|
|
219
|
+
* work until `pendingCount` is 0. Returns whether a row count changed.
|
|
220
|
+
*/
|
|
221
|
+
measurePending(budget, entryId = null) {
|
|
222
|
+
if (this.staleCount === 0) {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
const count = this.visibleCount;
|
|
226
|
+
const found = entryId === null ? count - 1 : this.firstIndexFrom(entryId);
|
|
227
|
+
const focus = Math.min(Math.max(0, found), count - 1);
|
|
228
|
+
const positions = this.positions;
|
|
229
|
+
let remaining = budget;
|
|
230
|
+
for (let distance = 0; remaining > 0 && this.staleCount > 0; distance++) {
|
|
231
|
+
const after = focus + distance;
|
|
232
|
+
const before = focus - distance - 1;
|
|
233
|
+
if (after >= count && before < 0) {
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
for (const index of [after, before]) {
|
|
237
|
+
if (index >= 0 && index < count && this.stale[this.visibleStart + index]) {
|
|
238
|
+
this.measureIndex(index);
|
|
239
|
+
remaining--;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return this.positions !== positions;
|
|
244
|
+
}
|
|
245
|
+
/** Returns rows starting at a row index. Call `sync` first. */
|
|
246
|
+
getRows(start, count) {
|
|
247
|
+
const rows = [];
|
|
248
|
+
let index = this.rowIndex.find(Math.max(0, start));
|
|
249
|
+
if (index < 0) {
|
|
250
|
+
return rows;
|
|
251
|
+
}
|
|
252
|
+
let skip = start - this.rowIndex.rowOf(index);
|
|
253
|
+
while (rows.length < count && index < this.visibleCount) {
|
|
254
|
+
const entry = this.visible[this.visibleStart + index];
|
|
255
|
+
const layout = this.layoutOf(entry);
|
|
256
|
+
let entryRow = 0;
|
|
257
|
+
for (let lineIndex = 0; lineIndex < layout.lines.length; lineIndex++) {
|
|
258
|
+
const line = layout.lines[lineIndex];
|
|
259
|
+
const wraps = layout.wraps[lineIndex];
|
|
260
|
+
let startCell = 0;
|
|
261
|
+
for (let lineRow = 0; lineRow < wraps.length; lineRow++) {
|
|
262
|
+
const from = wraps[lineRow];
|
|
263
|
+
const to = lineRow + 1 < wraps.length ? wraps[lineRow + 1] : line.length;
|
|
264
|
+
if (entryRow >= skip && rows.length < count) {
|
|
265
|
+
rows.push({
|
|
266
|
+
entry,
|
|
267
|
+
line: lineIndex,
|
|
268
|
+
lineRow,
|
|
269
|
+
entryRow,
|
|
270
|
+
first: entryRow === 0,
|
|
271
|
+
last: entryRow === layout.rows - 1,
|
|
272
|
+
indent: line.indent,
|
|
273
|
+
startCell,
|
|
274
|
+
cells: cellsBetween(line, from, to),
|
|
275
|
+
runs: this.buildRuns(line, from, to)
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
startCell += cellsBetween(line, from, to);
|
|
279
|
+
entryRow++;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
skip = 0;
|
|
283
|
+
index++;
|
|
284
|
+
}
|
|
285
|
+
return rows;
|
|
286
|
+
}
|
|
287
|
+
/** Returns the entry that holds a row and the row's index within that entry. */
|
|
288
|
+
locateRow(row) {
|
|
289
|
+
const index = this.rowIndex.find(row);
|
|
290
|
+
if (index < 0) {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
entry: this.visible[this.visibleStart + index],
|
|
295
|
+
entryRow: row - this.rowIndex.rowOf(index)
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
/** Returns the number of rows of a visible entry, or 0 when it is not visible. */
|
|
299
|
+
rowsOf(entryId) {
|
|
300
|
+
const index = this.indexOf(entryId);
|
|
301
|
+
return index < 0 ? 0 : this.rowIndex.get(index);
|
|
302
|
+
}
|
|
303
|
+
/** Returns the entry at a visible position, where 0 is the oldest visible entry. */
|
|
304
|
+
entryAt(index) {
|
|
305
|
+
return index >= 0 && index < this.visibleCount
|
|
306
|
+
? this.visible[this.visibleStart + index]
|
|
307
|
+
: undefined;
|
|
308
|
+
}
|
|
309
|
+
/** Returns the visible position of an entry, or -1 when it is not visible. */
|
|
310
|
+
indexOf(entryId) {
|
|
311
|
+
let low = this.visibleStart;
|
|
312
|
+
let high = this.visible.length - 1;
|
|
313
|
+
while (low <= high) {
|
|
314
|
+
const middle = (low + high) >> 1;
|
|
315
|
+
const id = this.visible[middle].id;
|
|
316
|
+
if (id === entryId) {
|
|
317
|
+
return middle - this.visibleStart;
|
|
318
|
+
}
|
|
319
|
+
if (id < entryId) {
|
|
320
|
+
low = middle + 1;
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
high = middle - 1;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return -1;
|
|
327
|
+
}
|
|
328
|
+
/** Returns the first row of an entry, or -1 when it is not visible. */
|
|
329
|
+
rowOfEntry(entryId) {
|
|
330
|
+
const index = this.indexOf(entryId);
|
|
331
|
+
return index < 0 ? -1 : this.rowIndex.rowOf(index);
|
|
332
|
+
}
|
|
333
|
+
/** Returns whether the value at a path of an entry is expanded. */
|
|
334
|
+
isExpanded(entry, path) {
|
|
335
|
+
return this.expansions.get(entry.id)?.paths.get(path) ?? isExpandedByDefault(entry, path);
|
|
336
|
+
}
|
|
337
|
+
/** Runs the action of a clicked span. */
|
|
338
|
+
runAction(entryId, action) {
|
|
339
|
+
const entry = this.store.get(entryId);
|
|
340
|
+
if (!entry) {
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (action.type === 'toggle-group') {
|
|
344
|
+
this.store.setCollapsed(entryId, !entry.collapsed);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (action.type === 'toggle-value') {
|
|
348
|
+
this.setExpanded(entry, action.path, !this.isExpanded(entry, action.path));
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
/** Expands or collapses the value at a path of an entry. */
|
|
352
|
+
setExpanded(entry, path, expanded) {
|
|
353
|
+
const expansion = this.expansions.get(entry.id) ?? { version: 0, paths: new Map() };
|
|
354
|
+
expansion.paths.set(path, expanded);
|
|
355
|
+
expansion.version++;
|
|
356
|
+
this.expansions.set(entry.id, expansion);
|
|
357
|
+
const index = this.indexOf(entry.id);
|
|
358
|
+
if (index >= 0) {
|
|
359
|
+
const position = this.visibleStart + index;
|
|
360
|
+
const rows = this.countRows(entry);
|
|
361
|
+
this.staleCount -= this.stale[position];
|
|
362
|
+
this.stale[position] = 0;
|
|
363
|
+
if (rows !== this.rowIndex.get(index)) {
|
|
364
|
+
this.rowIndex.set(index, rows);
|
|
365
|
+
this.positions++;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Returns the text position under a row and a column of the content area. The position
|
|
371
|
+
* snaps to the nearest boundary between clusters.
|
|
372
|
+
*/
|
|
373
|
+
positionAt(row, column) {
|
|
374
|
+
const total = this.rowCount;
|
|
375
|
+
if (total === 0) {
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
const [visualRow] = this.getRows(Math.min(Math.max(0, row), total - 1), 1);
|
|
379
|
+
if (!visualRow) {
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
if (row >= total) {
|
|
383
|
+
return {
|
|
384
|
+
entryId: visualRow.entry.id,
|
|
385
|
+
line: visualRow.line,
|
|
386
|
+
cell: visualRow.startCell + visualRow.cells
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
const target = Math.max(0, column - visualRow.indent);
|
|
390
|
+
let offset = 0;
|
|
391
|
+
let cell = visualRow.cells;
|
|
392
|
+
for (const run of visualRow.runs) {
|
|
393
|
+
if (target >= offset + run.cells) {
|
|
394
|
+
offset += run.cells;
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (run.simple) {
|
|
398
|
+
cell = target;
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
cell = offset;
|
|
402
|
+
for (const width of run.widths) {
|
|
403
|
+
if (target < cell + width) {
|
|
404
|
+
cell = target - cell < width / 2 ? cell : cell + width;
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
cell += width;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
return { entryId: visualRow.entry.id, line: visualRow.line, cell: visualRow.startCell + cell };
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Returns the start and end of the word at a position: a run of letters, digits, marks and
|
|
416
|
+
* underscores. On any other character, the range covers that character alone.
|
|
417
|
+
*/
|
|
418
|
+
wordAt(position) {
|
|
419
|
+
const index = this.indexOf(position.entryId);
|
|
420
|
+
if (index < 0) {
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
const entry = this.visible[this.visibleStart + index];
|
|
424
|
+
const line = this.layoutOf(entry).lines[position.line];
|
|
425
|
+
if (!line || line.length === 0) {
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
const starts = [];
|
|
429
|
+
let cell = 0;
|
|
430
|
+
let target = line.length - 1;
|
|
431
|
+
for (let cluster = 0; cluster < line.length; cluster++) {
|
|
432
|
+
starts.push(cell);
|
|
433
|
+
if (target === line.length - 1 && cell + widthAt(line, cluster) > position.cell) {
|
|
434
|
+
target = cluster;
|
|
435
|
+
}
|
|
436
|
+
cell += widthAt(line, cluster);
|
|
437
|
+
}
|
|
438
|
+
starts.push(cell);
|
|
439
|
+
const isWord = (cluster) => WORD_CHARACTER.test(textBetween(line, cluster, cluster + 1));
|
|
440
|
+
let first = target;
|
|
441
|
+
let last = target;
|
|
442
|
+
if (isWord(target)) {
|
|
443
|
+
while (first > 0 && isWord(first - 1)) {
|
|
444
|
+
first--;
|
|
445
|
+
}
|
|
446
|
+
while (last < line.length - 1 && isWord(last + 1)) {
|
|
447
|
+
last++;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return [
|
|
451
|
+
{ entryId: entry.id, line: position.line, cell: starts[first] },
|
|
452
|
+
{ entryId: entry.id, line: position.line, cell: starts[last + 1] }
|
|
453
|
+
];
|
|
454
|
+
}
|
|
455
|
+
/** Returns the text between two positions, one line of the output per logical line. */
|
|
456
|
+
getText(from, to) {
|
|
457
|
+
const [start, end] = comparePositions(from, to) <= 0 ? [from, to] : [to, from];
|
|
458
|
+
const lines = [];
|
|
459
|
+
let index = this.firstIndexFrom(start.entryId);
|
|
460
|
+
for (; index < this.visibleCount; index++) {
|
|
461
|
+
const entry = this.visible[this.visibleStart + index];
|
|
462
|
+
if (entry.id > end.entryId) {
|
|
463
|
+
break;
|
|
464
|
+
}
|
|
465
|
+
const layout = this.layoutOf(entry);
|
|
466
|
+
const baseIndent = entry.groups.length * INDENT_CELLS;
|
|
467
|
+
for (let lineIndex = 0; lineIndex < layout.lines.length; lineIndex++) {
|
|
468
|
+
if (entry.id === start.entryId && lineIndex < start.line) {
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
if (entry.id === end.entryId && lineIndex > end.line) {
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
const line = layout.lines[lineIndex];
|
|
475
|
+
const fromCell = entry.id === start.entryId && lineIndex === start.line ? start.cell : 0;
|
|
476
|
+
const toCell = entry.id === end.entryId && lineIndex === end.line ? end.cell : Infinity;
|
|
477
|
+
const indent = fromCell === 0 ? ' '.repeat(Math.max(0, line.indent - baseIndent)) : '';
|
|
478
|
+
lines.push(indent + this.textInCells(line, fromCell, toCell));
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return lines.join('\n');
|
|
482
|
+
}
|
|
483
|
+
/** Returns the whole text of the visible entries. */
|
|
484
|
+
getAllText() {
|
|
485
|
+
const first = this.entryAt(0);
|
|
486
|
+
const last = this.entryAt(this.visibleCount - 1);
|
|
487
|
+
if (!first || !last) {
|
|
488
|
+
return '';
|
|
489
|
+
}
|
|
490
|
+
return this.getText({ entryId: first.id, line: 0, cell: 0 }, { entryId: last.id, line: Number.MAX_SAFE_INTEGER, cell: Number.MAX_SAFE_INTEGER });
|
|
491
|
+
}
|
|
492
|
+
/** Stops listening to the store. */
|
|
493
|
+
dispose() {
|
|
494
|
+
this.unsubscribe();
|
|
495
|
+
this.layouts.clear();
|
|
496
|
+
this.rowCounts.clear();
|
|
497
|
+
this.expansions.clear();
|
|
498
|
+
}
|
|
499
|
+
onStoreChange(change) {
|
|
500
|
+
this.dirty = true;
|
|
501
|
+
if (change.type === 'clear') {
|
|
502
|
+
this.needsRebuild = true;
|
|
503
|
+
this.layouts.clear();
|
|
504
|
+
this.rowCounts.clear();
|
|
505
|
+
this.expansions.clear();
|
|
506
|
+
}
|
|
507
|
+
else if (change.type === 'update' && change.entry.kind === 'group') {
|
|
508
|
+
this.needsRebuild = true;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
invalidateRows() {
|
|
512
|
+
this.layoutVersion++;
|
|
513
|
+
this.needsRebuild = true;
|
|
514
|
+
this.dirty = true;
|
|
515
|
+
}
|
|
516
|
+
rebuild(budget) {
|
|
517
|
+
this.needsRebuild = false;
|
|
518
|
+
this.visible = [];
|
|
519
|
+
this.stale = [];
|
|
520
|
+
this.staleCount = 0;
|
|
521
|
+
this.visibleStart = 0;
|
|
522
|
+
this.rowIndex.clear();
|
|
523
|
+
this.widest = 0;
|
|
524
|
+
this.positions++;
|
|
525
|
+
for (let index = 0; index < this.store.size; index++) {
|
|
526
|
+
const entry = this.store.at(index);
|
|
527
|
+
if (this.isVisible(entry)) {
|
|
528
|
+
this.pushVisible(entry, budget);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
this.lastSyncedId = this.store.lastId;
|
|
532
|
+
this.pruneCaches();
|
|
533
|
+
}
|
|
534
|
+
/** Adds a visible entry with its exact row count, or an estimate once the budget is spent. */
|
|
535
|
+
pushVisible(entry, budget) {
|
|
536
|
+
const cached = this.rowCounts.get(entry.id);
|
|
537
|
+
let rows;
|
|
538
|
+
let stale = 0;
|
|
539
|
+
if (cached &&
|
|
540
|
+
cached.stateKey === this.stateKeyOf(entry) &&
|
|
541
|
+
cached.layoutVersion === this.layoutVersion) {
|
|
542
|
+
rows = cached.rows;
|
|
543
|
+
}
|
|
544
|
+
else if (budget.remaining > 0) {
|
|
545
|
+
budget.remaining--;
|
|
546
|
+
rows = this.countRows(entry);
|
|
547
|
+
}
|
|
548
|
+
else {
|
|
549
|
+
rows = this.estimateRows(cached);
|
|
550
|
+
stale = 1;
|
|
551
|
+
this.staleCount++;
|
|
552
|
+
}
|
|
553
|
+
this.visible.push(entry);
|
|
554
|
+
this.stale.push(stale);
|
|
555
|
+
this.rowIndex.push(rows);
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Guesses the row count of an entry that was not laid out at the current width: its previous
|
|
559
|
+
* row count scaled by the change in width, and never fewer rows than it has lines.
|
|
560
|
+
*/
|
|
561
|
+
estimateRows(previous) {
|
|
562
|
+
if (!previous) {
|
|
563
|
+
return 1;
|
|
564
|
+
}
|
|
565
|
+
if (this.options.wrap === 'none' || previous.wrap === 'none') {
|
|
566
|
+
return previous.lines;
|
|
567
|
+
}
|
|
568
|
+
return Math.max(previous.lines, Math.round((previous.rows * previous.columns) / this.columns));
|
|
569
|
+
}
|
|
570
|
+
/** Lays out the visible entry at an index exactly, if it is not already. Returns its rows. */
|
|
571
|
+
measureIndex(index) {
|
|
572
|
+
const position = this.visibleStart + index;
|
|
573
|
+
if (!this.stale[position]) {
|
|
574
|
+
return this.rowIndex.get(index);
|
|
575
|
+
}
|
|
576
|
+
const rows = this.countRows(this.visible[position]);
|
|
577
|
+
this.stale[position] = 0;
|
|
578
|
+
this.staleCount--;
|
|
579
|
+
if (rows !== this.rowIndex.get(index)) {
|
|
580
|
+
this.rowIndex.set(index, rows);
|
|
581
|
+
this.positions++;
|
|
582
|
+
}
|
|
583
|
+
return rows;
|
|
584
|
+
}
|
|
585
|
+
isVisible(entry) {
|
|
586
|
+
if (this.filter.matches && !this.filter.matches(entry)) {
|
|
587
|
+
return false;
|
|
588
|
+
}
|
|
589
|
+
for (const groupId of entry.groups) {
|
|
590
|
+
if (this.store.get(groupId)?.collapsed) {
|
|
591
|
+
return false;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return true;
|
|
595
|
+
}
|
|
596
|
+
stateKeyOf(entry) {
|
|
597
|
+
const version = this.expansions.get(entry.id)?.version ?? 0;
|
|
598
|
+
return version * 2 + (entry.collapsed ? 1 : 0);
|
|
599
|
+
}
|
|
600
|
+
countRows(entry) {
|
|
601
|
+
const stateKey = this.stateKeyOf(entry);
|
|
602
|
+
const cached = this.rowCounts.get(entry.id);
|
|
603
|
+
if (cached && cached.stateKey === stateKey && cached.layoutVersion === this.layoutVersion) {
|
|
604
|
+
return cached.rows;
|
|
605
|
+
}
|
|
606
|
+
const plainRows = this.countPlainRows(entry);
|
|
607
|
+
const layout = plainRows === null ? this.layoutOf(entry) : null;
|
|
608
|
+
const rows = plainRows ?? layout.rows;
|
|
609
|
+
this.rowCounts.set(entry.id, {
|
|
610
|
+
stateKey,
|
|
611
|
+
layoutVersion: this.layoutVersion,
|
|
612
|
+
rows,
|
|
613
|
+
lines: layout ? layout.lines.length : 1,
|
|
614
|
+
columns: this.columns,
|
|
615
|
+
wrap: this.options.wrap
|
|
616
|
+
});
|
|
617
|
+
return rows;
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Counts the rows of the most common entry, one line of plain ASCII text, without building and
|
|
621
|
+
* caching its layout. Returns `null` for any other entry.
|
|
622
|
+
*/
|
|
623
|
+
countPlainRows(entry) {
|
|
624
|
+
if (entry.kind !== 'message' || entry.groups.length > 0 || entry.parts.length !== 1) {
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
const [part] = entry.parts;
|
|
628
|
+
if (part.type !== 'text' ||
|
|
629
|
+
part.wrap === false ||
|
|
630
|
+
part.text.length > this.options.maxClusters ||
|
|
631
|
+
!PRINTABLE_ASCII.test(part.text)) {
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
const line = shapeLine([{ text: part.text }], 0, this.options);
|
|
635
|
+
const width = Math.max(MIN_WRAP_COLUMNS, this.columns);
|
|
636
|
+
const rows = wrapLine(line, width, this.options.wrap).length;
|
|
637
|
+
this.widest = Math.max(this.widest, rows > 1 ? Math.min(line.cells, width) : line.cells);
|
|
638
|
+
return rows;
|
|
639
|
+
}
|
|
640
|
+
layoutOf(entry) {
|
|
641
|
+
const stateKey = this.stateKeyOf(entry);
|
|
642
|
+
let layout = this.layouts.get(entry.id);
|
|
643
|
+
if (!layout || layout.stateKey !== stateKey || layout.optionsVersion !== this.optionsVersion) {
|
|
644
|
+
const lines = buildEntryLines(entry, (path) => this.isExpanded(entry, path)).map((logical) => {
|
|
645
|
+
const shaped = shapeLine(logical.spans, logical.indent, this.options);
|
|
646
|
+
shaped.wrap = logical.wrap !== false;
|
|
647
|
+
return shaped;
|
|
648
|
+
});
|
|
649
|
+
layout = {
|
|
650
|
+
stateKey,
|
|
651
|
+
optionsVersion: this.optionsVersion,
|
|
652
|
+
lines,
|
|
653
|
+
wrapKey: '',
|
|
654
|
+
wraps: [],
|
|
655
|
+
rows: 0,
|
|
656
|
+
cells: 0
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
const wrapKey = `${this.options.wrap}:${this.columns}`;
|
|
660
|
+
if (layout.wrapKey !== wrapKey) {
|
|
661
|
+
let rows = 0;
|
|
662
|
+
let cells = 0;
|
|
663
|
+
layout.wraps = layout.lines.map((line) => {
|
|
664
|
+
const width = Math.max(MIN_WRAP_COLUMNS, this.columns - line.indent);
|
|
665
|
+
const wraps = wrapLine(line, width, line.wrap ? this.options.wrap : 'none');
|
|
666
|
+
const widest = wraps.length > 1 ? Math.min(line.cells, width) : line.cells;
|
|
667
|
+
rows += wraps.length;
|
|
668
|
+
cells = Math.max(cells, line.indent + widest);
|
|
669
|
+
return wraps;
|
|
670
|
+
});
|
|
671
|
+
layout.rows = rows;
|
|
672
|
+
layout.cells = cells;
|
|
673
|
+
layout.wrapKey = wrapKey;
|
|
674
|
+
}
|
|
675
|
+
this.widest = Math.max(this.widest, layout.cells);
|
|
676
|
+
this.layouts.delete(entry.id);
|
|
677
|
+
this.layouts.set(entry.id, layout);
|
|
678
|
+
if (this.layouts.size > LAYOUT_CACHE_SIZE) {
|
|
679
|
+
const oldest = this.layouts.keys().next().value;
|
|
680
|
+
if (oldest !== undefined) {
|
|
681
|
+
this.layouts.delete(oldest);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
return layout;
|
|
685
|
+
}
|
|
686
|
+
buildRuns(line, from, to) {
|
|
687
|
+
const runs = [];
|
|
688
|
+
let spanIndex = spanAt(line, from);
|
|
689
|
+
let cursor = from;
|
|
690
|
+
let column = line.indent;
|
|
691
|
+
while (cursor < to && spanIndex < line.spans.length) {
|
|
692
|
+
const spanEnd = spanIndex + 1 < line.spans.length ? line.spanStarts[spanIndex + 1] : line.length;
|
|
693
|
+
const end = Math.min(to, spanEnd);
|
|
694
|
+
if (end > cursor) {
|
|
695
|
+
const span = line.spans[spanIndex];
|
|
696
|
+
const text = textBetween(line, cursor, end);
|
|
697
|
+
let cells = end - cursor;
|
|
698
|
+
let clusters = [];
|
|
699
|
+
let widths = [];
|
|
700
|
+
let simple = true;
|
|
701
|
+
if (line.clusters && line.widths) {
|
|
702
|
+
widths = Array.from(line.widths.subarray(cursor, end));
|
|
703
|
+
cells = widths.reduce((sum, width) => sum + width, 0);
|
|
704
|
+
simple = !span.icon && cells === end - cursor && PRINTABLE_ASCII.test(text);
|
|
705
|
+
if (!simple) {
|
|
706
|
+
clusters = line.clusters.slice(cursor, end);
|
|
707
|
+
}
|
|
708
|
+
else {
|
|
709
|
+
widths = [];
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
runs.push({
|
|
713
|
+
column,
|
|
714
|
+
cells,
|
|
715
|
+
text,
|
|
716
|
+
clusters,
|
|
717
|
+
widths,
|
|
718
|
+
simple,
|
|
719
|
+
token: span.token,
|
|
720
|
+
style: span.style,
|
|
721
|
+
action: span.action,
|
|
722
|
+
icon: span.icon,
|
|
723
|
+
expanded: span.expanded
|
|
724
|
+
});
|
|
725
|
+
column += cells;
|
|
726
|
+
}
|
|
727
|
+
cursor = end;
|
|
728
|
+
spanIndex++;
|
|
729
|
+
}
|
|
730
|
+
return runs;
|
|
731
|
+
}
|
|
732
|
+
textInCells(line, fromCell, toCell) {
|
|
733
|
+
let text = '';
|
|
734
|
+
let cell = 0;
|
|
735
|
+
for (let index = 0; index < line.length; index++) {
|
|
736
|
+
const width = widthAt(line, index);
|
|
737
|
+
if (cell >= toCell) {
|
|
738
|
+
break;
|
|
739
|
+
}
|
|
740
|
+
if (cell >= fromCell) {
|
|
741
|
+
const isIcon = line.clusters !== null && line.clusters[index] === '' && width === 2;
|
|
742
|
+
text += isIcon ? '' : textBetween(line, index, index + 1);
|
|
743
|
+
}
|
|
744
|
+
cell += width;
|
|
745
|
+
}
|
|
746
|
+
return text;
|
|
747
|
+
}
|
|
748
|
+
firstIndexFrom(entryId) {
|
|
749
|
+
let low = this.visibleStart;
|
|
750
|
+
let high = this.visible.length;
|
|
751
|
+
while (low < high) {
|
|
752
|
+
const middle = (low + high) >> 1;
|
|
753
|
+
if (this.visible[middle].id < entryId) {
|
|
754
|
+
low = middle + 1;
|
|
755
|
+
}
|
|
756
|
+
else {
|
|
757
|
+
high = middle;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
return low - this.visibleStart;
|
|
761
|
+
}
|
|
762
|
+
forget(entryId) {
|
|
763
|
+
this.layouts.delete(entryId);
|
|
764
|
+
this.rowCounts.delete(entryId);
|
|
765
|
+
this.expansions.delete(entryId);
|
|
766
|
+
}
|
|
767
|
+
compactVisible() {
|
|
768
|
+
if (this.visibleStart > 4096 && this.visibleStart > this.visible.length / 2) {
|
|
769
|
+
this.visible = this.visible.slice(this.visibleStart);
|
|
770
|
+
this.stale = this.stale.slice(this.visibleStart);
|
|
771
|
+
this.visibleStart = 0;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
pruneCaches() {
|
|
775
|
+
const firstId = this.store.firstId;
|
|
776
|
+
for (const map of [this.layouts, this.rowCounts, this.expansions]) {
|
|
777
|
+
for (const id of map.keys()) {
|
|
778
|
+
if (id < firstId) {
|
|
779
|
+
map.delete(id);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
}
|