diff --git a/package.json b/package.json index 8ff5f8cc..05f52ed0 100644 --- a/package.json +++ b/package.json @@ -885,7 +885,13 @@ { "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 Selection (Quick Pick)", + "enablement": "inQuickOpen && vscode-objectscript.ccs.jumpToTagQuickPickActive" }, { "category": "ObjectScript", @@ -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/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 6f52ec9d..af8183c9 100644 --- a/src/ccs/commands/jumpToTagOffsetCrossEntity.ts +++ b/src/ccs/commands/jumpToTagOffsetCrossEntity.ts @@ -24,11 +24,18 @@ 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"; +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; @@ -77,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; @@ -95,16 +109,112 @@ 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 Nome + Offset ^ Item"; + qp.title = "Navegar 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 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; + while (disposables.length) { + const d = disposables.pop(); + try { + d?.dispose(); + } catch { + // Ignore dispose errors. + } + } + clearHighlight(); + 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; @@ -112,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() !== "") { @@ -152,10 +269,51 @@ async function promptWithQuickPick( return p; } + const applySelectedItemToValue = ({ revalidate }: { revalidate?: boolean } = {}): boolean => { + 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; + + 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); + + 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; } @@ -164,24 +322,9 @@ async function promptWithQuickPick( const accepted = new Promise((resolve) => { qp.onDidAccept(async () => { - const trimmed = qp.value.trim(); - - 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. - } + applySelectedItemToValue(); - 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 +343,26 @@ async function promptWithQuickPick( if (!lastParse) return; resolve(lastParse); - qp.hide(); + 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); - qp.dispose(); + cleanup(); }); }); @@ -214,25 +370,42 @@ async function promptWithQuickPick( return accepted; } -function buildLocalItems(localNames: LocalNamesMap): vscode.QuickPickItem[] { +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 [ - { - 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, + }, + ], + }; } - 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}`, })); + + return { tipItem, items: [tipItem, ...items] }; } /** Replaces only the "name" portion in the expression, preserving +offset and ^item. */ @@ -340,7 +513,12 @@ async function collectLocalNames(document: vscode.TextDocument): Promise