@xprnvd/pi-context-view 0.7.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -12
- package/package.json +3 -3
- package/src/capture/diff.ts +72 -13
- package/src/capture/request.ts +47 -14
- package/src/ui/injections-model.ts +114 -5
- package/src/ui/injections-view.ts +336 -42
- package/src/ui/layout.ts +14 -1
- package/src/ui/search.ts +427 -0
- package/src/ui/usage-preview.ts +8 -0
- package/src/ui/usage-view.ts +200 -27
package/README.md
CHANGED
|
@@ -35,28 +35,28 @@ injected by other extensions.
|
|
|
35
35
|
defaults, useful for
|
|
36
36
|
[customization](https://github.com/dimk90/pi-context-view#customization).
|
|
37
37
|
|
|
38
|
-
## Demo
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
### `/context`
|
|
38
|
+
## Demo: `/context`
|
|
42
39
|
|
|
43
40
|
See what fills your context, for example, what survives compaction:
|
|
44
41
|
|
|
45
|
-

|
|
42
|
+

|
|
43
|
+
➥ Pi theme in this demo: [`dark-legacy`](https://github.com/dimk90/pi-context-view/blob/master/doc/dark-legacy.json).
|
|
46
44
|
|
|
47
45
|
|
|
48
|
-
|
|
46
|
+
## Demo: `/context injections`
|
|
49
47
|
|
|
50
48
|
Inspect hidden parts of the context, such as tool definitions:
|
|
51
49
|
|
|
52
|
-

|
|
51
|
+
➥ Pi theme in this demo: [`dark-legacy`](https://github.com/dimk90/pi-context-view/blob/master/doc/dark-legacy.json).
|
|
53
52
|
|
|
54
|
-
|
|
53
|
+
## Demo: Zoom
|
|
55
54
|
|
|
56
55
|
Zoom in for a more detailed breakdown of large context windows, such as
|
|
57
56
|
1M-token windows:
|
|
58
57
|
|
|
59
|
-

|
|
58
|
+

|
|
59
|
+
➥ Pi theme in this demo: [`dark-legacy`](https://github.com/dimk90/pi-context-view/blob/master/doc/dark-legacy.json).
|
|
60
60
|
|
|
61
61
|
## Install
|
|
62
62
|
|
|
@@ -73,7 +73,7 @@ To get started, create a configuration file populated with the current defaults:
|
|
|
73
73
|
```text
|
|
74
74
|
/context config
|
|
75
75
|
```
|
|
76
|
-
|
|
76
|
+
➥ This creates `~/.pi/agent/extensions/pi-context-view.json`.
|
|
77
77
|
|
|
78
78
|
See the [configuration reference](https://github.com/dimk90/pi-context-view/blob/master/doc/CONFIG.md)
|
|
79
79
|
for each parameter's meaning and default value.
|
|
@@ -85,7 +85,8 @@ colors that stay the same across themes. Here are examples of custom
|
|
|
85
85
|
[terrain](https://github.com/dimk90/pi-context-view/blob/master/doc/palettes/terrain.json) and
|
|
86
86
|
[rainbow](https://github.com/dimk90/pi-context-view/blob/master/doc/palettes/rainbow.json) palettes:
|
|
87
87
|
|
|
88
|
-

|
|
88
|
+

|
|
89
|
+
➥ Pi theme in this demo: [`dark-legacy`](https://github.com/dimk90/pi-context-view/blob/master/doc/dark-legacy.json).
|
|
89
90
|
|
|
90
91
|
See the [theme color reference](https://github.com/dimk90/pi-context-view/blob/develop/doc/PI-THEME-COLORS.md)
|
|
91
92
|
for color names that follow the current theme.
|
|
@@ -94,7 +95,8 @@ for color names that follow the current theme.
|
|
|
94
95
|
|
|
95
96
|
You can configure the number of rows and columns in the `Context Usage` map:
|
|
96
97
|
|
|
97
|
-

|
|
98
|
+

|
|
99
|
+
➥ Pi theme in this demo: [`dark-legacy`](https://github.com/dimk90/pi-context-view/blob/master/doc/dark-legacy.json).
|
|
98
100
|
|
|
99
101
|
|
|
100
102
|
## Context Footprint
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xprnvd/pi-context-view",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Pi extension to visualize context usage and inspect the hidden parts: base prompt, tools defs, and extension injections",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
"type": "git",
|
|
10
10
|
"url": "git+https://github.com/xprnvd/pi-context-view.git"
|
|
11
11
|
},
|
|
12
|
-
"homepage": "https://github.com/
|
|
12
|
+
"homepage": "https://github.com/xprnvd/pi-context-view",
|
|
13
13
|
"bugs": {
|
|
14
|
-
"url": "https://github.com/
|
|
14
|
+
"url": "https://github.com/xprnvd/pi-context-view/issues"
|
|
15
15
|
},
|
|
16
16
|
"keywords": [
|
|
17
17
|
"pi",
|
package/src/capture/diff.ts
CHANGED
|
@@ -100,10 +100,23 @@ export function messageKey(message: RequestMessage): string {
|
|
|
100
100
|
*/
|
|
101
101
|
export function sameMessage(a: RequestMessage, b: RequestMessage): boolean {
|
|
102
102
|
if (a.role !== b.role || customTypeKey(a) !== customTypeKey(b)) return false;
|
|
103
|
-
const result = compareJson(modelFacingPart(a), modelFacingPart(b), 0);
|
|
103
|
+
const result = compareJson(modelFacingPart(a), modelFacingPart(b), 0, "any");
|
|
104
104
|
return result === "unknown" ? messageKey(a) === messageKey(b) : result === "equal";
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Whether two tools declare the same interface, as Pi's `declarationsEqual()`
|
|
109
|
+
* decides, without serializing plain JSON declarations. Only the fields Pi
|
|
110
|
+
* keeps in a declaration are compared; anything outside plain JSON data falls
|
|
111
|
+
* back to `declarationsEqual()`.
|
|
112
|
+
*/
|
|
113
|
+
export function sameDeclaration(a: Tool, b: Tool): boolean {
|
|
114
|
+
if (a === b) return true;
|
|
115
|
+
// Pi compares declarations as plain JSON, so schema key order matters
|
|
116
|
+
const result = compareJson(declarationPart(a), declarationPart(b), 0, "same");
|
|
117
|
+
return result === "unknown" ? declarationsEqual(a, b) : result === "equal";
|
|
118
|
+
}
|
|
119
|
+
|
|
107
120
|
/**
|
|
108
121
|
* Remove the non-system messages that both sides share at the start and at
|
|
109
122
|
* the end. Alignment matches these ends first, so `diffConversation()` over
|
|
@@ -158,7 +171,7 @@ function diffTools(before: readonly Tool[], after: readonly Tool[]): SystemChang
|
|
|
158
171
|
const changes: SystemChange[] = [];
|
|
159
172
|
for (const tool of after) {
|
|
160
173
|
const previous = beforeByName.get(tool.name);
|
|
161
|
-
if (previous === undefined || !
|
|
174
|
+
if (previous === undefined || !sameDeclaration(previous, tool)) {
|
|
162
175
|
changes.push({ type: "tool", name: tool.name, declaration: tool });
|
|
163
176
|
}
|
|
164
177
|
}
|
|
@@ -168,6 +181,16 @@ function diffTools(before: readonly Tool[], after: readonly Tool[]): SystemChang
|
|
|
168
181
|
return changes;
|
|
169
182
|
}
|
|
170
183
|
|
|
184
|
+
/** The fields of a tool that Pi's `toToolDeclaration()` keeps; executable and display fields are left out. */
|
|
185
|
+
function declarationPart(tool: Tool): Tool {
|
|
186
|
+
return {
|
|
187
|
+
name: tool.name,
|
|
188
|
+
description: tool.description,
|
|
189
|
+
parameters: tool.parameters,
|
|
190
|
+
constrainedSampling: tool.constrainedSampling,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
171
194
|
/** Plain text of system content; text blocks join with a newline, as Pi renders them. */
|
|
172
195
|
function contentText(content: SystemMessage["content"]): string {
|
|
173
196
|
return typeof content === "string" ? content : content.map((block) => block.text).join("\n");
|
|
@@ -302,18 +325,25 @@ export function canonicalJson(value: unknown): string {
|
|
|
302
325
|
|
|
303
326
|
/**
|
|
304
327
|
* Result of comparing two values as JSON:
|
|
305
|
-
* equal both encode to the same
|
|
306
|
-
* different their
|
|
307
|
-
* unknown a value is outside plain JSON data; compare the encoded
|
|
328
|
+
* equal both encode to the same JSON
|
|
329
|
+
* different their JSON differs
|
|
330
|
+
* unknown a value is outside plain JSON data; compare the encoded values
|
|
308
331
|
*/
|
|
309
332
|
type JsonComparison = "equal" | "different" | "unknown";
|
|
310
333
|
|
|
334
|
+
/**
|
|
335
|
+
* Which JSON encoding a comparison agrees with:
|
|
336
|
+
* any `canonicalJson()`: object key order does not matter
|
|
337
|
+
* same `JSON.stringify()`: object keys must come in the same order
|
|
338
|
+
*/
|
|
339
|
+
type KeyOrder = "any" | "same";
|
|
340
|
+
|
|
311
341
|
/**
|
|
312
342
|
* Compare plain JSON data (strings, booleans, null, finite numbers, arrays,
|
|
313
|
-
* and plain objects) as
|
|
314
|
-
*
|
|
343
|
+
* and plain objects) as its JSON encoding would, without encoding it.
|
|
344
|
+
* `undefined` properties do not matter, as in JSON.
|
|
315
345
|
*/
|
|
316
|
-
function compareJson(a: unknown, b: unknown, depth: number): JsonComparison {
|
|
346
|
+
function compareJson(a: unknown, b: unknown, depth: number, keyOrder: KeyOrder): JsonComparison {
|
|
317
347
|
if (typeof a === "string" || typeof a === "boolean") {
|
|
318
348
|
if (typeof b !== typeof a) return "unknown";
|
|
319
349
|
return a === b ? "equal" : "different";
|
|
@@ -325,17 +355,23 @@ function compareJson(a: unknown, b: unknown, depth: number): JsonComparison {
|
|
|
325
355
|
if (a === null) return b === null ? "equal" : "unknown";
|
|
326
356
|
if (typeof a !== "object" || typeof b !== "object" || b === null || depth >= MAX_COMPARE_DEPTH) return "unknown";
|
|
327
357
|
if (Array.isArray(a) || Array.isArray(b)) {
|
|
328
|
-
return Array.isArray(a) && Array.isArray(b) ? compareJsonArrays(a, b, depth) : "unknown";
|
|
358
|
+
return Array.isArray(a) && Array.isArray(b) ? compareJsonArrays(a, b, depth, keyOrder) : "unknown";
|
|
329
359
|
}
|
|
330
360
|
if (!isPlainObject(a) || !isPlainObject(b)) return "unknown";
|
|
331
|
-
|
|
361
|
+
const compareObjects = keyOrder === "any" ? compareJsonObjects : compareOrderedJsonObjects;
|
|
362
|
+
return compareObjects(a as Record<string, unknown>, b as Record<string, unknown>, depth);
|
|
332
363
|
}
|
|
333
364
|
|
|
334
365
|
/** Compare two arrays element by element; JSON keeps their order. */
|
|
335
|
-
function compareJsonArrays(
|
|
366
|
+
function compareJsonArrays(
|
|
367
|
+
a: readonly unknown[],
|
|
368
|
+
b: readonly unknown[],
|
|
369
|
+
depth: number,
|
|
370
|
+
keyOrder: KeyOrder,
|
|
371
|
+
): JsonComparison {
|
|
336
372
|
if (a.length !== b.length) return "different";
|
|
337
373
|
for (let index = 0; index < a.length; index++) {
|
|
338
|
-
const result = compareJson(a[index], b[index], depth + 1);
|
|
374
|
+
const result = compareJson(a[index], b[index], depth + 1, keyOrder);
|
|
339
375
|
if (result !== "equal") return result;
|
|
340
376
|
}
|
|
341
377
|
return "equal";
|
|
@@ -351,7 +387,7 @@ function compareJsonObjects(
|
|
|
351
387
|
for (const key in a) {
|
|
352
388
|
if (a[key] === undefined) continue;
|
|
353
389
|
unpaired++;
|
|
354
|
-
const result = compareJson(a[key], b[key], depth + 1);
|
|
390
|
+
const result = compareJson(a[key], b[key], depth + 1, "any");
|
|
355
391
|
if (result !== "equal") return result;
|
|
356
392
|
}
|
|
357
393
|
for (const key in b) {
|
|
@@ -364,6 +400,29 @@ function compareJsonObjects(
|
|
|
364
400
|
return unpaired === 0 ? "equal" : "different";
|
|
365
401
|
}
|
|
366
402
|
|
|
403
|
+
/** Compare two plain objects by their defined properties, which must come in the same order. */
|
|
404
|
+
function compareOrderedJsonObjects(
|
|
405
|
+
a: Readonly<Record<string, unknown>>,
|
|
406
|
+
b: Readonly<Record<string, unknown>>,
|
|
407
|
+
depth: number,
|
|
408
|
+
): JsonComparison {
|
|
409
|
+
const aKeys = Object.keys(a).filter((key) => a[key] !== undefined);
|
|
410
|
+
const bKeys = Object.keys(b).filter((key) => b[key] !== undefined);
|
|
411
|
+
// JSON omits these, so the key lists alone cannot decide
|
|
412
|
+
if (aKeys.some((key) => isOmittedByJson(a[key])) || bKeys.some((key) => isOmittedByJson(b[key]))) return "unknown";
|
|
413
|
+
if (aKeys.length !== bKeys.length || aKeys.some((key, index) => key !== bKeys[index])) return "different";
|
|
414
|
+
for (const key of aKeys) {
|
|
415
|
+
const result = compareJson(a[key], b[key], depth + 1, "same");
|
|
416
|
+
if (result !== "equal") return result;
|
|
417
|
+
}
|
|
418
|
+
return "equal";
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Whether JSON leaves out an object property with this value. */
|
|
422
|
+
function isOmittedByJson(value: unknown): boolean {
|
|
423
|
+
return typeof value === "function" || typeof value === "symbol";
|
|
424
|
+
}
|
|
425
|
+
|
|
367
426
|
/** Whether a value is an object literal, not a class instance whose JSON may differ. */
|
|
368
427
|
function isPlainObject(value: object): boolean {
|
|
369
428
|
const prototype: unknown = Object.getPrototypeOf(value);
|
package/src/capture/request.ts
CHANGED
|
@@ -10,7 +10,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
|
10
10
|
|
|
11
11
|
import type { ProbeView } from "../probe/view.ts";
|
|
12
12
|
import type { CaptureOrigin, RequestMessage } from "../snapshot.ts";
|
|
13
|
-
import { trimMatchedEnds, type UnmatchedMessages } from "./diff.ts";
|
|
13
|
+
import { sameDeclaration, trimMatchedEnds, type UnmatchedMessages } from "./diff.ts";
|
|
14
14
|
import type { GuardModel } from "./guard.ts";
|
|
15
15
|
|
|
16
16
|
/**
|
|
@@ -47,7 +47,9 @@ export interface SystemText {
|
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
49
|
* The parts of a request the deferred diff and the payload guard need, as this
|
|
50
|
-
* extension's handler saw them. Request-side
|
|
50
|
+
* extension's handler saw them. Request-side objects are owned copies, except
|
|
51
|
+
* declarations equal to the baseline's: those are the baseline's own objects,
|
|
52
|
+
* which are only read. Strings are kept by reference.
|
|
51
53
|
*/
|
|
52
54
|
export interface RequestCopy {
|
|
53
55
|
/** The request's replayed system state; undefined when it has none. */
|
|
@@ -105,8 +107,8 @@ export interface CaptureInput {
|
|
|
105
107
|
|
|
106
108
|
/**
|
|
107
109
|
* Read the baseline, copy what differs from it, and keep the effective prompt
|
|
108
|
-
* for deferred forced-prompt detection. Throws when a differing message
|
|
109
|
-
* be cloned, for example when a handler added a function value to it.
|
|
110
|
+
* for deferred forced-prompt detection. Throws when a differing message or tool
|
|
111
|
+
* cannot be cloned, for example when a handler added a function value to it.
|
|
110
112
|
*/
|
|
111
113
|
export function captureRequest(input: CaptureInput): CapturedRequest {
|
|
112
114
|
const baseline = readBaseline(input.sessionManager, input.probe);
|
|
@@ -125,21 +127,22 @@ export function captureRequest(input: CaptureInput): CapturedRequest {
|
|
|
125
127
|
|
|
126
128
|
/**
|
|
127
129
|
* Copy the request's replayed system state and the messages that differ from
|
|
128
|
-
* the baseline. Messages equal to the baseline at both ends
|
|
129
|
-
*
|
|
130
|
-
*
|
|
130
|
+
* the baseline. Messages equal to the baseline at both ends, and declarations
|
|
131
|
+
* equal to the baseline's of the same name, are compared in place and never
|
|
132
|
+
* copied. System text positions and historical declarations also belong to
|
|
133
|
+
* the copy: serializers may keep them instead of collapsing.
|
|
131
134
|
*/
|
|
132
135
|
export function copyRequest(baseline: readonly BaselineMessage[], messages: readonly RequestMessage[]): RequestCopy {
|
|
133
136
|
const unmatched = trimMatchedEnds(baseline, messages);
|
|
134
|
-
// Later handlers share these objects
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
137
|
+
// Later handlers share these objects and may edit them in place
|
|
138
|
+
const request = structuredClone(unmatched.request);
|
|
139
|
+
const copyTool = createToolCopier(baseline);
|
|
140
|
+
const system = getCurrentSystemMessage(messages);
|
|
138
141
|
return {
|
|
139
|
-
...(
|
|
142
|
+
...(system === undefined ? {} : { system: copySystemState(system, copyTool) }),
|
|
140
143
|
systemTexts: copySystemTexts(messages),
|
|
141
|
-
declarations:
|
|
142
|
-
conversation: { prefix: unmatched.prefix, baseline: unmatched.baseline, request
|
|
144
|
+
declarations: getDeclaredTools(messages).map(copyTool),
|
|
145
|
+
conversation: { prefix: unmatched.prefix, baseline: unmatched.baseline, request },
|
|
143
146
|
};
|
|
144
147
|
}
|
|
145
148
|
|
|
@@ -181,6 +184,36 @@ export function detectForcedPrompt(effectivePrompt: string, baseline: readonly B
|
|
|
181
184
|
return effectivePrompt === replayed ? undefined : effectivePrompt;
|
|
182
185
|
}
|
|
183
186
|
|
|
187
|
+
/**
|
|
188
|
+
* A function that returns, for each request tool, the baseline's declaration
|
|
189
|
+
* of the same name when it is equal, else an owned copy. Each request object
|
|
190
|
+
* is compared and copied once, so the current and historical declarations
|
|
191
|
+
* keep sharing objects. Throws when a differing tool cannot be cloned.
|
|
192
|
+
*/
|
|
193
|
+
function createToolCopier(baseline: readonly BaselineMessage[]): (tool: Tool) => Tool {
|
|
194
|
+
const baselineTools = new Map(getDeclaredTools(baseline.map(({ message }) => message))
|
|
195
|
+
.map((tool) => [tool.name, tool]));
|
|
196
|
+
const copies = new Map<Tool, Tool>();
|
|
197
|
+
return (tool) => {
|
|
198
|
+
let copy = copies.get(tool);
|
|
199
|
+
if (copy === undefined) {
|
|
200
|
+
const previous = baselineTools.get(tool.name);
|
|
201
|
+
// Later handlers share request tools, schemas included, and may edit them in place
|
|
202
|
+
copy = previous !== undefined && sameDeclaration(previous, tool) ? previous : structuredClone(tool);
|
|
203
|
+
copies.set(tool, copy);
|
|
204
|
+
}
|
|
205
|
+
return copy;
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The replayed system state with copied tools. Replay builds new containers
|
|
211
|
+
* and strings are immutable, so content and sections need no copy.
|
|
212
|
+
*/
|
|
213
|
+
function copySystemState(system: SystemMessage, copyTool: (tool: Tool) => Tool): SystemMessage {
|
|
214
|
+
return system.toolsAdded === undefined ? system : { ...system, toolsAdded: system.toolsAdded.map(copyTool) };
|
|
215
|
+
}
|
|
216
|
+
|
|
184
217
|
/**
|
|
185
218
|
* Copy the text of every system message with its position. Strings are
|
|
186
219
|
* immutable, so a shallow copy of the sections suffices; tool changes are
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import {
|
|
7
7
|
type ChangeEditor,
|
|
8
8
|
type ForcedEdit,
|
|
9
|
+
type InjectionGroup,
|
|
9
10
|
type InjectionItem,
|
|
10
11
|
type InjectionSnapshot,
|
|
11
12
|
type RequestChange,
|
|
@@ -40,6 +41,30 @@ export type ChangeScope =
|
|
|
40
41
|
| "request-new"
|
|
41
42
|
| "payload";
|
|
42
43
|
|
|
44
|
+
/** What a list filter compares the query against. */
|
|
45
|
+
export type InjectionFilterMode = "name" | "content";
|
|
46
|
+
|
|
47
|
+
/** A non-blank list filter; the view owns how text is normalized and compared. */
|
|
48
|
+
export interface InjectionFilter {
|
|
49
|
+
readonly mode: InjectionFilterMode;
|
|
50
|
+
/** Whether a group or item label contains the query. */
|
|
51
|
+
readonly nameMatches: (label: string) => boolean;
|
|
52
|
+
/** Whether an item's content contains the query. */
|
|
53
|
+
readonly contentMatches: (item: InjectionItem) => boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Rows a filter leaves, by item id and `source:<id>` group key. The snapshot
|
|
58
|
+
* itself stays whole, so estimates, diffs, and change scopes never depend on
|
|
59
|
+
* which rows a filter hides.
|
|
60
|
+
*/
|
|
61
|
+
export interface InjectionFilterResult {
|
|
62
|
+
/** Rows to show: matches and the ancestors kept as their context. */
|
|
63
|
+
readonly kept: ReadonlySet<string>;
|
|
64
|
+
/** Rows that matched directly, not as context. */
|
|
65
|
+
readonly matched: ReadonlySet<string>;
|
|
66
|
+
}
|
|
67
|
+
|
|
43
68
|
/** One flattened list row derived from the snapshot hierarchy. */
|
|
44
69
|
export type InjectionRow =
|
|
45
70
|
| {
|
|
@@ -51,6 +76,8 @@ export type InjectionRow =
|
|
|
51
76
|
readonly diff?: TokenDiff;
|
|
52
77
|
/** Where the changes of the rows it counts live; absent when none changed. */
|
|
53
78
|
readonly scope?: ChangeScope;
|
|
79
|
+
/** Whether an active filter matched this row itself rather than keeping it as context. */
|
|
80
|
+
readonly matched?: boolean;
|
|
54
81
|
}
|
|
55
82
|
| {
|
|
56
83
|
readonly kind: "item";
|
|
@@ -84,6 +111,8 @@ export type InjectionRow =
|
|
|
84
111
|
readonly editors?: readonly ChangeEditor[];
|
|
85
112
|
/** Stable preview target id from the snapshot. */
|
|
86
113
|
readonly itemId: string;
|
|
114
|
+
/** Whether an active filter matched this row itself rather than keeping it as context. */
|
|
115
|
+
readonly matched?: boolean;
|
|
87
116
|
}
|
|
88
117
|
| {
|
|
89
118
|
readonly kind: "separator";
|
|
@@ -110,10 +139,80 @@ export function collectItemsById(snapshot: InjectionSnapshot): Map<string, Injec
|
|
|
110
139
|
return items;
|
|
111
140
|
}
|
|
112
141
|
|
|
113
|
-
/**
|
|
114
|
-
|
|
142
|
+
/**
|
|
143
|
+
* Rows the filter matches, plus each match's ancestors as context. In name
|
|
144
|
+
* mode a matching group or item keeps its whole subtree, since naming a
|
|
145
|
+
* container asks for its contents. In content mode an item with children
|
|
146
|
+
* matches through them: its own text concatenates theirs, so it stands alone
|
|
147
|
+
* only when no child matches.
|
|
148
|
+
*/
|
|
149
|
+
export function filterInjections(snapshot: InjectionSnapshot, filter: InjectionFilter): InjectionFilterResult {
|
|
150
|
+
const kept = new Set<string>();
|
|
151
|
+
const matched = new Set<string>();
|
|
152
|
+
const matches = (item: InjectionItem): boolean => filter.mode === "name"
|
|
153
|
+
? filter.nameMatches(item.label)
|
|
154
|
+
: filter.contentMatches(item);
|
|
155
|
+
for (const group of snapshot.groups) {
|
|
156
|
+
const wholeGroup = filter.mode === "name" && filter.nameMatches(group.source.label);
|
|
157
|
+
if (wholeGroup) matched.add(groupKey(group));
|
|
158
|
+
let groupKept = wholeGroup;
|
|
159
|
+
for (const item of group.items) {
|
|
160
|
+
if (filterItem(item, filter.mode, matches, wholeGroup, kept, matched)) groupKept = true;
|
|
161
|
+
}
|
|
162
|
+
if (groupKept) kept.add(groupKey(group));
|
|
163
|
+
}
|
|
164
|
+
return { kept, matched };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Record which rows of one item the filter keeps and matches; true when any
|
|
169
|
+
* is kept. `whole` keeps the full subtree, as inside a matching group.
|
|
170
|
+
*/
|
|
171
|
+
function filterItem(
|
|
172
|
+
item: InjectionItem,
|
|
173
|
+
mode: InjectionFilterMode,
|
|
174
|
+
matches: (item: InjectionItem) => boolean,
|
|
175
|
+
whole: boolean,
|
|
176
|
+
kept: Set<string>,
|
|
177
|
+
matched: Set<string>,
|
|
178
|
+
): boolean {
|
|
179
|
+
const children = item.children ?? [];
|
|
180
|
+
const matchedChildren = children.filter(matches);
|
|
181
|
+
for (const child of matchedChildren) matched.add(child.id);
|
|
182
|
+
const itemMatched = matches(item);
|
|
183
|
+
if (whole || (mode === "name" && itemMatched)) {
|
|
184
|
+
if (itemMatched) matched.add(item.id);
|
|
185
|
+
for (const entry of [item, ...children]) kept.add(entry.id);
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
if (matchedChildren.length > 0) {
|
|
189
|
+
for (const entry of [item, ...matchedChildren]) kept.add(entry.id);
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
if (mode === "content" && itemMatched) {
|
|
193
|
+
matched.add(item.id);
|
|
194
|
+
kept.add(item.id);
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Match key of a group row, kept apart from item ids. */
|
|
201
|
+
function groupKey(group: InjectionGroup): string {
|
|
202
|
+
return `source:${group.source.id}`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Flatten snapshot groups into rows separated from the non-selectable snapshot
|
|
207
|
+
* total. A filter result hides the rows it does not keep and flags the rows it
|
|
208
|
+
* matched; every shown row keeps the values and status of the whole snapshot.
|
|
209
|
+
*/
|
|
210
|
+
export function buildInjectionRows(snapshot: InjectionSnapshot, filter?: InjectionFilterResult): InjectionRow[] {
|
|
211
|
+
const shown = (key: string): boolean => filter === undefined || filter.kept.has(key);
|
|
212
|
+
const matched = (key: string): boolean | undefined => filter?.matched.has(key);
|
|
115
213
|
const rows: InjectionRow[] = [];
|
|
116
214
|
for (const group of snapshot.groups) {
|
|
215
|
+
if (!shown(groupKey(group))) continue;
|
|
117
216
|
rows.push({
|
|
118
217
|
kind: "group",
|
|
119
218
|
label: group.source.label,
|
|
@@ -121,9 +220,11 @@ export function buildInjectionRows(snapshot: InjectionSnapshot): InjectionRow[]
|
|
|
121
220
|
depth: 0,
|
|
122
221
|
diff: group.diff,
|
|
123
222
|
scope: combineScopes(group.items.filter((item) => item.diffOnly !== true).map(itemScope)),
|
|
223
|
+
matched: matched(groupKey(group)),
|
|
124
224
|
});
|
|
125
|
-
group.items.
|
|
126
|
-
|
|
225
|
+
const items = group.items.filter((item) => shown(item.id));
|
|
226
|
+
items.forEach((item, itemIndex) => {
|
|
227
|
+
const isLastItem = itemIndex === items.length - 1;
|
|
127
228
|
rows.push({
|
|
128
229
|
kind: "item",
|
|
129
230
|
label: item.label,
|
|
@@ -141,8 +242,9 @@ export function buildInjectionRows(snapshot: InjectionSnapshot): InjectionRow[]
|
|
|
141
242
|
diffOnly: item.diffOnly,
|
|
142
243
|
...rowStatus(item),
|
|
143
244
|
itemId: item.id,
|
|
245
|
+
matched: matched(item.id),
|
|
144
246
|
});
|
|
145
|
-
const children = item.children ?? [];
|
|
247
|
+
const children = (item.children ?? []).filter((child) => shown(child.id));
|
|
146
248
|
children.forEach((child, childIndex) => {
|
|
147
249
|
rows.push({
|
|
148
250
|
kind: "item",
|
|
@@ -159,6 +261,7 @@ export function buildInjectionRows(snapshot: InjectionSnapshot): InjectionRow[]
|
|
|
159
261
|
requestOnly: child.requestOnly,
|
|
160
262
|
...rowStatus(child),
|
|
161
263
|
itemId: child.id,
|
|
264
|
+
matched: matched(child.id),
|
|
162
265
|
});
|
|
163
266
|
});
|
|
164
267
|
});
|
|
@@ -372,6 +475,12 @@ export class PreviewScroller {
|
|
|
372
475
|
return this.scrollBy(direction * Math.max(1, this.visibleCount - 1));
|
|
373
476
|
}
|
|
374
477
|
|
|
478
|
+
/** Leave a visible line in place; otherwise scroll it near the top, below `context` lines. */
|
|
479
|
+
public reveal(line: number, context: number): boolean {
|
|
480
|
+
if (line >= this.offsetValue && line < this.offsetValue + this.visibleCount) return false;
|
|
481
|
+
return this.scrollTo(line - context);
|
|
482
|
+
}
|
|
483
|
+
|
|
375
484
|
public reset(): void {
|
|
376
485
|
this.offsetValue = 0;
|
|
377
486
|
}
|