Skip to content

Commit 6f0a5e3

Browse files
authored
@en element-ref autocomplete from the last snapshot (#11)
SnapshotIndex subscribes to ReplayRunner events. On start it captures the per-step commands from event.steps. On a stepSuccess where the corresponding command is snapshot, it parses event.stdout with ^@(eN)\s+\[type\](?:\s+"label")? and replaces its cache, firing onDidChange. ElementRefCompletionProvider triggers on @ and returns the cached refs whenever the cursor is right after @\w*. Each item shows the ref id as the label, type · "label" as the detail, and a small markdown popup with type/label fields. sortText preserves snapshot order so the picker mirrors the on-screen layout. Workflow: add `snapshot -i` early in the .ad, run that line via the Run CodeLens, then typing @ anywhere downstream lists refs like @e3 [text] "Sign in" — pick one and `click @e3` is filled in.
1 parent 9cf1e78 commit 6f0a5e3

3 files changed

Lines changed: 176 additions & 2 deletions

File tree

src/extension.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { PlatformDiagnostics } from './diagnostics/platformValidator';
1515
import { RunOutputPanel } from './panels/runOutputPanel';
1616
import { SettingsPanel } from './panels/settingsPanel';
1717
import { CommandCompletionProvider } from './providers/completionProvider';
18+
import { ElementRefCompletionProvider } from './providers/elementRefCompletionProvider';
1819
import { CommandHoverProvider } from './providers/hoverProvider';
1920
import { RunStepCodeLensProvider } from './providers/runStepCodeLensProvider';
2021
import { ValueCompletionProvider } from './providers/valueCompletionProvider';
@@ -24,6 +25,7 @@ import { ReplayRunner } from './runners/replayRunner';
2425
import { AdFileIndex } from './services/adFileIndex';
2526
import { AgentDeviceConfig } from './services/config';
2627
import { DeviceCatalog, type DeviceEntry } from './services/deviceCatalog';
28+
import { SnapshotIndex } from './services/snapshotIndex';
2729
import { AgentDeviceTestController } from './testing/agentDeviceTestController';
2830
import { formatDuration } from './util/duration';
2931
import { pluralize } from './util/pluralize';
@@ -51,7 +53,10 @@ export function activate(context: vscode.ExtensionContext): void {
5153
const reportWriter = new HtmlReportWriter(runner, config);
5254
context.subscriptions.push(reportWriter);
5355

54-
registerLanguageProviders(context);
56+
const snapshotIndex = new SnapshotIndex(runner);
57+
context.subscriptions.push(snapshotIndex);
58+
59+
registerLanguageProviders(context, snapshotIndex);
5560
registerDiagnostics(context);
5661
registerRunOutputPanel(context, runner, fileIndex, reportWriter);
5762
registerSettingsPanel(context, config);
@@ -206,7 +211,10 @@ function registerSettingsCommand(context: vscode.ExtensionContext): void {
206211
);
207212
}
208213

209-
function registerLanguageProviders(context: vscode.ExtensionContext): void {
214+
function registerLanguageProviders(
215+
context: vscode.ExtensionContext,
216+
snapshotIndex: SnapshotIndex,
217+
): void {
210218
const completion = new CommandCompletionProvider(
211219
COMMANDS,
212220
DIRECTIVES,
@@ -240,6 +248,15 @@ function registerLanguageProviders(context: vscode.ExtensionContext): void {
240248
),
241249
);
242250

251+
const elementRefs = new ElementRefCompletionProvider(snapshotIndex);
252+
context.subscriptions.push(
253+
vscode.languages.registerCompletionItemProvider(
254+
LANGUAGE_ID,
255+
elementRefs,
256+
...ElementRefCompletionProvider.triggerCharacters,
257+
),
258+
);
259+
243260
const hover = new CommandHoverProvider(COMMAND_BY_NAME, DIRECTIVE_BY_NAME);
244261
context.subscriptions.push(vscode.languages.registerHoverProvider(LANGUAGE_ID, hover));
245262

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import * as vscode from 'vscode';
2+
3+
import type { SnapshotIndex } from '../services/snapshotIndex';
4+
5+
const TRIGGER_PATTERN = /@\w*$/;
6+
7+
export class ElementRefCompletionProvider implements vscode.CompletionItemProvider {
8+
static readonly triggerCharacters: readonly string[] = ['@'];
9+
10+
constructor(private readonly index: SnapshotIndex) {}
11+
12+
provideCompletionItems(
13+
document: vscode.TextDocument,
14+
position: vscode.Position,
15+
): vscode.CompletionItem[] {
16+
const refs = this.index.refs;
17+
if (refs.length === 0) {
18+
return [];
19+
}
20+
const linePrefix = document.lineAt(position.line).text.slice(0, position.character);
21+
if (!TRIGGER_PATTERN.test(linePrefix)) {
22+
return [];
23+
}
24+
return refs.map((ref, idx) => {
25+
const item = new vscode.CompletionItem(`@${ref.id}`, vscode.CompletionItemKind.Reference);
26+
item.insertText = `@${ref.id}`;
27+
item.detail = formatDetail(ref);
28+
item.sortText = `00${idx.toString().padStart(4, '0')}`;
29+
const md = new vscode.MarkdownString(formatMarkdown(ref));
30+
md.supportHtml = false;
31+
item.documentation = md;
32+
return item;
33+
});
34+
}
35+
}
36+
37+
function formatDetail(ref: { type?: string; label?: string }): string {
38+
if (ref.type && ref.label) {
39+
return `${ref.type} · ${ref.label}`;
40+
}
41+
if (ref.type) {
42+
return ref.type;
43+
}
44+
if (ref.label) {
45+
return ref.label;
46+
}
47+
return 'element ref';
48+
}
49+
50+
function formatMarkdown(ref: { id: string; type?: string; label?: string }): string {
51+
const lines = ['*From the most recent snapshot.*'];
52+
if (ref.type) {
53+
lines.push(`Type: \`${ref.type}\``);
54+
}
55+
if (ref.label) {
56+
lines.push(`Label: \`${ref.label}\``);
57+
}
58+
return lines.join('\n\n');
59+
}

src/services/snapshotIndex.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import * as vscode from 'vscode';
2+
3+
import type { ReplayEvent, ReplayRunner } from '../runners/replayRunner';
4+
5+
export interface SnapshotRef {
6+
readonly id: string;
7+
readonly type?: string;
8+
readonly label?: string;
9+
}
10+
11+
const REF_LINE = /^\s*@(e\d+)\s+\[([^\]]+)\](?:\s+"((?:[^"\\]|\\.)*)")?/;
12+
13+
export class SnapshotIndex implements vscode.Disposable {
14+
private readonly emitter = new vscode.EventEmitter<void>();
15+
private readonly disposables: vscode.Disposable[] = [];
16+
private currentRefs: readonly SnapshotRef[] = [];
17+
private currentScriptPath: string | null = null;
18+
private stepCommands: readonly string[] = [];
19+
private currentRunScriptPath: string | null = null;
20+
21+
readonly onDidChange = this.emitter.event;
22+
23+
constructor(runner: ReplayRunner) {
24+
this.disposables.push(runner.onEvent((event) => this.handleEvent(event)));
25+
}
26+
27+
dispose(): void {
28+
this.emitter.dispose();
29+
for (const disposable of this.disposables) {
30+
disposable.dispose();
31+
}
32+
}
33+
34+
get refs(): readonly SnapshotRef[] {
35+
return this.currentRefs;
36+
}
37+
38+
get scriptPath(): string | null {
39+
return this.currentScriptPath;
40+
}
41+
42+
private handleEvent(event: ReplayEvent): void {
43+
switch (event.type) {
44+
case 'start':
45+
this.stepCommands = event.steps.map((s) => firstToken(s.display));
46+
this.currentRunScriptPath = event.scriptPath;
47+
break;
48+
case 'stepSuccess': {
49+
const command = this.stepCommands[event.index];
50+
if (command !== 'snapshot' || !this.currentRunScriptPath) {
51+
return;
52+
}
53+
const refs = parseSnapshotRefs(event.stdout);
54+
if (refs.length === 0) {
55+
return;
56+
}
57+
this.currentRefs = refs;
58+
this.currentScriptPath = this.currentRunScriptPath;
59+
this.emitter.fire();
60+
break;
61+
}
62+
case 'end':
63+
this.stepCommands = [];
64+
this.currentRunScriptPath = null;
65+
break;
66+
}
67+
}
68+
}
69+
70+
export function parseSnapshotRefs(stdout: string): SnapshotRef[] {
71+
const refs: SnapshotRef[] = [];
72+
const seen = new Set<string>();
73+
for (const line of stdout.split(/\r?\n/)) {
74+
const match = REF_LINE.exec(line);
75+
if (!match) {
76+
continue;
77+
}
78+
const [, id, type, label] = match;
79+
if (!id || seen.has(id)) {
80+
continue;
81+
}
82+
seen.add(id);
83+
refs.push({
84+
id,
85+
type: type?.trim(),
86+
label: label ? unescapeLabel(label) : undefined,
87+
});
88+
}
89+
return refs;
90+
}
91+
92+
function firstToken(line: string): string {
93+
return line.split(/\s+/)[0] ?? '';
94+
}
95+
96+
function unescapeLabel(value: string): string {
97+
return value.replace(/\\(.)/g, '$1');
98+
}

0 commit comments

Comments
 (0)