From cf3fc59b53dcd803d91b626910523e7a28c0b6ca Mon Sep 17 00:00:00 2001 From: jiasheng Date: Tue, 9 Sep 2025 23:47:06 +0800 Subject: [PATCH 1/6] feat: preview zmodel doc --- packages/schema/build/bundle.js | 6 + packages/schema/build/post-build.js | 11 + packages/schema/package.json | 69 +++- packages/schema/src/documentation-cache.ts | 153 +++++++++ packages/schema/src/extension.ts | 38 +++ packages/schema/src/language-server/main.ts | 48 +++ packages/schema/src/mermaid-generator.ts | 198 ++++++++++++ packages/schema/src/release-notes-manager.ts | 76 +++++ packages/schema/src/release-notes.html | 90 ++++++ packages/schema/src/zenstack-auth-provider.ts | 221 +++++++++++++ packages/schema/src/zmodel-preview.ts | 294 ++++++++++++++++++ 11 files changed, 1198 insertions(+), 6 deletions(-) create mode 100644 packages/schema/src/documentation-cache.ts create mode 100644 packages/schema/src/mermaid-generator.ts create mode 100644 packages/schema/src/release-notes-manager.ts create mode 100644 packages/schema/src/release-notes.html create mode 100644 packages/schema/src/zenstack-auth-provider.ts create mode 100644 packages/schema/src/zmodel-preview.ts diff --git a/packages/schema/build/bundle.js b/packages/schema/build/bundle.js index 9c4ee70c0..b189328f8 100644 --- a/packages/schema/build/bundle.js +++ b/packages/schema/build/bundle.js @@ -16,6 +16,12 @@ require('esbuild') .then(() => { fs.cpSync('./src/res', 'bundle/res', { force: true, recursive: true }); fs.cpSync('../language/syntaxes', 'bundle/syntaxes', { force: true, recursive: true }); + + // Copy release notes HTML file + if (fs.existsSync('src/release-notes.html')) { + fs.copyFileSync('src/release-notes.html', 'bundle/release-notes.html'); + console.log('Copied release notes HTML file to bundle'); + } }) .then(() => console.log(success)) .catch((err) => { diff --git a/packages/schema/build/post-build.js b/packages/schema/build/post-build.js index c8623f4c5..207a4e3e9 100644 --- a/packages/schema/build/post-build.js +++ b/packages/schema/build/post-build.js @@ -27,3 +27,14 @@ console.log('Updating file: dist/cli/index.js'); fs.writeFileSync('dist/cli/index.js', cliContent, { encoding: 'utf-8', }); + +// Copy release notes HTML file to dist +const releaseNotesSource = 'src/release-notes.html'; +const releaseNotesDest = 'dist/release-notes.html'; + +if (fs.existsSync(releaseNotesSource)) { + console.log('Copying release notes HTML file to dist'); + fs.copyFileSync(releaseNotesSource, releaseNotesDest); +} else { + console.warn('Release notes HTML file not found at:', releaseNotesSource); +} diff --git a/packages/schema/package.json b/packages/schema/package.json index 41bb70f7f..1cdbcd95c 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -27,12 +27,21 @@ "linkDirectory": true }, "engines": { - "vscode": "^1.63.0" + "vscode": "^1.90.0" }, "categories": [ "Programming Languages" ], "contributes": { + "languageModelTools": [ + { + "name": "zmodel_mermaid_generator", + "displayName": "ZModel Mermaid Generator", + "modelDescription": "Generate Mermaid charts from ZModel schema files. This tool analyzes the current ZModel file and creates comprehensive entity-relationship diagrams showing all models and their relationships.", + "canBeReferencedInPrompt": true, + "toolReferenceName": "zmodel_mermaid_generator" + } + ], "languages": [ { "id": "zmodel", @@ -64,13 +73,60 @@ "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" + } + ] + }, + "commands": [ + { + "command": "zenstack.preview-zmodel", + "title": "ZenStack: Preview ZModel", + "icon": "$(preview)" + }, + { + "command": "zenstack.clear-documentation-cache", + "title": "ZenStack: Clear Documentation Cache", + "icon": "$(trash)" + } + ], + "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 +183,8 @@ "@types/strip-color": "^0.1.0", "@types/tmp": "^0.2.3", "@types/uuid": "^8.3.4", - "@types/vscode": "^1.56.0", + "@types/vscode": "^1.102.0", + "@vscode/chat-extension-utils": "0.0.0-alpha.5", "@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..44167777f --- /dev/null +++ b/packages/schema/src/documentation-cache.ts @@ -0,0 +1,153 @@ +import * as vscode from 'vscode'; +import crypto 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 = 5 * 60 * 1000; // 5 minutes 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(requestBody: { 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 = requestBody.models.map((model) => model.replace(/\s/g, '')); + const hash = crypto + .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(requestBody: { models: string[] }): Promise { + const cacheKey = this.generateCacheKey(requestBody); + 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(requestBody: { models: string[] }, data: string): Promise { + const cacheKey = this.generateCacheKey(requestBody); + 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..e299ddc12 100644 --- a/packages/schema/src/extension.ts +++ b/packages/schema/src/extension.ts @@ -1,12 +1,50 @@ 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) { + vscode.window.showErrorMessage('ZenStack sign in failed: ' + 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/mermaid-generator.ts b/packages/schema/src/mermaid-generator.ts new file mode 100644 index 000000000..6ea6de2d8 --- /dev/null +++ b/packages/schema/src/mermaid-generator.ts @@ -0,0 +1,198 @@ +import { + getModelFieldsWithBases, + isDelegateModel, + isForeignKeyField, + isIdField, + isRelationshipField, +} from '@zenstackhq/sdk'; +import { DataModel, DataModelField, isDataModel, isTypeDef, Model, TypeDef } from '@zenstackhq/sdk/ast'; + +export default class MermaidGenerator { + constructor(private model: Model) {} + + generate(dataModel: DataModel) { + const allFields = getModelFieldsWithBases(dataModel); + + const fields = allFields + .filter((x) => !isRelationshipField(x) && !isTypeDef(x.type.reference?.ref)) + .map((x) => { + return [ + x.type.type || x.type.reference?.ref?.name, + x.name, + isIdField(x) ? 'PK' : isForeignKeyField(x) ? 'FK' : '', + x.type.optional ? '"?"' : '', + ].join(' '); + }) + .map((x) => ` ${x}`) + .join('\n'); + + const relations = allFields + .filter((x) => isRelationshipField(x)) + .map((x) => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const oppositeModelName = (x.type.reference!.ref as DataModel).name; + + const oppositeModel = this.model.declarations.find( + (y) => isDataModel(y) && y.name === oppositeModelName + ) as DataModel; + + const oppositeField = oppositeModel.fields.find( + (x) => x.type.reference?.ref?.name == dataModel.name + ) as DataModelField; + + let relation = ''; + + if (oppositeField) { + const currentType = x.type; + const oppositeType = oppositeField.type; + + if (currentType.array && oppositeType.array) { + //many to many + relation = '}o--o{'; + } else if (currentType.array && !oppositeType.array) { + //one to many + relation = '||--o{'; + } else if (!currentType.array && oppositeType.array) { + //many to one + relation = '}o--||'; + } else { + //one to one + relation = currentType.optional ? '||--o|' : '|o--||'; + } + return [`"${dataModel.name}"`, relation, `"${oppositeField.$container.name}": ${x.name}`].join(' '); + } else { + // ignore polymorphic relations + return [`"${dataModel.name}"`, relation].join(' '); + } + }) + .join('\n'); + + const jsonFields = allFields + .filter((x) => isTypeDef(x.type.reference?.ref)) + .map((x) => { + return this.generateTypeDef(x.type.reference?.ref as TypeDef, x.name, dataModel.name, new Set()); + }) + .join('\n'); + + let delegateInfo = ''; + if (dataModel.superTypes.length == 1 && isDelegateModel(dataModel.superTypes[0].ref as DataModel)) { + const delegateModel = dataModel.superTypes[0].ref as DataModel; + + delegateInfo = [ + `"${delegateModel.name}" {} \n"${delegateModel.name}" ||--|| "${dataModel.name}": delegates`, + ].join('\n'); + } + + return [ + '```mermaid', + 'erDiagram', + `"${dataModel.name}" {\n${fields}\n}`, + delegateInfo, + relations, + jsonFields, + '```', + ].join('\n'); + } + + // Generate a comprehensive ER diagram with all models and their relationships + generateComprehensive(): string { + console.log('Generating comprehensive ER diagram...'); + + const dataModels = this.model.declarations.filter((x) => isDataModel(x) && !x.isAbstract) as DataModel[]; + + if (dataModels.length === 0) { + return '```mermaid\nerDiagram\n```'; + } + + // Generate entities + const entities = dataModels + .map((model) => { + const allFields = getModelFieldsWithBases(model); + const fields = allFields + .filter((x) => !isRelationshipField(x) && !isTypeDef(x.type.reference?.ref)) + .map((x) => { + return [ + x.type.type || x.type.reference?.ref?.name, + x.name, + isIdField(x) ? 'PK' : isForeignKeyField(x) ? 'FK' : '', + x.type.optional ? '"?"' : '', + ].join(' '); + }) + .map((x) => ` ${x}`) + .join('\n'); + + return `"${model.name}" {\n${fields}\n}`; + }) + .join('\n'); + + // Collect all relationships + const relationships = new Set(); + dataModels.forEach((model) => { + const allFields = getModelFieldsWithBases(model); + allFields + .filter((x) => isRelationshipField(x)) + .forEach((x) => { + const oppositeModelName = (x.type.reference!.ref as DataModel).name; + const oppositeModel = dataModels.find((m) => m.name === oppositeModelName); + + if (oppositeModel) { + const oppositeField = oppositeModel.fields.find( + (field) => field.type.reference?.ref?.name === model.name + ); + + if (oppositeField) { + const currentType = x.type; + const oppositeType = oppositeField.type; + + let relation = ''; + if (currentType.array && oppositeType.array) { + relation = '}o--o{'; + } else if (currentType.array && !oppositeType.array) { + relation = '||--o{'; + } else if (!currentType.array && oppositeType.array) { + relation = '}o--||'; + } else { + relation = currentType.optional ? '||--o|' : '|o--||'; + } + relationships.add(`"${model.name}" ${relation} "${oppositeModelName}": ${x.name}`); + } + } + }); + }); + + return ['```mermaid', 'erDiagram', entities, Array.from(relationships).join('\n'), '```'].join('\n'); + } + + generateTypeDef( + typeDef: TypeDef, + fieldName: string, + relatedEntityName: string, + visited: Set = new Set() + ): string { + // Check if this TypeDef has already been visited to prevent infinite recursion + if (visited.has(typeDef.name)) { + return ''; + } + + // Add current TypeDef to visited set + visited.add(typeDef.name); + + const fields = typeDef.fields + .filter((x) => !isTypeDef(x.type.reference?.ref)) + .map((x) => { + return [x.type.type || x.type.reference?.ref?.name, x.name, x.type.optional ? '"?"' : ''].join(' '); + }) + .map((x) => ` ${x}`) + .join('\n'); + + const jsonFields = typeDef.fields + .filter((x) => isTypeDef(x.type.reference?.ref)) + .map((x) => this.generateTypeDef(x.type.reference?.ref as TypeDef, x.name, typeDef.name, visited)) + .join('\n'); + + return [ + `"${typeDef.name}" {\n${fields}\n} \n"${relatedEntityName}" ||--|| "${typeDef.name}": ${fieldName}`, + jsonFields, + ].join('\n'); + } +} diff --git a/packages/schema/src/release-notes-manager.ts b/packages/schema/src/release-notes-manager.ts new file mode 100644 index 000000000..5667048ba --- /dev/null +++ b/packages/schema/src/release-notes-manager.ts @@ -0,0 +1,76 @@ +import * as vscode from 'vscode'; + +/** + * ReleaseNotesManager class handles release notes functionality + */ +export class ReleaseNotesManager implements vscode.Disposable { + private extensionContext: vscode.ExtensionContext; + private releaseNoteVersionKey: string; + + constructor(context: vscode.ExtensionContext) { + this.extensionContext = context; + this.releaseNoteVersionKey = `release-notes-shown:${this.extensionContext.extension.packageJSON.version}`; + 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.releaseNoteVersionKey)) { + await this.showReleaseNotes(); + // Update the stored version to prevent showing again + await this.extensionContext.globalState.update(this.releaseNoteVersionKey, true); + // Add this key to sync keys for cross-machine synchronization + this.extensionContext.globalState.setKeysForSync([this.releaseNoteVersionKey]); + } + } + + /** + * Show release notes (can be called manually) + */ + async showReleaseNotes(): Promise { + try { + // Create and show the release notes webview + const panel = vscode.window.createWebviewPanel( + 'zenstackReleaseNotes', + 'ZenStack - New Feature Announcement!', + vscode.ViewColumn.One, + { + enableScripts: true, + retainContextWhenHidden: true, + } + ); + + // Read the release notes HTML file + const releaseNotesPath = vscode.Uri.joinPath(this.extensionContext.extensionUri, 'src/release-notes.html'); + + const htmlBytes = await vscode.workspace.fs.readFile(releaseNotesPath); + const htmlContent = Buffer.from(htmlBytes).toString('utf8'); + + 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/release-notes.html b/packages/schema/src/release-notes.html new file mode 100644 index 000000000..b037f3b49 --- /dev/null +++ b/packages/schema/src/release-notes.html @@ -0,0 +1,90 @@ + + + + + + + + +
+

🎉 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 previewing a + markdown file. +

+
+ +
+

🚀 How to Use

+
    +
  1. Open your .zmodel file
  2. +
  3. + Click () in the editor toolbar, or simply press + Cmd+Shift+V +
  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, it will include all imported models for a 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..4f9fd3f30 --- /dev/null +++ b/packages/schema/src/zenstack-auth-provider.ts @@ -0,0 +1,221 @@ +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 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?: { + state: string; + 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); + } + }, + }) + ); + } + + 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: [], + }); + } + } + + private async performLogin(scopes: readonly string[]): Promise { + return new Promise((resolve, reject) => { + // Generate a unique state parameter for security + const state = this.generateState(); + // Construct the ZenStack sign-in URL for implicit flow (returns access_token directly) + const signInUrl = new URL('/sign-in', AUTH_URL); + + // Store the state and resolve function for later use + this.pendingAuth = { state, resolve, reject, scopes }; + + // Open the ZenStack sign-in page in the user's default browser + vscode.env.openExternal(vscode.Uri.parse(signInUrl.toString())).then( + () => { + console.log('Opened ZenStack sign-in page in browser'); + }, + (error) => { + delete this.pendingAuth; + reject(new Error(`Failed to open sign-in page: ${error}`)); + } + ); + }); + } + 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); + + console.log('Parsed JWT claims:', claims); + + 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); + } + } + + async getUserEmail(session: vscode.AuthenticationSession): Promise { + try { + // Extract email from JWT claims instead of making API call + const claims = this.parseJWTClaims(session.accessToken); + return claims.email; + } catch (error) { + console.error('Error extracting email from JWT:', error); + // Fallback to account label if JWT parsing fails + return session.account.label.includes('@') ? session.account.label : undefined; + } + } + + /** + * 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..7467853ce --- /dev/null +++ b/packages/schema/src/zmodel-preview.ts @@ -0,0 +1,294 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as os from 'os'; +import { LanguageClient } from 'vscode-languageclient/node'; +import { URI } from 'vscode-uri'; +import { DocumentationCache } from './documentation-cache'; +import { requireAuth } from './extension'; + +/** + * ZModelPreview class handles ZModel file preview functionality + */ +export class ZModelPreview implements vscode.Disposable { + private documentationCache: DocumentationCache; + private languageClient: LanguageClient; + + 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 { + // 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); + this.checkForMermaidExtensions(); + } + } + ); + } 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 importedTexts = await Promise.all( + importedURIs.map(async (uri) => { + try { + const fileUri = vscode.Uri.file(uri.path); + const fileContent = await vscode.workspace.fs.readFile(fileUri); + return Buffer.from(fileContent).toString('utf8'); + } catch (error) { + console.warn(`Could not read file for URI ${uri}:`, error); + return null; + } + }) + ); + + const zmodelContent = [document.getText(), ...importedTexts.filter((text) => text !== null)]; + + // Trim whitespace from each model string + const trimmedZmodelContent = zmodelContent.map((content) => content.trim()); + + console.log('ZModel content generated:', trimmedZmodelContent); + + // Fallback: fetch from API endpoint + const session = await requireAuth(); + if (!session) { + throw new Error('Authentication required to generate documentation'); + } + + // Prepare request body + const requestBody = { + models: trimmedZmodelContent, + environments: { + editorName: vscode.env.appName, + vscodeVersion: vscode.version, + appHost: vscode.env.appHost, + osRelease: os.release(), + osType: os.type(), + }, + }; + + // Check cache first + const cachedResponse = await this.documentationCache.getCachedResponse(requestBody); + if (cachedResponse) { + return cachedResponse; + } + + // record the time spent + const startTime = Date.now(); + const apiResponse = await fetch('https://api.zenstack.dev/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(requestBody, 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 { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) { + vscode.window.showErrorMessage('No workspace folder found.'); + return; + } + + // Create a temporary markdown file with a descriptive name + const baseName = path.basename(originalFileName, '.zmodel'); + const tempFileName = `${baseName}-preview.md`; + const tempFile = vscode.Uri.joinPath(workspaceFolder.uri, tempFileName); + + 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); + + // Optionally clean up the temp file after a delay + setTimeout(async () => { + try { + await vscode.workspace.fs.delete(tempFile); + } catch (error) { + // Ignore cleanup errors + console.log('Could not clean up temp file:', error); + } + }, 5000); // Clean up after 5 seconds + } 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) => extension.packageJSON.name === 'markdown-mermaid' + ); + 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 + } +} From 88e4281ee1701de61db41c7b60e7af626168563c Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Tue, 9 Sep 2025 11:31:41 -0700 Subject: [PATCH 2/6] update lock file --- pnpm-lock.yaml | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae00dbbd0..78040654d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -548,8 +548,11 @@ importers: specifier: ^8.3.4 version: 8.3.4 '@types/vscode': - specifier: ^1.56.0 - version: 1.90.0 + specifier: ^1.102.0 + version: 1.103.0 + '@vscode/chat-extension-utils': + specifier: 0.0.0-alpha.5 + version: 0.0.0-alpha.5 '@vscode/vsce': specifier: ^3.5.0 version: 3.5.0 @@ -3236,8 +3239,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==} @@ -3354,6 +3357,12 @@ packages: '@vitest/utils@0.29.8': resolution: {integrity: sha512-qGzuf3vrTbnoY+RjjVVIBYfuWMjn3UMUqyQtdGNZ6ZIIyte7B37exj6LaVkrZiUTvzSadVvO/tJm8AEgbGCBPg==} + '@vscode/chat-extension-utils@0.0.0-alpha.5': + resolution: {integrity: sha512-EkfetTIGMDyClZkIx8oMOhprlXufnj0b/G1W4QGg4jhkWVUBE7kLRZsqEnpNjmtxHTugzc61gPQwT3zgH3HXgA==} + + '@vscode/prompt-tsx@0.3.0-alpha.24': + resolution: {integrity: sha512-WUz6rPLcN6F64WxxwTiLzHOuhUcdLKBWMckppn43DBC1Ba67Lvd9RV+2LOxj938YzvEVOKGoAY/qgRtXd77I7Q==} + '@vscode/vsce-sign-alpine-arm64@2.0.2': resolution: {integrity: sha512-E80YvqhtZCLUv3YAf9+tIbbqoinWLCO/B3j03yQPbjT3ZIHCliKZlsy1peNc4XNZ5uIb87Jn0HWx/ZbPXviuAQ==} cpu: [arm64] @@ -11502,7 +11511,7 @@ snapshots: '@types/uuid@8.3.4': {} - '@types/vscode@1.90.0': {} + '@types/vscode@1.103.0': {} '@types/yargs-parser@21.0.3': {} @@ -11676,6 +11685,12 @@ snapshots: loupe: 2.3.7 pretty-format: 27.5.1 + '@vscode/chat-extension-utils@0.0.0-alpha.5': + dependencies: + '@vscode/prompt-tsx': 0.3.0-alpha.24 + + '@vscode/prompt-tsx@0.3.0-alpha.24': {} + '@vscode/vsce-sign-alpine-arm64@2.0.2': optional: true From 56b77368335299e49b6112bb35408a4d25b1af55 Mon Sep 17 00:00:00 2001 From: jiasheng Date: Wed, 10 Sep 2025 21:20:43 +0800 Subject: [PATCH 3/6] fix: resolve comments --- packages/schema/build/bundle.js | 6 - packages/schema/build/post-build.js | 11 - packages/schema/package.json | 3 +- packages/schema/src/documentation-cache.ts | 9 +- packages/schema/src/extension.ts | 8 +- packages/schema/src/mermaid-generator.ts | 198 ------------------ packages/schema/src/release-notes-manager.ts | 25 +-- .../zmodel-preview-release-notes.html} | 9 +- packages/schema/src/zenstack-auth-provider.ts | 83 ++++---- packages/schema/src/zmodel-preview.ts | 32 +-- pnpm-lock.yaml | 3 - 11 files changed, 84 insertions(+), 303 deletions(-) delete mode 100644 packages/schema/src/mermaid-generator.ts rename packages/schema/src/{release-notes.html => res/zmodel-preview-release-notes.html} (89%) diff --git a/packages/schema/build/bundle.js b/packages/schema/build/bundle.js index b189328f8..9c4ee70c0 100644 --- a/packages/schema/build/bundle.js +++ b/packages/schema/build/bundle.js @@ -16,12 +16,6 @@ require('esbuild') .then(() => { fs.cpSync('./src/res', 'bundle/res', { force: true, recursive: true }); fs.cpSync('../language/syntaxes', 'bundle/syntaxes', { force: true, recursive: true }); - - // Copy release notes HTML file - if (fs.existsSync('src/release-notes.html')) { - fs.copyFileSync('src/release-notes.html', 'bundle/release-notes.html'); - console.log('Copied release notes HTML file to bundle'); - } }) .then(() => console.log(success)) .catch((err) => { diff --git a/packages/schema/build/post-build.js b/packages/schema/build/post-build.js index 207a4e3e9..c8623f4c5 100644 --- a/packages/schema/build/post-build.js +++ b/packages/schema/build/post-build.js @@ -27,14 +27,3 @@ console.log('Updating file: dist/cli/index.js'); fs.writeFileSync('dist/cli/index.js', cliContent, { encoding: 'utf-8', }); - -// Copy release notes HTML file to dist -const releaseNotesSource = 'src/release-notes.html'; -const releaseNotesDest = 'dist/release-notes.html'; - -if (fs.existsSync(releaseNotesSource)) { - console.log('Copying release notes HTML file to dist'); - fs.copyFileSync(releaseNotesSource, releaseNotesDest); -} else { - console.warn('Release notes HTML file not found at:', releaseNotesSource); -} diff --git a/packages/schema/package.json b/packages/schema/package.json index 1cdbcd95c..a8ac2a385 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -27,7 +27,7 @@ "linkDirectory": true }, "engines": { - "vscode": "^1.90.0" + "vscode": "^1.102.0" }, "categories": [ "Programming Languages" @@ -184,7 +184,6 @@ "@types/tmp": "^0.2.3", "@types/uuid": "^8.3.4", "@types/vscode": "^1.102.0", - "@vscode/chat-extension-utils": "0.0.0-alpha.5", "@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 index 44167777f..aff9848cf 100644 --- a/packages/schema/src/documentation-cache.ts +++ b/packages/schema/src/documentation-cache.ts @@ -1,5 +1,5 @@ import * as vscode from 'vscode'; -import crypto from 'crypto'; +import { createHash } from 'crypto'; // Cache entry interface interface CacheEntry { @@ -13,7 +13,7 @@ interface CacheEntry { * using VS Code's globalState for cross-session persistence */ export class DocumentationCache implements vscode.Disposable { - private static readonly CACHE_DURATION_MS = 5 * 60 * 1000; // 5 minutes cache duration + private static readonly CACHE_DURATION_MS = 7 * 24 * 60 * 60 * 1000; // 7 days cache duration private static readonly CACHE_PREFIX = 'doc-cache.'; private extensionContext: vscode.ExtensionContext; @@ -56,9 +56,8 @@ export class DocumentationCache implements vscode.Disposable { private generateCacheKey(requestBody: { 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 = requestBody.models.map((model) => model.replace(/\s/g, '')); - const hash = crypto - .createHash('sha512') + const normalizedModels = requestBody.models.map((model) => model.replace(/\s/g, '')).sort(); + const hash = createHash('sha512') .update(JSON.stringify({ models: normalizedModels })) .digest('hex'); return `${DocumentationCache.CACHE_PREFIX}${hash}`; diff --git a/packages/schema/src/extension.ts b/packages/schema/src/extension.ts index e299ddc12..c7abe53be 100644 --- a/packages/schema/src/extension.ts +++ b/packages/schema/src/extension.ts @@ -23,10 +23,12 @@ export async function requireAuth(): Promise !isRelationshipField(x) && !isTypeDef(x.type.reference?.ref)) - .map((x) => { - return [ - x.type.type || x.type.reference?.ref?.name, - x.name, - isIdField(x) ? 'PK' : isForeignKeyField(x) ? 'FK' : '', - x.type.optional ? '"?"' : '', - ].join(' '); - }) - .map((x) => ` ${x}`) - .join('\n'); - - const relations = allFields - .filter((x) => isRelationshipField(x)) - .map((x) => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const oppositeModelName = (x.type.reference!.ref as DataModel).name; - - const oppositeModel = this.model.declarations.find( - (y) => isDataModel(y) && y.name === oppositeModelName - ) as DataModel; - - const oppositeField = oppositeModel.fields.find( - (x) => x.type.reference?.ref?.name == dataModel.name - ) as DataModelField; - - let relation = ''; - - if (oppositeField) { - const currentType = x.type; - const oppositeType = oppositeField.type; - - if (currentType.array && oppositeType.array) { - //many to many - relation = '}o--o{'; - } else if (currentType.array && !oppositeType.array) { - //one to many - relation = '||--o{'; - } else if (!currentType.array && oppositeType.array) { - //many to one - relation = '}o--||'; - } else { - //one to one - relation = currentType.optional ? '||--o|' : '|o--||'; - } - return [`"${dataModel.name}"`, relation, `"${oppositeField.$container.name}": ${x.name}`].join(' '); - } else { - // ignore polymorphic relations - return [`"${dataModel.name}"`, relation].join(' '); - } - }) - .join('\n'); - - const jsonFields = allFields - .filter((x) => isTypeDef(x.type.reference?.ref)) - .map((x) => { - return this.generateTypeDef(x.type.reference?.ref as TypeDef, x.name, dataModel.name, new Set()); - }) - .join('\n'); - - let delegateInfo = ''; - if (dataModel.superTypes.length == 1 && isDelegateModel(dataModel.superTypes[0].ref as DataModel)) { - const delegateModel = dataModel.superTypes[0].ref as DataModel; - - delegateInfo = [ - `"${delegateModel.name}" {} \n"${delegateModel.name}" ||--|| "${dataModel.name}": delegates`, - ].join('\n'); - } - - return [ - '```mermaid', - 'erDiagram', - `"${dataModel.name}" {\n${fields}\n}`, - delegateInfo, - relations, - jsonFields, - '```', - ].join('\n'); - } - - // Generate a comprehensive ER diagram with all models and their relationships - generateComprehensive(): string { - console.log('Generating comprehensive ER diagram...'); - - const dataModels = this.model.declarations.filter((x) => isDataModel(x) && !x.isAbstract) as DataModel[]; - - if (dataModels.length === 0) { - return '```mermaid\nerDiagram\n```'; - } - - // Generate entities - const entities = dataModels - .map((model) => { - const allFields = getModelFieldsWithBases(model); - const fields = allFields - .filter((x) => !isRelationshipField(x) && !isTypeDef(x.type.reference?.ref)) - .map((x) => { - return [ - x.type.type || x.type.reference?.ref?.name, - x.name, - isIdField(x) ? 'PK' : isForeignKeyField(x) ? 'FK' : '', - x.type.optional ? '"?"' : '', - ].join(' '); - }) - .map((x) => ` ${x}`) - .join('\n'); - - return `"${model.name}" {\n${fields}\n}`; - }) - .join('\n'); - - // Collect all relationships - const relationships = new Set(); - dataModels.forEach((model) => { - const allFields = getModelFieldsWithBases(model); - allFields - .filter((x) => isRelationshipField(x)) - .forEach((x) => { - const oppositeModelName = (x.type.reference!.ref as DataModel).name; - const oppositeModel = dataModels.find((m) => m.name === oppositeModelName); - - if (oppositeModel) { - const oppositeField = oppositeModel.fields.find( - (field) => field.type.reference?.ref?.name === model.name - ); - - if (oppositeField) { - const currentType = x.type; - const oppositeType = oppositeField.type; - - let relation = ''; - if (currentType.array && oppositeType.array) { - relation = '}o--o{'; - } else if (currentType.array && !oppositeType.array) { - relation = '||--o{'; - } else if (!currentType.array && oppositeType.array) { - relation = '}o--||'; - } else { - relation = currentType.optional ? '||--o|' : '|o--||'; - } - relationships.add(`"${model.name}" ${relation} "${oppositeModelName}": ${x.name}`); - } - } - }); - }); - - return ['```mermaid', 'erDiagram', entities, Array.from(relationships).join('\n'), '```'].join('\n'); - } - - generateTypeDef( - typeDef: TypeDef, - fieldName: string, - relatedEntityName: string, - visited: Set = new Set() - ): string { - // Check if this TypeDef has already been visited to prevent infinite recursion - if (visited.has(typeDef.name)) { - return ''; - } - - // Add current TypeDef to visited set - visited.add(typeDef.name); - - const fields = typeDef.fields - .filter((x) => !isTypeDef(x.type.reference?.ref)) - .map((x) => { - return [x.type.type || x.type.reference?.ref?.name, x.name, x.type.optional ? '"?"' : ''].join(' '); - }) - .map((x) => ` ${x}`) - .join('\n'); - - const jsonFields = typeDef.fields - .filter((x) => isTypeDef(x.type.reference?.ref)) - .map((x) => this.generateTypeDef(x.type.reference?.ref as TypeDef, x.name, typeDef.name, visited)) - .join('\n'); - - return [ - `"${typeDef.name}" {\n${fields}\n} \n"${relatedEntityName}" ||--|| "${typeDef.name}": ${fieldName}`, - jsonFields, - ].join('\n'); - } -} diff --git a/packages/schema/src/release-notes-manager.ts b/packages/schema/src/release-notes-manager.ts index 5667048ba..0c44cba72 100644 --- a/packages/schema/src/release-notes-manager.ts +++ b/packages/schema/src/release-notes-manager.ts @@ -5,11 +5,10 @@ import * as vscode from 'vscode'; */ export class ReleaseNotesManager implements vscode.Disposable { private extensionContext: vscode.ExtensionContext; - private releaseNoteVersionKey: string; + private readonly zmodelPreviewReleaseNoteKey = 'zmodel-preview-release-note-shown'; constructor(context: vscode.ExtensionContext) { this.extensionContext = context; - this.releaseNoteVersionKey = `release-notes-shown:${this.extensionContext.extension.packageJSON.version}`; this.initialize(); } @@ -25,12 +24,12 @@ export class ReleaseNotesManager implements vscode.Disposable { */ async showReleaseNotesIfFirstTime(): Promise { // Show release notes if this is the first time activating this version - if (!this.extensionContext.globalState.get(this.releaseNoteVersionKey)) { + if (!this.extensionContext.globalState.get(this.zmodelPreviewReleaseNoteKey)) { await this.showReleaseNotes(); // Update the stored version to prevent showing again - await this.extensionContext.globalState.update(this.releaseNoteVersionKey, true); + await this.extensionContext.globalState.update(this.zmodelPreviewReleaseNoteKey, true); // Add this key to sync keys for cross-machine synchronization - this.extensionContext.globalState.setKeysForSync([this.releaseNoteVersionKey]); + this.extensionContext.globalState.setKeysForSync([this.zmodelPreviewReleaseNoteKey]); } } @@ -39,9 +38,17 @@ export class ReleaseNotesManager implements vscode.Disposable { */ 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', + 'ZenstackReleaseNotes', 'ZenStack - New Feature Announcement!', vscode.ViewColumn.One, { @@ -50,12 +57,6 @@ export class ReleaseNotesManager implements vscode.Disposable { } ); - // Read the release notes HTML file - const releaseNotesPath = vscode.Uri.joinPath(this.extensionContext.extensionUri, 'src/release-notes.html'); - - const htmlBytes = await vscode.workspace.fs.readFile(releaseNotesPath); - const htmlContent = Buffer.from(htmlBytes).toString('utf8'); - panel.webview.html = htmlContent; // Optional: Close the panel when user clicks outside or after some time diff --git a/packages/schema/src/release-notes.html b/packages/schema/src/res/zmodel-preview-release-notes.html similarity index 89% rename from packages/schema/src/release-notes.html rename to packages/schema/src/res/zmodel-preview-release-notes.html index b037f3b49..5f115fd70 100644 --- a/packages/schema/src/release-notes.html +++ b/packages/schema/src/res/zmodel-preview-release-notes.html @@ -53,7 +53,7 @@

🎉 Introducing ZModel Documentation Preview

📖 What's New

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

@@ -64,7 +64,8 @@

🚀 How to Use

  • Open your .zmodel file
  • Click () in the editor toolbar, or simply press - Cmd+Shift+V + Cmd + Shift + V (Mac) or + Ctrl + Shift + V (Windows)
  • Sign in with ZenStack (one-time setup)
  • Enjoy your AI-generated documentation
  • @@ -74,8 +75,8 @@

    🚀 How to Use

    💡 Tips

      -
    • Ensure your zmodel is error-free before generating
    • -
    • Use your main zmodel file, it will include all imported models for a complete documentation
    • +
    • 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. diff --git a/packages/schema/src/zenstack-auth-provider.ts b/packages/schema/src/zenstack-auth-provider.ts index 4f9fd3f30..19eb33e8f 100644 --- a/packages/schema/src/zenstack-auth-provider.ts +++ b/packages/schema/src/zenstack-auth-provider.ts @@ -78,26 +78,54 @@ export class ZenStackAuthenticationProvider implements vscode.AuthenticationProv } private async performLogin(scopes: readonly string[]): Promise { - return new Promise((resolve, reject) => { - // Generate a unique state parameter for security - const state = this.generateState(); - // Construct the ZenStack sign-in URL for implicit flow (returns access_token directly) - const signInUrl = new URL('/sign-in', AUTH_URL); - - // Store the state and resolve function for later use - this.pendingAuth = { state, resolve, reject, scopes }; - - // Open the ZenStack sign-in page in the user's default browser - vscode.env.openExternal(vscode.Uri.parse(signInUrl.toString())).then( - () => { - console.log('Opened ZenStack sign-in page in browser'); - }, - (error) => { - delete this.pendingAuth; - reject(new Error(`Failed to open sign-in page: ${error}`)); - } - ); - }); + // 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')); + }); + + // Generate a unique state parameter for security + const state = this.generateState(); + // Construct the ZenStack sign-in URL for implicit flow (returns access_token directly) + const signInUrl = new URL('/sign-in', AUTH_URL); + + // Store the state and resolve function for later use + this.pendingAuth = { state, resolve, reject, scopes }; + + // Open the ZenStack sign-in page in the user's default browser + vscode.env.openExternal(vscode.Uri.parse(signInUrl.toString())).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}`)); + } + ); + + setTimeout(() => { + if (this.pendingAuth) { + delete this.pendingAuth; + } + reject(new Error('Timeout')); + }, 60000); + }); + } + ); } private generateState(): string { return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); @@ -133,9 +161,6 @@ export class ZenStackAuthenticationProvider implements vscode.AuthenticationProv try { // Decode JWT to get claims const claims = this.parseJWTClaims(accessToken); - - console.log('Parsed JWT claims:', claims); - return { id: claims.jti || Math.random().toString(36), accessToken: accessToken, @@ -200,18 +225,6 @@ export class ZenStackAuthenticationProvider implements vscode.AuthenticationProv } } - async getUserEmail(session: vscode.AuthenticationSession): Promise { - try { - // Extract email from JWT claims instead of making API call - const claims = this.parseJWTClaims(session.accessToken); - return claims.email; - } catch (error) { - console.error('Error extracting email from JWT:', error); - // Fallback to account label if JWT parsing fails - return session.account.label.includes('@') ? session.account.label : undefined; - } - } - /** * Dispose the registered services */ diff --git a/packages/schema/src/zmodel-preview.ts b/packages/schema/src/zmodel-preview.ts index 7467853ce..98c745aa0 100644 --- a/packages/schema/src/zmodel-preview.ts +++ b/packages/schema/src/zmodel-preview.ts @@ -70,6 +70,7 @@ export class ZModelPreview implements vscode.Disposable { } try { + this.checkForMermaidExtensions(); // Show progress indicator await vscode.window.withProgress( { @@ -82,7 +83,6 @@ export class ZModelPreview implements vscode.Disposable { if (markdownContent) { await this.openMarkdownPreview(markdownContent, document.fileName); - this.checkForMermaidExtensions(); } } ); @@ -159,9 +159,6 @@ export class ZModelPreview implements vscode.Disposable { // Trim whitespace from each model string const trimmedZmodelContent = zmodelContent.map((content) => content.trim()); - console.log('ZModel content generated:', trimmedZmodelContent); - - // Fallback: fetch from API endpoint const session = await requireAuth(); if (!session) { throw new Error('Authentication required to generate documentation'); @@ -219,16 +216,11 @@ export class ZModelPreview implements vscode.Disposable { * Open markdown preview */ private async openMarkdownPreview(markdownContent: string, originalFileName: string): Promise { - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - if (!workspaceFolder) { - vscode.window.showErrorMessage('No workspace folder found.'); - return; - } - - // Create a temporary markdown file with a descriptive name + // 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 tempFile = vscode.Uri.joinPath(workspaceFolder.uri, tempFileName); + const tempFilePath = path.join(os.tmpdir(), tempFileName); + const tempFile = vscode.Uri.file(tempFilePath); try { // Write the markdown content to the temp file @@ -236,16 +228,6 @@ export class ZModelPreview implements vscode.Disposable { // Open the markdown preview side by side await vscode.commands.executeCommand('markdown.showPreviewToSide', tempFile); - - // Optionally clean up the temp file after a delay - setTimeout(async () => { - try { - await vscode.workspace.fs.delete(tempFile); - } catch (error) { - // Ignore cleanup errors - console.log('Could not clean up temp file:', error); - } - }, 5000); // Clean up after 5 seconds } catch (error) { console.error('Error creating markdown preview:', error); throw new Error( @@ -260,8 +242,10 @@ export class ZModelPreview implements vscode.Disposable { private checkForMermaidExtensions(): void { const setting = vscode.workspace.getConfiguration('zenstack').get('searchForExtensions'); if (setting !== false) { - const extensions = vscode.extensions.all.filter( - (extension) => extension.packageJSON.name === 'markdown-mermaid' + 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'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 78040654d..40b814531 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -550,9 +550,6 @@ importers: '@types/vscode': specifier: ^1.102.0 version: 1.103.0 - '@vscode/chat-extension-utils': - specifier: 0.0.0-alpha.5 - version: 0.0.0-alpha.5 '@vscode/vsce': specifier: ^3.5.0 version: 3.5.0 From 188ff5f3912e2ce22d15528446704996e058c119 Mon Sep 17 00:00:00 2001 From: jiasheng Date: Thu, 11 Sep 2025 14:15:18 +0800 Subject: [PATCH 4/6] fix: support cursor auth --- packages/schema/package.json | 15 ++---- packages/schema/src/documentation-cache.ts | 12 ++--- packages/schema/src/zenstack-auth-provider.ts | 20 ++++--- packages/schema/src/zmodel-preview.ts | 54 +++++++++++++------ pnpm-lock.yaml | 14 +---- 5 files changed, 62 insertions(+), 53 deletions(-) diff --git a/packages/schema/package.json b/packages/schema/package.json index a8ac2a385..4e5812000 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -27,21 +27,12 @@ "linkDirectory": true }, "engines": { - "vscode": "^1.102.0" + "vscode": "^1.99.3" }, "categories": [ "Programming Languages" ], "contributes": { - "languageModelTools": [ - { - "name": "zmodel_mermaid_generator", - "displayName": "ZModel Mermaid Generator", - "modelDescription": "Generate Mermaid charts from ZModel schema files. This tool analyzes the current ZModel file and creates comprehensive entity-relationship diagrams showing all models and their relationships.", - "canBeReferencedInPrompt": true, - "toolReferenceName": "zmodel_mermaid_generator" - } - ], "languages": [ { "id": "zmodel", @@ -102,7 +93,7 @@ "commands": [ { "command": "zenstack.preview-zmodel", - "title": "ZenStack: Preview ZModel", + "title": "ZenStack: Preview ZModel Documentation", "icon": "$(preview)" }, { @@ -183,7 +174,7 @@ "@types/strip-color": "^0.1.0", "@types/tmp": "^0.2.3", "@types/uuid": "^8.3.4", - "@types/vscode": "^1.102.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 index aff9848cf..7eab812c8 100644 --- a/packages/schema/src/documentation-cache.ts +++ b/packages/schema/src/documentation-cache.ts @@ -53,10 +53,10 @@ export class DocumentationCache implements vscode.Disposable { /** * Generate a cache key from request body with normalized content */ - private generateCacheKey(requestBody: { models: string[] }): string { + 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 = requestBody.models.map((model) => model.replace(/\s/g, '')).sort(); + const normalizedModels = models.map((model) => model.replace(/\s/g, '')).sort(); const hash = createHash('sha512') .update(JSON.stringify({ models: normalizedModels })) .digest('hex'); @@ -73,8 +73,8 @@ export class DocumentationCache implements vscode.Disposable { /** * Get cached response if available and valid */ - async getCachedResponse(requestBody: { models: string[] }): Promise { - const cacheKey = this.generateCacheKey(requestBody); + async getCachedResponse(models: string[]): Promise { + const cacheKey = this.generateCacheKey(models); const entry = this.extensionContext.globalState.get(cacheKey); if (entry && this.isCacheValid(entry)) { @@ -93,8 +93,8 @@ export class DocumentationCache implements vscode.Disposable { /** * Cache a response for future use */ - async setCachedResponse(requestBody: { models: string[] }, data: string): Promise { - const cacheKey = this.generateCacheKey(requestBody); + async setCachedResponse(models: string[], data: string): Promise { + const cacheKey = this.generateCacheKey(models); const cacheEntry: CacheEntry = { data, timestamp: Date.now(), diff --git a/packages/schema/src/zenstack-auth-provider.ts b/packages/schema/src/zenstack-auth-provider.ts index 19eb33e8f..12d3fcd4c 100644 --- a/packages/schema/src/zenstack-auth-provider.ts +++ b/packages/schema/src/zenstack-auth-provider.ts @@ -10,6 +10,7 @@ interface JWTClaims { 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 = @@ -20,7 +21,6 @@ export class ZenStackAuthenticationProvider implements vscode.AuthenticationProv private _context: vscode.ExtensionContext; private _disposable: vscode.Disposable; private pendingAuth?: { - state: string; resolve: (session: vscode.AuthenticationSession) => void; reject: (error: Error) => void; scopes: readonly string[]; @@ -95,16 +95,22 @@ export class ZenStackAuthenticationProvider implements vscode.AuthenticationProv reject(new Error('User Cancelled')); }); - // Generate a unique state parameter for security - const state = this.generateState(); - // Construct the ZenStack sign-in URL for implicit flow (returns access_token directly) - const signInUrl = new URL('/sign-in', AUTH_URL); + 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 = { state, resolve, reject, scopes }; + this.pendingAuth = { resolve, reject, scopes }; // Open the ZenStack sign-in page in the user's default browser - vscode.env.openExternal(vscode.Uri.parse(signInUrl.toString())).then( + vscode.env.openExternal(signInUrl).then( () => { console.log('Opened ZenStack sign-in page in browser'); progress.report({ message: 'Waiting for return from browser...' }); diff --git a/packages/schema/src/zmodel-preview.ts b/packages/schema/src/zmodel-preview.ts index 98c745aa0..96b1ab518 100644 --- a/packages/schema/src/zmodel-preview.ts +++ b/packages/schema/src/zmodel-preview.ts @@ -1,10 +1,12 @@ 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 @@ -13,6 +15,25 @@ 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; @@ -141,23 +162,24 @@ export class ZModelPreview implements vscode.Disposable { const importedURIs = astInfo?.importedURIs; // get vscode document from importedURIs - const importedTexts = await Promise.all( + 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); - return Buffer.from(fileContent).toString('utf8'); + const filePath = fileUri.path; + return { content: Buffer.from(fileContent).toString('utf8').trim(), path: filePath }; } catch (error) { - console.warn(`Could not read file for URI ${uri}:`, error); - return null; + throw new Error( + `Failed to read imported ZModel file at ${uri.path}: ${ + error instanceof Error ? error.message : String(error) + }` + ); } }) ); - const zmodelContent = [document.getText(), ...importedTexts.filter((text) => text !== null)]; - - // Trim whitespace from each model string - const trimmedZmodelContent = zmodelContent.map((content) => content.trim()); + const allModels = [{ content: document.getText().trim(), path: document.uri.path }, ...importedModels]; const session = await requireAuth(); if (!session) { @@ -165,26 +187,28 @@ export class ZModelPreview implements vscode.Disposable { } // Prepare request body - const requestBody = { - models: trimmedZmodelContent, + const requestBody: z.infer = { + models: allModels, environments: { - editorName: vscode.env.appName, + vscodeAppName: vscode.env.appName, vscodeVersion: vscode.version, - appHost: vscode.env.appHost, + 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(requestBody); + const cachedResponse = await this.documentationCache.getCachedResponse(allModelsContent); if (cachedResponse) { return cachedResponse; } // record the time spent const startTime = Date.now(); - const apiResponse = await fetch('https://api.zenstack.dev/api/doc', { + const apiResponse = await fetch(`${API_URL}/api/doc`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -202,7 +226,7 @@ export class ZModelPreview implements vscode.Disposable { const responseText = await apiResponse.text(); // Cache the response - await this.documentationCache.setCachedResponse(requestBody, responseText); + await this.documentationCache.setCachedResponse(allModelsContent, responseText); return responseText; } catch (error) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40b814531..0ab81316d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -548,7 +548,7 @@ importers: specifier: ^8.3.4 version: 8.3.4 '@types/vscode': - specifier: ^1.102.0 + specifier: ^1.99.3 version: 1.103.0 '@vscode/vsce': specifier: ^3.5.0 @@ -3354,12 +3354,6 @@ packages: '@vitest/utils@0.29.8': resolution: {integrity: sha512-qGzuf3vrTbnoY+RjjVVIBYfuWMjn3UMUqyQtdGNZ6ZIIyte7B37exj6LaVkrZiUTvzSadVvO/tJm8AEgbGCBPg==} - '@vscode/chat-extension-utils@0.0.0-alpha.5': - resolution: {integrity: sha512-EkfetTIGMDyClZkIx8oMOhprlXufnj0b/G1W4QGg4jhkWVUBE7kLRZsqEnpNjmtxHTugzc61gPQwT3zgH3HXgA==} - - '@vscode/prompt-tsx@0.3.0-alpha.24': - resolution: {integrity: sha512-WUz6rPLcN6F64WxxwTiLzHOuhUcdLKBWMckppn43DBC1Ba67Lvd9RV+2LOxj938YzvEVOKGoAY/qgRtXd77I7Q==} - '@vscode/vsce-sign-alpine-arm64@2.0.2': resolution: {integrity: sha512-E80YvqhtZCLUv3YAf9+tIbbqoinWLCO/B3j03yQPbjT3ZIHCliKZlsy1peNc4XNZ5uIb87Jn0HWx/ZbPXviuAQ==} cpu: [arm64] @@ -11682,12 +11676,6 @@ snapshots: loupe: 2.3.7 pretty-format: 27.5.1 - '@vscode/chat-extension-utils@0.0.0-alpha.5': - dependencies: - '@vscode/prompt-tsx': 0.3.0-alpha.24 - - '@vscode/prompt-tsx@0.3.0-alpha.24': {} - '@vscode/vsce-sign-alpine-arm64@2.0.2': optional: true From 8cb98ddaabd251c4c8bfb1156a2b9a1777c6898a Mon Sep 17 00:00:00 2001 From: jiasheng Date: Thu, 11 Sep 2025 18:36:34 +0800 Subject: [PATCH 5/6] feat: add zenstack logout command --- packages/schema/package.json | 8 ++++++++ packages/schema/src/zenstack-auth-provider.ts | 16 ++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/packages/schema/package.json b/packages/schema/package.json index 4e5812000..7f7aa07a1 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -87,6 +87,9 @@ }, { "command": "zenstack.clear-documentation-cache" + }, + { + "command": "zenstack.logout" } ] }, @@ -100,6 +103,11 @@ "command": "zenstack.clear-documentation-cache", "title": "ZenStack: Clear Documentation Cache", "icon": "$(trash)" + }, + { + "command": "zenstack.logout", + "title": "ZenStack: Logout", + "icon": "$(log-out)" } ], "keybindings": [ diff --git a/packages/schema/src/zenstack-auth-provider.ts b/packages/schema/src/zenstack-auth-provider.ts index 12d3fcd4c..31bc293a1 100644 --- a/packages/schema/src/zenstack-auth-provider.ts +++ b/packages/schema/src/zenstack-auth-provider.ts @@ -37,6 +37,10 @@ export class ZenStackAuthenticationProvider implements vscode.AuthenticationProv await this.handleAuthCallback(uri); } }, + }), + // Register logout command + vscode.commands.registerCommand('zenstack.logout', async () => { + await this.logoutAllSessions(); }) ); } @@ -77,6 +81,18 @@ export class ZenStackAuthenticationProvider implements vscode.AuthenticationProv } } + /** + * 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( From 97fbed12a6a0c40d520776dc8d09098b88a44e22 Mon Sep 17 00:00:00 2001 From: jiasheng Date: Thu, 11 Sep 2025 19:07:29 +0800 Subject: [PATCH 6/6] fix: increase timeout duration for pending authentication to 2 minutes --- packages/schema/src/documentation-cache.ts | 2 +- packages/schema/src/zenstack-auth-provider.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/schema/src/documentation-cache.ts b/packages/schema/src/documentation-cache.ts index 7eab812c8..6183ee38a 100644 --- a/packages/schema/src/documentation-cache.ts +++ b/packages/schema/src/documentation-cache.ts @@ -13,7 +13,7 @@ interface CacheEntry { * using VS Code's globalState for cross-session persistence */ export class DocumentationCache implements vscode.Disposable { - private static readonly CACHE_DURATION_MS = 7 * 24 * 60 * 60 * 1000; // 7 days cache duration + 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; diff --git a/packages/schema/src/zenstack-auth-provider.ts b/packages/schema/src/zenstack-auth-provider.ts index 31bc293a1..815913c56 100644 --- a/packages/schema/src/zenstack-auth-provider.ts +++ b/packages/schema/src/zenstack-auth-provider.ts @@ -139,12 +139,13 @@ export class ZenStackAuthenticationProvider implements vscode.AuthenticationProv } ); + // 2 minutes timeout setTimeout(() => { if (this.pendingAuth) { delete this.pendingAuth; } reject(new Error('Timeout')); - }, 60000); + }, 120000); }); } );