diff --git a/packages/schema/package.json b/packages/schema/package.json index 41bb70f7f..7f7aa07a1 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -27,7 +27,7 @@ "linkDirectory": true }, "engines": { - "vscode": "^1.63.0" + "vscode": "^1.99.3" }, "categories": [ "Programming Languages" @@ -64,13 +64,68 @@ "type": "boolean", "default": true, "description": "Use Prisma style indentation." + }, + "zenstack.searchForExtensions": { + "type": "boolean", + "default": true, + "description": "Search for Mermaid extensions when viewing Mermaid source." } } - } + }, + "menus": { + "editor/title": [ + { + "command": "zenstack.preview-zmodel", + "when": "editorLangId == zmodel", + "group": "navigation" + } + ], + "commandPalette": [ + { + "command": "zenstack.preview-zmodel", + "when": "editorLangId == zmodel" + }, + { + "command": "zenstack.clear-documentation-cache" + }, + { + "command": "zenstack.logout" + } + ] + }, + "commands": [ + { + "command": "zenstack.preview-zmodel", + "title": "ZenStack: Preview ZModel Documentation", + "icon": "$(preview)" + }, + { + "command": "zenstack.clear-documentation-cache", + "title": "ZenStack: Clear Documentation Cache", + "icon": "$(trash)" + }, + { + "command": "zenstack.logout", + "title": "ZenStack: Logout", + "icon": "$(log-out)" + } + ], + "keybindings": [ + { + "command": "zenstack.preview-zmodel", + "key": "ctrl+shift+v", + "mac": "cmd+shift+v", + "when": "editorLangId == zmodel" + } + ] + }, + "activationEvents": [], + "capabilities": { + "untrustedWorkspaces": { + "supported": true + }, + "virtualWorkspaces": true }, - "activationEvents": [ - "onLanguage:zmodel" - ], "bin": { "zenstack": "bin/cli" }, @@ -127,7 +182,7 @@ "@types/strip-color": "^0.1.0", "@types/tmp": "^0.2.3", "@types/uuid": "^8.3.4", - "@types/vscode": "^1.56.0", + "@types/vscode": "^1.99.3", "@vscode/vsce": "^3.5.0", "@zenstackhq/runtime": "workspace:*", "dotenv": "^16.0.3", diff --git a/packages/schema/src/documentation-cache.ts b/packages/schema/src/documentation-cache.ts new file mode 100644 index 000000000..6183ee38a --- /dev/null +++ b/packages/schema/src/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/schema/src/extension.ts b/packages/schema/src/extension.ts index a3e19d7f8..c7abe53be 100644 --- a/packages/schema/src/extension.ts +++ b/packages/schema/src/extension.ts @@ -1,12 +1,52 @@ import * as vscode from 'vscode'; import * as path from 'path'; + import { LanguageClient, LanguageClientOptions, ServerOptions, TransportKind } from 'vscode-languageclient/node'; +import { AUTH_PROVIDER_ID, ZenStackAuthenticationProvider } from './zenstack-auth-provider'; +import { DocumentationCache } from './documentation-cache'; +import { ZModelPreview } from './zmodel-preview'; +import { ReleaseNotesManager } from './release-notes-manager'; +// Global variables let client: LanguageClient; +// Utility to require authentication when needed +export async function 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); + if (selection === signIn) { + try { + session = await vscode.authentication.getSession(AUTH_PROVIDER_ID, [], { createIfNone: true }); + if (session) { + vscode.window.showInformationMessage('ZenStack sign-in successful!'); + } + } catch (e: unknown) { + vscode.window.showErrorMessage( + 'ZenStack sign-in failed: ' + (e instanceof Error ? e.message : String(e)) + ); + } + } + } + return session; +} + // This function is called when the extension is activated. export function activate(context: vscode.ExtensionContext): void { + // Initialize and register the ZenStack authentication provider + context.subscriptions.push(new ZenStackAuthenticationProvider(context)); + + // Start language client 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/schema/src/language-server/main.ts b/packages/schema/src/language-server/main.ts index 301561643..679012f2f 100644 --- a/packages/schema/src/language-server/main.ts +++ b/packages/schema/src/language-server/main.ts @@ -1,7 +1,9 @@ import { startLanguageServer } from 'langium'; import { NodeFileSystem } from 'langium/node'; import { createConnection, ProposedFeatures } from 'vscode-languageserver/node'; +import { URI } from 'vscode-uri'; import { createZModelServices } from './zmodel-module'; +import { eagerLoadAllImports } from '../cli/cli-util'; // Create a connection to the client const connection = createConnection(ProposedFeatures.all); @@ -9,5 +11,51 @@ const connection = createConnection(ProposedFeatures.all); // Inject the shared services and language-specific services const { shared } = createZModelServices({ connection, ...NodeFileSystem }); +// Add custom LSP request handlers +connection.onRequest('zenstack/getAllImportedZModelURIs', async (params: { textDocument: { uri: string } }) => { + try { + const uri = URI.parse(params.textDocument.uri); + const document = shared.workspace.LangiumDocuments.getOrCreateDocument(uri); + + // Ensure the document is parsed and built + if (!document.parseResult) { + await shared.workspace.DocumentBuilder.build([document]); + } + + // #region merge imported documents + const langiumDocuments = shared.workspace.LangiumDocuments; + + // load all imports + const importedURIs = eagerLoadAllImports(document, langiumDocuments); + + const importedDocuments = 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], { + validationChecks: 'all', + }); + + const hasSyntaxErrors = [uri, ...importedURIs].some((uri) => { + const doc = langiumDocuments.getOrCreateDocument(uri); + return ( + doc.parseResult.lexerErrors.length > 0 || + doc.parseResult.parserErrors.length > 0 || + doc.diagnostics?.some((e) => e.severity === 1) + ); + }); + + return { + hasSyntaxErrors, + importedURIs, + }; + } catch (error) { + console.error('Error getting imported ZModel file:', error); + return { + hasSyntaxErrors: true, + importedURIs: [], + }; + } +}); + // Start the language server with the shared services startLanguageServer(shared); diff --git a/packages/schema/src/release-notes-manager.ts b/packages/schema/src/release-notes-manager.ts new file mode 100644 index 000000000..0c44cba72 --- /dev/null +++ b/packages/schema/src/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-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, + 'bundle/res/zmodel-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/schema/src/res/zmodel-preview-release-notes.html b/packages/schema/src/res/zmodel-preview-release-notes.html new file mode 100644 index 000000000..5f115fd70 --- /dev/null +++ b/packages/schema/src/res/zmodel-preview-release-notes.html @@ -0,0 +1,91 @@ + + + + + + + + +
+

