diff --git a/packages/ide/vscode/package.json b/packages/ide/vscode/package.json index 80e8c9989..4c715c2f4 100644 --- a/packages/ide/vscode/package.json +++ b/packages/ide/vscode/package.json @@ -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", @@ -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": [ diff --git a/packages/ide/vscode/src/extension/documentation-cache.ts b/packages/ide/vscode/src/extension/documentation-cache.ts new file mode 100644 index 000000000..6183ee38a --- /dev/null +++ b/packages/ide/vscode/src/extension/documentation-cache.ts @@ -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 { + const cacheKey = this.generateCacheKey(models); + const entry = this.extensionContext.globalState.get(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 { + 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 { + 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(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 { + 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)`); + } +} diff --git a/packages/ide/vscode/src/extension/main.ts b/packages/ide/vscode/src/extension/main.ts index 367f85611..2b942146f 100644 --- a/packages/ide/vscode/src/extension/main.ts +++ b/packages/ide/vscode/src/extension/main.ts @@ -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. diff --git a/packages/ide/vscode/src/extension/release-notes-manager.ts b/packages/ide/vscode/src/extension/release-notes-manager.ts new file mode 100644 index 000000000..88349eeaf --- /dev/null +++ b/packages/ide/vscode/src/extension/release-notes-manager.ts @@ -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 { + // 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 { + 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 + } +} diff --git a/packages/ide/vscode/src/extension/vscode-telemetry.ts b/packages/ide/vscode/src/extension/vscode-telemetry.ts index 186c77efe..99a58a280 100644 --- a/packages/ide/vscode/src/extension/vscode-telemetry.ts +++ b/packages/ide/vscode/src/extension/vscode-telemetry.ts @@ -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 = ''; -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; @@ -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(); diff --git a/packages/ide/vscode/src/extension/zenstack-auth-provider.ts b/packages/ide/vscode/src/extension/zenstack-auth-provider.ts new file mode 100644 index 000000000..b8081e668 --- /dev/null +++ b/packages/ide/vscode/src/extension/zenstack-auth-provider.ts @@ -0,0 +1,252 @@ +import * as vscode from 'vscode'; +import telemetry from './vscode-telemetry'; +interface JWTClaims { + jti?: string; + sub?: string; + email?: string; + exp?: number; + [key: string]: unknown; +} + +export const AUTH_PROVIDER_ID = 'ZenStack'; +export const AUTH_URL = 'https://accounts.zenstack.dev'; +export const API_URL = 'https://api.zenstack.dev'; +const EXTENSION_ID = 'zenstack.zenstack-v3'; + +export class ZenStackAuthenticationProvider implements vscode.AuthenticationProvider, vscode.Disposable { + private _onDidChangeSessions = + new vscode.EventEmitter(); + public readonly onDidChangeSessions = this._onDidChangeSessions.event; + + private _sessions: vscode.AuthenticationSession[] = []; + private _context: vscode.ExtensionContext; + private _disposable: vscode.Disposable; + private pendingAuth?: { + resolve: (session: vscode.AuthenticationSession) => void; + reject: (error: Error) => void; + scopes: readonly string[]; + }; + + constructor(context: vscode.ExtensionContext) { + this._context = context; + + this._disposable = vscode.Disposable.from( + vscode.authentication.registerAuthenticationProvider(AUTH_PROVIDER_ID, 'ZenStack', this), + vscode.window.registerUriHandler({ + handleUri: async (uri: vscode.Uri) => { + if (uri.path === '/auth-callback') { + await this.handleAuthCallback(uri); + } + }, + }), + // Register logout command + vscode.commands.registerCommand('zenstack.logout-v3', async () => { + await this.logoutAllSessions(); + }), + ); + } + + async getSessions(_scopes?: readonly string[]): Promise { + // Check if we have stored sessions in VS Code's secret storage + const storedSessions = await this.getStoredSessions(); + this._sessions = storedSessions; + return this._sessions; + } + + async createSession(scopes: readonly string[]): Promise { + // Create a login flow + const session = await this.performLogin(scopes); + if (session) { + this._sessions.push(session); + await this.storeSession(session); + this._onDidChangeSessions.fire({ + added: [session], + removed: [], + changed: [], + }); + } + return session; + } + + async removeSession(sessionId: string): Promise { + const sessionIndex = this._sessions.findIndex((s) => s.id === sessionId); + if (sessionIndex > -1) { + const session = this._sessions[sessionIndex]!; + this._sessions.splice(sessionIndex, 1); + await this.removeStoredSession(sessionId); + this._onDidChangeSessions.fire({ + added: [], + removed: [session], + changed: [], + }); + } + } + + /** + * Log out all sessions + */ + async logoutAllSessions(): Promise { + if (this._sessions.length === 0) { + return; + } + + (await this.getSessions()).forEach(async (s) => await this.removeSession(s.id)); + vscode.window.showInformationMessage('Successfully logged out of ZenStack.'); + } + + private async performLogin(scopes: readonly string[]): Promise { + // Create the authentication promise + return vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Signing in to ZenStack', + cancellable: true, + }, + async (progress, token) => { + return new Promise((resolve, reject) => { + // Handle cancellation + token.onCancellationRequested(() => { + if (this.pendingAuth) { + delete this.pendingAuth; + } + reject(new Error('User Cancelled')); + }); + + const appName = vscode.env.uriScheme; + const redirectUrl = `${API_URL}/oauth/oauth_callback?vscodeapp=${appName}&extId=${EXTENSION_ID}`; + const signInUrl = vscode.Uri.parse(new URL('/sign-in', AUTH_URL).toString()).with({ + query: `redirect_url=${encodeURIComponent(redirectUrl)}`, + }); + + const finalUrl = signInUrl.toString(true); + console.log('ZenStack sign-in URL:', finalUrl); + + // Store the state and resolve function for later use + this.pendingAuth = { resolve, reject, scopes }; + + // Open the ZenStack sign-in page in the user's default browser + // @ts-ignore - vscode issue: https://github.com/microsoft/vscode/issues/85930 + vscode.env.openExternal(finalUrl).then( + () => { + console.log('Opened ZenStack sign-in page in browser'); + progress.report({ message: 'Waiting for return from browser...' }); + }, + (error) => { + if (this.pendingAuth) { + delete this.pendingAuth; + } + reject(new Error(`Failed to open sign-in page: ${error}`)); + }, + ); + + // 2 minutes timeout + setTimeout(() => { + if (this.pendingAuth) { + delete this.pendingAuth; + } + reject(new Error('Timeout')); + }, 120000); + }); + }, + ); + } + + // Handle authentication callback from ZenStack + public async handleAuthCallback(callbackUri: vscode.Uri): Promise { + const query = new URLSearchParams(callbackUri.query); + const accessToken = query.get('access_token'); + if (!this.pendingAuth) { + console.warn('No pending authentication found'); + return; + } + if (!accessToken) { + this.pendingAuth.reject(new Error('No access token received')); + delete this.pendingAuth; + return; + } + try { + // Create session from the access token + const session = await this.createSessionFromAccessToken(accessToken); + this.pendingAuth.resolve(session); + delete this.pendingAuth; + } catch (error) { + if (this.pendingAuth) { + this.pendingAuth.reject(error instanceof Error ? error : new Error(String(error))); + delete this.pendingAuth; + } + } + } + + private async createSessionFromAccessToken(accessToken: string): Promise { + try { + // Decode JWT to get claims + const claims = this.parseJWTClaims(accessToken); + telemetry.identify(claims.email!); + return { + id: claims.jti || Math.random().toString(36), + accessToken: accessToken, + account: { + id: claims.sub || 'unknown', + label: claims.email || 'unknown@zenstack.dev', + }, + scopes: [], + }; + } catch (error) { + throw new Error(`Failed to create session from access token: ${error}`); + } + } + + private parseJWTClaims(token: string): JWTClaims { + try { + // JWT tokens have 3 parts separated by dots: header.payload.signature + const parts = token.split('.'); + if (parts.length !== 3) { + throw new Error('Invalid JWT format'); + } + + // Decode the payload (second part) - JWT uses base64url encoding + const decoded = Buffer.from(parts[1]!, 'base64url').toString('utf8'); + + return JSON.parse(decoded); + } catch (error) { + throw new Error(`Failed to parse JWT claims: ${error}`); + } + } + + private async getStoredSessions(): Promise { + try { + const stored = await this._context.secrets.get('zenstack-auth-sessions'); + return stored ? JSON.parse(stored) : []; + } catch (error) { + console.error('Error retrieving stored sessions:', error); + return []; + } + } + + private async storeSession(session: vscode.AuthenticationSession): Promise { + try { + const sessions = await this.getStoredSessions(); + sessions.push(session); + await this._context.secrets.store('zenstack-auth-sessions', JSON.stringify(sessions)); + } catch (error) { + console.error('Error storing session:', error); + } + } + + private async removeStoredSession(sessionId: string): Promise { + try { + const sessions = await this.getStoredSessions(); + const filteredSessions = sessions.filter((s) => s.id !== sessionId); + await this._context.secrets.store('zenstack-auth-sessions', JSON.stringify(filteredSessions)); + } catch (error) { + console.error('Error removing stored session:', error); + } + } + + /** + * Dispose the registered services + */ + public async dispose() { + this._disposable.dispose(); + } +} diff --git a/packages/ide/vscode/src/extension/zmodel-preview.ts b/packages/ide/vscode/src/extension/zmodel-preview.ts new file mode 100644 index 000000000..f4d7bf224 --- /dev/null +++ b/packages/ide/vscode/src/extension/zmodel-preview.ts @@ -0,0 +1,405 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as os from 'os'; +import { z } from 'zod'; +import { LanguageClient } from 'vscode-languageclient/node'; +import { URI } from 'vscode-uri'; +import { DocumentationCache } from './documentation-cache'; +import { API_URL, AUTH_PROVIDER_ID } from './zenstack-auth-provider'; +import telemetry from './vscode-telemetry'; + +/** + * ZModelPreview class handles ZModel file preview functionality + */ +export class ZModelPreview implements vscode.Disposable { + private documentationCache: DocumentationCache; + private languageClient: LanguageClient; + private lastGeneratedMarkdown: string | null = null; + // use a zero-width space in the file name to make it non-colliding with user file + private readonly previewZModelFileName = `zmodel${'\u200B'}-preview.md`; + + // Schema for validating the request body + private static DocRequestSchema = z.object({ + models: z.array( + z.object({ + path: z.string().optional(), + content: z.string(), + }), + ), + environments: z + .object({ + vscodeAppName: z.string(), + vscodeVersion: z.string(), + vscodeAppHost: z.string(), + osRelease: z.string(), + osType: z.string(), + }) + .optional(), + }); + + constructor(context: vscode.ExtensionContext, client: LanguageClient, cache: DocumentationCache) { + this.documentationCache = cache; + this.languageClient = client; + this.initialize(context); + } + + /** + * Initialize and register commands + */ + initialize(context: vscode.ExtensionContext): void { + this.registerCommands(context); + + context.subscriptions.push( + vscode.window.tabGroups.onDidChangeTabs(() => { + const activeTabLabels = vscode.window.tabGroups.all.filter((group) => + group.activeTab?.label?.endsWith(this.previewZModelFileName), + ); + if (activeTabLabels.length > 0) { + vscode.commands.executeCommand('setContext', 'zenstack.isMarkdownPreview', true); + } else { + vscode.commands.executeCommand('setContext', 'zenstack.isMarkdownPreview', false); + } + }), + ); + } + + /** + * Register ZModel preview commands + */ + private registerCommands(context: vscode.ExtensionContext): void { + // Register the preview command for zmodel files + context.subscriptions.push( + vscode.commands.registerCommand('zenstack.preview-zmodel-v3', async () => { + await this.previewZModelFile(); + }), + ); + + // Register the save documentation command for zmodel files + context.subscriptions.push( + vscode.commands.registerCommand('zenstack.save-zmodel-documentation-v3', async () => { + await this.saveZModelDocumentation(); + }), + ); + + // Register cache management commands + context.subscriptions.push( + vscode.commands.registerCommand('zenstack.clear-documentation-cache-v3', async () => { + await this.documentationCache.clearAllCache(); + vscode.window.showInformationMessage('ZenStack documentation cache cleared'); + }), + ); + } + + /** + * Preview a ZModel file + */ + async previewZModelFile(): Promise { + telemetry.track('extension:zmodel-preview'); + const editor = vscode.window.activeTextEditor; + + if (!editor) { + vscode.window.showErrorMessage('No active editor found.'); + return; + } + + const document = editor.document; + if (!document.fileName.endsWith('.zmodel')) { + vscode.window.showErrorMessage('The active file is not a ZModel file.'); + return; + } + + // Check authentication before proceeding + const session = await this.requireAuth(); + if (!session) { + return; + } + + try { + this.checkForMermaidExtensions(); + // Show progress indicator + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Generating ZModel documentation...', + cancellable: false, + }, + async () => { + const markdownContent = await this.generateZModelDocumentation(document); + + if (markdownContent) { + // Store the generated content for potential saving later + this.lastGeneratedMarkdown = markdownContent; + + await this.openMarkdownPreview(markdownContent); + } + }, + ); + } catch (error) { + console.error('Error previewing ZModel:', error); + vscode.window.showErrorMessage( + `Failed to preview ZModel: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * Get all imported ZModel URIs using the language server + */ + private async getAllImportedZModelURIs(document: vscode.TextDocument): Promise<{ + hasSyntaxErrors: boolean; + importedURIs: URI[]; + }> { + if (!this.languageClient) { + throw new Error('Language client not initialized'); + } + + try { + // Ensure the language server is ready + await this.languageClient.start(); + + // Send the custom request to get all imported ZModel URIs + const result = await this.languageClient.sendRequest('zenstack/getAllImportedZModelURIs', { + textDocument: { + uri: document.uri.toString(), + }, + }); + + return result as { + hasSyntaxErrors: boolean; + importedURIs: URI[]; + }; + } catch (error) { + console.error('Error getting AST from language server:', error); + throw error; + } + } + + /** + * Generate documentation for ZModel + */ + private async generateZModelDocumentation(document: vscode.TextDocument): Promise { + try { + const astInfo = await this.getAllImportedZModelURIs(document); + + if (astInfo?.hasSyntaxErrors !== false) { + vscode.window.showWarningMessage('Please fix the errors in the ZModel first'); + return ''; + } + + const importedURIs = astInfo?.importedURIs; + + // get vscode document from importedURIs + const importedModels = await Promise.all( + importedURIs.map(async (uri) => { + try { + const fileUri = vscode.Uri.file(uri.path); + const fileContent = await vscode.workspace.fs.readFile(fileUri); + const filePath = fileUri.path; + return { content: Buffer.from(fileContent).toString('utf8').trim(), path: filePath }; + } catch (error) { + throw new Error( + `Failed to read imported ZModel file at ${uri.path}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + }), + ); + + const allModels = [{ content: document.getText().trim(), path: document.uri.path }, ...importedModels]; + + const session = await this.requireAuth(); + if (!session) { + throw new Error('Authentication required to generate documentation'); + } + + // Prepare request body + const requestBody: z.infer = { + models: allModels, + environments: { + vscodeAppName: vscode.env.appName, + vscodeVersion: vscode.version, + vscodeAppHost: vscode.env.appHost, + osRelease: os.release(), + osType: os.type(), + }, + }; + + const allModelsContent = allModels.map((m) => m.content); + + // Check cache first + const cachedResponse = await this.documentationCache.getCachedResponse(allModelsContent); + if (cachedResponse) { + return cachedResponse; + } + + // record the time spent + const startTime = Date.now(); + const apiResponse = await fetch(`${API_URL}/api/doc`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + authorization: session.accessToken, + }, + body: JSON.stringify(requestBody), + }); + + console.log(`API request completed in ${Date.now() - startTime} ms`); + + if (!apiResponse.ok) { + throw new Error(`API request failed: ${apiResponse.status} ${apiResponse.statusText}`); + } + + const responseText = await apiResponse.text(); + + // Cache the response + await this.documentationCache.setCachedResponse(allModelsContent, responseText); + + return responseText; + } catch (error) { + console.error('Error generating documentation:', error); + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to generate documentation: ${errorMessage}`); + } + } + + /** + * Open markdown preview + */ + private async openMarkdownPreview(markdownContent: string): Promise { + // Create a temporary markdown file with a descriptive name in the system temp folder + const tempFilePath = path.join(os.tmpdir(), this.previewZModelFileName); + const tempFile = vscode.Uri.file(tempFilePath); + + try { + // Write the markdown content to the temp file + await vscode.workspace.fs.writeFile(tempFile, new TextEncoder().encode(markdownContent)); + // Open the markdown preview side by side + await vscode.commands.executeCommand('markdown.showPreviewToSide', tempFile); + } catch (error) { + console.error('Error creating markdown preview:', error); + throw new Error( + `Failed to create markdown preview: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * Save ZModel documentation to a user-selected file + */ + async saveZModelDocumentation(): Promise { + telemetry.track('extension:zmodel-save'); + // Check if we have cached content first + if (!this.lastGeneratedMarkdown) { + vscode.window.showErrorMessage( + 'No documentation content available to save. Please generate the documentation first by running "Preview ZModel Documentation".', + ); + return; + } + + // Show save dialog + let defaultFilePath = `zmodel-doc.md`; + + const workspaceFolders = vscode.workspace.workspaceFolders; + if (workspaceFolders && workspaceFolders.length > 0) { + const workspacePath = workspaceFolders[0]!.uri.fsPath; + // If the workspace folder exists, use it + defaultFilePath = path.join(workspacePath, defaultFilePath); + } + + const saveUri = await vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file(defaultFilePath), + filters: { + Markdown: ['md'], + 'All Files': ['*'], + }, + saveLabel: 'Save Documentation', + }); + + if (!saveUri) { + return; // User cancelled + } + + try { + // Write the markdown content to the selected file + await vscode.workspace.fs.writeFile(saveUri, new TextEncoder().encode(this.lastGeneratedMarkdown)); + // Open and close the saved file to refresh the shown markdown preview + await vscode.commands.executeCommand('vscode.open', saveUri); + await vscode.commands.executeCommand('workbench.action.closeActiveEditor'); + } catch (error) { + console.error('Error saving markdown file:', error); + vscode.window.showErrorMessage( + `Failed to save documentation: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * Check for Mermaid extensions + */ + private checkForMermaidExtensions(): void { + const setting = vscode.workspace.getConfiguration('zenstack').get('searchForExtensions'); + if (setting !== false) { + const extensions = vscode.extensions.all.filter((extension) => + ['markdown-mermaid', 'vscode-mermaid-chart', 'vscode-mermaid-preview'].some((name) => + extension.packageJSON.name?.toLowerCase().includes(name.toLowerCase()), + ), + ); + if (extensions.length === 0) { + const searchAction = 'Search'; + const stopShowing = "Don't show again"; + vscode.window + .showInformationMessage( + 'Search for extensions to view mermaid chart in ZModel preview doc?', + searchAction, + stopShowing, + ) + .then((selectedAction) => { + if (selectedAction === searchAction) { + vscode.commands.executeCommand('workbench.extensions.search', 'markdown-mermaid'); + } else if (selectedAction === stopShowing) { + vscode.workspace + .getConfiguration('zenstack') + .update('searchForExtensions', false, vscode.ConfigurationTarget.Global); + } + }); + } + } + } + + // Utility to require authentication when needed + private async requireAuth(): Promise { + let session: vscode.AuthenticationSession | undefined; + + session = await vscode.authentication.getSession(AUTH_PROVIDER_ID, [], { createIfNone: false }); + + if (!session) { + const signIn = 'Sign in'; + const selection = await vscode.window.showWarningMessage('Please sign in to use this feature', signIn); + telemetry.track('extension:signin:show'); + if (selection === signIn) { + telemetry.track('extension:signin:start'); + try { + session = await vscode.authentication.getSession(AUTH_PROVIDER_ID, [], { createIfNone: true }); + if (session) { + telemetry.track('extension:signin:complete'); + vscode.window.showInformationMessage('ZenStack sign-in successful!'); + } + } catch (e: unknown) { + telemetry.track('extension:signin:error', { error: e instanceof Error ? e.message : String(e) }); + vscode.window.showErrorMessage( + 'ZenStack sign-in failed: ' + (e instanceof Error ? e.message : String(e)), + ); + } + } + } + return session; + } + + /** + * Dispose of resources + */ + dispose(): void { + // Any cleanup if needed + } +} diff --git a/packages/ide/vscode/src/language-server/main.ts b/packages/ide/vscode/src/language-server/main.ts index efa215690..b51ebaf3d 100644 --- a/packages/ide/vscode/src/language-server/main.ts +++ b/packages/ide/vscode/src/language-server/main.ts @@ -1,4 +1,7 @@ import { createZModelLanguageServices } from '@zenstackhq/language'; +import type { Model } from '@zenstackhq/language/ast'; +import { getDocument, resolveImport } from '@zenstackhq/language/utils'; +import { URI, type LangiumDocument, type LangiumDocuments } from 'langium'; import { startLanguageServer } from 'langium/lsp'; import { NodeFileSystem } from 'langium/node'; import { createConnection, ProposedFeatures } from 'vscode-languageserver/node.js'; @@ -15,5 +18,75 @@ const { shared } = createZModelLanguageServices( true, ); +// Add custom LSP request handlers +connection.onRequest('zenstack/getAllImportedZModelURIs', async (params: { textDocument: { uri: string } }) => { + try { + const uri = URI.parse(params.textDocument.uri); + const document = await shared.workspace.LangiumDocuments.getOrCreateDocument(uri); + + // Ensure the document is parsed and built + if (!document.parseResult) { + await shared.workspace.DocumentBuilder.build([document]); + } + + const langiumDocuments = shared.workspace.LangiumDocuments; + + // load all imports + const importedURIs = eagerLoadAllImports(document, langiumDocuments); + + const importedDocuments = await Promise.all( + importedURIs.map((uri) => langiumDocuments.getOrCreateDocument(uri)), + ); + + // build the document together with standard library, plugin modules, and imported documents + await shared.workspace.DocumentBuilder.build([document, ...importedDocuments], { + validation: true, + }); + + let hasSyntaxErrors = false; + for (const doc of [document, ...importedDocuments]) { + if ( + doc.parseResult.lexerErrors.length > 0 || + doc.parseResult.parserErrors.length > 0 || + doc.diagnostics?.some((e) => e.severity === 1) + ) { + hasSyntaxErrors = true; + break; + } + } + + return { + hasSyntaxErrors, + importedURIs, + }; + } catch (error) { + console.error('Error getting imported ZModel file:', error); + return { + hasSyntaxErrors: true, + importedURIs: [], + }; + } +}); + +function eagerLoadAllImports(document: LangiumDocument, documents: LangiumDocuments, uris: Set = new Set()) { + const uriString = document.uri.toString(); + if (!uris.has(uriString)) { + uris.add(uriString); + const model = document.parseResult.value as Model; + + for (const imp of model.imports) { + const importedModel = resolveImport(documents, imp); + if (importedModel) { + const importedDoc = getDocument(importedModel); + eagerLoadAllImports(importedDoc, documents, uris); + } + } + } + + return Array.from(uris) + .filter((x) => uriString != x) + .map((e) => URI.parse(e)); +} + // Start the language server with the shared services startLanguageServer(shared); diff --git a/packages/language/res/zmodel-v3-preview-release-notes.html b/packages/language/res/zmodel-v3-preview-release-notes.html new file mode 100644 index 000000000..262895d87 --- /dev/null +++ b/packages/language/res/zmodel-v3-preview-release-notes.html @@ -0,0 +1,95 @@ + + + + + + + + +
+

🎉 Introducing ZModel Documentation Preview

+

Preview documentation directly from your ZModel powered by AI

+
+ +
+

📖 What's New

+

+ You can now preview comprehensive documentation for your ZModel files, just like you would preview a + markdown file. +

+
+ +
+

🚀 How to Use

+
    +
  1. Open your .zmodel file
  2. +
  3. + Click () in the editor toolbar, or press + Cmd + Shift + V (Mac) or + Ctrl + Shift + V (Windows) +
  4. +
  5. Sign in with ZenStack (one-time setup)
  6. +
  7. + Click () in the preview toolbar to save the doc, or press + Cmd + Shift + S (Mac) or + Ctrl + Shift + S (Windows) +
  8. +
+
+ +
+

💡 Tips

+
    +
  • Ensure your zmodel is error-free before generating.
  • +
  • Use your main zmodel file, which will include all imported models, for complete documentation.
  • +
  • + Add clear, descriptive comments in your ZModel. The more context you provide, the better the + results. +
  • +
+
+ +

+ Happy coding with ZenStack! 🚀
+

+ + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d84ca177..1f2366cb8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -477,6 +477,12 @@ importers: vscode-languageserver: specifier: ^9.0.1 version: 9.0.1 + vscode-uri: + specifier: ^3.1.0 + version: 3.1.0 + zod: + specifier: 'catalog:' + version: 4.1.12 devDependencies: '@types/vscode': specifier: ^1.90.0