diff --git a/apps/vscode/package.json b/apps/vscode/package.json index 3d71249c2..d85f7e916 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -472,6 +472,11 @@ }, "views": { "rocketride": [ + { + "type": "webview", + "id": "rocketride.provider.connection", + "name": "Connections" + }, { "type": "webview", "id": "rocketride.sidebar.main", diff --git a/apps/vscode/rsbuild.config.mjs b/apps/vscode/rsbuild.config.mjs index 06db6b6ac..08cb7f6dd 100644 --- a/apps/vscode/rsbuild.config.mjs +++ b/apps/vscode/rsbuild.config.mjs @@ -57,6 +57,7 @@ export default defineConfig({ 'page-account': './src/providers/views/PageAccount/index.tsx', 'page-billing': './src/providers/views/PageBilling/index.tsx', 'page-auth': './src/providers/views/PageAuth/index.tsx', + 'page-connection': './src/providers/views/PageConnection/index.tsx', }, }, diff --git a/apps/vscode/src/connection/local-manager.ts b/apps/vscode/src/connection/local-manager.ts index 9845b1a5e..6a9d6ed82 100644 --- a/apps/vscode/src/connection/local-manager.ts +++ b/apps/vscode/src/connection/local-manager.ts @@ -83,8 +83,17 @@ export class LocalManager extends BaseManager { let attempts = 0; while (attempts < MAX_CONNECT_ATTEMPTS) { + if (token?.isCancellationRequested) { + this.logger.output(`${icons.warning} Connection cancelled by user`); + throw new Error('Connection cancelled'); + } + try { - await client.connect(LOCAL_AUTH, { uri, timeout: 5000 }); + await client.connect({ + redirectUri: uri, + auth: LOCAL_AUTH, + timeout: 5000 + } as any); return; } catch (error: unknown) { attempts++; @@ -99,7 +108,9 @@ export class LocalManager extends BaseManager { const delaySec = Math.round(delayMs / 1000); this.logger.output(`${icons.info} Connection attempt ${attempts} failed, waiting ${delaySec}s...`); this.emit('status', `Waiting ${delaySec}s before next attempt`); - await LocalManager.delay(delayMs); + + // Cancellable delay + await this.delayWithToken(delayMs, token); } } } @@ -136,7 +147,35 @@ export class LocalManager extends BaseManager { return Math.min(delay, BACKOFF_MAX_MS); } - private static delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); + private delayWithToken(ms: number, token?: vscode.CancellationToken): Promise { + return new Promise((resolve, reject) => { + if (token?.isCancellationRequested) { + return reject(new Error('Connection cancelled')); + } + + let timeout: NodeJS.Timeout | undefined; + let sub: vscode.Disposable | undefined; + + const cleanup = () => { + if (timeout !== undefined) { + clearTimeout(timeout); + timeout = undefined; + } + if (sub !== undefined) { + sub.dispose(); + sub = undefined; + } + }; + + sub = token?.onCancellationRequested(() => { + cleanup(); + reject(new Error('Connection cancelled')); + }); + + timeout = setTimeout(() => { + cleanup(); + resolve(); + }, ms); + }); } } diff --git a/apps/vscode/src/extension.ts b/apps/vscode/src/extension.ts index b8a1559a0..22c427c71 100644 --- a/apps/vscode/src/extension.ts +++ b/apps/vscode/src/extension.ts @@ -51,6 +51,7 @@ import { PageAuthProvider } from './providers/PageAuthProvider'; import { AgentManager } from './agents/agent-manager'; import { syncServiceCatalog } from './agents/services'; import { CloudAuthProvider } from './auth/CloudAuthProvider'; +import { PageConnectionProvider } from './providers/PageConnectionProvider'; // Core managers let connectionManager: ConnectionManager | undefined; @@ -228,6 +229,12 @@ export async function activate(context: vscode.ExtensionContext): Promise logger.output(`${icons.info} Creating tree providers...`); progress.report({ increment: 50, message: 'Creating tree providers...' }); + //------------------------------------- + // + //------------------------------------- + const connectionProvider = new PageConnectionProvider(context.extensionUri); + context.subscriptions.push(vscode.window.registerWebviewViewProvider(PageConnectionProvider.viewType, connectionProvider)); + // Register unified sidebar webview pageSidebar = new PageSidebarProvider(context.extensionUri); const sidebarWebviewProvider = vscode.window.registerWebviewViewProvider(PageSidebarProvider.viewType, pageSidebar); diff --git a/apps/vscode/src/providers/PageConnectionProvider.ts b/apps/vscode/src/providers/PageConnectionProvider.ts new file mode 100644 index 000000000..3bf7cac82 --- /dev/null +++ b/apps/vscode/src/providers/PageConnectionProvider.ts @@ -0,0 +1,254 @@ +// ============================================================================= +// MIT License +// Copyright (c) 2026 Aparavi Software AG +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// ============================================================================= + +/** + * Connection Webview Provider for Connection Management + * + * Provides a rich webview interface for connection management with: + * - Real-time connection status updates + * - Visual connection state indicators + * - Interactive connection controls + * - Configuration status display + * - Settings access + */ + +import * as vscode from 'vscode'; +import { RocketRideClient } from 'rocketride'; +import { ConfigManager } from '../config'; +import { ConnectionManager } from '../connection/connection'; + +export class PageConnectionProvider implements vscode.WebviewViewProvider { + public static readonly viewType = 'rocketride.provider.connection'; + + private _view?: vscode.WebviewView; + private disposables: vscode.Disposable[] = []; + private configManager = ConfigManager.getInstance(); + private connectionManager = ConnectionManager.getInstance(); + + constructor(private readonly extensionUri: vscode.Uri) { + this.setupEventListeners(); + } + + /** + * Resolves the webview view + */ + public resolveWebviewView( + webviewView: vscode.WebviewView, + _context: vscode.WebviewViewResolveContext, + _token: vscode.CancellationToken, + ) { + this._view = webviewView; + + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [this.extensionUri] + }; + + webviewView.webview.html = this.getHtmlForWebview(webviewView.webview); + + // Handle messages from the webview + const messageDisposable = webviewView.webview.onDidReceiveMessage(async (message) => { + try { + switch (message.type) { + case 'ready': + await this.sendConnectionUpdate(); + break; + + case 'connect': + await this.connectionManager.connect(); + break; + + case 'disconnect': + await this.connectionManager.disconnect(); + break; + + case 'reconnect': + await this.connectionManager.reconnect(); + break; + + case 'openSettings': + vscode.commands.executeCommand('rocketride.page.settings.open'); + break; + + case 'openDocs': + vscode.env.openExternal(vscode.Uri.parse('https://docs.rocketride.org')); + break; + + case 'openDeploy': + vscode.commands.executeCommand('rocketride.page.deploy.open'); + break; + + } + } catch (error) { + console.error('[PageConnectionProvider] Message handling error:', error); + } + }); + + this.disposables.push(messageDisposable); + } + + /** + * Sets up event listeners for connection and configuration changes + */ + private setupEventListeners(): void { + // Listen for connection state changes + const connectionStateListener = this.connectionManager.on('connectionStateChanged', () => { + this.sendConnectionUpdate(); + }); + + const connectedListener = this.connectionManager.on('connected', () => { + this.sendConnectionUpdate(); + }); + + const errorListener = this.connectionManager.on('error', () => { + this.sendConnectionUpdate(); + }); + + // Listen for config changes + const configChangeListener = vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('rocketride')) { + this.sendConnectionUpdate(); + } + }); + + this.disposables.push( + connectionStateListener, + connectedListener, + errorListener, + configChangeListener + ); + } + + /** + * Sends connection status update to webview + */ + private async sendConnectionUpdate(): Promise { + if (!this._view) { + return; + } + + try { + const connectionState = this.connectionManager.getConnectionStatus(); + const config = this.configManager.getConfig(); + const hasApiKey = this.configManager.hasApiKey(); + const engineInfo = this.connectionManager.getEngineInfo(); + + this._view.webview.postMessage({ + type: 'connectionUpdate', + data: { + connectionState, + config: { + hostUrl: RocketRideClient.normalizeUri(config.hostUrl), + connectionMode: config.connectionMode, + autoConnect: config.autoConnect + }, + hasApiKey, + engineInfo + } + }); + } catch (error) { + console.error('[PageConnectionProvider] Failed to send connection update:', error); + } + } + + /** + * Generates HTML content for the webview + */ + private getHtmlForWebview(webview: vscode.Webview): string { + const nonce = this.generateNonce(); + const htmlPath = vscode.Uri.joinPath(this.extensionUri, 'webview', 'page-connection.html'); + + try { + let htmlContent = require('fs').readFileSync(htmlPath.fsPath, 'utf8'); + + // Replace template placeholders + htmlContent = htmlContent + .replace(/\{\{nonce\}\}/g, nonce) + .replace(/\{\{cspSource\}\}/g, webview.cspSource); + + // Convert resource URLs to webview URIs + return htmlContent.replace( + /(?:src|href)="(\/static\/[^"]+)"/g, + (match: string, relativePath: string): string => { + const cleanPath = relativePath.startsWith('/') ? relativePath.substring(1) : relativePath; + const resourceUri = webview.asWebviewUri( + vscode.Uri.joinPath(this.extensionUri, 'webview', cleanPath) + ); + return match.replace(relativePath, resourceUri.toString()); + } + ); + } catch (error) { + console.error('Error loading connection HTML:', error); + return this.getErrorHtml(error, htmlPath.fsPath); + } + } + + /** + * Generates fallback HTML for when the main HTML file can't be loaded + */ + private getErrorHtml(error: unknown, expectedPath: string): string { + return ` + + + + + Connection View Error + + +
+

Error Loading Connection View

+

Error: ${error}

+

Run npm run build:webview to build the webview.

+

Expected: ${expectedPath}

+
+ + `; + } + + /** + * Generates a random nonce for Content Security Policy + */ + private generateNonce(): string { + let text = ''; + const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + for (let i = 0; i < 32; i++) { + text += possible.charAt(Math.floor(Math.random() * possible.length)); + } + return text; + } + + /** + * Public method to refresh the connection view + */ + public refresh(): void { + this.sendConnectionUpdate(); + } + + /** + * Cleans up event listeners and resources + */ + public dispose(): void { + this.disposables.forEach(disposable => disposable.dispose()); + this.disposables = []; + } +} diff --git a/apps/vscode/src/providers/views/PageConnection/PageConnection.tsx b/apps/vscode/src/providers/views/PageConnection/PageConnection.tsx new file mode 100644 index 000000000..c201bc093 --- /dev/null +++ b/apps/vscode/src/providers/views/PageConnection/PageConnection.tsx @@ -0,0 +1,374 @@ +// ============================================================================= +// MIT License +// Copyright (c) 2026 Aparavi Software AG +// ============================================================================= + +import React, { useState, useEffect, CSSProperties } from 'react'; +import { useMessaging } from '../hooks/useMessaging'; +import 'shared/themes/rocketride-default.css'; +import 'shared/themes/rocketride-vscode.css'; +import '../../styles/root.css'; + +// ============================================================================= +// TYPES +// ============================================================================= + +interface ConnectionState { + state: 'connected' | 'connecting' | 'downloading-engine' | 'starting-engine' | 'stopping-engine' | 'disconnected' | 'engine-startup-failed'; + connectionMode: 'cloud' | 'onprem' | 'local'; + retryAttempt: number; + maxRetryAttempts: number; + lastError?: string; + progressMessage?: string; +} + +const TRANSITIONAL_STATES: ReadonlySet = new Set(['connecting', 'downloading-engine', 'starting-engine', 'stopping-engine']); + +interface Config { + hostUrl: string; + connectionMode: 'cloud' | 'onprem' | 'local'; + autoConnect: boolean; +} + +interface EngineInfo { + version: string | null; + publishedAt: string | null; +} + +interface ConnectionData { + connectionState: ConnectionState; + config: Config; + hasApiKey: boolean; + engineInfo?: EngineInfo; +} + +type PageConnectionIncomingMessage = { + type: 'connectionUpdate'; + data: ConnectionData; +}; + +type PageConnectionOutgoingMessage = { type: 'ready' } | { type: 'connect' } | { type: 'disconnect' } | { type: 'reconnect' } | { type: 'openSettings' } | { type: 'openDocs' } | { type: 'openDeploy' } | { type: 'openDashboard' }; + +// ============================================================================= +// STYLES +// ============================================================================= + +const styles = { + container: { + position: 'fixed', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'var(--rr-bg-widget)', + overflowY: 'auto', + } as CSSProperties, + view: { + backgroundColor: 'var(--rr-bg-widget)', + color: 'var(--rr-fg-widget)', + padding: 8, + display: 'flex', + flexDirection: 'column', + gap: 8, + minHeight: '100%', + } as CSSProperties, + warningBanner: { + display: 'flex', + alignItems: 'flex-start', + gap: 8, + padding: 8, + background: 'var(--vscode-inputValidation-warningBackground)', + border: '1px solid var(--vscode-inputValidation-warningBorder)', + borderRadius: 3, + } as CSSProperties, + warningIcon: { + fontSize: 16, + lineHeight: 1, + flexShrink: 0, + } as CSSProperties, + warningTitle: { + fontWeight: 600, + fontSize: 12, + color: 'var(--rr-text-primary)', + marginBottom: 2, + } as CSSProperties, + warningMessage: { + fontSize: 11, + color: 'var(--rr-text-secondary)', + } as CSSProperties, + statusCard: { + display: 'flex', + alignItems: 'center', + gap: 10, + padding: 8, + } as CSSProperties, + statusIndicatorBase: { + width: 32, + height: 32, + borderRadius: '50%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: 16, + flexShrink: 0, + transition: 'all 0.3s ease', + color: 'white', + fontWeight: 'bold', + } as CSSProperties, + statusText: { + flex: 1, + minWidth: 0, + } as CSSProperties, + statusLabel: { + fontSize: 13, + fontWeight: 600, + color: 'var(--rr-text-primary)', + marginBottom: 2, + } as CSSProperties, + statusDetail: { + fontSize: 11, + color: 'var(--rr-text-secondary)', + minHeight: '1.25em', + lineHeight: '1.25em', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + } as CSSProperties, + connectionInfo: { + display: 'flex', + flexDirection: 'column', + gap: 6, + padding: 8, + } as CSSProperties, + infoRow: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + fontSize: 12, + gap: 8, + } as CSSProperties, + infoLabel: { + color: 'var(--rr-text-secondary)', + fontWeight: 400, + flexShrink: 0, + } as CSSProperties, + infoValue: { + color: 'var(--rr-text-primary)', + fontFamily: 'var(--vscode-editor-font-family)', + wordBreak: 'break-all', + textAlign: 'right', + fontSize: 11, + } as CSSProperties, + actionButtons: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: 6, + padding: '0 12px', + marginTop: 8, + } as CSSProperties, + btn: { + padding: '4px 10px', + border: 'none', + borderRadius: 4, + fontSize: 13, + fontWeight: 400, + cursor: 'pointer', + textAlign: 'center', + lineHeight: '20px', + width: '100%', + maxWidth: 200, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + background: 'var(--rr-bg-button)', + color: 'var(--rr-fg-button)', + } as CSSProperties, + btnDisabled: { + opacity: 0.4, + cursor: 'not-allowed', + } as CSSProperties, +}; + +const STATUS_COLORS: Record = { + connected: 'var(--rr-color-success)', + connecting: 'var(--rr-color-warning)', + 'downloading-engine': 'var(--rr-color-warning)', + 'starting-engine': 'var(--rr-color-warning)', + 'stopping-engine': 'var(--rr-color-warning)', + disconnected: 'var(--rr-text-disabled)', + 'engine-startup-failed': 'var(--rr-text-disabled)', + loading: 'var(--rr-text-disabled)', +}; + +// ============================================================================= +// COMPONENT +// ============================================================================= + +export const PageConnection: React.FC = () => { + const [connectionData, setConnectionData] = useState(null); + const [animationPhase, setAnimationPhase] = useState(0); + const [hoveredBtn, setHoveredBtn] = useState(null); + + const { sendMessage } = useMessaging({ + onMessage: (message) => { + if (message.type === 'connectionUpdate') { + setConnectionData(message.data); + } + }, + }); + + // Animate connecting dots + useEffect(() => { + const isTransitional = connectionData?.connectionState.state && TRANSITIONAL_STATES.has(connectionData.connectionState.state); + if (isTransitional) { + const interval = setInterval(() => setAnimationPhase((prev) => (prev + 1) % 4), 500); + return () => clearInterval(interval); + } + }, [connectionData?.connectionState.state]); + + const getAnimatedDots = (): string => '.'.repeat(animationPhase); + + const getStatusLabel = (): string => { + if (!connectionData) return 'Loading...'; + const { state } = connectionData.connectionState; + if (state === 'connected') return 'Connected'; + if (TRANSITIONAL_STATES.has(state)) return `Connecting${getAnimatedDots()}`; + return 'Disconnected'; + }; + + const getStatusIcon = (): string => { + if (!connectionData) return '○'; + const { state } = connectionData.connectionState; + if (state === 'connected') return '✓'; + if (TRANSITIONAL_STATES.has(state)) return '◷'; + return '○'; + }; + + const getStatusDetailLine = (): string => { + if (!connectionData) return ''; + const { state, lastError, progressMessage } = connectionData.connectionState; + const isTransitional = TRANSITIONAL_STATES.has(state); + + if (isTransitional && progressMessage) return progressMessage; + + switch (state) { + case 'downloading-engine': + return 'Downloading server...'; + case 'starting-engine': + return 'Starting server...'; + case 'connecting': + return connectionData.connectionState.retryAttempt > 0 ? 'Retrying...' : 'Connecting to server...'; + case 'stopping-engine': + return 'Stopping server...'; + default: + break; + } + + if (isTransitional && lastError) { + const lower = lastError.toLowerCase(); + if (lower.includes('rate limit') || lower.includes('github')) return 'No release info.'; + return lastError.length > 40 ? lastError.slice(0, 37) + '...' : lastError; + } + + if ((state === 'disconnected' || state === 'engine-startup-failed') && lastError) { + return lastError.length > 40 ? lastError.slice(0, 37) + '...' : lastError; + } + return ''; + }; + + const isConnecting = connectionData?.connectionState.state ? TRANSITIONAL_STATES.has(connectionData.connectionState.state) : false; + const isConnected = connectionData?.connectionState.state === 'connected'; + const needsApiKeySetup = (connectionData?.config.connectionMode === 'cloud' || connectionData?.config.connectionMode === 'onprem') && !connectionData?.hasApiKey; + const statusColor = STATUS_COLORS[connectionData?.connectionState.state ?? 'loading']; + + const btnStyle = (id: string, disabled?: boolean): CSSProperties => ({ + ...styles.btn, + ...(disabled ? styles.btnDisabled : {}), + ...(hoveredBtn === id && !disabled ? { filter: 'brightness(1.2)' } : {}), + }); + + return ( +
+
+ {needsApiKeySetup && ( +
+
⚠️
+
+
Setup Required
+
API Key must be configured for cloud mode
+
+
+ )} + +
+
+ {getStatusIcon()} +
+
+
{getStatusLabel()}
+ {getStatusDetailLine() && ( +
+ {getStatusDetailLine()} +
+ )} +
+
+ +
+
+ Server: + {connectionData?.config.connectionMode === 'local' ? 'Local' : connectionData?.config.hostUrl || 'N/A'} +
+ {connectionData?.config.connectionMode === 'local' && connectionData?.engineInfo?.version && ( +
+ Engine: + + {connectionData.engineInfo.version.replace(/^server-/, '')} + {connectionData.engineInfo.publishedAt && ` (${new Date(connectionData.engineInfo.publishedAt).toLocaleDateString()})`} + +
+ )} +
+ +
+ {isConnected ? ( + + ) : isConnecting ? ( + + ) : ( + + )} + + + + +
+
+
+ ); +}; diff --git a/apps/vscode/src/providers/views/PageConnection/index.tsx b/apps/vscode/src/providers/views/PageConnection/index.tsx new file mode 100644 index 000000000..77f37706c --- /dev/null +++ b/apps/vscode/src/providers/views/PageConnection/index.tsx @@ -0,0 +1,10 @@ +// ============================================================================= +// MIT License +// Copyright (c) 2026 Aparavi Software AG +// ============================================================================= + +import { PageConnection } from './PageConnection'; +import { mountComponent } from '../../../shared/util/mount'; + +mountComponent(PageConnection, 'Connection'); +export default PageConnection;