Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 55 additions & 2 deletions .github/workflows/build-vscode-extension.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ on:
type: boolean

jobs:
build-and-publish:
name: Build, Test, and Publish VS Code Extension
build-and-test:
name: Build and Test VS Code Extension
runs-on: ubuntu-latest

steps:
Expand All @@ -43,6 +43,59 @@ jobs:
- name: Build and Test VS Code Extension
run: npm run test:vscode

integration-test:
name: Integration Test (VSCode ${{ matrix.vscode-version }})
runs-on: ubuntu-latest
needs: build-and-test
strategy:
fail-fast: false
matrix:
# 1.115.0 is the last known-good version for issue #2361.
# 1.116.0 is the first version exhibiting the blank-paint regression.
# 'stable' tracks whatever is current — early warning for new regressions.
vscode-version: ['1.115.0', '1.116.0', 'stable']

steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6

- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: v22

- name: Install workspace
run: npm ci

- name: Build workspace dependencies
# tsup in the vscode extension needs @finos/calm-shared and
# @finos/calm-models pre-built (they're file:../../ workspace deps).
run: npm run build:shared

- name: Integration Test VS Code Extension (Xvfb)
Comment thread
rocketstack-matt marked this conversation as resolved.
# Xvfb provides a virtual display so @vscode/test-electron can launch
# a real VSCode instance headlessly. xvfb-run wraps the command.
env:
VSCODE_VERSION: ${{ matrix.vscode-version }}
run: xvfb-run -a npm run test:integration --workspace=calm-plugins/vscode

package-and-publish:
name: Package and Publish VS Code Extension
runs-on: ubuntu-latest
needs: [build-and-test, integration-test]

steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6

- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: v22

- name: Install workspace
run: npm ci

- name: Package VS Code Extension
run: npm run package:vscode

