diff --git a/packages/schema/build/bundle.js b/packages/schema/build/bundle.js index 9c4ee70c0..e851b7ce9 100644 --- a/packages/schema/build/bundle.js +++ b/packages/schema/build/bundle.js @@ -2,6 +2,20 @@ const watch = process.argv.includes('--watch'); const minify = process.argv.includes('--minify'); const success = watch ? 'Watch build succeeded' : 'Build succeeded'; const fs = require('fs'); +const path = require('path'); + +// Replace telemetry token in generated bundle files after building +function replaceTelemetryTokenInBundle() { + const telemetryToken = process.env.VSCODE_TELEMETRY_TRACKING_TOKEN; + if (!telemetryToken) { + console.error('Error: VSCODE_TELEMETRY_TRACKING_TOKEN environment variable is not set'); + process.exit(1); + } + const file = 'bundle/extension.js'; + let content = fs.readFileSync(file, 'utf-8'); + content = content.replace('', telemetryToken); + fs.writeFileSync(file, content, 'utf-8'); +} require('esbuild') .build({ @@ -13,8 +27,13 @@ require('esbuild') sourcemap: !minify, minify, }) + .then(() => { + // Replace the token after building outputs + replaceTelemetryTokenInBundle(); + }) .then(() => { fs.cpSync('./src/res', 'bundle/res', { force: true, recursive: true }); + fs.cpSync('./src/vscode/res', 'bundle/res', { force: true, recursive: true }); fs.cpSync('../language/syntaxes', 'bundle/syntaxes', { force: true, recursive: true }); }) .then(() => console.log(success)) diff --git a/packages/schema/package.json b/packages/schema/package.json index b5a10cbe9..97d7bf6c3 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -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": [ @@ -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", @@ -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" } ] }, diff --git a/packages/schema/src/extension.ts b/packages/schema/src/extension.ts index c7abe53be..ad1c886e4 100644 --- a/packages/schema/src/extension.ts +++ b/packages/schema/src/extension.ts @@ -2,10 +2,11 @@ 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'; +import { AUTH_PROVIDER_ID, ZenStackAuthenticationProvider } from './vscode/zenstack-auth-provider'; +import { DocumentationCache } from './vscode/documentation-cache'; +import { ZModelPreview } from './vscode/zmodel-preview'; +import { ReleaseNotesManager } from './vscode/release-notes-manager'; +import telemetry from './vscode/vscode-telemetry'; // Global variables let client: LanguageClient; @@ -19,13 +20,17 @@ export async function requireAuth(): Promise🚀 How to Use
  1. Open your .zmodel file
  2. - Click () in the editor toolbar, or simply press + Click () in the editor toolbar, or press Cmd + Shift + V (Mac) or Ctrl + Shift + V (Windows)
  3. Sign in with ZenStack (one-time setup)
  4. -
  5. Enjoy your AI-generated documentation
  6. +
  7. + Click () in the preview toolbar to save the doc, or press + Cmd + Shift + S (Mac) or + Ctrl + Shift + S (Windows) +
