Skip to content

Commit 9142b16

Browse files
authored
Merge pull request #2372 from rocketstack-matt/fix/2361-blank-preview-panel-vscode-1.116
fix(vscode): blank preview panel on VSCode 1.116 (#2361)
2 parents eb3bba6 + 03fc0ac commit 9142b16

18 files changed

Lines changed: 1238 additions & 11 deletions

.github/workflows/build-vscode-extension.yml

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ on:
2121
type: boolean
2222

2323
jobs:
24-
build-and-publish:
25-
name: Build, Test, and Publish VS Code Extension
24+
build-and-test:
25+
name: Build and Test VS Code Extension
2626
runs-on: ubuntu-latest
2727

2828
steps:
@@ -43,6 +43,62 @@ jobs:
4343
- name: Build and Test VS Code Extension
4444
run: npm run test:vscode
4545

46+
integration-test:
47+
name: Integration Test (VSCode ${{ matrix.vscode-version }})
48+
runs-on: ubuntu-latest
49+
needs: build-and-test
50+
# Stable is an informational matrix entry (CDN-resolved, can flake on
51+
# transient network issues); pinned versions are the real gate.
52+
continue-on-error: ${{ matrix.vscode-version == 'stable' }}
53+
strategy:
54+
fail-fast: false
55+
matrix:
56+
# 1.115.0 is the last known-good version for issue #2361.
57+
# 1.116.0 is the first version exhibiting the blank-paint regression.
58+
# 'stable' tracks whatever is current — early warning for new regressions.
59+
vscode-version: ['1.115.0', '1.116.0', 'stable']
60+
61+
steps:
62+
- name: Checkout code
63+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
64+
65+
- name: Setup Node.js
66+
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
67+
with:
68+
node-version: v22
69+
70+
- name: Install workspace
71+
run: npm ci
72+
73+
- name: Build workspace dependencies
74+
# tsup in the vscode extension needs @finos/calm-shared and
75+
# @finos/calm-models pre-built (they're file:../../ workspace deps).
76+
run: npm run build:shared
77+
78+
- name: Integration Test VS Code Extension (Xvfb)
79+
# Xvfb provides a virtual display so @vscode/test-electron can launch
80+
# a real VSCode instance headlessly. xvfb-run wraps the command.
81+
env:
82+
VSCODE_VERSION: ${{ matrix.vscode-version }}
83+
run: xvfb-run -a npm run test:integration --workspace=calm-plugins/vscode
84+
85+
package-and-publish:
86+
name: Package and Publish VS Code Extension
87+
runs-on: ubuntu-latest
88+
needs: [build-and-test, integration-test]
89+
90+
steps:
91+
- name: Checkout code
92+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
93+
94+
- name: Setup Node.js
95+
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
96+
with:
97+
node-version: v22
98+
99+
- name: Install workspace
100+
run: npm ci
101+
46102
- name: Package VS Code Extension
47103
run: npm run package:vscode
48104

calm-plugins/vscode/package.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "calm-vscode-plugin",
33
"displayName": "CALM Tools",
44
"description": "Live-visualize CALM architecture models, validate, and generate docs.",
5-
"version": "0.5.0",
5+
"version": "0.6.0",
66
"publisher": "FINOS",
77
"homepage": "https://calm.finos.org",
88
"repository": {
@@ -177,19 +177,25 @@
177177
"test": "vitest run",
178178
"test:watch": "vitest",
179179
"test:coverage": "vitest run --coverage",
180+
"test:integration:compile": "tsc -p test/integration",
181+
"test:integration": "npm run build && npm run test:integration:compile && node ./out/integration/runTest.js",
180182
"lint": "eslint src",
181183
"lint-fix": "eslint src --fix",
182184
"package": "npm run build && npx @vscode/vsce package --no-dependencies",
183185
"vscode:prepublish": "npm run build"
184186
},
185187
"devDependencies": {
186188
"@types/markdown-it": "^14.1.2",
189+
"@types/mocha": "^10.0.6",
187190
"@types/svg-pan-zoom": "^3.3.0",
188191
"@types/vscode": "^1.88.0",
189192
"@vscode/dts": "^0.4.1",
193+
"@vscode/test-electron": "^2.3.9",
190194
"@vscode/vsce": "^3.7.1",
191195
"copyfiles": "^2.4.1",
192196
"eslint-plugin-import": "^2.32.0",
197+
"glob": "^10.3.10",
198+
"mocha": "^10.2.0",
193199
"tsup": "^8.4.0"
194200
},
195201
"dependencies": {
@@ -205,4 +211,4 @@
205211
"yaml": "^2.8.3",
206212
"zustand": "^5.0.8"
207213
}
208-
}
214+
}