🎉 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 simply press + Cmd + Shift + V (Mac) or + Ctrl + Shift + V (Windows) +
  4. +
  5. Sign in with ZenStack (one-time setup)
  6. +
  7. Enjoy your AI-generated documentation
  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/packages/schema/src/zenstack-auth-provider.ts b/packages/schema/src/zenstack-auth-provider.ts new file mode 100644 index 000000000..815913c56 --- /dev/null +++ b/packages/schema/src/zenstack-auth-provider.ts @@ -0,0 +1,257 @@ +import * as vscode from 'vscode'; + +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'; + +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', 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 isCursor = vscode.env.appName == 'Cursor'; + + let signInUrl = vscode.Uri.parse(new URL('/sign-in', AUTH_URL).toString()); + + if (isCursor) { + signInUrl = signInUrl.with({ + query: `redirect_url=${API_URL}/oauth/oauth_callback?vscodeapp=cursor`, + }); + } + + console.log('ZenStack sign-in URL:', signInUrl.toString()); + // 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 + vscode.env.openExternal(signInUrl).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); + }); + } + ); + } + private generateState(): string { + return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); + } + + // 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); + 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) + const payload = parts[1]; + // Add padding if needed for base64 decoding + const paddedPayload = payload + '='.repeat((4 - (payload.length % 4)) % 4); + const decoded = atob(paddedPayload); + + 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/schema/src/zmodel-preview.ts b/packages/schema/src/zmodel-preview.ts new file mode 100644 index 000000000..96b1ab518 --- /dev/null +++ b/packages/schema/src/zmodel-preview.ts @@ -0,0 +1,302 @@ +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 { requireAuth } from './extension'; +import { API_URL } from './zenstack-auth-provider'; + +/** + * ZModelPreview class handles ZModel file preview functionality + */ +export class ZModelPreview implements vscode.Disposable { + private documentationCache: DocumentationCache; + private languageClient: LanguageClient; + + // 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); + } + + /** + * 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', async () => { + await this.previewZModelFile(); + }) + ); + + // Register cache management commands + context.subscriptions.push( + vscode.commands.registerCommand('zenstack.clear-documentation-cache', async () => { + await this.documentationCache.clearAllCache(); + vscode.window.showInformationMessage('ZenStack documentation cache cleared'); + }) + ); + } + + /** + * Preview a ZModel file + */ + async previewZModelFile(): Promise { + 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 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) { + await this.openMarkdownPreview(markdownContent, document.fileName); + } + } + ); + } 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 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, originalFileName: string): Promise { + // Create a temporary markdown file with a descriptive name in the system temp folder + const baseName = path.basename(originalFileName, '.zmodel'); + const tempFileName = `${baseName}-preview.md`; + const tempFilePath = path.join(os.tmpdir(), tempFileName); + 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)}` + ); + } + } + + /** + * 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); + } + }); + } + } + } + + /** + * Dispose of resources + */ + dispose(): void { + // Any cleanup if needed + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae00dbbd0..0ab81316d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -548,8 +548,8 @@ importers: specifier: ^8.3.4 version: 8.3.4 '@types/vscode': - specifier: ^1.56.0 - version: 1.90.0 + specifier: ^1.99.3 + version: 1.103.0 '@vscode/vsce': specifier: ^3.5.0 version: 3.5.0 @@ -3236,8 +3236,8 @@ packages: '@types/uuid@8.3.4': resolution: {integrity: sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==} - '@types/vscode@1.90.0': - resolution: {integrity: sha512-oT+ZJL7qHS9Z8bs0+WKf/kQ27qWYR3trsXpq46YDjFqBsMLG4ygGGjPaJ2tyrH0wJzjOEmDyg9PDJBBhWg9pkQ==} + '@types/vscode@1.103.0': + resolution: {integrity: sha512-o4hanZAQdNfsKecexq9L3eHICd0AAvdbLk6hA60UzGXbGH/q8b/9xv2RgR7vV3ZcHuyKVq7b37IGd/+gM4Tu+Q==} '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -11502,7 +11502,7 @@ snapshots: '@types/uuid@8.3.4': {} - '@types/vscode@1.90.0': {} + '@types/vscode@1.103.0': {} '@types/yargs-parser@21.0.3': {}