From 70edbbcb5cc2b38b164d1ddd44395b63bbef22f8 Mon Sep 17 00:00:00 2001 From: Leonardo Anders Date: Mon, 3 Nov 2025 18:11:36 -0300 Subject: [PATCH 1/3] Make Enter navigate and Tab insert in Cross-Entity Jump QuickPick --- package.json | 11 +++ .../commands/jumpToTagOffsetCrossEntity.ts | 75 ++++++++++++++----- 2 files changed, 66 insertions(+), 20 deletions(-) diff --git a/package.json b/package.json index 8ff5f8cc..275f3f7b 100644 --- a/package.json +++ b/package.json @@ -887,6 +887,12 @@ "command": "vscode-objectscript.ccs.jumpToTagOffsetCrossEntity", "title": "Go to Name + Offset ^ Item" }, + { + "category": "Consistem", + "command": "vscode-objectscript.ccs.jumpToTagOffsetCrossEntity.insertSelection", + "title": "Insert Selected Name (Consistem Quick Pick)", + "enablement": "inQuickOpen && vscode-objectscript.ccs.jumpToTagQuickPickActive" + }, { "category": "ObjectScript", "command": "vscode-objectscript.export", @@ -1300,6 +1306,11 @@ "mac": "Cmd+Q", "when": "editorTextFocus && editorLangId =~ /^objectscript/" }, + { + "command": "vscode-objectscript.ccs.jumpToTagOffsetCrossEntity.insertSelection", + "key": "Tab", + "when": "inQuickOpen && vscode-objectscript.ccs.jumpToTagQuickPickActive" + }, { "command": "vscode-objectscript.viewOthers", "key": "Ctrl+Shift+V", diff --git a/src/ccs/commands/jumpToTagOffsetCrossEntity.ts b/src/ccs/commands/jumpToTagOffsetCrossEntity.ts index 6f52ec9d..f2757aff 100644 --- a/src/ccs/commands/jumpToTagOffsetCrossEntity.ts +++ b/src/ccs/commands/jumpToTagOffsetCrossEntity.ts @@ -24,6 +24,9 @@ const ROUTINE_NAME_PATTERN = new RegExp(`^${IDENTIFIER_START}${IDENTIFIER_BODY}* const CLASS_METHOD_NAME_PATTERN = new RegExp(`^${IDENTIFIER_START}${IDENTIFIER_BODY}*$`); const ROUTINE_LABEL_NAME_PATTERN = new RegExp(`^[A-Za-z0-9_%][A-Za-z0-9_%]*$`); +const JUMP_QP_CONTEXT_KEY = "vscode-objectscript.ccs.jumpToTagQuickPickActive"; +const INSERT_SELECTION_COMMAND_ID = "vscode-objectscript.ccs.jumpToTagOffsetCrossEntity.insertSelection"; + type EntityKind = "class" | "routine" | "unknown"; interface LocalNameInfo { @@ -98,13 +101,32 @@ async function promptWithQuickPick( docCtx: DocContext ): Promise { const qp = vscode.window.createQuickPick(); - qp.title = "Consistem — Ir para Nome + Offset ^ Item"; + qp.title = "Consistem — Ir para Definição + Offset ^ Item"; qp.placeholder = docCtx.placeholder; qp.ignoreFocusOut = true; qp.matchOnDescription = true; qp.matchOnDetail = true; qp.canSelectMany = false; + const disposables: vscode.Disposable[] = []; + let cleanedUp = false; + + const cleanup = () => { + if (cleanedUp) return; + cleanedUp = true; + while (disposables.length) { + const d = disposables.pop(); + try { + d?.dispose(); + } catch { + // Ignore dispose errors. + } + } + void vscode.commands.executeCommand("setContext", JUMP_QP_CONTEXT_KEY, false); + }; + + void vscode.commands.executeCommand("setContext", JUMP_QP_CONTEXT_KEY, true); + let lastParse: ParseSuccess | undefined; let lastValidatedValue: string | undefined; let currentValidationId = 0; @@ -152,6 +174,34 @@ async function promptWithQuickPick( return p; } + const applySelectedItemToValue = ({ revalidate }: { revalidate?: boolean } = {}): boolean => { + const picked = qp.selectedItems[0] ?? qp.activeItems[0]; + if (!picked) return false; + + const trimmed = qp.value.trim(); + const normalized = replaceNameInExpression(trimmed, picked.label); + if (normalized === qp.value) return false; + + qp.value = normalized; + + try { + (qp as any).selectedItems = []; + } catch { + // Ignore errors from manipulating QuickPick internals. + } + + if (revalidate && qp.value.trim() !== "") { + void runValidation(qp.value, localNames, docCtx, false); + } + + return true; + }; + + const insertSelectionDisposable = vscode.commands.registerCommand(INSERT_SELECTION_COMMAND_ID, () => { + applySelectedItemToValue({ revalidate: true }); + }); + disposables.push(insertSelectionDisposable); + qp.onDidChangeValue((value) => { if (value.trim() === "") { lastParse = undefined; @@ -164,24 +214,9 @@ async function promptWithQuickPick( const accepted = new Promise((resolve) => { qp.onDidAccept(async () => { - const trimmed = qp.value.trim(); + applySelectedItemToValue(); - if (qp.selectedItems.length) { - const picked = qp.selectedItems[0]; - const normalized = replaceNameInExpression(trimmed, picked.label); - if (normalized !== trimmed) { - qp.value = normalized; - - try { - (qp as any).selectedItems = []; - } catch { - // Ignore errors from manipulating QuickPick internals. - } - - if (qp.value.trim() !== "") void runValidation(qp.value, localNames, docCtx, false); - return; - } - } + const trimmed = qp.value.trim(); if (trimmed === "") { vscode.window.showErrorMessage(ERR_NAME_REQUIRED); @@ -200,13 +235,13 @@ async function promptWithQuickPick( if (!lastParse) return; resolve(lastParse); - qp.hide(); + cleanup(); qp.dispose(); }); qp.onDidHide(() => { resolve(undefined); - qp.dispose(); + cleanup(); }); }); From a441f35f4254e42c1e5ddd3830596539b7c15db1 Mon Sep 17 00:00:00 2001 From: Leonardo Anders Date: Mon, 3 Nov 2025 20:19:51 -0300 Subject: [PATCH 2/3] Add informative QuickPick header showing keyboard shortcuts (Tab to insert, Enter to navigate) --- src/ccs/commands/jumpToTagOffsetCrossEntity.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/ccs/commands/jumpToTagOffsetCrossEntity.ts b/src/ccs/commands/jumpToTagOffsetCrossEntity.ts index f2757aff..04df7528 100644 --- a/src/ccs/commands/jumpToTagOffsetCrossEntity.ts +++ b/src/ccs/commands/jumpToTagOffsetCrossEntity.ts @@ -249,9 +249,20 @@ async function promptWithQuickPick( return accepted; } +/** + * Adds a non-selectable tip at the top of the QuickPick using a separator. + * This avoids interfering with selection while informing the shortcuts. + */ function buildLocalItems(localNames: LocalNamesMap): vscode.QuickPickItem[] { + // Non-selectable header (separator) with the tip + const infoSeparator = { + label: "Tab ↹ Inserir • Enter ↩ Navegar", + kind: vscode.QuickPickItemKind.Separator, + } as unknown as vscode.QuickPickItem; // keep type compatible for qp.items + if (!localNames.size) { return [ + infoSeparator, { label: "Nenhum nome local encontrado", description: "—", @@ -261,13 +272,16 @@ function buildLocalItems(localNames: LocalNamesMap): vscode.QuickPickItem[] { ]; } - return [...localNames.values()] + const items = [...localNames.values()] .sort((a, b) => a.line - b.line || a.originalName.localeCompare(b.originalName)) .map((info) => ({ label: info.originalName, description: "definição local", detail: `linha ${info.line + 1}`, })); + + // Keep the informative separator on top. + return [infoSeparator, ...items]; } /** Replaces only the "name" portion in the expression, preserving +offset and ^item. */ From 40d1be88783d369e7349f9affd8be09f88dafe75 Mon Sep 17 00:00:00 2001 From: Leonardo Anders Date: Mon, 3 Nov 2025 21:38:04 -0300 Subject: [PATCH 3/3] Implement dynamic navigation with full label highlighting and scroll reveal behavior --- package.json | 4 +- src/ccs/commands/createItem.ts | 2 +- .../commands/jumpToTagOffsetCrossEntity.ts | 183 +++++++++++++++--- 3 files changed, 159 insertions(+), 30 deletions(-) diff --git a/package.json b/package.json index 275f3f7b..05f52ed0 100644 --- a/package.json +++ b/package.json @@ -885,12 +885,12 @@ { "category": "Consistem", "command": "vscode-objectscript.ccs.jumpToTagOffsetCrossEntity", - "title": "Go to Name + Offset ^ Item" + "title": "Go to Definition (+Offset ^Item)" }, { "category": "Consistem", "command": "vscode-objectscript.ccs.jumpToTagOffsetCrossEntity.insertSelection", - "title": "Insert Selected Name (Consistem Quick Pick)", + "title": "Insert Selection (Quick Pick)", "enablement": "inQuickOpen && vscode-objectscript.ccs.jumpToTagQuickPickActive" }, { diff --git a/src/ccs/commands/createItem.ts b/src/ccs/commands/createItem.ts index 694915ae..f77969e4 100644 --- a/src/ccs/commands/createItem.ts +++ b/src/ccs/commands/createItem.ts @@ -18,7 +18,7 @@ async function promptForItemName(options: PromptForItemNameOptions = {}): Promis const hasBadChars = (s: string) => /[\\/]/.test(s) || /\s/.test(s); const ib = vscode.window.createInputBox(); - ib.title = "Consistem — Criar Item"; + ib.title = "Criar Item"; ib.prompt = "Informe o nome da classe ou rotina a ser criada (.cls ou .mac)"; ib.placeholder = "MeuPacote.MinhaClasse.cls ou MINHAROTINA.mac"; ib.ignoreFocusOut = true; diff --git a/src/ccs/commands/jumpToTagOffsetCrossEntity.ts b/src/ccs/commands/jumpToTagOffsetCrossEntity.ts index 04df7528..af8183c9 100644 --- a/src/ccs/commands/jumpToTagOffsetCrossEntity.ts +++ b/src/ccs/commands/jumpToTagOffsetCrossEntity.ts @@ -26,12 +26,16 @@ const ROUTINE_LABEL_NAME_PATTERN = new RegExp(`^[A-Za-z0-9_%][A-Za-z0-9_%]*$`); const JUMP_QP_CONTEXT_KEY = "vscode-objectscript.ccs.jumpToTagQuickPickActive"; const INSERT_SELECTION_COMMAND_ID = "vscode-objectscript.ccs.jumpToTagOffsetCrossEntity.insertSelection"; +const QUICK_PICK_OVERLAY_LINE_PADDING = 6; +const EXTRA_LINES_BELOW_QP = 2; type EntityKind = "class" | "routine" | "unknown"; interface LocalNameInfo { readonly line: number; readonly originalName: string; + readonly selectionRange?: vscode.Range; + readonly blockRange?: vscode.Range; } type LocalNamesMap = Map; @@ -80,7 +84,14 @@ export async function jumpToTagAndOffsetCrossEntity(): Promise { let pendingValidationError: string | undefined; while (true) { - const parsed = await promptWithQuickPick(previousValue, pendingValidationError, localNames, docCtx); + const parsed = await promptWithQuickPick( + previousValue, + pendingValidationError, + localNames, + docCtx, + document, + editor + ); if (!parsed) return; previousValue = parsed.input; @@ -98,10 +109,18 @@ async function promptWithQuickPick( previousValue: string | undefined, initialValidationError: string | undefined, localNames: LocalNamesMap, - docCtx: DocContext + docCtx: DocContext, + document: vscode.TextDocument, + editor: vscode.TextEditor ): Promise { + // Remember where the user was before opening the QuickPick, + // so we can restore on ESC (cancel). + const originalSelection = editor.selection; + const originalVisible = editor.visibleRanges?.[0]; + let wasAccepted = false; + const qp = vscode.window.createQuickPick(); - qp.title = "Consistem — Ir para Definição + Offset ^ Item"; + qp.title = "Navegar para Definição (+Offset ^Item)"; qp.placeholder = docCtx.placeholder; qp.ignoreFocusOut = true; qp.matchOnDescription = true; @@ -111,6 +130,74 @@ async function promptWithQuickPick( const disposables: vscode.Disposable[] = []; let cleanedUp = false; + const blockHighlightDecoration = vscode.window.createTextEditorDecorationType({ + backgroundColor: new vscode.ThemeColor("editor.rangeHighlightBackground"), + isWholeLine: true, + }); + disposables.push(blockHighlightDecoration); + + const highlightDecoration = vscode.window.createTextEditorDecorationType({ + borderColor: new vscode.ThemeColor("editor.selectionHighlightBorder"), + borderStyle: "solid", + borderWidth: "1px", + }); + disposables.push(highlightDecoration); + + let lastHighlightedRange: vscode.Range | undefined; + let lastHighlightedBlockRange: vscode.Range | undefined; + + const clearHighlight = () => { + if (!lastHighlightedRange && !lastHighlightedBlockRange) return; + lastHighlightedRange = undefined; + lastHighlightedBlockRange = undefined; + editor.setDecorations(highlightDecoration, []); + editor.setDecorations(blockHighlightDecoration, []); + }; + + const highlightInfo = (info?: LocalNameInfo) => { + if (!info) { + clearHighlight(); + return; + } + + const range = info.selectionRange ?? document.lineAt(info.line).range; + const blockRange = info.blockRange ?? range; + lastHighlightedRange = range; + lastHighlightedBlockRange = blockRange; + editor.setDecorations(blockHighlightDecoration, [blockRange]); + editor.setDecorations(highlightDecoration, [range]); + + // Keep highlighted block below the QuickPick overlay. + // We derive a dynamic padding from the current visible height, + // falling back to the fixed constant when needed. + const visible = editor.visibleRanges?.[0]; + const visibleHeight = visible + ? Math.max(0, visible.end.line - visible.start.line) + : QUICK_PICK_OVERLAY_LINE_PADDING * 3; + const dynamicGap = Math.floor(visibleHeight * 0.35); + const gap = Math.max(QUICK_PICK_OVERLAY_LINE_PADDING, dynamicGap) + EXTRA_LINES_BELOW_QP; + + const revealStartLine = Math.max(blockRange.start.line - gap, 0); + const revealRangeStart = new vscode.Position(revealStartLine, 0); + const revealRange = new vscode.Range(revealRangeStart, blockRange.end); + editor.revealRange(revealRange, vscode.TextEditorRevealType.AtTop); + }; + + const updateHighlightFromItem = (item: vscode.QuickPickItem | undefined) => { + if (!item) { + clearHighlight(); + return; + } + + // Ignore tip item (first blank row) + if ((item as any).__isTipItem) { + clearHighlight(); + return; + } + const info = localNames.get(item.label.toLowerCase()); + highlightInfo(info); + }; + const cleanup = () => { if (cleanedUp) return; cleanedUp = true; @@ -122,6 +209,7 @@ async function promptWithQuickPick( // Ignore dispose errors. } } + clearHighlight(); void vscode.commands.executeCommand("setContext", JUMP_QP_CONTEXT_KEY, false); }; @@ -134,10 +222,17 @@ async function promptWithQuickPick( qp.value = previousValue ?? ""; - const localItems: vscode.QuickPickItem[] = buildLocalItems(localNames); + const { items: localItems, tipItem } = buildLocalItems(localNames); const setItems = () => (qp.items = localItems); setItems(); + try { + (qp as any).activeItems = [tipItem]; + (qp as any).selectedItems = []; + } catch { + /* ignore */ + } + if (initialValidationError) { vscode.window.showErrorMessage(initialValidationError); } else if (qp.value.trim() !== "") { @@ -178,6 +273,8 @@ async function promptWithQuickPick( const picked = qp.selectedItems[0] ?? qp.activeItems[0]; if (!picked) return false; + if ((picked as any).__isTipItem) return false; + const trimmed = qp.value.trim(); const normalized = replaceNameInExpression(trimmed, picked.label); if (normalized === qp.value) return false; @@ -202,10 +299,21 @@ async function promptWithQuickPick( }); disposables.push(insertSelectionDisposable); + const changeActiveDisposable = qp.onDidChangeActive((items) => { + updateHighlightFromItem(items[0]); + }); + disposables.push(changeActiveDisposable); + + const changeSelectionDisposable = qp.onDidChangeSelection((items) => { + updateHighlightFromItem(items[0]); + }); + disposables.push(changeSelectionDisposable); + qp.onDidChangeValue((value) => { if (value.trim() === "") { lastParse = undefined; lastValidatedValue = undefined; + clearHighlight(); return; } @@ -235,11 +343,24 @@ async function promptWithQuickPick( if (!lastParse) return; resolve(lastParse); + wasAccepted = true; cleanup(); qp.dispose(); }); qp.onDidHide(() => { + // If user cancelled (ESC), restore cursor and viewport. + if (!wasAccepted) { + try { + editor.selection = originalSelection; + if (originalVisible) { + // Use Default so VS Code restores without forcing center/top. + editor.revealRange(originalVisible, vscode.TextEditorRevealType.Default); + } + } catch { + /* ignore */ + } + } resolve(undefined); cleanup(); }); @@ -249,27 +370,32 @@ async function promptWithQuickPick( return accepted; } -/** - * Adds a non-selectable tip at the top of the QuickPick using a separator. - * This avoids interfering with selection while informing the shortcuts. - */ -function buildLocalItems(localNames: LocalNamesMap): vscode.QuickPickItem[] { - // Non-selectable header (separator) with the tip - const infoSeparator = { - label: "Tab ↹ Inserir • Enter ↩ Navegar", - kind: vscode.QuickPickItemKind.Separator, - } as unknown as vscode.QuickPickItem; // keep type compatible for qp.items +function buildLocalItems(localNames: LocalNamesMap): { + items: vscode.QuickPickItem[]; + tipItem: vscode.QuickPickItem; +} { + const tipItem: vscode.QuickPickItem = { + label: "", + description: "Tab ↹ Inserir • Enter ↩ Navegar", + detail: "", + alwaysShow: true, + } as vscode.QuickPickItem; + + (tipItem as any).__isTipItem = true; if (!localNames.size) { - return [ - infoSeparator, - { - label: "Nenhum nome local encontrado", - description: "—", - detail: "Defina métodos/labels no arquivo atual para listá-los aqui.", - alwaysShow: true, - }, - ]; + return { + tipItem, + items: [ + tipItem, + { + label: "Nenhum nome local encontrado", + description: "—", + detail: "Defina métodos/labels no arquivo atual para listá-los aqui.", + alwaysShow: true, + }, + ], + }; } const items = [...localNames.values()] @@ -277,11 +403,9 @@ function buildLocalItems(localNames: LocalNamesMap): vscode.QuickPickItem[] { .map((info) => ({ label: info.originalName, description: "definição local", - detail: `linha ${info.line + 1}`, })); - // Keep the informative separator on top. - return [infoSeparator, ...items]; + return { tipItem, items: [tipItem, ...items] }; } /** Replaces only the "name" portion in the expression, preserving +offset and ^item. */ @@ -389,7 +513,12 @@ async function collectLocalNames(document: vscode.TextDocument): Promise