calm-plugins/vscode/src/calm-extension-controller.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,19 @@ import { DiagnosticsService } from './core/services/diagnostics-service'
1616
import { createApplicationStore, type ApplicationStoreApi } from './application-store'
1717
import { setWidgetLogger } from '@finos/calm-shared'
1818
import { ValidationService } from './features/validation/validation-service'
19+
import { createTestApi, CalmExtensionTestApi } from './test-api'
1920

2021
/**
2122
* Main extension controller that orchestrates all VS Code extension functionality
2223
*/
2324
export class CalmExtensionController {
2425
private disposables: vscode.Disposable[] = []
2526
private logging: LoggingService | undefined
27+
private previewPanelFactory: PreviewPanelFactory | undefined
28+
29+
getTestApi(): CalmExtensionTestApi | undefined {
30+
return this.previewPanelFactory ? createTestApi(this.previewPanelFactory) : undefined
31+
}
2632

2733
async start(context: vscode.ExtensionContext) {
2834
this.logging = new LoggingService('vscode-ext')
@@ -42,6 +48,7 @@ export class CalmExtensionController {
4248

4349
const configService: Config = new ConfigService()
4450
const previewPanelFactory = new PreviewPanelFactory()
51+
this.previewPanelFactory = previewPanelFactory
4552
const treeManager = new TreeViewFactory(store)
4653
const editorFactory = new EditorFactory(store)
4754
const navigationService = new NavigationService(log, configService)

calm-plugins/vscode/src/extension.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import * as vscode from 'vscode'
22
import { CalmExtensionController } from './calm-extension-controller'
3+
import type { CalmExtensionTestApi } from './test-api'
34

45

56
let controller: CalmExtensionController | undefined
67

7-
export function activate(context: vscode.ExtensionContext) {
8+
export async function activate(context: vscode.ExtensionContext): Promise<CalmExtensionTestApi | undefined> {
89
controller = new CalmExtensionController()
9-
controller.start(context)
10+
await controller.start(context)
11+
return controller.getTestApi()
1012
}
1113

1214
export function deactivate() {

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export type InMsg =
33
| { type: 'revealInEditor'; id: string }
44
| { type: 'selected'; id: string }
55
| { type: 'ready' }
6+
| { type: 'rendered' }
67
| { type: 'runDocify'; templatePath?: string }
78
| { type: 'requestModelData' }
89
| { type: 'requestTemplateData' }
@@ -20,6 +21,7 @@ export interface PreviewCommandTarget {
2021
handleRevealInEditor(id: string): void
2122
handleSelected(id: string): void
2223
handleReady(): void
24+
handleRendered(): void
2325
handleRunDocify(): void
2426
handleRequestModelData(): void
2527
handleRequestTemplateData(): void
@@ -57,6 +59,11 @@ export class ReadyCmd implements WebviewCommand<{ type: 'ready' }> {
5759
constructor(private p: PreviewCommandTarget) { }
5860
execute() { this.p.handleReady() }
5961
}
62+
export class RenderedCmd implements WebviewCommand<{ type: 'rendered' }> {
63+
readonly type = 'rendered' as const
64+
constructor(private p: PreviewCommandTarget) { }
65+
execute() { this.p.handleRendered() }
66+
}
6067
export class RunDocifyCmd implements WebviewCommand<{ type: 'runDocify'; templatePath?: string }> {
6168
readonly type = 'runDocify' as const
6269
constructor(private p: PreviewCommandTarget) { }

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,33 @@ describe('CalmPreviewPanel', () => {
143143
}
144144
})
145145

146+
describe('createOrShow panel lifecycle', () => {
147+
// Regression guard for issue #2361: on VSCode 1.116+, calling panel.reveal()
148+
// on a panel that createWebviewPanel just created causes a blank first paint.
149+
// createWebviewPanel already shows the panel, so reveal() must only fire on reuse.
150+
151+
const fakeUri = { fsPath: '/test/arch.json' } as any
152+
153+
it('does NOT call panel.reveal() when creating a brand-new panel', () => {
154+
expect(CalmPreviewPanel.currentPanel).toBeUndefined()
155+
156+
CalmPreviewPanel.createOrShow(mockContext, fakeUri, mockConfig, mockLogger)
157+
158+
expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1)
159+
expect(mockPanel.reveal).not.toHaveBeenCalled()
160+
})
161+
162+
it('DOES call panel.reveal() when reusing an existing panel', () => {
163+
CalmPreviewPanel.createOrShow(mockContext, fakeUri, mockConfig, mockLogger)
164+
expect(mockPanel.reveal).not.toHaveBeenCalled() // sanity: brand-new path did not reveal
165+
166+
CalmPreviewPanel.createOrShow(mockContext, fakeUri, mockConfig, mockLogger)
167+
168+
expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1) // not recreated
169+
expect(mockPanel.reveal).toHaveBeenCalled()
170+
})
171+
})
172+
146173
describe('isRelativePath method', () => {
147174
let panel: CalmPreviewPanel
148175

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

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
RevealInEditorCmd,
1616
SelectedCmd,
1717
ReadyCmd,
18+
RenderedCmd,
1819
RunDocifyCmd,
1920
RequestModelDataCmd,
2021
RequestTemplateDataCmd,
@@ -76,7 +77,9 @@ export class CalmPreviewPanel {
7677
],
7778
})
7879
CalmPreviewPanel.currentPanel = new CalmPreviewPanel(panel, context, config, log)
79-
CalmPreviewPanel.currentPanel.reveal(uri)
80+
// createWebviewPanel already shows the panel; calling reveal() here causes a
81+
// blank first paint on VSCode 1.116+. Initialize state without re-revealing.
82+
CalmPreviewPanel.currentPanel.reveal(uri, { revealPanel: false })
8083
return CalmPreviewPanel.currentPanel
8184
}
8285

@@ -111,7 +114,8 @@ export class CalmPreviewPanel {
111114
],
112115
})
113116
CalmPreviewPanel.currentPanel = new CalmPreviewPanel(panel, context, config, log, viewModel)
114-
CalmPreviewPanel.currentPanel.reveal(uri)
117+
// See note in createOrShow: skip panel.reveal() on brand-new panels.
118+
CalmPreviewPanel.currentPanel.reveal(uri, { revealPanel: false })
115119
return CalmPreviewPanel.currentPanel
116120
}
117121