diff --git a/packages/schema/src/vscode/vscode-telemetry.ts b/packages/schema/src/vscode/vscode-telemetry.ts new file mode 100644 index 000000000..a9ffbeb26 --- /dev/null +++ b/packages/schema/src/vscode/vscode-telemetry.ts @@ -0,0 +1,75 @@ +import { init, Mixpanel } from 'mixpanel'; +import * as os from 'os'; +import * as vscode from 'vscode'; +import { getMachineId } from '../utils/machine-id-utils'; +import { v5 as uuidv5 } from 'uuid'; + +export const VSCODE_TELEMETRY_TRACKING_TOKEN = ''; + +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(VSCODE_TELEMETRY_TRACKING_TOKEN, { + geolocate: true, + }); + } + } + + private getDeviceId() { + const hostId = getMachineId(); + // namespace UUID for generating UUIDv5 from DNS 'zenstack.dev' + return uuidv5(hostId, '133cac15-3efb-50fa-b5fc-4b90e441e563'); + } + + track(event: TelemetryEvents, properties: Record = {}) { + 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(); diff --git a/packages/schema/src/zenstack-auth-provider.ts b/packages/schema/src/vscode/zenstack-auth-provider.ts similarity index 98% rename from packages/schema/src/zenstack-auth-provider.ts rename to packages/schema/src/vscode/zenstack-auth-provider.ts index 815913c56..ba7c32a0b 100644 --- a/packages/schema/src/zenstack-auth-provider.ts +++ b/packages/schema/src/vscode/zenstack-auth-provider.ts @@ -1,5 +1,5 @@ import * as vscode from 'vscode'; - +import telemetry from './vscode-telemetry'; interface JWTClaims { jti?: string; sub?: string; @@ -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 { @@ -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, diff --git a/packages/schema/src/zmodel-preview.ts b/packages/schema/src/vscode/zmodel-preview.ts similarity index 75% rename from packages/schema/src/zmodel-preview.ts rename to packages/schema/src/vscode/zmodel-preview.ts index 96b1ab518..9826bd980 100644 --- a/packages/schema/src/zmodel-preview.ts +++ b/packages/schema/src/vscode/zmodel-preview.ts @@ -5,8 +5,9 @@ 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 { requireAuth } from '../extension'; import { API_URL } from './zenstack-auth-provider'; +import telemetry from './vscode-telemetry'; /** * ZModelPreview class handles ZModel file preview functionality @@ -14,6 +15,9 @@ import { API_URL } from './zenstack-auth-provider'; 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({ @@ -45,6 +49,19 @@ export class ZModelPreview implements vscode.Disposable { */ initialize(context: vscode.ExtensionContext): void { this.registerCommands(context); + + context.subscriptions.push( + vscode.window.tabGroups.onDidChangeTabs(() => { + const activeTabLabels = vscode.window.tabGroups.all.filter((group) => + group.activeTab?.label?.endsWith(this.previewZModelFileName) + ); + if (activeTabLabels.length > 0) { + vscode.commands.executeCommand('setContext', 'zenstack.isMarkdownPreview', true); + } else { + vscode.commands.executeCommand('setContext', 'zenstack.isMarkdownPreview', false); + } + }) + ); } /** @@ -58,6 +75,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 () => { @@ -71,6 +95,7 @@ export class ZModelPreview implements vscode.Disposable { * Preview a ZModel file */ async previewZModelFile(): Promise { + telemetry.track('extension:zmodel-preview'); const editor = vscode.window.activeTextEditor; if (!editor) { @@ -103,7 +128,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); } } ); @@ -239,17 +267,14 @@ export class ZModelPreview implements vscode.Disposable { /** * Open markdown preview */ - private async openMarkdownPreview(markdownContent: string, originalFileName: string): Promise { + private async openMarkdownPreview(markdownContent: string): Promise { // Create a temporary markdown file with a descriptive name in the system temp folder - const baseName = path.basename(originalFileName, '.zmodel'); - const tempFileName = `${baseName}-preview.md`; - const tempFilePath = path.join(os.tmpdir(), tempFileName); + const 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) { @@ -260,6 +285,56 @@ export class ZModelPreview implements vscode.Disposable { } } + /** + * Save ZModel documentation to a user-selected file + */ + async saveZModelDocumentation(): Promise { + telemetry.track('extension:zmodel-save'); + // Check if we have cached content first + if (!this.lastGeneratedMarkdown) { + vscode.window.showErrorMessage( + 'No documentation content available to save. Please generate the documentation first by running "Preview ZModel Documentation".' + ); + return; + } + + // Show save dialog + let defaultFilePath = `zmodel-doc.md`; + + const workspaceFolders = vscode.workspace.workspaceFolders; + if (workspaceFolders && workspaceFolders.length > 0) { + const workspacePath = workspaceFolders[0].uri.fsPath; + // If the workspace folder exists, use it + defaultFilePath = path.join(workspacePath, defaultFilePath); + } + + const saveUri = await vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file(defaultFilePath), + filters: { + Markdown: ['md'], + 'All Files': ['*'], + }, + saveLabel: 'Save Documentation', + }); + + if (!saveUri) { + return; // User cancelled + } + + try { + // Write the markdown content to the selected file + await vscode.workspace.fs.writeFile(saveUri, new TextEncoder().encode(this.lastGeneratedMarkdown)); + // Open and close the saved file to refresh the shown markdown preview + await vscode.commands.executeCommand('vscode.open', saveUri); + await vscode.commands.executeCommand('workbench.action.closeActiveEditor'); + } catch (error) { + console.error('Error saving markdown file:', error); + vscode.window.showErrorMessage( + `Failed to save documentation: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + /** * Check for Mermaid extensions */ diff --git a/packages/schema/tsconfig.json b/packages/schema/tsconfig.json index 0cc75a43a..5e01b8ac9 100644 --- a/packages/schema/tsconfig.json +++ b/packages/schema/tsconfig.json @@ -4,5 +4,5 @@ "outDir": "dist" }, "include": ["src/**/*.ts"], - "exclude": ["src/extension.ts"] + "exclude": ["src/extension.ts", "src/vscode/**/*.ts"] }