Expand Down
6 changes: 6 additions & 0 deletions calm-plugins/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -177,19 +177,25 @@
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:integration:compile": "tsc -p test/integration",
"test:integration": "npm run build && npm run test:integration:compile && node ./out/integration/runTest.js",
"lint": "eslint src",
"lint-fix": "eslint src --fix",
"package": "npm run build && npx @vscode/vsce package --no-dependencies",
"vscode:prepublish": "npm run build"
},
"devDependencies": {
"@types/markdown-it": "^14.1.2",
"@types/mocha": "^10.0.6",
"@types/svg-pan-zoom": "^3.3.0",
"@types/vscode": "^1.88.0",
"@vscode/dts": "^0.4.1",
"@vscode/test-electron": "^2.3.9",
"@vscode/vsce": "^3.7.1",
"copyfiles": "^2.4.1",
"eslint-plugin-import": "^2.32.0",
"glob": "^10.3.10",
"mocha": "^10.2.0",
"tsup": "^8.4.0"
},
"dependencies": {
Expand Down
7 changes: 7 additions & 0 deletions calm-plugins/vscode/src/calm-extension-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,19 @@ import { DiagnosticsService } from './core/services/diagnostics-service'
import { createApplicationStore, type ApplicationStoreApi } from './application-store'
import { setWidgetLogger } from '@finos/calm-shared'
import { ValidationService } from './features/validation/validation-service'
import { createTestApi, CalmExtensionTestApi } from './test-api'

/**
* Main extension controller that orchestrates all VS Code extension functionality
*/
export class CalmExtensionController {
private disposables: vscode.Disposable[] = []
private logging: LoggingService | undefined
private previewPanelFactory: PreviewPanelFactory | undefined

getTestApi(): CalmExtensionTestApi | undefined {
return this.previewPanelFactory ? createTestApi(this.previewPanelFactory) : undefined
}

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

const configService: Config = new ConfigService()
const previewPanelFactory = new PreviewPanelFactory()
this.previewPanelFactory = previewPanelFactory
const treeManager = new TreeViewFactory(store)
const editorFactory = new EditorFactory(store)
const navigationService = new NavigationService(log, configService)
Expand Down
6 changes: 4 additions & 2 deletions calm-plugins/vscode/src/extension.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import * as vscode from 'vscode'
import { CalmExtensionController } from './calm-extension-controller'
import type { CalmExtensionTestApi } from './test-api'


let controller: CalmExtensionController | undefined

export function activate(context: vscode.ExtensionContext) {
export async function activate(context: vscode.ExtensionContext): Promise<CalmExtensionTestApi | undefined> {
controller = new CalmExtensionController()
controller.start(context)
await controller.start(context)
return controller.getTestApi()
}

export function deactivate() {
Expand Down
7 changes: 7 additions & 0 deletions calm-plugins/vscode/src/features/preview/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export type InMsg =
| { type: 'revealInEditor'; id: string }
| { type: 'selected'; id: string }
| { type: 'ready' }
| { type: 'rendered' }
| { type: 'runDocify'; templatePath?: string }
| { type: 'requestModelData' }
| { type: 'requestTemplateData' }
Expand All @@ -20,6 +21,7 @@ export interface PreviewCommandTarget {
handleRevealInEditor(id: string): void
handleSelected(id: string): void
handleReady(): void
handleRendered(): void
handleRunDocify(): void
handleRequestModelData(): void
handleRequestTemplateData(): void
Expand Down Expand Up @@ -57,6 +59,11 @@ export class ReadyCmd implements WebviewCommand<{ type: 'ready' }> {
constructor(private p: PreviewCommandTarget) { }
execute() { this.p.handleReady() }
}
export class RenderedCmd implements WebviewCommand<{ type: 'rendered' }> {
readonly type = 'rendered' as const
constructor(private p: PreviewCommandTarget) { }
execute() { this.p.handleRendered() }
}
export class RunDocifyCmd implements WebviewCommand<{ type: 'runDocify'; templatePath?: string }> {
readonly type = 'runDocify' as const
constructor(private p: PreviewCommandTarget) { }
Expand Down
27 changes: 27 additions & 0 deletions calm-plugins/vscode/src/features/preview/preview-panel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,33 @@ describe('CalmPreviewPanel', () => {
}
})

describe('createOrShow panel lifecycle', () => {
// Regression guard for issue #2361: on VSCode 1.116+, calling panel.reveal()
// on a panel that createWebviewPanel just created causes a blank first paint.
// createWebviewPanel already shows the panel, so reveal() must only fire on reuse.

const fakeUri = { fsPath: '/test/arch.json' } as any

it('does NOT call panel.reveal() when creating a brand-new panel', () => {
expect(CalmPreviewPanel.currentPanel).toBeUndefined()

CalmPreviewPanel.createOrShow(mockContext, fakeUri, mockConfig, mockLogger)

expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1)
expect(mockPanel.reveal).not.toHaveBeenCalled()
})

it('DOES call panel.reveal() when reusing an existing panel', () => {
CalmPreviewPanel.createOrShow(mockContext, fakeUri, mockConfig, mockLogger)
expect(mockPanel.reveal).not.toHaveBeenCalled() // sanity: brand-new path did not reveal

CalmPreviewPanel.createOrShow(mockContext, fakeUri, mockConfig, mockLogger)

expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1) // not recreated
expect(mockPanel.reveal).toHaveBeenCalled()
})
})

describe('isRelativePath method', () => {
let panel: CalmPreviewPanel

Expand Down
21 changes: 17 additions & 4 deletions calm-plugins/vscode/src/features/preview/preview-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
RevealInEditorCmd,
SelectedCmd,
ReadyCmd,
RenderedCmd,
RunDocifyCmd,
RequestModelDataCmd,
RequestTemplateDataCmd,
Expand Down Expand Up @@ -76,7 +77,9 @@ export class CalmPreviewPanel {
],
})
CalmPreviewPanel.currentPanel = new CalmPreviewPanel(panel, context, config, log)
CalmPreviewPanel.currentPanel.reveal(uri)
// createWebviewPanel already shows the panel; calling reveal() here causes a
// blank first paint on VSCode 1.116+. Initialize state without re-revealing.
CalmPreviewPanel.currentPanel.reveal(uri, { revealPanel: false })
return CalmPreviewPanel.currentPanel
}

Expand Down Expand Up @@ -111,7 +114,8 @@ export class CalmPreviewPanel {
],
})
CalmPreviewPanel.currentPanel = new CalmPreviewPanel(panel, context, config, log, viewModel)
CalmPreviewPanel.currentPanel.reveal(uri)
// See note in createOrShow: skip panel.reveal() on brand-new panels.
CalmPreviewPanel.currentPanel.reveal(uri, { revealPanel: false })
return CalmPreviewPanel.currentPanel
}

