Skip to content

Commit 862ad4d

Browse files
Merge pull request #1772 from rocketstack-matt/vscode-bugs
Add ability to navigate the architecture via the preview pane
2 parents 4a124e7 + e41cf78 commit 862ad4d

File tree

9 files changed

+245
-24
lines changed

9 files changed

+245
-24
lines changed

calm-plugins/vscode/media/preview.css

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,4 +343,29 @@ body {
343343
display: block;
344344
}
345345

346+
/* Clickable nodes and edges in Mermaid diagrams */
347+
.clickable-node,
348+
.clickable-edge {
349+
cursor: pointer !important;
350+
transition: opacity 0.2s ease;
351+
}
352+
353+
.clickable-node:hover {
354+
opacity: 0.7;
355+
}
356+
357+
.clickable-edge:hover {
358+
opacity: 0.8;
359+
}
360+
361+
/* Back button styles */
362+
#back-button:hover {
363+
background: var(--vscode-button-secondaryHoverBackground);
364+
opacity: 0.9;
365+
}
366+
367+
#back-button:active {
368+
transform: translateY(1px);
369+
}
370+
346371
/* End of styles */

calm-plugins/vscode/media/preview.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@
3737
<button class="tab-button" role="tab" data-target="model-panel">Model</button>
3838
</div>
3939
<div style="display:flex;align-items:center;gap:12px;">
40+
<button id="back-button" style="padding:4px 12px;border:1px solid var(--vscode-button-border);background:var(--vscode-button-secondaryBackground);color:var(--vscode-button-secondaryForeground);border-radius:2px;font-size:12px;cursor:pointer;" title="Clear selection and show full architecture">
41+
🏠 Home
42+
</button>
4043
<div id="show-labels-control" style="display:none;align-items:center;gap:6px;font-size:12px;">
4144
<input type="checkbox" id="show-labels-checkbox" checked>
4245
<label for="show-labels-checkbox" style="color:var(--vscode-editor-foreground);user-select:none;cursor:pointer;">Show Labels</label>

