Skip to content
Merged
18 changes: 17 additions & 1 deletion packages/schema/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@
"command": "zenstack.preview-zmodel",
"when": "editorLangId == zmodel",
"group": "navigation"
},
{
"command": "zenstack.save-zmodel-documentation",
"when": "(activeWebviewPanelId == 'markdown.preview' || activeCustomEditorId == 'vscode.markdown.preview.editor') && zenstack.isMarkdownPreview == true",
"group": "navigation"
}
],
"commandPalette": [
Expand All @@ -99,6 +104,11 @@
"title": "ZenStack: Preview ZModel Documentation",
"icon": "$(preview)"
},
{
"command": "zenstack.save-zmodel-documentation",
"title": "ZenStack: Save ZModel Documentation",
"icon": "$(save)"
},
{
"command": "zenstack.clear-documentation-cache",
"title": "ZenStack: Clear Documentation Cache",
Expand All @@ -116,6 +126,12 @@
"key": "ctrl+shift+v",
"mac": "cmd+shift+v",
"when": "editorLangId == zmodel"
},
{
"command": "zenstack.save-zmodel-documentation",
"key": "ctrl+shift+s",
"mac": "cmd+shift+s",
"when": "(activeWebviewPanelId == 'markdown.preview' || activeCustomEditorId == 'vscode.markdown.preview.editor') && zenstack.isMarkdownPreview == true"
}
]
},
Expand All @@ -129,7 +145,7 @@
"bin": {
"zenstack": "bin/cli"
},
"main": "./bundle/extension.js",
"main": "./dist/extension.js",
"scripts": {
"vscode:publish": "vsce publish --no-dependencies",
"vscode:prerelease": "vsce publish --no-dependencies --pre-release",
Expand Down
6 changes: 6 additions & 0 deletions packages/schema/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { AUTH_PROVIDER_ID, ZenStackAuthenticationProvider } from './zenstack-aut
import { DocumentationCache } from './documentation-cache';
import { ZModelPreview } from './zmodel-preview';
import { ReleaseNotesManager } from './release-notes-manager';
import telemetry from './vscode-telemetry';

// Global variables
let client: LanguageClient;
Expand All @@ -19,13 +20,17 @@ export async function requireAuth(): Promise<vscode.AuthenticationSession | unde
if (!session) {
const signIn = 'Sign in';
const selection = await vscode.window.showWarningMessage('Please sign in to use this feature', signIn);
telemetry.track('extension:signin:show');
if (selection === signIn) {
telemetry.track('extension:signin:start');
try {
session = await vscode.authentication.getSession(AUTH_PROVIDER_ID, [], { createIfNone: true });
if (session) {
telemetry.track('extension:signin:complete');
vscode.window.showInformationMessage('ZenStack sign-in successful!');
}
} catch (e: unknown) {
telemetry.track('extension:signin:error', { error: e instanceof Error ? e.message : String(e) });
vscode.window.showErrorMessage(
'ZenStack sign-in failed: ' + (e instanceof Error ? e.message : String(e))
);
Expand All @@ -37,6 +42,7 @@ export async function requireAuth(): Promise<vscode.AuthenticationSession | unde

// This function is called when the extension is activated.
export function activate(context: vscode.ExtensionContext): void {
telemetry.track('extension:activate');
// Initialize and register the ZenStack authentication provider
context.subscriptions.push(new ZenStackAuthenticationProvider(context));

Expand Down
8 changes: 6 additions & 2 deletions packages/schema/src/res/zmodel-preview-release-notes.html
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,16 @@ <h3>🚀 How to Use</h3>
<ol>
<li>Open your <code>.zmodel</code> file</li>
<li>
Click (<span class="codicon codicon-preview"></span>) in the editor toolbar, or simply press
Click (<span class="codicon codicon-preview"></span>) in the editor toolbar, or press
<code>Cmd&nbsp;+&nbsp;Shift&nbsp;+&nbsp;V</code> (Mac) or
<code>Ctrl&nbsp;+&nbsp;Shift&nbsp;+&nbsp;V</code> (Windows)
</li>
<li>Sign in with ZenStack (one-time setup)</li>
<li>Enjoy your AI-generated documentation</li>
<li>
Click (<span class="codicon codicon-save"></span>) in the preview toolbar to save the doc, or press
<code>Cmd&nbsp;+&nbsp;Shift&nbsp;+&nbsp;S</code> (Mac) or
<code>Ctrl&nbsp;+&nbsp;Shift&nbsp;+&nbsp;S</code> (Windows)
</li>
</ol>
</div>

Expand Down
73 changes: 73 additions & 0 deletions packages/schema/src/vscode-telemetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { init, Mixpanel } from 'mixpanel';
import * as os from 'os';
import vscode from 'vscode';
import { getMachineId } from './utils/machine-id-utils';
import { v5 as uuidv5 } from 'uuid';
import { TELEMETRY_TRACKING_TOKEN } from './constants';

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

export class VSCodeTelemetry {
private readonly mixpanel: Mixpanel | undefined;
private readonly deviceId = this.getDeviceId();
private readonly _os_type = os.type();
private readonly _os_release = os.release();
private readonly _os_arch = os.arch();
private readonly _os_version = os.version();
private readonly _os_platform = os.platform();
private readonly vscodeAppName = vscode.env.appName;
private readonly vscodeVersion = vscode.version;
private readonly vscodeAppHost = vscode.env.appHost;

constructor() {
if (vscode.env.isTelemetryEnabled) {
this.mixpanel = init(TELEMETRY_TRACKING_TOKEN, {
geolocate: true,
});
}
}

private getDeviceId() {
const hostId = getMachineId();
return uuidv5(hostId, '133cac15-3efb-50fa-b5fc-4b90e441e563');
}

track(event: TelemetryEvents, properties: Record<string, unknown> = {}) {
if (this.mixpanel) {
const payload = {
distinct_id: this.deviceId,
time: new Date(),
$os: this._os_type,
osType: this._os_type,
osRelease: this._os_release,
osPlatform: this._os_platform,
osArch: this._os_arch,
osVersion: this._os_version,
nodeVersion: process.version,
vscodeAppName: this.vscodeAppName,
vscodeVersion: this.vscodeVersion,
vscodeAppHost: this.vscodeAppHost,
...properties,
};
this.mixpanel.track(event, payload);
}
}

identify(userId: string) {
if (this.mixpanel) {
this.mixpanel.track('$identify', {
$identified_id: userId,
$anon_id: this.deviceId,
token: TELEMETRY_TRACKING_TOKEN,
});
}
}
}
export default new VSCodeTelemetry();
6 changes: 2 additions & 4 deletions packages/schema/src/zenstack-auth-provider.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as vscode from 'vscode';

import telemetry from './vscode-telemetry';
interface JWTClaims {
jti?: string;
sub?: string;
Expand Down Expand Up @@ -150,9 +150,6 @@ export class ZenStackAuthenticationProvider implements vscode.AuthenticationProv
}
);
}
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<void> {
Expand Down Expand Up @@ -184,6 +181,7 @@ export class ZenStackAuthenticationProvider implements vscode.AuthenticationProv
try {
// Decode JWT to get claims
const claims = this.parseJWTClaims(accessToken);
telemetry.identify(claims.email!);
return {
id: claims.jti || Math.random().toString(36),
accessToken: accessToken,
Expand Down
85 changes: 79 additions & 6 deletions packages/schema/src/zmodel-preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,17 @@ import { URI } from 'vscode-uri';
import { DocumentationCache } from './documentation-cache';
import { requireAuth } from './extension';
import { API_URL } from './zenstack-auth-provider';
import telemetry from './vscode-telemetry';

/**
* ZModelPreview class handles ZModel file preview functionality
*/
export class ZModelPreview implements vscode.Disposable {
private documentationCache: DocumentationCache;
private languageClient: LanguageClient;
private lastGeneratedMarkdown: string | null = null;
// use a zero-width space in the file name to make it non-colliding with user file
private readonly previewZModelFileName = `zmodel${'\u200B'}-preview.md`;

// Schema for validating the request body
private static DocRequestSchema = z.object({
Expand Down Expand Up @@ -45,6 +49,17 @@ export class ZModelPreview implements vscode.Disposable {
*/
initialize(context: vscode.ExtensionContext): void {
this.registerCommands(context);

vscode.window.tabGroups.onDidChangeTabs(() => {
const activeTabLabels = vscode.window.tabGroups.all.filter((group) =>
group.activeTab?.label?.endsWith(this.previewZModelFileName)
);
if (activeTabLabels.length > 0) {
vscode.commands.executeCommand('setContext', 'zenstack.isMarkdownPreview', true);
} else {
vscode.commands.executeCommand('setContext', 'zenstack.isMarkdownPreview', false);
}
});
}

/**
Expand All @@ -58,6 +73,13 @@ export class ZModelPreview implements vscode.Disposable {
})
);

// Register the save documentation command for zmodel files
context.subscriptions.push(
vscode.commands.registerCommand('zenstack.save-zmodel-documentation', async () => {
await this.saveZModelDocumentation();
})
);

// Register cache management commands
context.subscriptions.push(
vscode.commands.registerCommand('zenstack.clear-documentation-cache', async () => {
Expand All @@ -71,6 +93,7 @@ export class ZModelPreview implements vscode.Disposable {
* Preview a ZModel file
*/
async previewZModelFile(): Promise<void> {
telemetry.track('extension:zmodel-preview');
const editor = vscode.window.activeTextEditor;

if (!editor) {
Expand Down Expand Up @@ -103,7 +126,10 @@ export class ZModelPreview implements vscode.Disposable {
const markdownContent = await this.generateZModelDocumentation(document);

if (markdownContent) {
await this.openMarkdownPreview(markdownContent, document.fileName);
// Store the generated content for potential saving later
this.lastGeneratedMarkdown = markdownContent;

await this.openMarkdownPreview(markdownContent);
}
}
);
Expand Down Expand Up @@ -239,17 +265,14 @@ export class ZModelPreview implements vscode.Disposable {
/**
* Open markdown preview
*/
private async openMarkdownPreview(markdownContent: string, originalFileName: string): Promise<void> {
private async openMarkdownPreview(markdownContent: string): Promise<void> {
// 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 tempFilePath = path.join(os.tmpdir(), this.previewZModelFileName);
const tempFile = vscode.Uri.file(tempFilePath);

try {
// Write the markdown content to the temp file
await vscode.workspace.fs.writeFile(tempFile, new TextEncoder().encode(markdownContent));

// Open the markdown preview side by side
await vscode.commands.executeCommand('markdown.showPreviewToSide', tempFile);
} catch (error) {
Expand All @@ -260,6 +283,56 @@ export class ZModelPreview implements vscode.Disposable {
}
}

/**
* Save ZModel documentation to a user-selected file
*/
async saveZModelDocumentation(): Promise<void> {
telemetry.track('extension:zmodel-save');
// Check if we have cached content first
if (!this.lastGeneratedMarkdown) {
vscode.window.showErrorMessage(
'No documentation content available to save. Please generate the documentation first by running "Preview ZModel Documentation".'
);
return;
}

// Show save dialog
let defaultFilePath = `zmodel-doc.md`;

const workspaceFolders = vscode.workspace.workspaceFolders;
if (workspaceFolders && workspaceFolders.length > 0) {
const workspacePath = workspaceFolders[0].uri.fsPath;
// If the workspace folder exists, use it
defaultFilePath = path.join(workspacePath, defaultFilePath);
}

const saveUri = await vscode.window.showSaveDialog({
defaultUri: vscode.Uri.file(defaultFilePath),
filters: {
Markdown: ['md'],
'All Files': ['*'],
},
saveLabel: 'Save Documentation',
});

if (!saveUri) {
return; // User cancelled
}

try {
// Write the markdown content to the selected file
await vscode.workspace.fs.writeFile(saveUri, new TextEncoder().encode(this.lastGeneratedMarkdown));
// Open and close the saved file to refresh the shown markdown preview
await vscode.commands.executeCommand('vscode.open', saveUri);
await vscode.commands.executeCommand('workbench.action.closeActiveEditor');
} catch (error) {
console.error('Error saving markdown file:', error);
vscode.window.showErrorMessage(
`Failed to save documentation: ${error instanceof Error ? error.message : String(error)}`
);
}
}

/**
* Check for Mermaid extensions
*/
Expand Down
Loading