Expand All @@ -138,6 +142,7 @@ export class CalmPreviewPanel {
this.commands.register(new RevealInEditorCmd(this))
this.commands.register(new SelectedCmd(this))
this.commands.register(new ReadyCmd(this))
this.commands.register(new RenderedCmd(this))
this.commands.register(new RunDocifyCmd(this))
this.commands.register(new RequestModelDataCmd(this))
this.commands.register(new RequestTemplateDataCmd(this))
Expand Down Expand Up @@ -212,7 +217,8 @@ export class CalmPreviewPanel {
}

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

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

this.panel.reveal(vscode.ViewColumn.Beside)
if (revealPanel) {
this.panel.reveal(vscode.ViewColumn.Beside)
}
}

getCurrentUri(): vscode.Uri | undefined {
Expand Down Expand Up @@ -297,6 +305,11 @@ export class CalmPreviewPanel {
this.viewModel.handleReady()
}

public handleRendered() {
this.log.info('[preview] handleRendered() called - webview compositor produced a frame')
this.viewModel.handleRendered()
}

public handleRunDocify() {
this.viewModel.handleRunDocify()
}
Expand Down
20 changes: 20 additions & 0 deletions calm-plugins/vscode/src/features/preview/preview.view-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export class PreviewViewModel implements PreviewViewModelInterface {
// Main orchestration emitters
private activeTabChangedEmitter = new Emitter<'model' | 'template' | 'docify'>()
private readyStateChangedEmitter = new Emitter<boolean>()
private renderedStateChangedEmitter = new Emitter<boolean>()
private visibilityChangedEmitter = new Emitter<boolean>()
private versionAnnouncementEmitter = new Emitter<{ version: string; message: string }>()
private stateChangedEmitter = new Emitter<void>()
Expand All @@ -54,12 +55,14 @@ export class PreviewViewModel implements PreviewViewModelInterface {
private activeTab: 'model' | 'template' | 'docify' = 'model'
private isVisible = false
private isReady = false
private isRendered = false
private currentUri: string | undefined
private extensionVersion: string = ''

// Events
onActiveTabChanged = this.activeTabChangedEmitter.event
onReadyStateChanged = this.readyStateChangedEmitter.event
onRenderedStateChanged = this.renderedStateChangedEmitter.event
onVisibilityChanged = this.visibilityChangedEmitter.event
Comment thread
rocketstack-matt marked this conversation as resolved.
onVersionAnnouncement = this.versionAnnouncementEmitter.event
onStateChanged = this.stateChangedEmitter.event
Expand Down Expand Up @@ -350,6 +353,22 @@ export class PreviewViewModel implements PreviewViewModelInterface {
this.setReady(true)
}

/**
* Handle webview 'rendered' message — posted after 2 rAF ticks, proving
* the compositor is producing frames. Used as a paint-level probe for
* regressions like issue #2361 where the paint pipeline stalls.
*/
handleRendered(): void {
if (!this.isRendered) {
this.isRendered = true
this.renderedStateChangedEmitter.fire(true)
}
}

getIsRendered(): boolean {
return this.isRendered
}

handleToggleLabels(showLabels: boolean): void {
this.template.setShowLabels(showLabels)
}
Expand Down Expand Up @@ -385,6 +404,7 @@ export class PreviewViewModel implements PreviewViewModelInterface {
this.docify.dispose()
this.activeTabChangedEmitter.dispose()
this.readyStateChangedEmitter.dispose()
this.renderedStateChangedEmitter.dispose()
this.visibilityChangedEmitter.dispose()
this.versionAnnouncementEmitter.dispose()
this.stateChangedEmitter.dispose()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,18 @@ export class PanelViewModel {
* Initialize the panel
*/
initialize(): void {
// Signal that webview is ready
// Signal that webview is ready (JS has executed).
this.vscode.postMessage({ type: 'ready' })

// Request initial docify data
this.vscode.postMessage({ type: 'runDocify' })

// Paint probe: after 2 rAF ticks, post 'rendered'. rAF only fires
// when the compositor is producing frames, so this is a live signal
// that paint is actually happening — a guard against regressions like
// issue #2361 where the pane stays blank despite JS running.
requestAnimationFrame(() => requestAnimationFrame(() => {
this.vscode.postMessage({ type: 'rendered' })
}))
}
}
Loading
Loading