calm-plugins/vscode/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "calm-vscode-plugin",
33
"displayName": "CALM Preview & Tools",
44
"description": "Live-visualize CALM architecture models, validate, and generate docs.",
5-
"version": "0.0.7",
5+
"version": "0.0.8",
66
"publisher": "FINOS",
77
"homepage": "https://calm.finos.org",
88
"repository": {

calm-plugins/vscode/src/features/preview/commands.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export type InMsg =
66
| { type: 'runDocify'; templatePath?: string }
77
| { type: 'requestModelData' }
88
| { type: 'requestTemplateData' }
9+
| { type: 'refreshAll' }
910
| { type: 'toggleLabels'; showLabels: boolean }
1011
| { type: 'log'; message: string }
1112
| { type: 'error'; message: string; stack?: string }
@@ -22,6 +23,7 @@ export interface PreviewCommandTarget {
2223
handleRunDocify(): void
2324
handleRequestModelData(): void
2425
handleRequestTemplateData(): void
26+
handleRefreshAll(): void
2527
handleToggleLabels(showLabels: boolean): void
2628
handleLog(message: string): void
2729
handleError(message: string, stack?: string): void
@@ -70,6 +72,11 @@ export class RequestTemplateDataCmd implements WebviewCommand<{ type: 'requestTe
7072
constructor(private p: PreviewCommandTarget) { }
7173
execute() { this.p.handleRequestTemplateData() }
7274
}
75+
export class RefreshAllCmd implements WebviewCommand<{ type: 'refreshAll' }> {
76+
readonly type = 'refreshAll' as const
77+
constructor(private p: PreviewCommandTarget) { }
78+
execute() { this.p.handleRefreshAll() }
79+
}
7380
export class ToggleLabelsCmd implements WebviewCommand<{ type: 'toggleLabels'; showLabels: boolean }> {
7481
readonly type = 'toggleLabels' as const
7582
constructor(private p: PreviewCommandTarget) { }

calm-plugins/vscode/src/features/preview/docify-tab/view/docify-tab.view.ts

Lines changed: 164 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import type { DocifyViewModel } from '../view-model/docify.view-model'
2+
import type { VsCodeApi } from '../../webview/panel.view-model'
23
import MermaidRenderer from '../../webview/mermaid-renderer'
34
import { DiagramControls } from '../../webview/diagram-controls'
45

56
const DOM_SETTLE_DELAY_MS = 150
7+
const MIN_CLICKABLE_STROKE_WIDTH = 8
8+
const HOVER_STROKE_WIDTH = 12
69

710
/**
811
* DocifyTabView - Manages the DOM for the docify tab in the webview
@@ -13,10 +16,12 @@ export class DocifyTabView {
1316
private container: HTMLElement
1417
private markdownRenderer = new MermaidRenderer()
1518
private diagramControls: Map<string, DiagramControls> = new Map()
19+
private vscode: VsCodeApi
1620

17-
constructor(viewModel: DocifyViewModel, container: HTMLElement) {
21+
constructor(viewModel: DocifyViewModel, container: HTMLElement, vscode: VsCodeApi) {
1822
this.viewModel = viewModel
1923
this.container = container
24+
this.vscode = vscode
2025
this.bindViewModel()
2126
this.initialize()
2227
}
@@ -40,7 +45,7 @@ export class DocifyTabView {
4045
* Initialize with default state
4146
*/
4247
public initialize(): void {
43-
;(this.container as any).innerHTML = '<em>Initializing...</em>'
48+
(this.container as any).innerHTML = '<em>Initializing...</em>'
4449
}
4550

4651
/**
@@ -53,11 +58,11 @@ export class DocifyTabView {
5358
this.cleanupDiagramControls()
5459

5560
if (format === 'html') {
56-
;(this.container as any).innerHTML = content
61+
(this.container as any).innerHTML = content
5762
} else {
5863
// For markdown, render it through MermaidRenderer with source file path for image resolution
59-
const renderedHtml = await this.markdownRenderer.render(content, sourceFile)
60-
;(this.container as any).innerHTML = renderedHtml
64+
const renderedHtml = await this.markdownRenderer.render(content, sourceFile);
65+
(this.container as any).innerHTML = renderedHtml
6166

6267
// Initialize pan/zoom on all rendered diagrams
6368
this.initializePanZoomForDiagrams()
@@ -90,10 +95,161 @@ export class DocifyTabView {
9095
controls.createControls(container as HTMLElement)
9196
this.diagramControls.set(diagramId, controls)
9297
}
98+
99+
// Add click event listeners to Mermaid diagram nodes
100+
this.addClickHandlersToMermaidDiagram(container as HTMLElement)
93101
})
94102
}, DOM_SETTLE_DELAY_MS)
95103
}
96104

105+
/**
106+
* Add click event listeners to Mermaid diagram nodes to enable selection
107+
*/
108+
private addClickHandlersToMermaidDiagram(container: HTMLElement): void {
109+
const svg = container.querySelector('svg')
110+
if (!svg) {
111+
console.warn('[docify-tab] No SVG found in container')
112+
return
113+
}
114+
115+
console.log('[docify-tab] Setting up click handlers for Mermaid diagram')
116+
const nodeGroups = svg.querySelectorAll('g.node')
117+
console.log(`[docify-tab] Found ${nodeGroups.length} node groups in diagram`)
118+
119+
nodeGroups.forEach(nodeGroup => {
120+
// Extract the node ID from the group's ID attribute
121+
// Mermaid generates IDs like "flowchart-conference-website-123" for node "conference-website"
122+
const fullId = nodeGroup.getAttribute('id')
123+
if (!fullId) return
124+
125+
// Extract the actual node ID by removing the diagram prefix and suffix
126+
const nodeId = this.extractNodeIdFromMermaidElement(fullId)
127+
if (!nodeId) return
128+
129+
console.log(`[docify-tab] Processing node: ${fullId} -> ${nodeId}`);
130+
131+
// Make the entire node group clickable (includes shape + label)
132+
(nodeGroup as SVGElement).style.cursor = 'pointer';
133+
(nodeGroup as SVGElement).style.pointerEvents = 'all'
134+
135+
// Prevent text selection cursor on labels
136+
const labels = nodeGroup.querySelectorAll('text, tspan, foreignObject')
137+
labels.forEach(label => {
138+
(label as SVGElement).style.cursor = 'pointer';
139+
(label as SVGElement).style.userSelect = 'none';
140+
(label as SVGElement).style.pointerEvents = 'none' // Let clicks bubble to parent group
141+
})
142+
143+
// Add click event listener to the entire node group
144+
nodeGroup.addEventListener('click', (event) => {
145+
event.stopPropagation()
146+
event.preventDefault()
147+
console.log(`[docify-tab] Clicked on node: ${nodeId}`)
148+
// Send selection message to the extension
149+
this.vscode.postMessage({ type: 'selected', id: nodeId })
150+
})
151+
})
152+
153+
// Also handle edge clicks (relationships)
154+
const edgePaths = svg.querySelectorAll('g.edgePath')
155+
console.log(`[docify-tab] Found ${edgePaths.length} edge paths in diagram`)
156+
157+
edgePaths.forEach(edgePath => {
158+
const fullId = edgePath.getAttribute('id')
159+
if (!fullId) return
160+
161+
// Edge IDs are typically formatted differently, extract relationship ID
162+
const relationshipId = this.extractRelationshipIdFromMermaidElement(fullId)
163+
if (!relationshipId) return
164+
165+
console.log(`[docify-tab] Processing edge: ${fullId} -> ${relationshipId}`);
166+
167+
// Find the path element within the edge group
168+
const path = edgePath.querySelector('path.path')
169+
if (!path) {
170+
console.warn(`[docify-tab] No path found for edge ${relationshipId}`)
171+
return
172+
}
173+
174+
// Make the path clickable - increase stroke width for easier clicking
175+
path.classList.add('clickable-edge');
176+
(path as SVGElement).style.cursor = 'pointer';
177+
(path as SVGElement).style.pointerEvents = 'visibleStroke' // Make the visible stroke area clickable
178+
179+
// Store original stroke width and increase for clickability
180+
const originalStrokeWidth = window.getComputedStyle(path as Element).strokeWidth;
181+
path.setAttribute('data-original-stroke-width', originalStrokeWidth)
182+
183+
// Increase stroke width for better clickability
184+
const currentWidth = parseFloat(originalStrokeWidth) || 2;
185+
(path as SVGElement).style.strokeWidth = `${Math.max(currentWidth, MIN_CLICKABLE_STROKE_WIDTH)}px`
186+
187+
// Add hover effect via event listeners instead of CSS (more reliable for SVG)
188+
path.addEventListener('mouseenter', () => {
189+
(path as SVGElement).style.strokeWidth = `${HOVER_STROKE_WIDTH}px`
190+
})
191+
path.addEventListener('mouseleave', () => {
192+
const baseWidth = parseFloat(path.getAttribute('data-original-stroke-width') || '2');
193+
(path as SVGElement).style.strokeWidth = `${Math.max(baseWidth, MIN_CLICKABLE_STROKE_WIDTH)}px`
194+
})
195+
196+
// Add click event listener to the path
197+
path.addEventListener('click', (event) => {
198+
event.stopPropagation()
199+
event.preventDefault()
200+
console.log(`[docify-tab] Clicked on relationship: ${relationshipId}`)
201+
this.vscode.postMessage({ type: 'selected', id: relationshipId })
202+
})
203+
})
204+
205+
console.log('[docify-tab] Click handlers attached successfully')
206+
}
207+
208+
/**
209+
* Extract CALM node ID from Mermaid-generated element ID.
210+
*
211+
* Expected input format: Mermaid typically generates IDs like "flowchart-conference-website-123".
212+
* This function removes the "flowchart-" prefix and the trailing numeric suffix.
213+
*
214+
* Example:
215+
* Input: "flowchart-conference-website-123"
216+
* Output: "conference-website"
217+
*
218+
* @param mermaidId The Mermaid-generated element ID string.
219+
* @returns The extracted node ID, or null if extraction fails.
220+
*/
221+
private extractNodeIdFromMermaidElement(mermaidId: string): string | null {
222+
// Remove common Mermaid prefixes
223+
let cleaned = mermaidId.replace(/^flowchart-/, '')
224+
225+
// Remove trailing numbers (Mermaid appends random numbers)
226+
// Match everything except the last segment if it's purely numeric
227+
const match = cleaned.match(/^(.+?)-\d+$/)
228+
if (match) {
229+
return match[1]
230+
}
231+
232+
// If no numeric suffix, return the cleaned ID
233+
return cleaned || null
234+
}
235+
236+
/**
237+
* Extract CALM relationship ID from Mermaid-generated edge element ID
238+
* Mermaid edge IDs are formatted like "L-node1-node2-0" or similar
239+
*/
240+
private extractRelationshipIdFromMermaidElement(mermaidId: string): string | null {
241+
// Mermaid edge IDs often start with "L-" or "LE-"
242+
let cleaned = mermaidId.replace(/^L[E]?-/, '')
243+
244+
// Remove trailing numbers
245+
const match = cleaned.match(/^(.+?)-\d+$/)
246+
if (match) {
247+
return match[1]
248+
}
249+
250+
return cleaned || null
251+
}
252+
97253
/**
98254
* Clean up diagram controls
99255
*/
@@ -107,7 +263,7 @@ export class DocifyTabView {
107263
* Render docify error
108264
*/
109265
private renderError(error: string): void {
110-
;(this.container as any).innerHTML = `<div style="color:var(--vscode-editorError-foreground)">Error: ${this.escapeHtml(error)}</div>`
266+
(this.container as any).innerHTML = `<div style="color:var(--vscode-editorError-foreground)">Error: ${this.escapeHtml(error)}</div>`
111267
}
112268

113269
/**
@@ -134,7 +290,7 @@ export class DocifyTabView {
134290
* Cleanup event listeners
135291
*/
136292
public dispose(): void {
137-
this.cleanupDiagramControls()
138-
;(this.container as any).innerHTML = ''
293+
this.cleanupDiagramControls();
294+
(this.container as any).innerHTML = ''
139295
}
140296
}

calm-plugins/vscode/src/features/preview/preview-panel.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
RunDocifyCmd,
1919
RequestModelDataCmd,
2020
RequestTemplateDataCmd,
21+
RefreshAllCmd,
2122
ToggleLabelsCmd,
2223
LogCmd,
2324
ErrorCmd,
@@ -140,6 +141,7 @@ export class CalmPreviewPanel {
140141
this.commands.register(new RunDocifyCmd(this))
141142
this.commands.register(new RequestModelDataCmd(this))
142143
this.commands.register(new RequestTemplateDataCmd(this))
144+
this.commands.register(new RefreshAllCmd(this))
143145
this.commands.register(new ToggleLabelsCmd(this))
144146
this.commands.register(new LogCmd(this))
145147
this.commands.register(new ErrorCmd(this))
@@ -299,10 +301,17 @@ export class CalmPreviewPanel {
299301
this.viewModel.handleRequestModelData()
300302
}
301303

302-
public async handleRequestTemplateData() {
304+
public handleRequestTemplateData() {
303305
this.viewModel.handleRequestTemplateData()
304306
}
305307

308+
public handleRefreshAll() {
309+
this.log.info('[preview] handleRefreshAll() called - refreshing all tabs')
310+
this.handleRequestModelData()
311+
this.handleRequestTemplateData()
312+
this.handleRunDocify()
313+
}
314+
306315
public async handleToggleLabels(showLabels: boolean) {
307316
this.viewModel.handleToggleLabels(showLabels)
308317
}

calm-plugins/vscode/src/features/preview/webview/panel.view-model.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,21 @@ import { CalmModelViewModel } from '../model-tab/view-model/calm-model.view-mode
22
import { TemplateViewModel } from '../template-tab/view-model/template.view-model'
33
import { DocifyViewModel } from '../docify-tab/view-model/docify.view-model'
44

5-
interface VsCodeApi {
5+
export interface VsCodeApi {
66
postMessage(msg: any): void;
77
}
88

99
/**
1010
* TabsViewModel - Manages tab selection and coordinates child tab ViewModels
1111
*/
12-
class TabsViewModel {
12+
export class TabsViewModel {
1313
private activeTab: 'docify-panel' | 'template-panel' | 'model-panel' = 'docify-panel'
14-
private vscode: VsCodeApi
1514

1615
// Child ViewModels
1716
public readonly model = new CalmModelViewModel()
1817
public readonly template = new TemplateViewModel()
1918
public readonly docify = new DocifyViewModel()
19+
public readonly vscode: VsCodeApi
2020

2121
// Observer callback for tab changes
2222
public onTabChanged: (tabId: string) => void = () => { }

0 commit comments

Comments
 (0)