Skip to content

Commit cf3fc59

Browse files
committed
feat: preview zmodel doc
1 parent 25da0c7 commit cf3fc59

File tree

11 files changed

+1198
-6
lines changed

11 files changed

+1198
-6
lines changed

packages/schema/build/bundle.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ require('esbuild')
1616
.then(() => {
1717
fs.cpSync('./src/res', 'bundle/res', { force: true, recursive: true });
1818
fs.cpSync('../language/syntaxes', 'bundle/syntaxes', { force: true, recursive: true });
19+
20+
// Copy release notes HTML file
21+
if (fs.existsSync('src/release-notes.html')) {
22+
fs.copyFileSync('src/release-notes.html', 'bundle/release-notes.html');
23+
console.log('Copied release notes HTML file to bundle');
24+
}
1925
})
2026
.then(() => console.log(success))
2127
.catch((err) => {

packages/schema/build/post-build.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,14 @@ console.log('Updating file: dist/cli/index.js');
2727
fs.writeFileSync('dist/cli/index.js', cliContent, {
2828
encoding: 'utf-8',
2929
});
30+
31+
// Copy release notes HTML file to dist
32+
const releaseNotesSource = 'src/release-notes.html';
33+
const releaseNotesDest = 'dist/release-notes.html';
34+
35+
if (fs.existsSync(releaseNotesSource)) {
36+
console.log('Copying release notes HTML file to dist');
37+
fs.copyFileSync(releaseNotesSource, releaseNotesDest);
38+
} else {
39+
console.warn('Release notes HTML file not found at:', releaseNotesSource);
40+
}

packages/schema/package.json

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,21 @@
2727
"linkDirectory": true
2828
},
2929
"engines": {
30-
"vscode": "^1.63.0"
30+
"vscode": "^1.90.0"
3131
},
3232
"categories": [
3333
"Programming Languages"
3434
],
3535
"contributes": {
36+
"languageModelTools": [
37+
{
38+
"name": "zmodel_mermaid_generator",
39+
"displayName": "ZModel Mermaid Generator",
40+
"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.",
41+
"canBeReferencedInPrompt": true,
42+
"toolReferenceName": "zmodel_mermaid_generator"
43+
}
44+
],
3645
"languages": [
3746
{
3847
"id": "zmodel",
@@ -64,13 +73,60 @@
6473
"type": "boolean",
6574
"default": true,
6675
"description": "Use Prisma style indentation."
76+
},
77+
"zenstack.searchForExtensions": {
78+
"type": "boolean",
79+
"default": true,
80+
"description": "Search for Mermaid extensions when viewing Mermaid source."
6781
}
6882
}
69-
}
83+
},
84+
"menus": {
85+
"editor/title": [
86+
{
87+
"command": "zenstack.preview-zmodel",
88+
"when": "editorLangId == zmodel",
89+
"group": "navigation"
90+
}
91+
],
92+
"commandPalette": [
93+
{
94+
"command": "zenstack.preview-zmodel",
95+
"when": "editorLangId == zmodel"
96+
},
97+
{
98+
"command": "zenstack.clear-documentation-cache"
99+
}
100+
]
101+
},
102+
"commands": [
103+
{
104+
"command": "zenstack.preview-zmodel",
105+
"title": "ZenStack: Preview ZModel",
106+
"icon": "$(preview)"
107+
},
108+
{
109+
"command": "zenstack.clear-documentation-cache",
110+
"title": "ZenStack: Clear Documentation Cache",
111+
"icon": "$(trash)"
112+
}
113+
],
114+
"keybindings": [
115+
{
116+
"command": "zenstack.preview-zmodel",
117+
"key": "ctrl+shift+v",
118+
"mac": "cmd+shift+v",
119+
"when": "editorLangId == zmodel"
120+
}
121+
]
122+
},
123+
"activationEvents": [],
124+
"capabilities": {
125+
"untrustedWorkspaces": {
126+
"supported": true
127+
},
128+
"virtualWorkspaces": true
70129
},
71-
"activationEvents": [
72-
"onLanguage:zmodel"
73-
],
74130
"bin": {
75131
"zenstack": "bin/cli"
76132
},
@@ -127,7 +183,8 @@
127183
"@types/strip-color": "^0.1.0",
128184
"@types/tmp": "^0.2.3",
129185
"@types/uuid": "^8.3.4",
130-
"@types/vscode": "^1.56.0",
186+
"@types/vscode": "^1.102.0",
187+
"@vscode/chat-extension-utils": "0.0.0-alpha.5",
131188
"@vscode/vsce": "^3.5.0",
132189
"@zenstackhq/runtime": "workspace:*",
133190
"dotenv": "^16.0.3",
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import * as vscode from 'vscode';
2+
import crypto from 'crypto';
3+
4+
// Cache entry interface
5+
interface CacheEntry {
6+
data: string;
7+
timestamp: number;
8+
extensionVersion: string;
9+
}
10+
11+
/**
12+
* DocumentationCache class handles persistent caching of ZModel documentation
13+
* using VS Code's globalState for cross-session persistence
14+
*/
15+
export class DocumentationCache implements vscode.Disposable {
16+
private static readonly CACHE_DURATION_MS = 5 * 60 * 1000; // 5 minutes cache duration
17+
private static readonly CACHE_PREFIX = 'doc-cache.';
18+
19+
private extensionContext: vscode.ExtensionContext;
20+
private extensionVersion: string;
21+
22+
constructor(context: vscode.ExtensionContext) {
23+
this.extensionContext = context;
24+
this.extensionVersion = context.extension.packageJSON.version as string;
25+
// clear expired cache entries on initialization
26+
this.clearExpiredCache();
27+
}
28+
29+
/**
30+
* Dispose of the cache resources (implements vscode.Disposable)
31+
*/
32+
dispose(): void {}
33+
34+
/**
35+
* Get the cache prefix used for keys
36+
*/
37+
getCachePrefix(): string {
38+
return DocumentationCache.CACHE_PREFIX;
39+
}
40+
41+
/**
42+
* Enable cache synchronization across machines via VS Code Settings Sync
43+
*/
44+
private enableCacheSync(): void {
45+
const cacheKeys = this.extensionContext.globalState
46+
.keys()
47+
.filter((key) => key.startsWith(DocumentationCache.CACHE_PREFIX));
48+
if (cacheKeys.length > 0) {
49+
this.extensionContext.globalState.setKeysForSync(cacheKeys);
50+
}
51+
}
52+
53+
/**
54+
* Generate a cache key from request body with normalized content
55+
*/
56+
private generateCacheKey(requestBody: { models: string[] }): string {
57+
// Remove ALL whitespace characters from each model string for cache key generation
58+
// This ensures identical content with different formatting uses the same cache
59+
const normalizedModels = requestBody.models.map((model) => model.replace(/\s/g, ''));
60+
const hash = crypto
61+
.createHash('sha512')
62+
.update(JSON.stringify({ models: normalizedModels }))
63+
.digest('hex');
64+
return `${DocumentationCache.CACHE_PREFIX}${hash}`;
65+
}
66+
67+
/**
68+
* Check if cache entry is still valid (not expired)
69+
*/
70+
private isCacheValid(entry: CacheEntry): boolean {
71+
return Date.now() - entry.timestamp < DocumentationCache.CACHE_DURATION_MS;
72+
}
73+
74+
/**
75+
* Get cached response if available and valid
76+
*/
77+
async getCachedResponse(requestBody: { models: string[] }): Promise<string | null> {
78+
const cacheKey = this.generateCacheKey(requestBody);
79+
const entry = this.extensionContext.globalState.get<CacheEntry>(cacheKey);
80+
81+
if (entry && this.isCacheValid(entry)) {
82+
console.log('Using cached documentation response from persistent storage');
83+
return entry.data;
84+
}
85+
86+
// Clean up expired entry if it exists
87+
if (entry) {
88+
await this.extensionContext.globalState.update(cacheKey, undefined);
89+
}
90+
91+
return null;
92+
}
93+
94+
/**
95+
* Cache a response for future use
96+
*/
97+
async setCachedResponse(requestBody: { models: string[] }, data: string): Promise<void> {
98+
const cacheKey = this.generateCacheKey(requestBody);
99+
const cacheEntry: CacheEntry = {
100+
data,
101+
timestamp: Date.now(),
102+
extensionVersion: this.extensionVersion,
103+
};
104+
105+
await this.extensionContext.globalState.update(cacheKey, cacheEntry);
106+
107+
// Update sync keys to include new cache entry
108+
this.enableCacheSync();
109+
}
110+
111+
/**
112+
* Clear expired cache entries from persistent storage
113+
*/
114+
async clearExpiredCache(): Promise<void> {
115+
const now = Date.now();
116+
let clearedCount = 0;
117+
const allKeys = this.extensionContext.globalState.keys();
118+
119+
for (const key of allKeys) {
120+
if (key.startsWith(DocumentationCache.CACHE_PREFIX)) {
121+
const entry = this.extensionContext.globalState.get<CacheEntry>(key);
122+
if (
123+
entry?.extensionVersion !== this.extensionVersion ||
124+
now - entry.timestamp >= DocumentationCache.CACHE_DURATION_MS
125+
) {
126+
await this.extensionContext.globalState.update(key, undefined);
127+
clearedCount++;
128+
}
129+
}
130+
}
131+
132+
if (clearedCount > 0) {
133+
console.log(`Cleared ${clearedCount} expired cache entries from persistent storage`);
134+
}
135+
}
136+
137+
/**
138+
* Clear all cache entries from persistent storage
139+
*/
140+
async clearAllCache(): Promise<void> {
141+
const allKeys = this.extensionContext.globalState.keys();
142+
let clearedCount = 0;
143+
144+
for (const key of allKeys) {
145+
if (key.startsWith(DocumentationCache.CACHE_PREFIX)) {
146+
await this.extensionContext.globalState.update(key, undefined);
147+
clearedCount++;
148+
}
149+
}
150+
151+
console.log(`Cleared all cache entries from persistent storage (${clearedCount} items)`);
152+
}
153+
}

