diff --git a/client/src/extension.ts b/client/src/extension.ts index adf7f12..3f7e8f6 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -9,6 +9,7 @@ import { registerWebview } from "./services/webview"; import { registerHover } from "./services/hover"; import { registerEvents } from "./services/events"; import { registerAutocomplete } from "./services/autocomplete"; +import { registerCodeLens } from "./services/codelens"; import { runLanguageServer, stopLanguageServer } from "./lsp/server"; import { runClient, stopClient } from "./lsp/client"; @@ -25,6 +26,7 @@ export async function activate(context: vscode.ExtensionContext) { registerEvents(context); registerWebview(context); registerAutocomplete(context); + registerCodeLens(context); registerHover(); await applyItalicOverlay(); await startExtension(context); @@ -100,4 +102,3 @@ export async function restartExtension(context: vscode.ExtensionContext) { // start again await startExtension(context); } - diff --git a/client/src/services/codelens.ts b/client/src/services/codelens.ts new file mode 100644 index 0000000..ac02e0d --- /dev/null +++ b/client/src/services/codelens.ts @@ -0,0 +1,41 @@ +import * as vscode from "vscode"; +import { extension } from "../state"; +import type { LJDiagnostic } from "../types/diagnostics"; +import { getDiagnosticRevealTarget } from "../webview/diagnostic-reveal"; +import { normalizeFilePath } from "../utils/utils"; + +const codeLensEmitter = new vscode.EventEmitter(); + +export function registerCodeLens(context: vscode.ExtensionContext) { + context.subscriptions.push( + vscode.languages.registerCodeLensProvider("java", { + onDidChangeCodeLenses: codeLensEmitter.event, + provideCodeLenses(document) { + const file = normalizeFilePath(document.uri.fsPath); + return (extension.diagnostics || []) + .map(diagnostic => createDiagnosticCodeLens(diagnostic, file)) + .filter((codeLens): codeLens is vscode.CodeLens => Boolean(codeLens)); + } + }) + ); +} + +export function refreshCodeLenses() { + codeLensEmitter.fire(); +} + +function createDiagnosticCodeLens(diagnostic: LJDiagnostic, file: string): vscode.CodeLens | undefined { + const targetDiagnostic = getDiagnosticRevealTarget(diagnostic); + if (!targetDiagnostic || targetDiagnostic.file !== file) return undefined; + + const position = targetDiagnostic.position; + const range = new vscode.Range( + new vscode.Position(position.lineStart, position.colStart), + new vscode.Position(position.lineStart, position.colStart) + ); + return new vscode.CodeLens(range, { + title: diagnostic.title, + command: "liquidjava.showView", + arguments: [targetDiagnostic] + }); +} diff --git a/client/src/services/diagnostics.ts b/client/src/services/diagnostics.ts index 92b3f9e..0f79831 100644 --- a/client/src/services/diagnostics.ts +++ b/client/src/services/diagnostics.ts @@ -3,6 +3,7 @@ import { extension } from "../state"; import { LJDiagnostic } from "../types/diagnostics"; import { StatusBarState, updateStatusBar } from "./status-bar"; import { updateErrorAtCursor } from "./context"; +import { refreshCodeLenses } from "./codelens"; /** * Handles LiquidJava diagnostics received from the language server @@ -13,6 +14,7 @@ export function handleLJDiagnostics(diagnostics: LJDiagnostic[]) { const statusBarState: StatusBarState = containsError ? "failed" : "passed"; updateStatusBar(statusBarState); extension.diagnostics = diagnostics; + refreshCodeLenses(); updateErrorAtCursor(); extension.webview?.sendMessage({ type: "diagnostics", diagnostics }); if (extension.context) diff --git a/client/src/services/hover.ts b/client/src/services/hover.ts index 4780d2f..5b39ce6 100644 --- a/client/src/services/hover.ts +++ b/client/src/services/hover.ts @@ -26,12 +26,6 @@ export function registerHover() { } } - const diagnostics = vscode.languages.getDiagnostics(document.uri); - const containsDiagnostic = !!diagnostics.find(d => d.range.contains(position) && d.source === 'liquidjava'); - if (containsDiagnostic) { - if (hoverContent.value.length > 0) hoverContent.appendMarkdown(`\n\n`); - hoverContent.appendMarkdown(`[Open LiquidJava view](command:liquidjava.showView) for more details.`); - } if (hoverContent.value.length === 0) return null; return new vscode.Hover(hoverContent); } diff --git a/client/src/services/webview.ts b/client/src/services/webview.ts index adbcca6..b07ceb5 100644 --- a/client/src/services/webview.ts +++ b/client/src/services/webview.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode"; import { LiquidJavaWebviewProvider } from "../webview/provider"; import { extension } from "../state"; +import type { DiagnosticRevealTarget } from "../types/diagnostics"; /** * Initializes the webview panel for the extension @@ -15,8 +16,9 @@ export function registerWebview(context: vscode.ExtensionContext) { ); // show view command context.subscriptions.push( - vscode.commands.registerCommand("liquidjava.showView", async () => { + vscode.commands.registerCommand("liquidjava.showView", async (diagnostic?: DiagnosticRevealTarget) => { await vscode.commands.executeCommand("liquidJavaView.focus"); + if (diagnostic) extension.webview.sendMessage({ type: "revealDiagnostic", diagnostic }); }) ); // listen for messages from the webview diff --git a/client/src/types/diagnostics.ts b/client/src/types/diagnostics.ts index 320a2e1..cdc395b 100644 --- a/client/src/types/diagnostics.ts +++ b/client/src/types/diagnostics.ts @@ -110,4 +110,9 @@ export type ExternalMethodNotFoundWarning = BaseDiagnostic & { overloads: string[]; } -export type RefinementMismatchError = RefinementError | StateRefinementError; \ No newline at end of file +export type RefinementMismatchError = RefinementError | StateRefinementError; + +export type DiagnosticRevealTarget = { + file: string; + position: SourcePosition; +} diff --git a/client/src/webview/diagnostic-reveal.ts b/client/src/webview/diagnostic-reveal.ts new file mode 100644 index 0000000..a30cfd8 --- /dev/null +++ b/client/src/webview/diagnostic-reveal.ts @@ -0,0 +1,31 @@ +import type { DiagnosticRevealTarget, LJDiagnostic } from "../types/diagnostics"; + +export function getDiagnosticRevealTargetKey(target: DiagnosticRevealTarget): string { + const { lineStart, colStart, lineEnd, colEnd } = target.position; + return `${target.file}:${lineStart}:${colStart}-${lineEnd}:${colEnd}`; +} + +export function getDiagnosticRevealTarget(diagnostic: LJDiagnostic): DiagnosticRevealTarget | undefined { + if (!diagnostic.position) return undefined; + return { + file: diagnostic.position.file || diagnostic.file, + position: diagnostic.position + }; +} + +export function getDiagnosticRevealTargetFromKey(value?: string): DiagnosticRevealTarget | undefined { + const match = value?.match(/^(.*):(\d+):(\d+)-(\d+):(\d+)$/); + if (!match) return undefined; + + const [, file, lineStart, colStart, lineEnd, colEnd] = match; + return { + file, + position: { + file, + lineStart: parseInt(lineStart, 10), + colStart: parseInt(colStart, 10), + lineEnd: parseInt(lineEnd, 10), + colEnd: parseInt(colEnd, 10) + } + }; +} diff --git a/client/src/webview/script.ts b/client/src/webview/script.ts index c71fb28..db1f169 100644 --- a/client/src/webview/script.ts +++ b/client/src/webview/script.ts @@ -9,6 +9,12 @@ import type { NavTab } from "./views/sections"; import { copyDiagnosticToClipboard, getDisplayDiagnostics, renderDiagnosticsView } from "./views/diagnostics/diagnostics"; import type { LJContext } from "../types/context"; import { ContextSectionState, renderContextView } from "./views/context/context"; +import type { DiagnosticRevealTarget } from "../types/diagnostics"; +import { getDiagnosticRevealTargetFromKey, getDiagnosticRevealTargetKey } from "./diagnostic-reveal"; + +type VSCodeApi = { + postMessage(message: unknown): void; +}; /** * Initializes the webview script @@ -16,8 +22,9 @@ import { ContextSectionState, renderContextView } from "./views/context/context" * @param document * @param window */ -export function getScript(vscode: any, document: any, window: any) { +export function getScript(vscode: VSCodeApi, document: Document, window: Window) { const root = document.getElementById('root'); + if (!root) return; let diagnostics: LJDiagnostic[] = []; let showAllDiagnostics = false; let currentFile: string; @@ -28,6 +35,7 @@ export function getScript(vscode: any, document: any, window: any) { let selectedTab: NavTab = 'diagnostics'; let diagramOrientation: "LR" | "TB" = "TB"; let currentDiagram: string = ''; + let revealTimeout: ReturnType | undefined; const contextSectionState: ContextSectionState = { aliases: false, ghosts: false, @@ -39,8 +47,8 @@ export function getScript(vscode: any, document: any, window: any) { vscode.postMessage({ type: 'ready' }); // on click - root.addEventListener('click', (e: any) => { - const target = e.target as any; + root.addEventListener('click', (e: MouseEvent) => { + const target = e.target instanceof Element ? e.target : null; if (!target) return; // context section toggle @@ -90,6 +98,17 @@ export function getScript(vscode: any, document: any, window: any) { return; } + // reveal failing refinement diagnostic + if (target.classList.contains('diagnostic-reveal-btn')) { + e.preventDefault(); + e.stopPropagation(); + + const revealTarget = getDiagnosticRevealTargetFromKey(target.getAttribute('data-diagnostic-target')); + if (!revealTarget) return; + revealDiagnostic(revealTarget); + return; + } + // derivation expansion click if (target.classList.contains('derivable-node')) { e.stopPropagation(); @@ -244,6 +263,9 @@ export function getScript(vscode: any, document: any, window: any) { errorAtCursor = msg.errorAtCursor as RefinementMismatchError; if (selectedTab === 'context') updateView(); break; + case 'revealDiagnostic': + revealDiagnostic(msg.diagnostic as DiagnosticRevealTarget); + break; } }); @@ -266,4 +288,31 @@ export function getScript(vscode: any, document: any, window: any) { break; } } + + function revealDiagnostic(target: DiagnosticRevealTarget) { + selectedTab = 'diagnostics'; + + const isVisibleInCurrentFile = showAllDiagnostics || !target.file || target.file.toLowerCase() === currentFile?.toLowerCase(); + if (!isVisibleInCurrentFile) { + showAllDiagnostics = true; + } + + updateView(); + const element = Array.from(root.querySelectorAll('.diagnostic-item')).find(item => + item.getAttribute('data-diagnostic-target') === getDiagnosticRevealTargetKey(target) + ); + if (!element) return; + + const previousRevealed = root.querySelector('.diagnostic-item.revealed'); + if (previousRevealed) previousRevealed.classList.remove('revealed'); + if (revealTimeout) clearTimeout(revealTimeout); + + element.classList.add('revealed'); + element.scrollIntoView({ block: 'center', behavior: 'smooth' }); + revealTimeout = setTimeout(() => { + element.classList.remove('revealed'); + revealTimeout = undefined; + }, 1800); + } + } diff --git a/client/src/webview/styles.ts b/client/src/webview/styles.ts index 3120b55..93012a8 100644 --- a/client/src/webview/styles.ts +++ b/client/src/webview/styles.ts @@ -124,6 +124,30 @@ export function getStyles(): string { opacity: 0.8; cursor: default; } + .diagnostic-item.revealed { + outline: 2px solid var(--vscode-focusBorder); + animation: diagnostic-reveal-flash 1.8s ease-out; + } + @keyframes diagnostic-reveal-flash { + 0% { + box-shadow: 0 0 0 0 var(--vscode-focusBorder); + transform: translateX(0); + } + 12% { + box-shadow: 0 0 0 3px var(--vscode-focusBorder); + transform: translateX(3px); + } + 24% { + transform: translateX(0); + } + 55% { + box-shadow: 0 0 0 2px var(--vscode-focusBorder); + } + 100% { + box-shadow: 0 0 0 0 transparent; + transform: translateX(0); + } + } .error-item { border-left: 4px solid var(--vscode-editorError-foreground); } @@ -335,15 +359,10 @@ export function getStyles(): string { .context-variables-table th:first-child { padding-left: calc(0.75rem + 0.8rem); } - - .context-variables-table th:last-child, - .context-section table td:last-child { - text-align: left; - } .context-variables-table td.failing-refinement { text-align: center; } - .failing-refinement .highlight-var-btn { + .failing-refinement .diagnostic-reveal-btn { display: flex; justify-content: center; width: 100%; @@ -375,26 +394,30 @@ export function getStyles(): string { .context-section-content.collapsed { display: none; } - .highlight-var-btn { + .highlight-var-btn, + .diagnostic-reveal-btn { background-color: transparent; border: none; transition: background-color 0.1s; text-align: left; padding: 0.2rem 0.8rem; } - .highlight-var-btn code { + .highlight-var-btn code, + .diagnostic-reveal-btn code { pointer-events: none; } .highlight-var-btn.selected { background-color: var(--vscode-button-background); } - .highlight-var-btn.error { + .highlight-var-btn.error, + .diagnostic-reveal-btn.error { background-color: #d6382f; } .highlight-var-btn.error.selected { background-color: #c92e26; } - .highlight-var-btn.error:hover { + .highlight-var-btn.error:hover, + .diagnostic-reveal-btn.error:hover { background-color: #c92e26; } .diagram-section { diff --git a/client/src/webview/views/context/variables.ts b/client/src/webview/views/context/variables.ts index 27eeef8..d44a07d 100644 --- a/client/src/webview/views/context/variables.ts +++ b/client/src/webview/views/context/variables.ts @@ -1,6 +1,6 @@ import { LJVariable } from "../../../types/context"; import { RefinementMismatchError } from "../../../types/diagnostics"; -import { renderToggleSection, renderHighlightButton } from "../sections"; +import { renderToggleSection, renderHighlightButton, renderDiagnosticRevealButton } from "../sections"; export function renderContextVariables(variables: LJVariable[], isExpanded: boolean, errorAtCursor?: RefinementMismatchError): string { const expected = errorAtCursor ? errorAtCursor.type == "refinement-error" ? errorAtCursor.expected.value : errorAtCursor.expected : undefined; @@ -27,8 +27,8 @@ export function renderContextVariables(variables: LJVariable[], isExpanded: bool ${variable.refinement} `).join('')} - ${errorAtCursor ? /*html*/` - ${renderHighlightButton(errorAtCursor.position, '⊢ ' + expected, true)}` + ${errorAtCursor?.position ? /*html*/` + ${renderDiagnosticRevealButton(errorAtCursor.position, '⊢ ' + expected)}` : ''} diff --git a/client/src/webview/views/diagnostics/errors.ts b/client/src/webview/views/diagnostics/errors.ts index 64f6e08..6e0c6af 100644 --- a/client/src/webview/views/diagnostics/errors.ts +++ b/client/src/webview/views/diagnostics/errors.ts @@ -1,4 +1,4 @@ -import { renderDiagnosticHeader, renderLocation, renderSection, renderCustomSection, renderTranslationTable, } from "../sections"; +import { renderDiagnosticDataAttributes, renderDiagnosticHeader, renderLocation, renderSection, renderCustomSection, renderTranslationTable, } from "../sections"; import { renderDerivationNode } from "./derivation-nodes"; import type { ArgumentMismatchError, @@ -19,7 +19,7 @@ export function renderErrors(errors: LJError[], expandedErrors: Set): st ${errors.map((error, index) => { const isExpanded = expandedErrors.has(index); return /*html*/` -
  • +
  • ${renderCopyDiagnosticButton('error', index)} ${renderError(error, index, isExpanded)}
  • diff --git a/client/src/webview/views/diagnostics/warnings.ts b/client/src/webview/views/diagnostics/warnings.ts index dc0cf24..20c8051 100644 --- a/client/src/webview/views/diagnostics/warnings.ts +++ b/client/src/webview/views/diagnostics/warnings.ts @@ -1,12 +1,12 @@ import type { ExternalClassNotFoundWarning, ExternalMethodNotFoundWarning, LJWarning } from "../../../types/diagnostics"; -import { renderDiagnosticHeader, renderLocation, renderSection } from "../sections"; +import { renderDiagnosticDataAttributes, renderDiagnosticHeader, renderLocation, renderSection } from "../sections"; import { renderCopyDiagnosticButton } from "./diagnostics"; export function renderWarnings(warnings: LJWarning[]): string { return /*html*/`
      - ${warnings.map((warning, index) => /*html*/` -
    • + ${warnings.map((warning, index) => /*html*/` +
    • ${renderCopyDiagnosticButton('warning', index)} ${renderWarning(warning)}
    • diff --git a/client/src/webview/views/sections.ts b/client/src/webview/views/sections.ts index 04d30de..2188455 100644 --- a/client/src/webview/views/sections.ts +++ b/client/src/webview/views/sections.ts @@ -1,4 +1,5 @@ import type { LJDiagnostic, PlacementInCode, SourcePosition, TranslationTable } from "../../types/diagnostics"; +import { getDiagnosticRevealTarget, getDiagnosticRevealTargetKey } from "../diagnostic-reveal"; export const renderMainHeader = (title: string, selectedTab: NavTab): string => /*html*/`
      @@ -23,6 +24,11 @@ export const renderToggleSection = (title: string, targetId: string, isExpanded: export const renderDiagnosticHeader = (title: string, message: string): string => /*html*/ `

      ${title}

      ${message}

      `; +export function renderDiagnosticDataAttributes(diagnostic: LJDiagnostic): string { + const target = getDiagnosticRevealTarget(diagnostic); + return target ? `data-diagnostic-target="${getDiagnosticRevealTargetKey(target)}"` : ""; +} + export const renderLocation = (diagnostic: LJDiagnostic): string => { if (!diagnostic.position || !diagnostic.file) return ""; return renderCustomSection("Location", /*html*/`
      ${renderLocationLink(diagnostic.position)}
      `); @@ -72,6 +78,17 @@ export function renderHighlightButton(position: SourcePosition, content: string, `; } +export function renderDiagnosticRevealButton(position: SourcePosition, content: string): string { + return /*html*/` + + `; +} + export function renderLocationLink(position?: SourcePosition): string { if (!position) return 'No location'; return /*html*/`