diff --git a/client/.vscodeignore b/client/.vscodeignore
index c04789a..0f5bd73 100644
--- a/client/.vscodeignore
+++ b/client/.vscodeignore
@@ -11,6 +11,12 @@ vsc-extension-quickstart.md
# Build artifacts
out/**
node_modules/**
+!node_modules/
+!node_modules/@vscode/
+!node_modules/@vscode/codicons/
+!node_modules/@vscode/codicons/dist/
+!node_modules/@vscode/codicons/dist/codicon.css
+!node_modules/@vscode/codicons/dist/codicon.ttf
*.vsix
# Test files
diff --git a/client/package-lock.json b/client/package-lock.json
index c40ab3c..bfd164f 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -9,6 +9,7 @@
"version": "0.0.78",
"license": "MIT",
"dependencies": {
+ "@vscode/codicons": "^0.0.45",
"ftp": "^0.3.10",
"get-port": "^5.1.1",
"jsonc-parser": "^0.4.2",
@@ -3212,9 +3213,20 @@
"engines": {
"node": ">=0.10.0"
}
+ },
+ "node_modules/@vscode/codicons": {
+ "version": "0.0.45",
+ "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45.tgz",
+ "integrity": "sha512-1KAZ7XCMagp5Gdrlr4bbbcAqgcIL623iO1wW6rfcSVGAVUQvR0WP7bQx1SbJ11gmV3fdQTSEFIJQ/5C+HuVasw==",
+ "license": "CC-BY-4.0"
}
},
"dependencies": {
+ "@vscode/codicons": {
+ "version": "0.0.45",
+ "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45.tgz",
+ "integrity": "sha512-1KAZ7XCMagp5Gdrlr4bbbcAqgcIL623iO1wW6rfcSVGAVUQvR0WP7bQx1SbJ11gmV3fdQTSEFIJQ/5C+HuVasw=="
+ },
"@babel/code-frame": {
"version": "7.12.11",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz",
diff --git a/client/package.json b/client/package.json
index 3febc8e..da7ebae 100644
--- a/client/package.json
+++ b/client/package.json
@@ -178,6 +178,7 @@
"webpack-cli": "^5.1.4"
},
"dependencies": {
+ "@vscode/codicons": "^0.0.45",
"ftp": "^0.3.10",
"get-port": "^5.1.1",
"jsonc-parser": "^0.4.2",
diff --git a/client/src/webview/clipboard.ts b/client/src/webview/clipboard.ts
new file mode 100644
index 0000000..8a40025
--- /dev/null
+++ b/client/src/webview/clipboard.ts
@@ -0,0 +1,22 @@
+const COPY_BUTTON_RESET_MS = 2000;
+
+export async function copyToClipboard(button: HTMLButtonElement, text: string) {
+ const originalTitle = button.getAttribute('title');
+
+ try {
+ button.disabled = true;
+ await navigator.clipboard.writeText(text);
+ button.setAttribute('title', 'Copied!');
+ } catch (e) {
+ button.setAttribute('title', 'Copy failed');
+ } finally {
+ setTimeout(() => {
+ if (originalTitle !== null) {
+ button.setAttribute('title', originalTitle);
+ } else {
+ button.removeAttribute('title');
+ }
+ button.disabled = false;
+ }, COPY_BUTTON_RESET_MS);
+ }
+}
diff --git a/client/src/webview/diagram.ts b/client/src/webview/diagram.ts
index 19c6a7e..5683c02 100644
--- a/client/src/webview/diagram.ts
+++ b/client/src/webview/diagram.ts
@@ -1,4 +1,5 @@
import type { LJStateMachine } from "../types/fsm";
+import { copyToClipboard } from "./clipboard";
// constants
const MIN_ZOOM = 0.2;
@@ -6,7 +7,6 @@ const MAX_ZOOM = 5;
const ZOOM_BUTTON_FACTOR = 1.5;
const SCROLL_ZOOM_IN_FACTOR = 1.05;
const SCROLL_ZOOM_OUT_FACTOR = 0.95;
-const COPY_TIMEOUT_MS = 2000;
// state variables
let zoomLevel = 1;
@@ -51,7 +51,7 @@ export function createMermaidDiagram(sm: LJStateMachine | undefined, orientation
// add transitions
transitionMap.forEach((labels, key) => {
const [from, to] = key.split('|');
- const mergedLabel = labels.join(', ');
+ const mergedLabel = labels.join('
');
lines.push(` ${from} --> ${to} : ${mergedLabel}`);
});
@@ -59,10 +59,7 @@ export function createMermaidDiagram(sm: LJStateMachine | undefined, orientation
}
function getTransitionLabel(label: string, preCond?: string | null, postCond?: string | null, showConditions = false): string {
- if (!showConditions) {
- return escapeMermaidLabel(label);
- }
-
+ if (!showConditions) return escapeMermaidLabel(label);
return [
getConditionLabel('pre', preCond),
escapeMermaidLabel(label),
@@ -75,9 +72,8 @@ function getInitialTransitionLabel(postCond?: string | null, showConditions = fa
}
function getConditionLabel(kind: 'pre' | 'post', cond?: string | null): string {
- if (!cond) {
- return '';
- }
+ if (!cond) return '';
+
return `${escapeMermaidLabel(cond)}`;
}
@@ -101,6 +97,8 @@ export async function renderMermaidDiagram(document: any, window: any) {
await mermaid.run({ nodes: mermaidElements });
applyTransform(document);
registerPanListeners(document);
+ const diagramContainer = document.querySelector('.diagram-container') as HTMLElement | null;
+ if (diagramContainer) diagramContainer.style.minHeight = '';
} catch (e) {
console.error('Failed to render Mermaid diagram:', e);
}
@@ -241,20 +239,5 @@ export function registerPanListeners(document: any) {
}
export async function copyDiagramToClipboard(target: any, diagram: string) {
- const title = target.getAttribute('title');
- try {
- target.disabled = true;
- await navigator.clipboard.writeText(diagram);
- target.classList.add('copied');
- target.setAttribute('title', 'Copied!');
- } catch (e) {
- target.setAttribute('title', 'Copy failed');
- } finally {
- // reset button after timeout
- setTimeout(() => {
- target.setAttribute('title', title);
- target.classList.remove('copied');
- target.disabled = false;
- }, COPY_TIMEOUT_MS);
- }
+ await copyToClipboard(target, diagram);
}
diff --git a/client/src/webview/html.ts b/client/src/webview/html.ts
index c878007..f444fb1 100644
--- a/client/src/webview/html.ts
+++ b/client/src/webview/html.ts
@@ -13,6 +13,7 @@ export function getHtml(webview: vscode.Webview, extensionUri: vscode.Uri): stri
const nonce = Date.now().toString();
const cspSource = webview.cspSource;
const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(extensionUri, "media", "webview.js"));
+ const codiconsUri = webview.asWebviewUri(vscode.Uri.joinPath(extensionUri, "node_modules", "@vscode", "codicons", "dist", "codicon.css"));
return /*html*/ `
@@ -21,8 +22,9 @@ export function getHtml(webview: vscode.Webview, extensionUri: vscode.Uri): stri
+
diff --git a/client/src/webview/icons.ts b/client/src/webview/icons.ts
new file mode 100644
index 0000000..367047b
--- /dev/null
+++ b/client/src/webview/icons.ts
@@ -0,0 +1,23 @@
+export function renderCodicon(name: string, className = ""): string {
+ const classes = ["codicon", `codicon-${name}`, className].filter(Boolean).join(" ");
+ return ``;
+}
+
+type CodiconButtonOptions = {
+ id?: string;
+ className?: string;
+ title: string;
+ ariaLabel?: string;
+ attributes?: string;
+ disabled?: boolean;
+};
+
+export function renderCodiconButton(iconName: string, options: CodiconButtonOptions): string {
+ const classes = ["icon-button", options.className].filter(Boolean).join(" ");
+ const id = options.id ? ` id="${options.id}"` : "";
+ const ariaLabel = ` aria-label="${options.ariaLabel ?? options.title}"`;
+ const attributes = options.attributes ? ` ${options.attributes}` : "";
+ const disabled = options.disabled ? " disabled" : "";
+
+ return ``;
+}
diff --git a/client/src/webview/script.ts b/client/src/webview/script.ts
index d017fdd..d48401d 100644
--- a/client/src/webview/script.ts
+++ b/client/src/webview/script.ts
@@ -54,6 +54,12 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window)
root.addEventListener('click', (e: MouseEvent) => {
const target = e.target instanceof Element ? e.target : null;
if (!target) return;
+ const iconButton = target.closest?.('.icon-button');
+ if (iconButton && !(iconButton as HTMLButtonElement).disabled) {
+ iconButton.classList.remove('icon-button-pop');
+ void (iconButton as HTMLElement).offsetWidth;
+ iconButton.classList.add('icon-button-pop');
+ }
// context section toggle
const contextToggleButton = target.closest?.('.context-toggle-btn');
@@ -77,7 +83,8 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window)
const icon = contextToggleButton.querySelector('.context-toggle-icon');
if (icon) {
- icon.textContent = nextExpanded ? '▾' : '▸';
+ icon.classList.toggle('codicon-triangle-down', nextExpanded);
+ icon.classList.toggle('codicon-triangle-right', !nextExpanded);
}
return;
@@ -143,7 +150,7 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window)
}
// toggle diagram orientation
- if (target.id === 'diagram-orientation-btn') {
+ if (target.closest?.('#diagram-orientation-btn')) {
e.stopPropagation();
diagramOrientation = diagramOrientation === "TB" ? "LR" : "TB";
resetZoom(document);
@@ -152,40 +159,42 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window)
}
// toggle diagram conditions
- if (target.id === 'diagram-conditions-btn') {
+ const diagramConditionsButton = target.closest?.('#diagram-conditions-btn');
+ if (diagramConditionsButton) {
e.stopPropagation();
- if ((target as HTMLButtonElement).disabled) return;
+ if ((diagramConditionsButton as HTMLButtonElement).disabled) return;
showDiagramConditions = !showDiagramConditions;
updateView();
return;
}
// zoom in
- if (target.id === 'zoom-in-btn') {
+ if (target.closest?.('#zoom-in-btn')) {
e.stopPropagation();
zoomIn(document);
return;
}
// zoom out
- if (target.id === 'zoom-out-btn') {
+ if (target.closest?.('#zoom-out-btn')) {
e.stopPropagation();
zoomOut(document);
return;
}
// reset zoom
- if (target.id === 'zoom-reset-btn') {
+ if (target.closest?.('#zoom-reset-btn')) {
e.stopPropagation();
resetZoom(document);
return;
}
// copy diagram source
- if (target.id === 'copy-diagram-btn') {
+ const copyDiagramButton = target.closest?.('#copy-diagram-btn');
+ if (copyDiagramButton) {
e.stopPropagation();
if (!currentDiagram) return
- copyDiagramToClipboard(target, currentDiagram);
+ copyDiagramToClipboard(copyDiagramButton, currentDiagram);
return;
}
@@ -315,7 +324,7 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window)
case 'fsm': {
const diagram = createMermaidDiagram(stateMachine, diagramOrientation, showDiagramConditions);
currentDiagram = diagram;
- root.innerHTML = renderStateMachineView(stateMachine, diagram, diagramOrientation, showDiagramConditions);
+ renderStateMachineView(root, stateMachine, diagram, diagramOrientation, showDiagramConditions);
if (stateMachine) renderMermaidDiagram(document, window);
break;
}
diff --git a/client/src/webview/styles.ts b/client/src/webview/styles.ts
index a09bf13..d76c927 100644
--- a/client/src/webview/styles.ts
+++ b/client/src/webview/styles.ts
@@ -11,6 +11,7 @@ export function getStyles(): string {
height: 100%;
margin: 0;
box-sizing: border-box;
+ scrollbar-gutter: stable both-edges;
}
#root {
width: 100%;
@@ -27,7 +28,8 @@ export function getStyles(): string {
height: 100%;
padding: 1rem;
font-family: var(--vscode-font-family);
- overflow: auto;
+ overflow-x: hidden;
+ overflow-y: auto;
--lj-token-keyword: var(--vscode-symbolIcon-keywordForeground, var(--vscode-editor-foreground));
--lj-token-control: var(--vscode-debugTokenExpression-name, var(--vscode-symbolIcon-keywordForeground, var(--vscode-editor-foreground)));
--lj-token-function: var(--vscode-symbolIcon-functionForeground, var(--vscode-editor-foreground));
@@ -38,7 +40,7 @@ export function getStyles(): string {
--lj-token-boolean: var(--vscode-debugTokenExpression-boolean, var(--vscode-symbolIcon-booleanForeground, var(--vscode-editor-foreground)));
--lj-token-identifier: var(--vscode-debugTokenExpression-name, var(--vscode-editor-foreground));
--lj-token-punctuation: var(--vscode-editor-foreground);
- --lj-state-cond-pre: var(--lj-token-function);
+ --lj-state-cond-pre: var(--lj-token-identifier);
--lj-state-cond-post: var(--lj-token-type);
}
body.vscode-light {
@@ -164,6 +166,16 @@ export function getStyles(): string {
border-radius: 4px;
position: relative;
}
+ .codicon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1em;
+ height: 1em;
+ font-size: 1rem;
+ line-height: 1;
+ pointer-events: none;
+ }
.copy-diagnostic-btn {
position: absolute;
top: 0.5rem;
@@ -179,7 +191,6 @@ export function getStyles(): string {
border: 1px solid transparent;
border-radius: 4px;
opacity: 0.65;
- font-size: 1.25rem;
transition: background-color 0.16s ease, border-color 0.16s ease, opacity 0.16s ease, transform 0.16s ease;
}
.copy-diagnostic-btn:hover {
@@ -191,15 +202,15 @@ export function getStyles(): string {
opacity: 0.8;
cursor: default;
}
- .copied {
- animation: copy-diagnostic-pop 0.42s ease-out;
+ .icon-button-pop {
+ animation: icon-button-pop 0.28s ease-out;
}
- @keyframes copy-diagnostic-pop {
+ @keyframes icon-button-pop {
0% {
- transform: scale(0.8);
+ transform: scale(0.96);
}
- 45% {
- transform: scale(1.18);
+ 55% {
+ transform: scale(1.05);
}
100% {
transform: scale(1);
@@ -328,14 +339,18 @@ export function getStyles(): string {
}
.reset-btn {
margin: 0;
- padding: 0.4rem 0.8rem;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.75rem;
+ height: 1.75rem;
+ padding: 0;
background-color: transparent;
color: var(--vscode-button-foreground);
border: none;
border-radius: 4px;
cursor: pointer;
font-family: var(--vscode-font-family);
- font-size: larger;
flex-shrink: 0;
opacity: 0.7;
}
@@ -560,7 +575,6 @@ export function getStyles(): string {
width: 1rem;
text-align: center;
flex-shrink: 0;
- font-size: larger;
}
.context-section-content.collapsed {
display: none;
@@ -633,19 +647,40 @@ export function getStyles(): string {
}
.diagram-controls {
position: absolute;
- top: 0.5rem;
- right: 0.5rem;
+ top: 1rem;
+ right: 1rem;
display: flex;
gap: 0.5rem;
z-index: 10;
}
+ .diagram-condition-legend {
+ position: absolute;
+ bottom: 1rem;
+ right: 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ font-size: 0.8rem;
+ z-index: 10;
+ pointer-events: none;
+ font-weight: 500;
+ }
+ .diagram-condition-legend-pre {
+ color: var(--lj-state-cond-pre);
+ }
+ .diagram-condition-legend-post {
+ color: var(--lj-state-cond-post);
+ }
.diagram-control-btn {
- font-size: clamp(0.75rem, 5vw, 1.5rem);
- padding: clamp(0.25rem, 1vw, 0.5rem);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.75rem;
+ height: 1.75rem;
+ padding: 0;
color: var(--vscode-foreground);
background: none;
border: none;
- font-family: 'Courier New', Courier, monospace;
opacity: 0.7;
}
.diagram-control-btn:hover {
@@ -689,7 +724,7 @@ export function getStyles(): string {
.diagram-container .mermaid .edgeLabel .state-cond {
color: var(--vscode-descriptionForeground) !important;
display: inline-block !important;
- font-size: 0.82em !important;
+ font-size: 0.76em !important;
line-height: 1.2 !important;
}
.diagram-container .mermaid .edgeLabel .state-cond-pre {
diff --git a/client/src/webview/views/diagnostics/derivation-nodes.ts b/client/src/webview/views/diagnostics/derivation-nodes.ts
index ca8e8a3..8a75964 100644
--- a/client/src/webview/views/diagnostics/derivation-nodes.ts
+++ b/client/src/webview/views/diagnostics/derivation-nodes.ts
@@ -1,6 +1,7 @@
import type { LJError, RefinementMismatchError } from "../../../types/diagnostics";
import type { DerivationNode, ValDerivationNode } from "../../../types/derivation-nodes";
import { renderHighlightedExpression, renderHighlightedInlineExpression } from "../../highlighting";
+import { renderCodicon } from "../../icons";
import { escapeHtml } from "../../utils";
// Handles rendering and interaction of derivation nodes in refinement errors
@@ -138,7 +139,7 @@ export function renderDerivationNode(
${renderJsonTree(error, node, errorId, "root", expansions)}
${expansions.size === 0 ? ' (click to expand)' : ''}
-
+
`;
}
diff --git a/client/src/webview/views/diagnostics/diagnostics.ts b/client/src/webview/views/diagnostics/diagnostics.ts
index 8895cdf..f0b4232 100644
--- a/client/src/webview/views/diagnostics/diagnostics.ts
+++ b/client/src/webview/views/diagnostics/diagnostics.ts
@@ -1,10 +1,10 @@
import { LJDiagnostic, LJError, LJWarning } from "../../../types/diagnostics";
+import { copyToClipboard } from "../../clipboard";
+import { renderCodiconButton } from "../../icons";
import { renderErrors } from "./errors";
import { renderMainHeader } from "../sections";
import { renderWarnings } from "./warnings";
-const COPY_BUTTON_RESET_MS = 2000;
-
export function renderDiagnosticsView(
diagnostics: LJDiagnostic[],
showAll: boolean,
@@ -51,7 +51,11 @@ export function getDisplayDiagnostics(diagnostics: LJDiagnostic[], showAll: bool
}
export function renderCopyDiagnosticButton(indexType: 'error' | 'warning', index: number): string {
- return /*html*/``;
+ return renderCodiconButton("copy", {
+ className: "copy-diagnostic-btn",
+ title: "Copy diagnostic",
+ attributes: `data-${indexType}-index="${index}"`,
+ });
}
export async function copyDiagnosticToClipboard(button: any, displayDiagnostics: LJDiagnostic[]) {
@@ -63,24 +67,7 @@ export async function copyDiagnosticToClipboard(button: any, displayDiagnostics:
if (!diagnostic) return;
const diagnosticText = formatDiagnosticForClipboard(diagnostic);
- const originalTitle = button.getAttribute('title');
- const originalContent = button.innerHTML;
-
- try {
- button.disabled = true;
- await navigator.clipboard.writeText(diagnosticText);
- button.classList.add('copied');
- button.setAttribute('title', 'Copied!');
- } catch (e) {
- button.setAttribute('title', 'Copy failed');
- } finally {
- setTimeout(() => {
- button.innerHTML = originalContent;
- button.setAttribute('title', originalTitle);
- button.classList.remove('copied');
- button.disabled = false;
- }, COPY_BUTTON_RESET_MS);
- }
+ await copyToClipboard(button, diagnosticText);
}
export function formatDiagnosticForClipboard(diagnostic: LJDiagnostic): string {
diff --git a/client/src/webview/views/fsm/fsm.ts b/client/src/webview/views/fsm/fsm.ts
index bf10010..3080baa 100644
--- a/client/src/webview/views/fsm/fsm.ts
+++ b/client/src/webview/views/fsm/fsm.ts
@@ -1,29 +1,41 @@
import type { LJStateMachine } from "../../../types/fsm";
+import { renderCodiconButton } from "../../icons";
import { renderMainHeader } from "../sections";
-export function renderStateMachineView(sm: LJStateMachine | undefined, diagram: string, orientation: "LR" | "TB", showConditions: boolean): string {
+export function renderStateMachineView(root: HTMLElement, sm: LJStateMachine | undefined, diagram: string, orientation: "LR" | "TB", showConditions: boolean) {
+ const previousDiagramContainer = root.querySelector('.diagram-container') as HTMLElement | null;
+ const diagramHeight = previousDiagramContainer?.offsetHeight;
+ root.innerHTML = renderStateMachineViewHtml(sm, diagram, orientation, showConditions, diagramHeight);
+}
+
+function renderStateMachineViewHtml(sm: LJStateMachine | undefined, diagram: string, orientation: "LR" | "TB", showConditions: boolean, diagramHeight?: number): string {
const initialStateNames = sm ? [...new Set(sm.initialTransitions.map(transition => transition.to))] : [];
const hasConditionExpansions = sm
? sm.initialTransitions.some(transition => !!transition.postCond)
|| sm.transitions.some(transition => !!transition.preCond || !!transition.postCond)
: false;
const conditionToggleLabel = showConditions ? 'Collapse Conditions' : 'Expand Conditions';
- const conditionToggleIcon = showConditions ? '⊟' : '⊞';
return /*html*/`
${renderMainHeader("", 'fsm')}
${sm ? /*html*/`
-
+
-
-
-
-
-
-
+ ${renderCodiconButton("zoom-in", { id: "zoom-in-btn", className: "diagram-control-btn", title: "Zoom In" })}
+ ${renderCodiconButton("zoom-out", { id: "zoom-out-btn", className: "diagram-control-btn", title: "Zoom Out" })}
+ ${renderCodiconButton("screen-normal", { id: "zoom-reset-btn", className: "diagram-control-btn", title: "Reset Zoom" })}
+ ${renderCodiconButton(orientation === "TB" ? "arrow-down" : "arrow-right", { id: "diagram-orientation-btn", className: "diagram-control-btn", title: "Toggle Orientation" })}
+ ${renderCodiconButton(showConditions ? "collapse-all" : "expand-all", { id: "diagram-conditions-btn", className: `diagram-control-btn${showConditions ? ' active' : ''}`, title: conditionToggleLabel, attributes: `aria-pressed="${showConditions ? 'true' : 'false'}"`, disabled: !hasConditionExpansions })}
+ ${renderCodiconButton("copy", { id: "copy-diagram-btn", className: "diagram-control-btn", title: "Copy Mermaid Source" })}
+ ${showConditions && hasConditionExpansions ? /*html*/`
+
+ Precondition
+ Postcondition
+
+ ` : ''}
diff --git a/client/src/webview/views/sections.ts b/client/src/webview/views/sections.ts
index 0ce66fe..3b13934 100644
--- a/client/src/webview/views/sections.ts
+++ b/client/src/webview/views/sections.ts
@@ -2,6 +2,7 @@ import type { LJDiagnostic, PlacementInCode, SourcePosition, TranslationTable }
import { escapeHtml } from "../utils";
import { renderHighlightedExpression, renderHighlightedInlineExpression } from "../highlighting";
import { getDiagnosticRevealTarget, getDiagnosticRevealTargetKey } from "../diagnostic-reveal";
+import { renderCodicon } from "../icons";
export const renderMainHeader = (title: string, selectedTab: NavTab): string => /*html*/`