packages/schema/src/extension.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,50 @@
11
import * as vscode from 'vscode';
22
import * as path from 'path';
3+
34
import { LanguageClient, LanguageClientOptions, ServerOptions, TransportKind } from 'vscode-languageclient/node';
5+
import { AUTH_PROVIDER_ID, ZenStackAuthenticationProvider } from './zenstack-auth-provider';
6+
import { DocumentationCache } from './documentation-cache';
7+
import { ZModelPreview } from './zmodel-preview';
8+
import { ReleaseNotesManager } from './release-notes-manager';
49

10+
// Global variables
511
let client: LanguageClient;
612

13+
// Utility to require authentication when needed
14+
export async function requireAuth(): Promise<vscode.AuthenticationSession | undefined> {
15+
let session: vscode.AuthenticationSession | undefined;
16+
17+
session = await vscode.authentication.getSession(AUTH_PROVIDER_ID, [], { createIfNone: false });
18+
19+
if (!session) {
20+
const signIn = 'Sign in';
21+
const selection = await vscode.window.showWarningMessage('Please sign in to use this feature', signIn);
22+
if (selection === signIn) {
23+
try {
24+
session = await vscode.authentication.getSession(AUTH_PROVIDER_ID, [], { createIfNone: true });
25+
if (session) {
26+
vscode.window.showInformationMessage('ZenStack sign in successful!');
27+
}
28+
} catch (e) {
29+
vscode.window.showErrorMessage('ZenStack sign in failed: ' + String(e));
30+
}
31+
}
32+
}
33+
return session;
34+
}
35+
736
// This function is called when the extension is activated.
837
export function activate(context: vscode.ExtensionContext): void {
38+
// Initialize and register the ZenStack authentication provider
39+
context.subscriptions.push(new ZenStackAuthenticationProvider(context));
40+
41+
// Start language client
942
client = startLanguageClient(context);
43+
44+
const documentationCache = new DocumentationCache(context);
45+
context.subscriptions.push(documentationCache);
46+
context.subscriptions.push(new ZModelPreview(context, client, documentationCache));
47+
context.subscriptions.push(new ReleaseNotesManager(context));
1048
}
1149