@@ -138,6 +142,7 @@ export class CalmPreviewPanel {
138142
this.commands.register(new RevealInEditorCmd(this))
139143
this.commands.register(new SelectedCmd(this))
140144
this.commands.register(new ReadyCmd(this))
145+
this.commands.register(new RenderedCmd(this))
141146
this.commands.register(new RunDocifyCmd(this))
142147
this.commands.register(new RequestModelDataCmd(this))
143148
this.commands.register(new RequestTemplateDataCmd(this))
@@ -212,7 +217,8 @@ export class CalmPreviewPanel {
212217
}
213218

214219
/** --------- public API - now delegates to ViewModel --------- */
215-
reveal(uri: vscode.Uri) {
220+
reveal(uri: vscode.Uri, options: { revealPanel?: boolean } = {}) {
221+
const revealPanel = options.revealPanel !== false
216222
this.viewModel.setCurrentUri(uri.fsPath)
217223
const fileInfo = detectFileType(uri.fsPath)
218224
const isTemplateMode = fileInfo.type === FileType.TemplateFile && fileInfo.isValid
@@ -245,7 +251,9 @@ export class CalmPreviewPanel {
245251

246252
// Don't trigger docify immediately here - let refreshForDocument handle it after selection is determined
247253

248-
this.panel.reveal(vscode.ViewColumn.Beside)
254+
if (revealPanel) {
255+
this.panel.reveal(vscode.ViewColumn.Beside)
256+
}
249257
}
250258

251259
getCurrentUri(): vscode.Uri | undefined {
@@ -273,6 +281,7 @@ export class CalmPreviewPanel {
273281
dispose() {
274282
this.viewModel.setVisible(false)
275283
this.viewModel.setReady(false) // Reset ready state so new panel can trigger state changes
284+
this.viewModel.setRendered(false) // Reset rendered probe so next panel gets a fresh paint check
276285
this.viewModel.clearCurrentUri() // Clear URI so reopening will trigger proper data loading
277286
CalmPreviewPanel.currentPanel = undefined
278287
while (this.disposables.length) {
@@ -297,6 +306,11 @@ export class CalmPreviewPanel {
297306
this.viewModel.handleReady()
298307
}
299308

309+
public handleRendered() {
310+
this.log.info('[preview] handleRendered() called - webview compositor produced a frame')
311+
this.viewModel.handleRendered()
312+
}
313+
300314
public handleRunDocify() {
301315
this.viewModel.handleRunDocify()
302316
}

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export class PreviewViewModel implements PreviewViewModelInterface {
3838
// Main orchestration emitters
3939
private activeTabChangedEmitter = new Emitter<'model' | 'template' | 'docify'>()
4040
private readyStateChangedEmitter = new Emitter<boolean>()
41+
private renderedStateChangedEmitter = new Emitter<boolean>()
4142
private visibilityChangedEmitter = new Emitter<boolean>()
4243
private versionAnnouncementEmitter = new Emitter<{ version: string; message: string }>()
4344
private stateChangedEmitter = new Emitter<void>()
@@ -54,12 +55,14 @@ export class PreviewViewModel implements PreviewViewModelInterface {
5455
private activeTab: 'model' | 'template' | 'docify' = 'model'
5556
private isVisible = false
5657
private isReady = false
58+
private isRendered = false
5759
private currentUri: string | undefined
5860
private extensionVersion: string = ''
5961

6062
// Events
6163
onActiveTabChanged = this.activeTabChangedEmitter.event
6264
onReadyStateChanged = this.readyStateChangedEmitter.event
65+
onRenderedStateChanged = this.renderedStateChangedEmitter.event
6366
onVisibilityChanged = this.visibilityChangedEmitter.event
6467
onVersionAnnouncement = this.versionAnnouncementEmitter.event
6568
onStateChanged = this.stateChangedEmitter.event
@@ -350,6 +353,31 @@ export class PreviewViewModel implements PreviewViewModelInterface {
350353
this.setReady(true)
351354
}
352355

356+
/**
357+
* Handle webview 'rendered' message — posted after 2 rAF ticks, proving
358+
* the compositor is producing frames. Used as a paint-level probe for
359+
* regressions like issue #2361 where the paint pipeline stalls.
360+
*/
361+
handleRendered(): void {
362+
this.setRendered(true)
363+
}
364+
365+
/**
366+
* Set rendered state. Must be reset to false when the webview is disposed
367+
* so the next panel instance gets a fresh probe — otherwise a stale
368+
* rendered=true from a previous panel would mask a new blank-paint bug.
369+
*/
370+
setRendered(rendered: boolean): void {
371+
if (this.isRendered !== rendered) {
372+
this.isRendered = rendered
373+
this.renderedStateChangedEmitter.fire(rendered)
374+
}
375+
}
376+
377+
getIsRendered(): boolean {
378+
return this.isRendered
379+
}
380+
353381
handleToggleLabels(showLabels: boolean): void {
354382
this.template.setShowLabels(showLabels)
355383
}
@@ -385,6 +413,7 @@ export class PreviewViewModel implements PreviewViewModelInterface {
385413
this.docify.dispose()
386414
this.activeTabChangedEmitter.dispose()
387415
this.readyStateChangedEmitter.dispose()
416+
this.renderedStateChangedEmitter.dispose()
388417
this.visibilityChangedEmitter.dispose()
389418
this.versionAnnouncementEmitter.dispose()
390419
this.stateChangedEmitter.dispose()

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,10 +122,18 @@ export class PanelViewModel {
122122
* Initialize the panel
123123
*/
124124
initialize(): void {
125-
// Signal that webview is ready
125+
// Signal that webview is ready (JS has executed).
126126
this.vscode.postMessage({ type: 'ready' })
127127

128128
// Request initial docify data
129129
this.vscode.postMessage({ type: 'runDocify' })
130+
131+
// Paint probe: after 2 rAF ticks, post 'rendered'. rAF only fires
132+
// when the compositor is producing frames, so this is a live signal
133+
// that paint is actually happening — a guard against regressions like
134+
// issue #2361 where the pane stays blank despite JS running.
135+
requestAnimationFrame(() => requestAnimationFrame(() => {
136+
this.vscode.postMessage({ type: 'rendered' })
137+
}))
130138
}
131139
}

0 commit comments

Comments
 (0)