Skip to content

Commit cb95e50

Browse files
authored
Refine Cross-Entity Jump QuickPick navigation and UX (#53)
* Make Enter navigate and Tab insert in Cross-Entity Jump QuickPick * Add informative QuickPick header showing keyboard shortcuts (Tab to insert, Enter to navigate) * Implement dynamic navigation with full label highlighting and scroll reveal behavior
1 parent 7742929 commit cb95e50

File tree

3 files changed

+226
-37
lines changed

3 files changed

+226
-37
lines changed

package.json

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -885,7 +885,13 @@
885885
{
886886
"category": "Consistem",
887887
"command": "vscode-objectscript.ccs.jumpToTagOffsetCrossEntity",
888-
"title": "Go to Name + Offset ^ Item"
888+
"title": "Go to Definition (+Offset ^Item)"
889+
},
890+
{
891+
"category": "Consistem",
892+
"command": "vscode-objectscript.ccs.jumpToTagOffsetCrossEntity.insertSelection",
893+
"title": "Insert Selection (Quick Pick)",
894+
"enablement": "inQuickOpen && vscode-objectscript.ccs.jumpToTagQuickPickActive"
889895
},
890896
{
891897
"category": "ObjectScript",
@@ -1300,6 +1306,11 @@
13001306
"mac": "Cmd+Q",
13011307
"when": "editorTextFocus && editorLangId =~ /^objectscript/"
13021308
},
1309+
{
1310+
"command": "vscode-objectscript.ccs.jumpToTagOffsetCrossEntity.insertSelection",
1311+
"key": "Tab",
1312+
"when": "inQuickOpen && vscode-objectscript.ccs.jumpToTagQuickPickActive"
1313+
},
13031314
{
13041315
"command": "vscode-objectscript.viewOthers",
13051316
"key": "Ctrl+Shift+V",

src/ccs/commands/createItem.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ async function promptForItemName(options: PromptForItemNameOptions = {}): Promis
1818
const hasBadChars = (s: string) => /[\\/]/.test(s) || /\s/.test(s);
1919

2020
const ib = vscode.window.createInputBox();
21-
ib.title = "Consistem — Criar Item";
21+
ib.title = "Criar Item";
2222
ib.prompt = "Informe o nome da classe ou rotina a ser criada (.cls ou .mac)";
2323
ib.placeholder = "MeuPacote.MinhaClasse.cls ou MINHAROTINA.mac";
2424
ib.ignoreFocusOut = true;

src/ccs/commands/jumpToTagOffsetCrossEntity.ts

Lines changed: 213 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,18 @@ const ROUTINE_NAME_PATTERN = new RegExp(`^${IDENTIFIER_START}${IDENTIFIER_BODY}*
2424
const CLASS_METHOD_NAME_PATTERN = new RegExp(`^${IDENTIFIER_START}${IDENTIFIER_BODY}*$`);
2525
const ROUTINE_LABEL_NAME_PATTERN = new RegExp(`^[A-Za-z0-9_%][A-Za-z0-9_%]*$`);
2626

27+
const JUMP_QP_CONTEXT_KEY = "vscode-objectscript.ccs.jumpToTagQuickPickActive";
28+
const INSERT_SELECTION_COMMAND_ID = "vscode-objectscript.ccs.jumpToTagOffsetCrossEntity.insertSelection";
29+
const QUICK_PICK_OVERLAY_LINE_PADDING = 6;
30+
const EXTRA_LINES_BELOW_QP = 2;
31+
2732
type EntityKind = "class" | "routine" | "unknown";
2833

2934
interface LocalNameInfo {
3035
readonly line: number;
3136
readonly originalName: string;
37+
readonly selectionRange?: vscode.Range;
38+
readonly blockRange?: vscode.Range;
3239
}
3340

3441
type LocalNamesMap = Map<string, LocalNameInfo>;
@@ -77,7 +84,14 @@ export async function jumpToTagAndOffsetCrossEntity(): Promise<void> {
7784
let pendingValidationError: string | undefined;
7885

7986
while (true) {
80-
const parsed = await promptWithQuickPick(previousValue, pendingValidationError, localNames, docCtx);
87+
const parsed = await promptWithQuickPick(
88+
previousValue,
89+
pendingValidationError,
90+
localNames,
91+
docCtx,
92+
document,
93+
editor
94+
);
8195
if (!parsed) return;
8296

8397
previousValue = parsed.input;
@@ -95,27 +109,130 @@ async function promptWithQuickPick(
95109
previousValue: string | undefined,
96110
initialValidationError: string | undefined,
97111
localNames: LocalNamesMap,
98-
docCtx: DocContext
112+
docCtx: DocContext,
113+
document: vscode.TextDocument,
114+
editor: vscode.TextEditor
99115
): Promise<ParseSuccess | undefined> {
116+
// Remember where the user was before opening the QuickPick,
117+
// so we can restore on ESC (cancel).
118+
const originalSelection = editor.selection;
119+
const originalVisible = editor.visibleRanges?.[0];
120+
let wasAccepted = false;
121+
100122
const qp = vscode.window.createQuickPick<vscode.QuickPickItem>();
101-
qp.title = "Consistem — Ir para Nome + Offset ^ Item";
123+
qp.title = "Navegar para Definição (+Offset ^Item)";
102124
qp.placeholder = docCtx.placeholder;
103125
qp.ignoreFocusOut = true;
104126
qp.matchOnDescription = true;
105127
qp.matchOnDetail = true;
106128
qp.canSelectMany = false;
107129

130+
const disposables: vscode.Disposable[] = [];
131+
let cleanedUp = false;
132+
133+
const blockHighlightDecoration = vscode.window.createTextEditorDecorationType({
134+
backgroundColor: new vscode.ThemeColor("editor.rangeHighlightBackground"),
135+
isWholeLine: true,
136+
});
137+
disposables.push(blockHighlightDecoration);
138+
139+
const highlightDecoration = vscode.window.createTextEditorDecorationType({
140+
borderColor: new vscode.ThemeColor("editor.selectionHighlightBorder"),
141+
borderStyle: "solid",
142+
borderWidth: "1px",
143+
});
144+
disposables.push(highlightDecoration);
145+
146+
let lastHighlightedRange: vscode.Range | undefined;
147+
let lastHighlightedBlockRange: vscode.Range | undefined;
148+
149+
const clearHighlight = () => {
150+
if (!lastHighlightedRange && !lastHighlightedBlockRange) return;
151+
lastHighlightedRange = undefined;
152+
lastHighlightedBlockRange = undefined;
153+
editor.setDecorations(highlightDecoration, []);
154+
editor.setDecorations(blockHighlightDecoration, []);
155+
};
156+
157+
const highlightInfo = (info?: LocalNameInfo) => {
158+
if (!info) {
159+
clearHighlight();
160+
return;
161+
}
162+
163+
const range = info.selectionRange ?? document.lineAt(info.line).range;
164+
const blockRange = info.blockRange ?? range;
165+
lastHighlightedRange = range;
166+
lastHighlightedBlockRange = blockRange;
167+
editor.setDecorations(blockHighlightDecoration, [blockRange]);
168+
editor.setDecorations(highlightDecoration, [range]);
169+
170+
// Keep highlighted block below the QuickPick overlay.
171+
// We derive a dynamic padding from the current visible height,
172+
// falling back to the fixed constant when needed.
173+
const visible = editor.visibleRanges?.[0];
174+
const visibleHeight = visible
175+
? Math.max(0, visible.end.line - visible.start.line)
176+
: QUICK_PICK_OVERLAY_LINE_PADDING * 3;
177+
const dynamicGap = Math.floor(visibleHeight * 0.35);
178+
const gap = Math.max(QUICK_PICK_OVERLAY_LINE_PADDING, dynamicGap) + EXTRA_LINES_BELOW_QP;
179+
180+
const revealStartLine = Math.max(blockRange.start.line - gap, 0);
181+
const revealRangeStart = new vscode.Position(revealStartLine, 0);
182+
const revealRange = new vscode.Range(revealRangeStart, blockRange.end);
183+
editor.revealRange(revealRange, vscode.TextEditorRevealType.AtTop);
184+
};
185+
186+
const updateHighlightFromItem = (item: vscode.QuickPickItem | undefined) => {
187+
if (!item) {
188+
clearHighlight();
189+
return;
190+
}
191+
192+
// Ignore tip item (first blank row)
193+
if ((item as any).__isTipItem) {
194+
clearHighlight();
195+
return;
196+
}
197+
const info = localNames.get(item.label.toLowerCase());
198+
highlightInfo(info);
199+
};
200+
201+
const cleanup = () => {
202+
if (cleanedUp) return;
203+
cleanedUp = true;
204+
while (disposables.length) {
205+
const d = disposables.pop();
206+
try {
207+
d?.dispose();
208+
} catch {
209+
// Ignore dispose errors.
210+
}
211+
}
212+
clearHighlight();
213+
void vscode.commands.executeCommand("setContext", JUMP_QP_CONTEXT_KEY, false);
214+
};
215+
216+
void vscode.commands.executeCommand("setContext", JUMP_QP_CONTEXT_KEY, true);
217+
108218
let lastParse: ParseSuccess | undefined;
109219
let lastValidatedValue: string | undefined;
110220
let currentValidationId = 0;
111221
let lastValidationPromise: Promise<void> | undefined;
112222

113223
qp.value = previousValue ?? "";
114224

115-
const localItems: vscode.QuickPickItem[] = buildLocalItems(localNames);
225+
const { items: localItems, tipItem } = buildLocalItems(localNames);
116226
const setItems = () => (qp.items = localItems);
117227
setItems();
118228

229+
try {
230+
(qp as any).activeItems = [tipItem];
231+
(qp as any).selectedItems = [];
232+
} catch {
233+
/* ignore */
234+
}
235+
119236
if (initialValidationError) {
120237
vscode.window.showErrorMessage(initialValidationError);
121238
} else if (qp.value.trim() !== "") {
@@ -152,10 +269,51 @@ async function promptWithQuickPick(
152269
return p;
153270
}
154271

272+
const applySelectedItemToValue = ({ revalidate }: { revalidate?: boolean } = {}): boolean => {
273+
const picked = qp.selectedItems[0] ?? qp.activeItems[0];
274+
if (!picked) return false;
275+
276+
if ((picked as any).__isTipItem) return false;
277+
278+
const trimmed = qp.value.trim();
279+
const normalized = replaceNameInExpression(trimmed, picked.label);
280+
if (normalized === qp.value) return false;
281+
282+
qp.value = normalized;
283+
284+
try {
285+
(qp as any).selectedItems = [];
286+
} catch {
287+
// Ignore errors from manipulating QuickPick internals.
288+
}
289+
290+
if (revalidate && qp.value.trim() !== "") {
291+
void runValidation(qp.value, localNames, docCtx, false);
292+
}
293+
294+
return true;
295+
};
296+
297+
const insertSelectionDisposable = vscode.commands.registerCommand(INSERT_SELECTION_COMMAND_ID, () => {
298+
applySelectedItemToValue({ revalidate: true });
299+
});
300+
disposables.push(insertSelectionDisposable);
301+
302+
const changeActiveDisposable = qp.onDidChangeActive((items) => {
303+
updateHighlightFromItem(items[0]);
304+
});
305+
disposables.push(changeActiveDisposable);
306+
307+
const changeSelectionDisposable = qp.onDidChangeSelection((items) => {
308+
updateHighlightFromItem(items[0]);
309+
});
310+
disposables.push(changeSelectionDisposable);
311+
155312
qp.onDidChangeValue((value) => {
156313
if (value.trim() === "") {
157314
lastParse = undefined;
158315
lastValidatedValue = undefined;
316+
clearHighlight();
159317
return;
160318
}
161319

@@ -164,24 +322,9 @@ async function promptWithQuickPick(
164322

165323
const accepted = new Promise<ParseSuccess | undefined>((resolve) => {
166324
qp.onDidAccept(async () => {
167-
const trimmed = qp.value.trim();
168-
169-
if (qp.selectedItems.length) {
170-
const picked = qp.selectedItems[0];
171-
const normalized = replaceNameInExpression(trimmed, picked.label);
172-
if (normalized !== trimmed) {
173-
qp.value = normalized;
174-
175-
try {
176-
(qp as any).selectedItems = [];
177-
} catch {
178-
// Ignore errors from manipulating QuickPick internals.
179-
}
325+
applySelectedItemToValue();
180326

181-
if (qp.value.trim() !== "") void runValidation(qp.value, localNames, docCtx, false);
182-
return;
183-
}
184-
}
327+
const trimmed = qp.value.trim();
185328

186329
if (trimmed === "") {
187330
vscode.window.showErrorMessage(ERR_NAME_REQUIRED);
@@ -200,39 +343,69 @@ async function promptWithQuickPick(
200343
if (!lastParse) return;
201344

202345
resolve(lastParse);
203-
qp.hide();
346+
wasAccepted = true;
347+
cleanup();
204348
qp.dispose();
205349
});
206350

207351
qp.onDidHide(() => {
352+
// If user cancelled (ESC), restore cursor and viewport.
353+
if (!wasAccepted) {
354+
try {
355+
editor.selection = originalSelection;
356+
if (originalVisible) {
357+
// Use Default so VS Code restores without forcing center/top.
358+
editor.revealRange(originalVisible, vscode.TextEditorRevealType.Default);
359+
}
360+
} catch {
361+
/* ignore */
362+
}
363+
}
208364
resolve(undefined);
209-
qp.dispose();
365+
cleanup();
210366
});
211367
});
212368

213369
qp.show();
214370
return accepted;
215371
}
216372

217-
function buildLocalItems(localNames: LocalNamesMap): vscode.QuickPickItem[] {
373+
function buildLocalItems(localNames: LocalNamesMap): {
374+
items: vscode.QuickPickItem[];
375+
tipItem: vscode.QuickPickItem;
376+
} {
377+
const tipItem: vscode.QuickPickItem = {
378+
label: "",
379+
description: "Tab ↹ Inserir • Enter ↩ Navegar",
380+
detail: "",
381+
alwaysShow: true,
382+
} as vscode.QuickPickItem;
383+
384+
(tipItem as any).__isTipItem = true;
385+
218386
if (!localNames.size) {
219-
return [
220-
{
221-
label: "Nenhum nome local encontrado",
222-
description: "—",
223-
detail: "Defina métodos/labels no arquivo atual para listá-los aqui.",
224-
alwaysShow: true,
225-
},
226-
];
387+
return {
388+
tipItem,
389+
items: [
390+
tipItem,
391+
{
392+
label: "Nenhum nome local encontrado",
393+
description: "—",
394+
detail: "Defina métodos/labels no arquivo atual para listá-los aqui.",
395+
alwaysShow: true,
396+
},
397+
],
398+
};
227399
}
228400

229-
return [...localNames.values()]
401+
const items = [...localNames.values()]
230402
.sort((a, b) => a.line - b.line || a.originalName.localeCompare(b.originalName))
231403
.map((info) => ({
232404
label: info.originalName,
233405
description: "definição local",
234-
detail: `linha ${info.line + 1}`,
235406
}));
407+
408+
return { tipItem, items: [tipItem, ...items] };
236409
}
237410

238411
/** Replaces only the "name" portion in the expression, preserving +offset and ^item. */
@@ -340,7 +513,12 @@ async function collectLocalNames(document: vscode.TextDocument): Promise<LocalNa
340513
const line = symbol.selectionRange?.start.line ?? symbol.range.start.line;
341514
const key = symbol.name.toLowerCase();
342515
if (!map.has(key)) {
343-
map.set(key, { line, originalName: symbol.name });
516+
map.set(key, {
517+
line,
518+
originalName: symbol.name,
519+
selectionRange: symbol.selectionRange ?? symbol.range,
520+
blockRange: symbol.range,
521+
});
344522
}
345523
}
346524
if (symbol.children?.length) pending.push(...symbol.children);

0 commit comments

Comments
 (0)