1250
// This function is called when the extension is deactivated.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,61 @@
11
import { startLanguageServer } from 'langium';
22
import { NodeFileSystem } from 'langium/node';
33
import { createConnection, ProposedFeatures } from 'vscode-languageserver/node';
4+
import { URI } from 'vscode-uri';
45
import { createZModelServices } from './zmodel-module';
6+
import { eagerLoadAllImports } from '../cli/cli-util';
57

68
// Create a connection to the client
79
const connection = createConnection(ProposedFeatures.all);
810

911
// Inject the shared services and language-specific services
1012
const { shared } = createZModelServices({ connection, ...NodeFileSystem });
1113

14+
// Add custom LSP request handlers
15+
connection.onRequest('zenstack/getAllImportedZModelURIs', async (params: { textDocument: { uri: string } }) => {
16+
try {
17+
const uri = URI.parse(params.textDocument.uri);
18+
const document = shared.workspace.LangiumDocuments.getOrCreateDocument(uri);
19+
20+
// Ensure the document is parsed and built
21+
if (!document.parseResult) {
22+
await shared.workspace.DocumentBuilder.build([document]);
23+
}
24+
25+
// #region merge imported documents
26+
const langiumDocuments = shared.workspace.LangiumDocuments;
27+
28+
// load all imports
29+
const importedURIs = eagerLoadAllImports(document, langiumDocuments);
30+
31+
const importedDocuments = importedURIs.map((uri) => langiumDocuments.getOrCreateDocument(uri));
32+
33+
// build the document together with standard library, plugin modules, and imported documents
34+
await shared.workspace.DocumentBuilder.build([document, ...importedDocuments], {
35+
validationChecks: 'all',
36+
});
37+
38+
const hasSyntaxErrors = [uri, ...importedURIs].some((uri) => {
39+
const doc = langiumDocuments.getOrCreateDocument(uri);
40+
return (
41+
doc.parseResult.lexerErrors.length > 0 ||
42+
doc.parseResult.parserErrors.length > 0 ||
43+
doc.diagnostics?.some((e) => e.severity === 1)
44+
);
45+
});
46+
47+
return {
48+
hasSyntaxErrors,
49+
importedURIs,
50+
};
51+
} catch (error) {
52+
console.error('Error getting imported ZModel file:', error);
53+
return {
54+
hasSyntaxErrors: true,
55+
importedURIs: [],
56+
};
57+
}
58+
});
59+
1260
// Start the language server with the shared services
1361
startLanguageServer(shared);

0 commit comments

Comments
 (0)