Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
66 changes: 65 additions & 1 deletion packages/ide/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@
"mixpanel": "^0.18.0",
"uuid": "^11.1.0",
"vscode-languageclient": "^9.0.1",
"vscode-languageserver": "^9.0.1"
"vscode-languageserver": "^9.0.1",
"vscode-uri": "^3.1.0",
"zod": "catalog:"
},
"devDependencies": {
"@types/vscode": "^1.90.0",
Expand Down Expand Up @@ -82,6 +84,68 @@
"scopeName": "source.zmodel-v3",
"path": "./syntaxes/zmodel.tmLanguage.json"
}
],
"menus": {
"editor/title": [
{
"command": "zenstack.preview-zmodel-v3",
"when": "editorLangId == zmodel-v3",
"group": "navigation"
},
{
"command": "zenstack.save-zmodel-documentation-v3",
"when": "(activeWebviewPanelId == 'markdown.preview' || activeCustomEditorId == 'vscode.markdown.preview.editor') && zenstack.isMarkdownPreview == true",
"group": "navigation"
}
],
"commandPalette": [
{
"command": "zenstack.preview-zmodel-v3",
"when": "editorLangId == zmodel-v3"
},
{
"command": "zenstack.clear-documentation-cache-v3"
},
{
"command": "zenstack.logout-v3"
}
]
},
"commands": [
{
"command": "zenstack.preview-zmodel-v3",
"title": "ZenStack: Preview ZModel Documentation",
"icon": "$(preview)"
},
{
"command": "zenstack.save-zmodel-documentation-v3",
"title": "ZenStack: Save ZModel Documentation",
"icon": "$(save)"
},
{
"command": "zenstack.clear-documentation-cache-v3",
"title": "ZenStack: Clear Documentation Cache",
"icon": "$(trash)"
},
{
"command": "zenstack.logout-v3",
"title": "ZenStack: Logout",
"icon": "$(log-out)"
}
],
"keybindings": [
{
"command": "zenstack.preview-zmodel-v3",
"key": "ctrl+shift+v",
"mac": "cmd+shift+v",
"when": "editorLangId == zmodel-v3"
},
{
"command": "zenstack.save-zmodel-documentation-v3",
"key": "ctrl+shift+s",
"mac": "cmd+shift+s",
"when": "(activeWebviewPanelId == 'markdown.preview' || activeCustomEditorId == 'vscode.markdown.preview.editor') && zenstack.isMarkdownPreview == true"
}
]
},
"activationEvents": [
Expand Down
152 changes: 152 additions & 0 deletions packages/ide/vscode/src/extension/documentation-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import * as vscode from 'vscode';
import { createHash } from 'crypto';

// Cache entry interface
interface CacheEntry {
data: string;
timestamp: number;
extensionVersion: string;
}

/**
* DocumentationCache class handles persistent caching of ZModel documentation
* using VS Code's globalState for cross-session persistence
*/
export class DocumentationCache implements vscode.Disposable {
private static readonly CACHE_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days cache duration
private static readonly CACHE_PREFIX = 'doc-cache.';

private extensionContext: vscode.ExtensionContext;
private extensionVersion: string;

constructor(context: vscode.ExtensionContext) {
this.extensionContext = context;
this.extensionVersion = context.extension.packageJSON.version as string;
// clear expired cache entries on initialization
this.clearExpiredCache();
}

/**
* Dispose of the cache resources (implements vscode.Disposable)
*/
dispose(): void {}

/**
* Get the cache prefix used for keys
*/
getCachePrefix(): string {
return DocumentationCache.CACHE_PREFIX;
}

/**
* Enable cache synchronization across machines via VS Code Settings Sync
*/
private enableCacheSync(): void {
const cacheKeys = this.extensionContext.globalState
.keys()
.filter((key) => key.startsWith(DocumentationCache.CACHE_PREFIX));
if (cacheKeys.length > 0) {
this.extensionContext.globalState.setKeysForSync(cacheKeys);
}
}

/**
* Generate a cache key from request body with normalized content
*/
private generateCacheKey(models: string[]): string {
// Remove ALL whitespace characters from each model string for cache key generation
// This ensures identical content with different formatting uses the same cache
const normalizedModels = models.map((model) => model.replace(/\s/g, '')).sort();
const hash = createHash('sha512')
.update(JSON.stringify({ models: normalizedModels }))
.digest('hex');
return `${DocumentationCache.CACHE_PREFIX}${hash}`;
}

/**
* Check if cache entry is still valid (not expired)
*/
private isCacheValid(entry: CacheEntry): boolean {
return Date.now() - entry.timestamp < DocumentationCache.CACHE_DURATION_MS;
}

/**
* Get cached response if available and valid
*/
async getCachedResponse(models: string[]): Promise<string | null> {
const cacheKey = this.generateCacheKey(models);
const entry = this.extensionContext.globalState.get<CacheEntry>(cacheKey);

if (entry && this.isCacheValid(entry)) {
console.log('Using cached documentation response from persistent storage');
return entry.data;
}

// Clean up expired entry if it exists
if (entry) {
await this.extensionContext.globalState.update(cacheKey, undefined);
}

return null;
}

/**
* Cache a response for future use
*/
async setCachedResponse(models: string[], data: string): Promise<void> {
const cacheKey = this.generateCacheKey(models);
const cacheEntry: CacheEntry = {
data,
timestamp: Date.now(),
extensionVersion: this.extensionVersion,
};

await this.extensionContext.globalState.update(cacheKey, cacheEntry);

// Update sync keys to include new cache entry
this.enableCacheSync();
}

/**
* Clear expired cache entries from persistent storage
*/
async clearExpiredCache(): Promise<void> {
const now = Date.now();
let clearedCount = 0;
const allKeys = this.extensionContext.globalState.keys();

for (const key of allKeys) {
if (key.startsWith(DocumentationCache.CACHE_PREFIX)) {
const entry = this.extensionContext.globalState.get<CacheEntry>(key);
if (
entry?.extensionVersion !== this.extensionVersion ||
now - entry.timestamp >= DocumentationCache.CACHE_DURATION_MS
) {
await this.extensionContext.globalState.update(key, undefined);
clearedCount++;
}
}
}

if (clearedCount > 0) {
console.log(`Cleared ${clearedCount} expired cache entries from persistent storage`);
}
}

/**
* Clear all cache entries from persistent storage
*/
async clearAllCache(): Promise<void> {
const allKeys = this.extensionContext.globalState.keys();
let clearedCount = 0;

for (const key of allKeys) {
if (key.startsWith(DocumentationCache.CACHE_PREFIX)) {
await this.extensionContext.globalState.update(key, undefined);
clearedCount++;
}
}

console.log(`Cleared all cache entries from persistent storage (${clearedCount} items)`);
}
}
15 changes: 14 additions & 1 deletion packages/ide/vscode/src/extension/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,27 @@ import * as path from 'node:path';
import type * as vscode from 'vscode';
import type { LanguageClientOptions, ServerOptions } from 'vscode-languageclient/node.js';
import { LanguageClient, TransportKind } from 'vscode-languageclient/node.js';
import { DocumentationCache } from './documentation-cache';
import { ReleaseNotesManager } from './release-notes-manager';
import telemetry from './vscode-telemetry';
import { ZenStackAuthenticationProvider } from './zenstack-auth-provider';
import { ZModelPreview } from './zmodel-preview';

let client: LanguageClient;

// This function is called when the extension is activated.
export function activate(context: vscode.ExtensionContext): void {
client = startLanguageClient(context);
telemetry.track('extension:activate');

// Initialize and register the ZenStack authentication provider
context.subscriptions.push(new ZenStackAuthenticationProvider(context));

client = startLanguageClient(context);

const documentationCache = new DocumentationCache(context);
context.subscriptions.push(documentationCache);
context.subscriptions.push(new ZModelPreview(context, client, documentationCache));
context.subscriptions.push(new ReleaseNotesManager(context));
}

// This function is called when the extension is deactivated.
Expand Down
77 changes: 77 additions & 0 deletions packages/ide/vscode/src/extension/release-notes-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import * as vscode from 'vscode';

/**
* ReleaseNotesManager class handles release notes functionality
*/
export class ReleaseNotesManager implements vscode.Disposable {
private extensionContext: vscode.ExtensionContext;
private readonly zmodelPreviewReleaseNoteKey = 'zmodel-v3-preview-release-note-shown';

constructor(context: vscode.ExtensionContext) {
this.extensionContext = context;
this.initialize();
}

/**
* Initialize and register commands, show release notes if first time
*/
initialize(): void {
this.showReleaseNotesIfFirstTime();
}

/**
* Show release notes on first activation of this version
*/
async showReleaseNotesIfFirstTime(): Promise<void> {
// Show release notes if this is the first time activating this version
if (!this.extensionContext.globalState.get(this.zmodelPreviewReleaseNoteKey)) {
await this.showReleaseNotes();
// Update the stored version to prevent showing again
await this.extensionContext.globalState.update(this.zmodelPreviewReleaseNoteKey, true);
// Add this key to sync keys for cross-machine synchronization
this.extensionContext.globalState.setKeysForSync([this.zmodelPreviewReleaseNoteKey]);
}
}

/**
* Show release notes (can be called manually)
*/
async showReleaseNotes(): Promise<void> {
try {
// Read the release notes HTML file
const releaseNotesPath = vscode.Uri.joinPath(
this.extensionContext.extensionUri,
'res/zmodel-v3-preview-release-notes.html',
);

const htmlBytes = await vscode.workspace.fs.readFile(releaseNotesPath);
const htmlContent = Buffer.from(htmlBytes).toString('utf8');
// Create and show the release notes webview
const panel = vscode.window.createWebviewPanel(
'ZenstackReleaseNotes',
'ZenStack - New Feature Announcement!',
vscode.ViewColumn.One,
{
enableScripts: true,
retainContextWhenHidden: true,
},
);

panel.webview.html = htmlContent;

// Optional: Close the panel when user clicks outside or after some time
panel.onDidDispose(() => {
// Panel disposed
});
} catch (error) {
console.error('Error showing release notes:', error);
}
}

/**
* Dispose of resources
*/
dispose(): void {
// Any cleanup if needed
}
}
25 changes: 21 additions & 4 deletions packages/ide/vscode/src/extension/vscode-telemetry.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import { init } from 'mixpanel';
import type { Mixpanel } from 'mixpanel';
import { init } from 'mixpanel';
import * as os from 'os';
import * as vscode from 'vscode';
import { getMachineId } from './machine-id-utils';
import { v5 as uuidv5 } from 'uuid';
import * as vscode from 'vscode';
import { version as extensionVersion } from '../../package.json';
import { getMachineId } from './machine-id-utils';

export const VSCODE_TELEMETRY_TRACKING_TOKEN = '<VSCODE_TELEMETRY_TRACKING_TOKEN>';

export type TelemetryEvents = 'extension:activate' | 'extension:zmodel-preview' | 'extension:zmodel-save';
export type TelemetryEvents =
| 'extension:activate'
| 'extension:zmodel-preview'
| 'extension:zmodel-save'
| 'extension:signin:show'
| 'extension:signin:start'
| 'extension:signin:error'
| 'extension:signin:complete';

export class VSCodeTelemetry {
private readonly mixpanel: Mixpanel | undefined;
Expand Down Expand Up @@ -57,6 +64,16 @@ export class VSCodeTelemetry {
this.mixpanel.track(event, payload);
}
}

identify(userId: string) {
if (this.mixpanel) {
this.mixpanel.track('$identify', {
$identified_id: userId,
$anon_id: this.deviceId,
token: VSCODE_TELEMETRY_TRACKING_TOKEN,
});
}
}
}

export default new VSCodeTelemetry();
Loading
Loading