From f0d57a7a6ae5ee24b41d2c45895e0e38c4e9c0e7 Mon Sep 17 00:00:00 2001 From: Catarina Gamboa Date: Wed, 11 Nov 2026 23:35:25 +0000 Subject: [PATCH 1/3] Hints out of the hover by setting, and a usage log of the view for user studies Two settings: - liquidjava.hover.showHints (default true). Off: the editor hover leaves the hints out and says they are in the LiquidJava view, where they stay closed until opened. - liquidjava.usageLog (default empty): a JSON Lines file to which the extension appends what is used in the view: tab switches, hints opened or closed, Found steps, locations, the state machine (from the view or the code lens), the Verification/Context reveal buttons, the diagnostics scope, the diagram options and copies. One line per use, with the time. The webview posts a usage message for each of these; services/usage-log.ts writes it, and the openFile message of a location click is logged as location. Nothing is written when the setting is empty, and a write failure goes to the LiquidJava output channel only. Co-Authored-By: Claude Fable 5.1 --- client/package.json | 10 +++++ client/src/extension.ts | 2 + client/src/services/hover.ts | 7 +++- client/src/services/usage-log.ts | 49 +++++++++++++++++++++++ client/src/services/webview.ts | 5 ++- client/src/utils/message-markdown.spec.ts | 9 ++++- client/src/utils/message-markdown.ts | 11 ++++- client/src/utils/usage-log.spec.ts | 21 ++++++++++ client/src/utils/usage-log.ts | 48 ++++++++++++++++++++++ client/src/webview/script.spec.ts | 21 ++++++++++ client/src/webview/script.ts | 22 +++++++++- 11 files changed, 198 insertions(+), 7 deletions(-) create mode 100644 client/src/services/usage-log.ts create mode 100644 client/src/utils/usage-log.spec.ts create mode 100644 client/src/utils/usage-log.ts diff --git a/client/package.json b/client/package.json index db78374..dcd7e78 100644 --- a/client/package.json +++ b/client/package.json @@ -64,6 +64,16 @@ "type": "boolean", "default": true, "description": "Apply italic style to LiquidJava injected tokens on top of any active theme" + }, + "liquidjava.hover.showHints": { + "type": "boolean", + "default": true, + "description": "Show the hints of an error in the editor hover. When off, the hover says that the hints are in the LiquidJava view, where they stay closed until opened." + }, + "liquidjava.usageLog": { + "type": "string", + "default": "", + "description": "Path of a JSON Lines file to which the extension appends what is used in the LiquidJava view: tabs, hints opened, Found steps, locations, state machine, copies. Empty: nothing is written. Meant for user studies." } } }, diff --git a/client/src/extension.ts b/client/src/extension.ts index 3bb3d20..bf148f9 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -7,6 +7,7 @@ import { registerCommands } from "./services/commands"; import { registerStatusBar, updateStatusBar } from "./services/status-bar"; import { registerWebview } from "./services/webview"; import { registerHover } from "./services/hover"; +import { registerUsageLog } from "./services/usage-log"; import { registerEvents } from "./services/events"; import { registerAutocomplete } from "./services/autocomplete"; import { refreshCodeLenses, registerCodeLens } from "./services/codelens"; @@ -27,6 +28,7 @@ export async function activate(context: vscode.ExtensionContext): Promise formatDiagnosticMarkdown(diagnostic, toLocationLink)) + .map(diagnostic => formatDiagnosticMarkdown(diagnostic, toLocationLink, { hints: showHintsInHover() })) .filter((markdown): markdown is string => markdown !== null) .map(markdown => new vscode.MarkdownString(markdown)); } +// liquidjava.hover.showHints: off keeps the hints in the view only, closed until opened (user studies) +function showHintsInHover(): boolean { + return vscode.workspace.getConfiguration().get('liquidjava.hover.showHints', true); +} + function getHoveredVariable(document: vscode.TextDocument, position: vscode.Position): LJVariable | null { if (!extension.context) return null; diff --git a/client/src/services/usage-log.ts b/client/src/services/usage-log.ts new file mode 100644 index 0000000..cfbc3be --- /dev/null +++ b/client/src/services/usage-log.ts @@ -0,0 +1,49 @@ +import * as vscode from "vscode"; +import * as fs from "fs"; +import * as path from "path"; +import { extension } from "../state"; +import { formatUsageLine, usageEventFor, type UsageEvent } from "../utils/usage-log"; + +const SETTING = "liquidjava.usageLog"; + +/** + * Appends what is used in the LiquidJava view to the file named by liquidjava.usageLog (JSON Lines), + * for user studies. Nothing is written when the setting is empty. A failure to write never reaches the user. + * @param context The extension context + */ +export function registerUsageLog(context: vscode.ExtensionContext) { + let file = currentFile(); + context.subscriptions.push(vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(SETTING)) file = currentFile(); + })); + if (extension.webview) { + context.subscriptions.push(extension.webview.onDidReceiveMessage(message => { + const event = usageEventFor(message); + if (event) append(file, event); + })); + } +} + +/** + * Logs a use that does not come through the webview, such as the "Show in state machine" code lens + * @param event what was used + * @param detail the fields of the line + */ +export function logUsage(event: string, detail: Record = {}) { + append(currentFile(), { t: new Date().toISOString(), event, ...detail }); +} + +function currentFile(): string | null { + const value = vscode.workspace.getConfiguration().get(SETTING, "").trim(); + return value.length > 0 ? value : null; +} + +function append(file: string | null, event: UsageEvent) { + if (!file) return; + try { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.appendFileSync(file, formatUsageLine(event)); + } catch (error) { + extension.logger?.client.error(`usage log: cannot write ${file}: ${String(error)}`); + } +} diff --git a/client/src/services/webview.ts b/client/src/services/webview.ts index e4aab45..ce1d406 100644 --- a/client/src/services/webview.ts +++ b/client/src/services/webview.ts @@ -3,6 +3,7 @@ import { LiquidJavaWebviewProvider } from "../webview/provider"; import { extension } from "../state"; import type { DiagnosticRevealTarget } from "../types/diagnostics"; import type { NavTab } from "../webview/views/sections"; +import { logUsage } from "./usage-log"; /** * Initializes the webview panel for the extension @@ -23,7 +24,9 @@ export function registerWebview(context: vscode.ExtensionContext) { vscode.commands.registerCommand("liquidjava.showView", async (diagnostic?: DiagnosticRevealTarget, tab?: NavTab) => { const isVisible = extension.webview?.isVisible(); await vscode.commands.executeCommand("liquidJavaView.focus"); - if (!diagnostic) return; + if (!diagnostic) return; + // the code lenses above an error: its title opens the Verification tab, "Show in state machine" the State Machine tab + logUsage(tab === 'fsm' ? 'state-machine' : 'show-view', { from: 'code-lens', file: diagnostic.file, line: diagnostic.position.lineStart + 1 }); if (isVisible) { extension.webview?.sendMessage({ type: "revealDiagnostic", diagnostic, tab }); diff --git a/client/src/utils/message-markdown.spec.ts b/client/src/utils/message-markdown.spec.ts index d81bdf2..31e510b 100644 --- a/client/src/utils/message-markdown.spec.ts +++ b/client/src/utils/message-markdown.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { formatDiagnosticMarkdown, formatHintsMarkdown } from './message-markdown'; +import { HINTS_IN_VIEW, formatDiagnosticMarkdown, formatHintsMarkdown } from './message-markdown'; describe('hints in markdown', () => { it('keeps the code and links the lines, and escapes the rest', () => { @@ -47,6 +47,13 @@ describe('a diagnostic in markdown', () => { ].join('\n\n')); }); + it('says where the hints are instead of showing them when hints are off (liquidjava.hover.showHints)', () => { + const withHints = { category: 'error', type: 'custom-error', title: 'Error', message: 'a < b', hints: [{ kind: 'general', message: 'Call `f()` first' }], file: '', position: null, messageNotes: [], references: [] } as unknown as import('../types/diagnostics').LJDiagnostic; + expect(formatDiagnosticMarkdown(withHints, () => null, { hints: false })).toBe('a \\< b\n\n' + HINTS_IN_VIEW); + const without = { ...withHints, hints: [] } as unknown as import('../types/diagnostics').LJDiagnostic; + expect(formatDiagnosticMarkdown(without, () => null, { hints: false })).toBe('a \\< b'); + }); + it('is the headline alone when the diagnostic has no notes, counterexample or hints', () => { const diagnostic = { category: 'error', type: 'custom-error', title: 'Error', message: 'a < b', hints: [], file: '', position: null, messageNotes: [], references: [] } as unknown as import('../types/diagnostics').LJDiagnostic; expect(formatDiagnosticMarkdown(diagnostic, () => null)).toBe('a \\< b'); diff --git a/client/src/utils/message-markdown.ts b/client/src/utils/message-markdown.ts index e148824..ea734bb 100644 --- a/client/src/utils/message-markdown.ts +++ b/client/src/utils/message-markdown.ts @@ -4,19 +4,26 @@ import { groupHints, splitMessageText } from "./message-text"; // the link that opens a place in the source, or null if it has no file export type LinkTarget = (position: SourcePosition) => string | null; +// what the hover says instead of the hints when liquidjava.hover.showHints is off +export const HINTS_IN_VIEW = "**Hints**: in the LiquidJava view (Hints, under the error)."; + /** * Formats what the editor hover shows of a diagnostic, in markdown: its headline, the notes that explain it (such as * where a value comes from), the counterexample and the hints, with their code as inline code and the places they * name as links. VS Code's own part of the hover, which is plain text, shows only the diagnostic's title * @param linkTo the link that opens a place + * @param options hints: false leaves the hints out and says where they are (the LiquidJava view) when there are any * @returns the markdown, or null if the diagnostic has no headline */ -export function formatDiagnosticMarkdown(diagnostic: LJDiagnostic, linkTo: LinkTarget): string | null { +export function formatDiagnosticMarkdown(diagnostic: LJDiagnostic, linkTo: LinkTarget, options: { hints?: boolean } = {}): string | null { + const hints = options.hints === false + ? diagnostic.hints && diagnostic.hints.length > 0 ? HINTS_IN_VIEW : null + : formatHintsMarkdown(diagnostic.hints, diagnostic.references, linkTo); const parts = [ formatMessageMarkdown(diagnostic.message, diagnostic.references, linkTo), ...diagnostic.messageNotes.map(note => formatMessageMarkdown(note, diagnostic.references, linkTo)), formatCounterexampleMarkdown(diagnostic), - formatHintsMarkdown(diagnostic.hints, diagnostic.references, linkTo), + hints, ].filter((part): part is string => part !== null && part.length > 0); return parts.length > 0 ? parts.join("\n\n") : null; } diff --git a/client/src/utils/usage-log.spec.ts b/client/src/utils/usage-log.spec.ts new file mode 100644 index 0000000..e36494f --- /dev/null +++ b/client/src/utils/usage-log.spec.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { formatUsageLine, usageEventFor } from './usage-log'; + +describe('the usage log', () => { + const now = new Date('2026-11-12T10:00:00.000Z'); + + it('turns a usage message into a line with the time, the action as event, and its details', () => { + expect(usageEventFor({ type: 'usage', action: 'tab', tab: 'fsm' }, now)) + .toEqual({ t: '2026-11-12T10:00:00.000Z', event: 'tab', tab: 'fsm' }); + expect(formatUsageLine(usageEventFor({ type: 'usage', action: 'hints', open: true, error: 'e1' }, now)!)) + .toBe('{"t":"2026-11-12T10:00:00.000Z","event":"hints","open":true,"error":"e1"}\n'); + }); + + it('logs a click on a location as location, and nothing for the other messages', () => { + expect(usageEventFor({ type: 'openFile', filePath: '/w/M.java', line: 12, character: 4 }, now)) + .toEqual({ t: '2026-11-12T10:00:00.000Z', event: 'location', file: '/w/M.java', line: 12, character: 4 }); + expect(usageEventFor({ type: 'ready' }, now)).toBeNull(); + expect(usageEventFor({ type: 'highlight', range: null }, now)).toBeNull(); + expect(usageEventFor({ type: 'usage' }, now)).toBeNull(); + }); +}); diff --git a/client/src/utils/usage-log.ts b/client/src/utils/usage-log.ts new file mode 100644 index 0000000..58d9417 --- /dev/null +++ b/client/src/utils/usage-log.ts @@ -0,0 +1,48 @@ +/** + * The usage log (liquidjava.usageLog): one JSON line per use of the LiquidJava view, for user studies. + * This module is the pure part, so it can be tested without VS Code; services/usage-log.ts writes the file. + */ + +/** A message the webview posts when something in the view is used */ +export interface UsageMessage { + type: "usage"; + /** what was used: tab, hints, found-step, state-machine, state-error-step, reveal, context, copy, scope, diagram, zoom */ + action: string; + [detail: string]: unknown; +} + +/** What one line of the log holds */ +export interface UsageEvent { + t: string; + event: string; + [detail: string]: unknown; +} + +export function isUsageMessage(message: unknown): message is UsageMessage { + return typeof message === "object" && message !== null + && (message as { type?: unknown }).type === "usage" + && typeof (message as { action?: unknown }).action === "string"; +} + +/** + * The log line for a message from the webview, or null for messages that are not uses of the view. + * An openFile message (a click on a location in the view) is logged as `location` + * @param message what the webview posted + * @param now the time of the event + */ +export function usageEventFor(message: unknown, now: Date = new Date()): UsageEvent | null { + if (isUsageMessage(message)) { + const { type: _type, action, ...detail } = message; + return { t: now.toISOString(), event: action, ...detail }; + } + if (typeof message === "object" && message !== null && (message as { type?: unknown }).type === "openFile") { + const { filePath, line, character } = message as { filePath?: unknown; line?: unknown; character?: unknown }; + return { t: now.toISOString(), event: "location", file: filePath, line, character }; + } + return null; +} + +/** The line appended to the file: JSON and a newline */ +export function formatUsageLine(event: UsageEvent): string { + return JSON.stringify(event) + "\n"; +} diff --git a/client/src/webview/script.spec.ts b/client/src/webview/script.spec.ts index 247ee76..2267d89 100644 --- a/client/src/webview/script.spec.ts +++ b/client/src/webview/script.spec.ts @@ -141,6 +141,24 @@ describe('webview script', () => { expect(root.querySelector('.info')?.textContent).toContain('Running the LiquidJava verification'); }); + it('posts a usage message for a tab switch and for the hints toggle (liquidjava.usageLog)', () => { + showDiagnostics([{ ...diagnostic(currentFile, 'State error'), hints: [{ kind: 'general', message: 'Call `open()` first' }] }]); + receive({ type: 'context', context: context() }); + postMessage.mockClear(); + + click('[data-tab="context"]'); + expect(postMessage.mock.calls).toContainEqual([{ type: 'usage', action: 'tab', tab: 'context' }]); + + click('[data-tab="diagnostics"]'); + postMessage.mockClear(); + const details = root.querySelector('details[data-hint-key]'); + expect(details, 'the hints are rendered closed').not.toBeNull(); + expect(details!.open).toBe(false); + details!.open = true; + details!.dispatchEvent(new dom.window.Event('toggle')); + expect(postMessage.mock.calls).toContainEqual([{ type: 'usage', action: 'hints', open: true, error: details!.getAttribute('data-hint-key') }]); + }); + it('switches between diagnostics, context, and state machine tabs', async () => { showDiagnostics([diagnostic(currentFile, 'Current error')]); receive({ type: 'context', context: context() }); @@ -168,8 +186,11 @@ describe('webview script', () => { expect(root.querySelector('.diagram-container')).toBeNull(); expect(postMessage.mock.calls).toEqual([ [{ type: 'highlight', range: null }], + [{ type: 'usage', action: 'tab', tab: 'context' }], [{ type: 'highlight', range: null }], + [{ type: 'usage', action: 'tab', tab: 'fsm' }], [{ type: 'highlight', range: null }], + [{ type: 'usage', action: 'tab', tab: 'diagnostics' }], ]); }); diff --git a/client/src/webview/script.ts b/client/src/webview/script.ts index bce2a0d..e913933 100644 --- a/client/src/webview/script.ts +++ b/client/src/webview/script.ts @@ -53,6 +53,9 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window) vars: true, }; + // what the view's controls are used for goes to the extension, which logs it when liquidjava.usageLog is set + const usage = (action: string, detail: Record = {}) => vscode.postMessage({ type: 'usage', action, ...detail }); + // initial state root.innerHTML = renderLoading(); vscode.postMessage({ type: 'ready' }); @@ -62,7 +65,10 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window) root.addEventListener('toggle', (e: Event) => { const details = e.target as HTMLDetailsElement; const key = details.getAttribute?.('data-hint-key'); - if (key !== null && key !== undefined) setHintOpen(key, details.open); + if (key !== null && key !== undefined) { + setHintOpen(key, details.open); + usage('hints', { open: details.open, error: key }); + } }, true); root.addEventListener('click', (e: MouseEvent) => { @@ -89,6 +95,7 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window) const isExpanded = contextToggleButton.getAttribute('aria-expanded') !== 'false'; const nextExpanded = !isExpanded; + usage('context-section', { section: sectionId, open: nextExpanded }); if (sectionId === 'context-vars') contextSectionState.vars = nextExpanded; if (sectionId === 'context-ghosts') contextSectionState.ghosts = nextExpanded; if (sectionId === 'context-aliases') contextSectionState.aliases = nextExpanded; @@ -131,6 +138,7 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window) const revealTarget = getDiagnosticRevealTargetFromKey(target.getAttribute('data-diagnostic-target')); if (!revealTarget) return; + usage('reveal', { file: revealTarget.file, line: revealTarget.position.lineStart + 1 }); revealDiagnostic(revealTarget); return; } @@ -142,6 +150,7 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window) const revealTarget = getDiagnosticRevealTargetFromKey(diagnosticContextButton.getAttribute('data-diagnostic-target')); if (!revealTarget) return; + usage('context', { file: revealTarget.file, line: revealTarget.position.lineStart + 1 }); revealContextForDiagnostic(revealTarget); return; } @@ -155,6 +164,7 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window) const diagnostic = getDisplayDiagnostics(diagnostics ?? [], showAllDiagnostics, currentFile) .filter(d => d.category === 'error')[errorIndex]; if (diagnostic?.type !== 'state-refinement-error' || diagnostic.stateMachines.length === 0) return; + usage('state-machine', { from: 'view', error: errorIndex }); showStateError(diagnostic); return; } @@ -166,7 +176,7 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window) if ((stateErrorStepButton as HTMLButtonElement).disabled) return; const errors = getCurrentStateErrors(); const index = getShownStateError(errors).index + parseInt(stateErrorStepButton.getAttribute('data-step') || '0', 10); - if (errors[index]) showStateError(errors[index]); + if (errors[index]) { usage('state-error-step', { to: index }); showStateError(errors[index]); } return; } @@ -183,6 +193,7 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window) const vcImplicationStepButton = target.closest?.('.vc-step-btn'); if (vcImplicationStepButton) { e.stopPropagation(); + usage('found-step', { direction: vcImplicationStepButton.getAttribute('data-vc-step'), error: vcImplicationStepButton.getAttribute('data-error-id') }); handleVCImplicationStepClick(vcImplicationStepButton, () => { root.querySelector('.highlight-var-btn.selected')?.classList.remove('selected'); vscode.postMessage({ type: 'highlight', range: null }); @@ -194,6 +205,7 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window) if (target.id === 'show-all-button') { e.stopPropagation(); showAllDiagnostics = !showAllDiagnostics; + usage('scope', { workspace: showAllDiagnostics }); updateView(); return; } @@ -203,6 +215,7 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window) if (scopeButton) { e.stopPropagation(); showAllDiagnostics = scopeButton.dataset.diagnosticsScope === 'workspace'; + usage('scope', { workspace: showAllDiagnostics }); updateView(); return; } @@ -211,6 +224,7 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window) if (target.closest?.('#diagram-orientation-btn')) { e.stopPropagation(); diagramOrientation = diagramOrientation === "TB" ? "LR" : "TB"; + usage('diagram', { orientation: diagramOrientation }); resetZoom(document); updateView(); return; @@ -222,6 +236,7 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window) e.stopPropagation(); if ((diagramConditionsButton as HTMLButtonElement).disabled) return; showDiagramConditions = !showDiagramConditions; + usage('diagram', { conditions: showDiagramConditions }); updateView(); return; } @@ -230,6 +245,7 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window) if (target.closest?.('#diagram-focus-btn')) { e.stopPropagation(); focusDiagramOnError = !focusDiagramOnError; + usage('diagram', { focus: focusDiagramOnError }); updateView(); return; } @@ -305,6 +321,7 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window) const tab = target.getAttribute('data-tab') as NavTab; if (tab && tab !== selectedTab) { vscode.postMessage({ type: 'highlight', range: null }); + usage('tab', { tab }); selectedTab = tab; updateView(); } @@ -316,6 +333,7 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window) if (diagnosticCopyBtn) { e.preventDefault(); e.stopPropagation(); + usage('copy'); copyDiagnosticToClipboard(diagnosticCopyBtn, getDisplayDiagnostics(diagnostics || [], showAllDiagnostics, currentFile)); return; } From 8ee51bd57cf4e4eaaeedc0bc716bcd3109e8bf82 Mon Sep 17 00:00:00 2001 From: Catarina Gamboa Date: Wed, 11 Nov 2026 23:42:30 +0000 Subject: [PATCH 2/3] Switch tabs also when the click lands on the badge inside the tab Co-Authored-By: Claude Fable 5.1 --- client/src/webview/script.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/client/src/webview/script.ts b/client/src/webview/script.ts index e913933..bc0786c 100644 --- a/client/src/webview/script.ts +++ b/client/src/webview/script.ts @@ -315,10 +315,11 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window) return; } - // nav tab click - if (target.classList.contains('nav-tab')) { + // nav tab click (also on the badge inside the tab) + const navTab = target.closest?.('.nav-tab'); + if (navTab) { e.stopPropagation(); - const tab = target.getAttribute('data-tab') as NavTab; + const tab = navTab.getAttribute('data-tab') as NavTab; if (tab && tab !== selectedTab) { vscode.postMessage({ type: 'highlight', range: null }); usage('tab', { tab }); From 4bb0d419ec1fc3d133b527c9e711f27b50096250 Mon Sep 17 00:00:00 2001 From: Catarina Gamboa Date: Wed, 11 Nov 2026 23:44:21 +0000 Subject: [PATCH 3/3] Usage log: count a hints toggle only after the summary was clicked or keyed, write asynchronously in order A render of an open
fires toggle too (Codex review). The writes are chained promises, so a slow disk never stalls the extension host. The documented event list no longer names zoom, which is not instrumented. Co-Authored-By: Claude Fable 5.1 --- client/src/services/usage-log.ts | 18 ++++++++++++------ client/src/utils/usage-log.ts | 2 +- client/src/webview/script.spec.ts | 6 ++++++ client/src/webview/script.ts | 10 ++++++++-- 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/client/src/services/usage-log.ts b/client/src/services/usage-log.ts index cfbc3be..a3b13da 100644 --- a/client/src/services/usage-log.ts +++ b/client/src/services/usage-log.ts @@ -38,12 +38,18 @@ function currentFile(): string | null { return value.length > 0 ? value : null; } +// the writes are asynchronous, so a slow disk never stalls the extension host, and chained, so the lines keep their order +let pending: Promise = Promise.resolve(); + function append(file: string | null, event: UsageEvent) { if (!file) return; - try { - fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.appendFileSync(file, formatUsageLine(event)); - } catch (error) { - extension.logger?.client.error(`usage log: cannot write ${file}: ${String(error)}`); - } + const line = formatUsageLine(event); + pending = pending.then(async () => { + try { + await fs.promises.mkdir(path.dirname(file), { recursive: true }); + await fs.promises.appendFile(file, line); + } catch (error) { + extension.logger?.client.error(`usage log: cannot write ${file}: ${String(error)}`); + } + }); } diff --git a/client/src/utils/usage-log.ts b/client/src/utils/usage-log.ts index 58d9417..a167815 100644 --- a/client/src/utils/usage-log.ts +++ b/client/src/utils/usage-log.ts @@ -6,7 +6,7 @@ /** A message the webview posts when something in the view is used */ export interface UsageMessage { type: "usage"; - /** what was used: tab, hints, found-step, state-machine, state-error-step, reveal, context, copy, scope, diagram, zoom */ + /** what was used: tab, hints, found-step, state-machine, state-error-step, reveal, context, context-section, copy, scope, diagram */ action: string; [detail: string]: unknown; } diff --git a/client/src/webview/script.spec.ts b/client/src/webview/script.spec.ts index 2267d89..df0f2cb 100644 --- a/client/src/webview/script.spec.ts +++ b/client/src/webview/script.spec.ts @@ -154,6 +154,12 @@ describe('webview script', () => { const details = root.querySelector('details[data-hint-key]'); expect(details, 'the hints are rendered closed').not.toBeNull(); expect(details!.open).toBe(false); + // a toggle without a click or key on the summary is a render (an open hint drawn again), not a use + details!.open = true; + details!.dispatchEvent(new dom.window.Event('toggle')); + expect(postMessage.mock.calls.filter(([m]) => (m as { action?: string }).action === 'hints')).toEqual([]); + details!.open = false; + click('details[data-hint-key] > summary'); details!.open = true; details!.dispatchEvent(new dom.window.Event('toggle')); expect(postMessage.mock.calls).toContainEqual([{ type: 'usage', action: 'hints', open: true, error: details!.getAttribute('data-hint-key') }]); diff --git a/client/src/webview/script.ts b/client/src/webview/script.ts index bc0786c..8ded3b9 100644 --- a/client/src/webview/script.ts +++ b/client/src/webview/script.ts @@ -61,14 +61,20 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window) vscode.postMessage({ type: 'ready' }); // on click - // toggle does not bubble, so it is caught on its way down + // toggle does not bubble, so it is caught on its way down. A render of an open
fires it too, so only a + // toggle right after the summary was clicked or keyed counts as the user opening or closing the hints + let hintToggleByUser = false; + const markHintToggle = (e: Event) => { if ((e.target as Element).closest?.('.hint-summary')) hintToggleByUser = true; }; + root.addEventListener('click', markHintToggle, true); + root.addEventListener('keydown', markHintToggle, true); root.addEventListener('toggle', (e: Event) => { const details = e.target as HTMLDetailsElement; const key = details.getAttribute?.('data-hint-key'); if (key !== null && key !== undefined) { setHintOpen(key, details.open); - usage('hints', { open: details.open, error: key }); + if (hintToggleByUser) usage('hints', { open: details.open, error: key }); } + hintToggleByUser = false; }, true); root.addEventListener('click', (e: MouseEvent) => {