UNPKG

@sbeeredd04/auto-git

Version:

AI-powered Git automation with intelligent commit decisions using Gemini function calling, smart diff optimization, push control, and enhanced interactive terminal session with persistent command history

1,795 lines (1,548 loc) 76.4 kB
// The module 'vscode' contains the VS Code extensibility API // Import the module and reference it with the alias vscode in your code below import * as vscode from 'vscode'; import * as path from 'path'; import { spawn, exec } from 'child_process'; import { promisify } from 'util'; import { GitCuePty } from './terminal/interactivePty'; import { configManager } from './utils/config'; import logger from './utils/logger'; import { generateErrorSuggestion, makeCommitDecisionWithAI, generateCommitMessageWithAI } from './utils/ai'; const execAsync = promisify(exec); interface GitCueConfig { geminiApiKey: string; commitMode: 'periodic' | 'intelligent'; autoPush: boolean; watchPaths: string[]; debounceMs: number; bufferTimeSeconds: number; maxCallsPerMinute: number; enableNotifications: boolean; autoWatch: boolean; // Interactive terminal settings interactiveOnError: boolean; enableSuggestions: boolean; terminalVerbose: boolean; sessionPersistence: boolean; maxHistorySize: number; } interface BufferNotification { panel: vscode.WebviewPanel; timer: NodeJS.Timeout; cancelled: boolean; } interface WatchStatus { isWatching: boolean; filesChanged: number; lastChange: string; lastCommit: string; pendingCommit: boolean; aiAnalysisInProgress: boolean; activityHistory: ActivityLogEntry[]; changedFiles: Set<string>; } interface ActivityLogEntry { timestamp: string; type: 'file_change' | 'ai_analysis' | 'commit' | 'error' | 'watch_start' | 'watch_stop'; message: string; details?: string; } class GitCueExtension { private statusBarItem: vscode.StatusBarItem; private fileWatcher: vscode.FileSystemWatcher | undefined; public isWatching = false; private outputChannel: vscode.OutputChannel; private statusProvider: GitCueStatusProvider; private debounceTimer: NodeJS.Timeout | undefined; private bufferNotification: BufferNotification | undefined; private terminal: vscode.Terminal | undefined; private watchStatus: WatchStatus = { isWatching: false, filesChanged: 0, lastChange: 'None', lastCommit: 'None', pendingCommit: false, aiAnalysisInProgress: false, activityHistory: [], changedFiles: new Set() }; private dashboardPanels: vscode.WebviewPanel[] = []; constructor(private context: vscode.ExtensionContext) { this.statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); this.outputChannel = vscode.window.createOutputChannel('GitCue'); this.statusProvider = new GitCueStatusProvider(); // Set up logger logger.setVerbose(this.getConfig().terminalVerbose); this.setupStatusBar(); this.registerCommands(); this.registerViews(); // Auto-start watching if configured const config = this.getConfig(); if (config.autoWatch) { this.startWatching(); } logger.info('GitCue extension initialized v0.3.8', 'STARTUP'); } private getConfig(): GitCueConfig { return configManager.getConfig(); } private setupStatusBar() { this.statusBarItem.command = 'gitcue.watchToggle'; this.updateStatusBar(); this.statusBarItem.show(); } private updateStatusBar() { if (this.isWatching) { this.statusBarItem.text = `$(eye) GitCue: Watching (${this.watchStatus.filesChanged} changes)`; this.statusBarItem.tooltip = 'GitCue is actively watching for file changes. Click to open dashboard.'; this.statusBarItem.color = undefined; } else { this.statusBarItem.text = `$(eye-closed) GitCue: Idle`; this.statusBarItem.tooltip = 'GitCue is not watching. Click to open dashboard or start watching.'; this.statusBarItem.color = new vscode.ThemeColor('statusBarItem.warningForeground'); } // Add command to open dashboard when clicked this.statusBarItem.command = 'gitcue.openDashboard'; this.statusBarItem.show(); } private updateDashboards() { // Update all open dashboard panels this.dashboardPanels.forEach(panel => { if (panel.visible) { panel.webview.postMessage({ action: 'statusUpdate', data: { isWatching: this.isWatching, config: this.getConfig(), watchStatus: this.watchStatus } }); } }); } private registerCommands() { const commands = [ vscode.commands.registerCommand('gitcue.commit', () => this.commitWithPreview()), vscode.commands.registerCommand('gitcue.watchToggle', () => this.toggleWatching()), vscode.commands.registerCommand('gitcue.openDashboard', () => this.openDashboard()), vscode.commands.registerCommand('gitcue.reset', () => this.resetCommits()), vscode.commands.registerCommand('gitcue.configure', () => this.openSettings()), vscode.commands.registerCommand('gitcue.showStatus', () => this.showStatus()), vscode.commands.registerCommand('gitcue.cancelCommit', () => this.cancelBufferedCommit()), vscode.commands.registerCommand('gitcue.openInteractiveTerminal', () => this.openTerminal()), vscode.commands.registerCommand('gitcue.openAITerminal', () => this.openTerminal()), vscode.commands.registerCommand('gitcue.dashboard', () => this.openDashboard()) ]; commands.forEach(command => this.context.subscriptions.push(command)); } private registerViews() { const statusView = vscode.window.createTreeView('gitcueStatus', { treeDataProvider: this.statusProvider, showCollapseAll: false }); this.context.subscriptions.push(statusView); } private async commitWithPreview() { try { const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; if (!workspaceFolder) { vscode.window.showErrorMessage('No workspace folder found'); return; } const config = this.getConfig(); if (!config.geminiApiKey) { const action = await vscode.window.showWarningMessage( 'Gemini API key not configured. Would you like to set it up?', 'Configure' ); if (action === 'Configure') { this.openSettings(); } return; } // Show progress await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: 'GitCue: Generating AI commit message...', cancellable: false }, async (progress) => { progress.report({ increment: 30, message: 'Analyzing changes...' }); // Get git status and diff const { stdout: status } = await execAsync('git status --porcelain', { cwd: workspaceFolder.uri.fsPath }); if (!status.trim()) { vscode.window.showInformationMessage('No changes to commit'); return; } progress.report({ increment: 40, message: 'Generating commit message...' }); // Use auto-git CLI to generate commit message const commitMessage = await this.generateCommitMessage(workspaceFolder.uri.fsPath, config); progress.report({ increment: 30, message: 'Opening preview...' }); // Show commit preview this.showCommitPreview(commitMessage, status, workspaceFolder.uri.fsPath, config); }); } catch (error) { this.outputChannel.appendLine(`Error in commitWithPreview: ${error}`); vscode.window.showErrorMessage(`GitCue Error: ${error}`); } } private async analyzeChangesWithAI(workspacePath: string): Promise<{ shouldCommit: boolean; reason: string; significance: string }> { try { this.watchStatus.aiAnalysisInProgress = true; this.updateDashboards(); // Get git diff and status const { stdout: diff } = await execAsync('git diff', { cwd: workspacePath }); const { stdout: status } = await execAsync('git status --porcelain', { cwd: workspacePath }); if (!diff.trim() && !status.trim()) { return { shouldCommit: false, reason: 'No changes detected', significance: 'NONE' }; } // Stage changes for analysis await execAsync('git add .', { cwd: workspacePath }); const { stdout: stagedDiff } = await execAsync('git diff --cached', { cwd: workspacePath }); // Use AI function calling to make commit decision const decision = await makeCommitDecisionWithAI(stagedDiff, status); return { shouldCommit: decision.shouldCommit, reason: decision.reason, significance: decision.significance }; } catch (error) { logger.error('AI analysis failed: ' + (error instanceof Error ? error.message : String(error))); return { shouldCommit: true, reason: 'AI analysis failed, defaulting to commit', significance: 'MEDIUM' }; } finally { this.watchStatus.aiAnalysisInProgress = false; this.updateDashboards(); } } private async commitWithBuffer(workspacePath: string, config: GitCueConfig) { try { // Get git status and diff const { stdout: status } = await execAsync('git status --porcelain', { cwd: workspacePath }); if (!status.trim()) { this.outputChannel.appendLine('No changes to commit'); return; } // Stage all changes await execAsync('git add .', { cwd: workspacePath }); // AI analysis for intelligent mode if (config.commitMode === 'intelligent') { const analysis = await this.analyzeChangesWithAI(workspacePath); if (!analysis.shouldCommit) { this.outputChannel.appendLine(`AI decided not to commit: ${analysis.reason}`); if (config.enableNotifications) { vscode.window.showInformationMessage(`🤖 GitCue: ${analysis.reason}`); } return; } this.outputChannel.appendLine(`AI analysis: ${analysis.reason} (${analysis.significance})`); } // Generate commit message const commitMessage = await this.generateCommitMessage(workspacePath, config); // Show buffer notification await this.showBufferNotification(commitMessage, status, workspacePath, config); } catch (error) { this.outputChannel.appendLine(`Error in commitWithBuffer: ${error}`); if (config.enableNotifications) { vscode.window.showErrorMessage(`GitCue Error: ${error}`); } } } private async showBufferNotification(message: string, status: string, workspacePath: string, config: GitCueConfig): Promise<void> { return new Promise((resolve) => { // Cancel any existing buffer notification if (this.bufferNotification) { this.cancelBufferedCommit(); } this.watchStatus.pendingCommit = true; this.updateDashboards(); const panel = vscode.window.createWebviewPanel( 'gitcueBuffer', '⏰ GitCue Commit Buffer', vscode.ViewColumn.Beside, { enableScripts: true, retainContextWhenHidden: true } ); let timeLeft = config.bufferTimeSeconds; let cancelled = false; const updatePanel = () => { panel.webview.html = this.getBufferNotificationHtml(message, status, timeLeft, config); }; updatePanel(); // Show notification if (config.enableNotifications) { vscode.window.showWarningMessage( `⏰ GitCue: Committing in ${timeLeft} seconds. Click to cancel.`, 'Cancel Commit' ).then(action => { if (action === 'Cancel Commit') { cancelled = true; clearInterval(timer); panel.dispose(); this.bufferNotification = undefined; this.watchStatus.pendingCommit = false; this.updateDashboards(); resolve(); } }); } const timer = setInterval(() => { timeLeft--; if (timeLeft <= 0 || cancelled) { clearInterval(timer); panel.dispose(); if (!cancelled) { // Proceed with commit this.executeCommit(message, workspacePath, config, config.autoPush) .finally(() => { this.watchStatus.pendingCommit = false; this.watchStatus.lastCommit = new Date().toLocaleTimeString(); this.updateDashboards(); resolve(); }); } else { this.watchStatus.pendingCommit = false; this.updateDashboards(); resolve(); } this.bufferNotification = undefined; } else { updatePanel(); } }, 1000); // Handle messages from the buffer panel panel.webview.onDidReceiveMessage((msg) => { if (msg.action === 'cancel') { cancelled = true; clearInterval(timer); panel.dispose(); this.bufferNotification = undefined; this.watchStatus.pendingCommit = false; this.updateDashboards(); if (config.enableNotifications) { vscode.window.showInformationMessage('🚫 GitCue: Commit cancelled'); } this.outputChannel.appendLine('Commit cancelled by user'); resolve(); } }); // Handle panel disposal panel.onDidDispose(() => { if (!cancelled && timeLeft > 0) { cancelled = true; clearInterval(timer); this.bufferNotification = undefined; this.watchStatus.pendingCommit = false; this.updateDashboards(); resolve(); } }); this.bufferNotification = { panel, timer, cancelled: false }; }); } private getBufferNotificationHtml(message: string, status: string, timeLeft: number, config: GitCueConfig): string { const fileCount = status.split('\n').filter(line => line.trim()).length; return ` <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>GitCue Commit Buffer</title> <style> :root { --primary: #007acc; --danger: #f44336; --warning: #ff9800; --success: #4caf50; --bg-primary: var(--vscode-editor-background); --bg-secondary: var(--vscode-sideBar-background); --text-primary: var(--vscode-foreground); --text-secondary: var(--vscode-descriptionForeground); --border: var(--vscode-panel-border); --radius: 12px; --shadow: 0 8px 32px rgba(0, 0, 0, 0.12); } * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: var(--vscode-font-family); background: var(--bg-primary); color: var(--text-primary); line-height: 1.6; overflow: hidden; } .container { height: 100vh; display: flex; flex-direction: column; padding: 24px; background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%); } .header { text-align: center; margin-bottom: 32px; animation: slideDown 0.6s ease-out; } .timer-circle { width: 120px; height: 120px; margin: 0 auto 16px; position: relative; background: conic-gradient(var(--warning) ${(timeLeft / config.bufferTimeSeconds) * 360}deg, var(--border) 0deg); border-radius: 50%; display: flex; align-items: center; justify-content: center; animation: pulse 2s infinite; } .timer-inner { width: 100px; height: 100px; background: var(--bg-primary); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 28px; font-weight: 700; color: var(--warning); } .title { font-size: 24px; font-weight: 600; margin-bottom: 8px; color: var(--text-primary); } .subtitle { font-size: 16px; color: var(--text-secondary); margin-bottom: 24px; } .commit-info { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 24px; margin-bottom: 24px; animation: slideUp 0.6s ease-out 0.2s both; } .commit-message { font-size: 16px; font-weight: 500; margin-bottom: 16px; padding: 16px; background: var(--vscode-textCodeBlock-background); border-radius: 8px; border-left: 4px solid var(--primary); } .file-stats { display: flex; align-items: center; gap: 16px; font-size: 14px; color: var(--text-secondary); } .stat { display: flex; align-items: center; gap: 6px; } .actions { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; margin-bottom: 24px; } .btn { flex: 1; padding: 16px 24px; border: none; border-radius: var(--radius); font-size: 16px; font-weight: 600; cursor: pointer; transition: all 0.3s ease; display: flex; align-items: center; justify-content: center; gap: 8px; } .btn-cancel { background: var(--danger); color: white; } .btn-cancel:hover { background: #d32f2f; transform: translateY(-2px); box-shadow: var(--shadow); } .progress-bar { width: 100%; height: 4px; background: var(--border); border-radius: 2px; overflow: hidden; margin: 16px 0; } .progress-fill { height: 100%; background: linear-gradient(90deg, var(--warning), var(--danger)); border-radius: 2px; transition: width 1s linear; width: ${(timeLeft / config.bufferTimeSeconds) * 100}%; } @keyframes slideDown { from { opacity: 0; transform: translateY(-30px); } to { opacity: 1; transform: translateY(0); } } @keyframes slideUp { from { opacity: 0; transform: translateY(30px); } to { opacity: 1; transform: translateY(0); } } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } } .animate-in { animation: slideUp 0.6s ease-out; } .warning-text { color: var(--warning); font-weight: 600; } .keyboard-hint { text-align: center; font-size: 14px; color: var(--text-secondary); margin-top: 16px; opacity: 0.8; } </style> </head> <body> <div class="container"> <div class="header"> <div class="timer-circle"> <div class="timer-inner">${timeLeft}</div> </div> <h1 class="title">⏰ Commit Buffer Period</h1> <p class="subtitle">GitCue is about to commit your changes</p> </div> <div class="commit-info"> <div class="commit-message"> 💬 ${message} </div> <div class="file-stats"> <div class="stat"> <span>📁</span> <span>${fileCount} files changed</span> </div> <div class="stat"> <span>🔄</span> <span>${config.commitMode} mode</span> </div> <div class="stat"> <span>🚀</span> <span>${config.autoPush ? 'Auto-push enabled' : 'No auto-push'}</span> </div> </div> </div> <div class="progress-bar"> <div class="progress-fill"></div> </div> <p class="warning-text" style="text-align: center; margin-bottom: 16px;"> ⚠️ Committing in ${timeLeft} seconds... </p> <div class="actions"> <button class="btn btn-primary" onclick="cancelCommit()"> <span></span> <span>Cancel Commit</span> </button> </div> <div class="keyboard-hint"> Press 'c', 'x', or Ctrl+X to cancel, or click the button above </div> </div> <script> const vscode = acquireVsCodeApi(); function cancelCommit() { vscode.postMessage({ action: 'cancel' }); } // Listen for keyboard shortcuts document.addEventListener('keydown', function(e) { // Handle 'c' or 'x' keys if (e.key.toLowerCase() === 'c' || e.key.toLowerCase() === 'x') { e.preventDefault(); cancelCommit(); } // Handle Ctrl+X if (e.ctrlKey && e.key.toLowerCase() === 'x') { e.preventDefault(); cancelCommit(); } }); // Auto-focus for keyboard input document.body.focus(); </script> </body> </html>`; } private cancelBufferedCommit() { if (this.bufferNotification) { this.bufferNotification.cancelled = true; clearInterval(this.bufferNotification.timer); this.bufferNotification.panel.dispose(); this.bufferNotification = undefined; const config = this.getConfig(); if (config.enableNotifications) { vscode.window.showInformationMessage('🚫 GitCue: Commit cancelled'); } this.outputChannel.appendLine('Commit cancelled by user'); } } private async generateCommitMessage(workspacePath: string, config: GitCueConfig): Promise<string> { try { // Get git status first const { stdout: status } = await execAsync('git status --porcelain', { cwd: workspacePath }); if (!status.trim()) { return 'feat: automated commit via GitCue'; } // Stage all changes to get proper diff for AI analysis await execAsync('git add .', { cwd: workspacePath }); // Get staged diff for AI analysis const { stdout: stagedDiff } = await execAsync('git diff --cached', { cwd: workspacePath }); // Also get unstaged diff for context const { stdout: unstagedDiff } = await execAsync('git diff', { cwd: workspacePath }); // Use AI function calling to generate commit message with better context const commitMessage = await generateCommitMessageWithAI(stagedDiff || unstagedDiff, status); return commitMessage || 'feat: automated commit via GitCue'; } catch (error) { logger.error('Commit message generation failed: ' + (error instanceof Error ? error.message : String(error))); return 'feat: automated commit via GitCue'; } } private showCommitPreview(message: string, status: string, workspacePath: string, config: GitCueConfig) { const panel = vscode.window.createWebviewPanel( 'gitcueCommitPreview', 'GitCue: Commit Preview', vscode.ViewColumn.One, { enableScripts: true, retainContextWhenHidden: true } ); panel.webview.html = this.getCommitPreviewHtml(message, status, config); panel.webview.onDidReceiveMessage(async (message) => { switch (message.action) { case 'commit': await this.executeCommit(message.commitMessage, workspacePath, config, message.shouldPush); panel.dispose(); break; case 'cancel': panel.dispose(); break; case 'edit': const newMessage = await vscode.window.showInputBox({ value: message.commitMessage, prompt: 'Edit commit message', placeHolder: 'Enter your commit message' }); if (newMessage) { panel.webview.postMessage({ action: 'updateMessage', message: newMessage }); } break; } }); } private getCommitPreviewHtml(message: string, status: string, config: GitCueConfig): string { return ` <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>GitCue Commit Preview</title> <style> :root { --primary-color: #007acc; --success-color: #4caf50; --warning-color: #ff9800; --danger-color: #f44336; --border-radius: 8px; --shadow: 0 2px 8px rgba(0, 0, 0, 0.1); --transition: all 0.2s ease-in-out; } * { box-sizing: border-box; } body { font-family: var(--vscode-font-family); padding: 0; margin: 0; color: var(--vscode-foreground); background: var(--vscode-editor-background); line-height: 1.6; } .container { max-width: 800px; margin: 0 auto; } .header { text-align: center; padding: 24px 0; border-bottom: 2px solid var(--vscode-panel-border); margin-bottom: 32px; background: linear-gradient(135deg, var(--vscode-textCodeBlock-background), var(--vscode-editor-background)); border-radius: var(--border-radius); box-shadow: var(--shadow); } .header h1 { margin: 0 0 8px 0; font-size: 28px; font-weight: 600; background: linear-gradient(45deg, var(--primary-color), var(--success-color)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } .header p { margin: 0; opacity: 0.8; font-size: 16px; } .section { margin-bottom: 24px; animation: slideInUp 0.3s ease-out; } .section-title { display: flex; align-items: center; gap: 8px; font-size: 18px; font-weight: 600; margin-bottom: 12px; color: var(--vscode-foreground); } .section-icon { font-size: 20px; } .commit-message-container { position: relative; background: var(--vscode-textCodeBlock-background); border: 2px solid var(--vscode-panel-border); border-radius: var(--border-radius); overflow: hidden; box-shadow: var(--shadow); transition: var(--transition); } .commit-message-container:hover { border-color: var(--primary-color); box-shadow: 0 4px 12px rgba(0, 122, 204, 0.2); } .commit-message { padding: 20px; font-family: var(--vscode-editor-font-family); font-size: 16px; line-height: 1.5; min-height: 60px; word-wrap: break-word; position: relative; } .commit-message::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: linear-gradient(to bottom, var(--primary-color), var(--success-color)); } .changes-container { background: var(--vscode-textCodeBlock-background); border: 2px solid var(--vscode-panel-border); border-radius: var(--border-radius); overflow: hidden; box-shadow: var(--shadow); transition: var(--transition); } .changes-header { background: var(--vscode-panel-border); padding: 12px 20px; font-weight: 600; border-bottom: 1px solid var(--vscode-panel-border); } .changes { padding: 20px; font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace; font-size: 14px; white-space: pre-wrap; max-height: 300px; overflow-y: auto; line-height: 1.4; } .changes::-webkit-scrollbar { width: 8px; } .changes::-webkit-scrollbar-track { background: var(--vscode-scrollbarSlider-background); } .changes::-webkit-scrollbar-thumb { background: var(--vscode-scrollbarSlider-hoverBackground); border-radius: 4px; } .options-section { background: var(--vscode-textCodeBlock-background); border: 2px solid var(--vscode-panel-border); border-radius: var(--border-radius); padding: 20px; box-shadow: var(--shadow); } .checkbox-container { display: flex; align-items: center; gap: 12px; padding: 16px; background: var(--vscode-input-background); border-radius: var(--border-radius); border: 1px solid var(--vscode-input-border); transition: var(--transition); cursor: pointer; } .checkbox-container:hover { background: var(--vscode-list-hoverBackground); border-color: var(--primary-color); } .custom-checkbox { position: relative; width: 20px; height: 20px; margin: 0; } .custom-checkbox input { opacity: 0; position: absolute; width: 100%; height: 100%; margin: 0; cursor: pointer; } .checkmark { position: absolute; top: 0; left: 0; height: 20px; width: 20px; background: var(--vscode-input-background); border: 2px solid var(--vscode-input-border); border-radius: 4px; transition: var(--transition); } .custom-checkbox input:checked ~ .checkmark { background: var(--primary-color); border-color: var(--primary-color); } .checkmark:after { content: ""; position: absolute; display: none; left: 6px; top: 2px; width: 6px; height: 10px; border: solid white; border-width: 0 2px 2px 0; transform: rotate(45deg); } .custom-checkbox input:checked ~ .checkmark:after { display: block; } .checkbox-label { font-size: 16px; font-weight: 500; cursor: pointer; user-select: none; } .actions { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; margin-top: 32px; } .btn { display: inline-flex; align-items: center; gap: 8px; padding: 12px 24px; border: none; border-radius: var(--border-radius); font-size: 16px; font-weight: 600; cursor: pointer; transition: var(--transition); text-decoration: none; position: relative; overflow: hidden; } .btn::before { content: ''; position: absolute; top: 0; left: -100%; width: 100%; height: 100%; background: linear-gradient(90deg, transparent, rgba(255,255,255,0.1), transparent); transition: left 0.5s; } .btn:hover::before { left: 100%; } .btn-primary { background: linear-gradient(135deg, var(--primary-color), var(--info)); color: white; box-shadow: 0 4px 12px rgba(0, 122, 204, 0.3); } .btn-primary:hover { background: linear-gradient(135deg, #005a9e, var(--primary-color)); transform: translateY(-2px); box-shadow: 0 6px 16px rgba(0, 122, 204, 0.4); } .btn-secondary { background: var(--vscode-button-secondaryBackground); color: var(--vscode-button-secondaryForeground); border: 2px solid var(--vscode-panel-border); } .btn-secondary:hover { border-color: var(--primary-color); background: var(--vscode-button-secondaryHoverBackground); } .btn-danger { background: linear-gradient(135deg, var(--danger-color), #d32f2f); color: white; } .btn-danger:hover { background: linear-gradient(135deg, #d32f2f, var(--danger-color)); transform: translateY(-2px); } .btn-icon { font-size: 18px; } .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; } .stat-card { background: var(--vscode-textCodeBlock-background); border: 1px solid var(--vscode-panel-border); border-radius: var(--border-radius); padding: 16px; text-align: center; transition: var(--transition); } .stat-card:hover { border-color: var(--primary-color); transform: translateY(-2px); box-shadow: var(--shadow); } .stat-value { font-size: 24px; font-weight: 700; color: var(--primary-color); margin-bottom: 4px; } .stat-label { font-size: 14px; opacity: 0.8; } @keyframes slideInUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.7; } } .loading { animation: pulse 1.5s infinite; } @media (max-width: 600px) { .container { padding: 16px; } .actions { flex-direction: column; } .btn { width: 100%; } .stats-grid { grid-template-columns: 1fr; } } </style> </head> <body> <div class="container"> <div class="header"> <h1>🤖 GitCue AI Commit</h1> <p>Review your AI-generated commit message and make any final adjustments</p> </div> <div class="stats-grid"> <div class="stat-card"> <div class="stat-value">${status.split('\n').filter(line => line.trim()).length}</div> <div class="stat-label">Files Changed</div> </div> <div class="stat-card"> <div class="stat-value">${config.commitMode}</div> <div class="stat-label">Commit Mode</div> </div> <div class="stat-card"> <div class="stat-value">${config.autoPush ? 'Yes' : 'No'}</div> <div class="stat-label">Auto Push</div> </div> </div> <div class="section"> <div class="section-title"> <span class="section-icon">💬</span> Commit Message </div> <div class="commit-message-container"> <div class="commit-message" id="commitMessage">${message}</div> </div> </div> <div class="section"> <div class="section-title"> <span class="section-icon">📋</span> Changes to Commit </div> <div class="changes-container"> <div class="changes-header"> Modified Files </div> <div class="changes">${status}</div> </div> </div> <div class="section"> <div class="section-title"> <span class="section-icon">⚙️</span> Options </div> <div class="options-section"> <label class="checkbox-container" for="shouldPush"> <div class="custom-checkbox"> <input type="checkbox" id="shouldPush" ${config.autoPush ? 'checked' : ''}> <span class="checkmark"></span> </div> <span class="checkbox-label">Push to remote repository after commit</span> </label> </div> </div> <div class="actions"> <button class="btn btn-primary" onclick="commit()"> <span class="btn-icon">🚀</span> <span>Commit & ${config.autoPush ? 'Push' : 'Save'}</span> </button> <button class="btn btn-secondary" onclick="editMessage()"> <span class="btn-icon">✏️</span> <span>Edit Message</span> </button> <button class="btn btn-secondary btn-danger" onclick="cancel()"> <span class="btn-icon">❌</span> <span>Cancel</span> </button> </div> </div> <script> const vscode = acquireVsCodeApi(); // Add smooth interactions document.addEventListener('DOMContentLoaded', function() { // Animate elements on load const sections = document.querySelectorAll('.section'); sections.forEach((section, index) => { section.style.animationDelay = \`\${index * 0.1}s\`; }); // Add click effects to buttons const buttons = document.querySelectorAll('.btn'); buttons.forEach(button => { button.addEventListener('click', function(e) { const ripple = document.createElement('span'); const rect = button.getBoundingClientRect(); const size = Math.max(rect.width, rect.height); const x = e.clientX - rect.left - size / 2; const y = e.clientY - rect.top - size / 2; ripple.style.cssText = \` position: absolute; width: \${size}px; height: \${size}px; left: \${x}px; top: \${y}px; background: rgba(255, 255, 255, 0.3); border-radius: 50%; transform: scale(0); animation: ripple 0.6s linear; pointer-events: none; \`; button.appendChild(ripple); setTimeout(() => ripple.remove(), 600); }); }); }); // Add ripple animation const style = document.createElement('style'); style.textContent = \` @keyframes ripple { to { transform: scale(4); opacity: 0; } } \`; document.head.appendChild(style); function commit() { const shouldPush = document.getElementById('shouldPush').checked; const commitMessage = document.getElementById('commitMessage').textContent; // Add loading state const btn = event.target.closest('.btn'); btn.classList.add('loading'); btn.disabled = true; vscode.postMessage({ action: 'commit', commitMessage: commitMessage, shouldPush: shouldPush }); } function editMessage() { const commitMessage = document.getElementById('commitMessage').textContent; vscode.postMessage({ action: 'edit', commitMessage: commitMessage }); } function cancel() { vscode.postMessage({ action: 'cancel' }); } // Listen for message updates window.addEventListener('message', event => { const message = event.data; if (message.action === 'updateMessage') { const messageEl = document.getElementById('commitMessage'); messageEl.textContent = message.message; // Add update animation messageEl.style.animation = 'none'; messageEl.offsetHeight; // Trigger reflow messageEl.style.animation = 'slideInUp 0.3s ease-out'; } }); // Auto-resize commit message area const commitMessage = document.getElementById('commitMessage'); if (commitMessage) { commitMessage.addEventListener('input', function() { this.style.height = 'auto'; this.style.height = this.scrollHeight + 'px'; }); } </script> </body> </html>`; } private async executeCommit(message: string, workspacePath: string, config: GitCueConfig, shouldPush: boolean) { const maxRetries = 3; let retryCount = 0; while (retryCount < maxRetries) { try { await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: `GitCue: Committing changes${retryCount > 0 ? ` (retry ${retryCount}/${maxRetries})` : ''}...`, cancellable: false }, async (progress) => { progress.report({ increment: 30, message: 'Adding files...' }); await execAsync('git add .', { cwd: workspacePath }); progress.report({ increment: 40, message: 'Creating commit...' }); await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { cwd: workspacePath }); if (shouldPush) { progress.report({ increment: 30, message: 'Pushing to remote...' }); await execAsync('git push', { cwd: workspacePath }); } }); const pushText = shouldPush ? ' and pushed' : ''; if (config.enableNotifications) { vscode.window.showInformationMessage(`GitCue: Changes committed${pushText} successfully!`); } this.outputChannel.appendLine(`Commit successful: ${message}`); this.statusProvider.refresh(); this.watchStatus.lastCommit = new Date().toLocaleTimeString(); this.watchStatus.filesChanged = 0; this.watchStatus.changedFiles.clear(); // Log commit activity this.logActivity('commit', `Committed: ${message}`, shouldPush ? 'Pushed to remote' : 'Local commit only'); this.updateDashboards(); return; // Success, exit retry loop } catch (error) { retryCount++; const errorMsg = error instanceof Error ? error.message : String(error); this.outputChannel.appendLine(`Commit attempt ${retryCount} failed: ${errorMsg}`); if (retryCount >= maxRetries) { // Final failure - prompt user for manual intervention const action = await vscode.window.showErrorMessage( `GitCue: Commit failed after ${maxRetries} attempts. Please fix the issue manually.`, 'Open Terminal', 'View Output', 'Retry Later' ); switch (action) { case 'Open Terminal': this.openTerminal(); break; case 'View Output': this.outputChannel.show(); break; case 'Retry Later': // Schedule retry in 5 minutes setTimeout(() => { this.executeCommit(message, workspacePath, config, shouldPush); }, 5 * 60 * 1000); break; } logger.error(`Commit failed after ${maxRetries} attempts: ${errorMsg}`); throw error; } else { // Wait before retry (exponential backoff) const waitTime = Math.pow(2, retryCount) * 1000; await new Promise(resolve => setTimeout(resolve, waitTime)); if (config.enableNotifications) { vscode.window.showWarningMessage(`GitCue: Commit failed, retrying in ${waitTime/1000}s... (${retryCount}/${maxRetries})`); } } } } } private toggleWatching() { if (this.isWatching) { this.stopWatching(); } else { this.startWatching(); } } private startWatching() { const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; if (!workspaceFolder) { vscode.window.showErrorMessage('No workspace folder found'); return; } const config = this.getConfig(); if (!config.geminiApiKey) { vscode.window.showWarningMessage('Gemini API key not configured. Please configure it in settings.'); return; } // Use comprehensive watch patterns const watchPatterns = configManager.getWatchPatterns(); const watchPattern = `{${watchPatterns.join(',')}}`; this.fileWatcher = vscode.workspace.createFileSystemWatcher(watchPattern); // Track changes to avoid duplicate processing - similar to auto-git library const changeTracker = new Set<string>(); let lastDiffHash: string | null = null; const onFileChange = async (uri: vscode.Uri) => { const filePath = uri.fsPath; const fileName = path.basename(filePath); // Filter out Git internal files and other system files const gitInternalFiles = [ 'index.lock', 'COMMIT_EDITMSG', 'MERGE_HEAD', 'MERGE_MSG', 'FETCH_HEAD', 'HEAD.lock', 'config.lock', 'packed-refs.lock' ]; const systemFiles = [ '.DS_Store', 'Thumbs.db', 'desktop.ini' ]; // Skip Git internal files and system files if (gitInternalFiles.includes(fileName) || systemFiles.includes(fileName)) { return; } // Skip files in .git directory if (filePath.includes('/.git/') || filePath.includes('\\.git\\')) { return; } // Skip if we've already processed this file recently if (changeTracker.has(filePath)) { return; } changeTracker.add(filePath); // Remove from tracker after a short delay setTimeout(() => { changeTracker.delete(filePath); }, 1000); // Check actual git changes to get accurate count (like auto-git library) try { const { stdout: gitStatus } = await execAsync('git status --porcelain', { cwd: workspaceFolder.uri.fsPath }); if (gitStatus.trim()) { // Parse git status to get unique changed files const changedFiles = gitStatus.trim().split('\n') .map(line => line.substring(3).trim()) // Remove status prefix .filter(file => file.length > 0); // Update changed files set this.watchStatus.changedFiles.clear(); changedFiles.forEach(file => this.watchStatus.changedFiles.add(file)); this.watchStatus.filesChanged = this.watchStatus.changedFiles.size; // Only log and update if there are actual changes if (this.watchStatus.filesChanged > 0) { // Log the file change activity only for actual user files this.logActivity('file_change', `File changed: ${fileName}`, filePath); // Update last change info this.watchStatus.lastChange = fileName + ' at ' + new Date().toLocaleTimeString(); // Log the change this.outputChannel.appendLine(`File changed: ${uri.fsPath}`); logger.debug(`File change detected: ${uri.fsPath}`); } // Create diff hash to avoid duplicate processing (like auto-git library) const { stdout: diff } = await execAsync('git diff', { cwd: workspaceFolder.uri.fsPath }); const currentDiffHash = this.createDiffHash(diff); // Skip if this is the same diff we already processed if (currentDiffHash === lastDiffHash) { this.updateStatusBar(); this.updateDashboards(); return; } lastDiffHash = currentDiffHash; } else { // No git changes, reset counters this.watchStatus.changedFiles.clear(); this.watchStatus.filesChanged = 0; } } catch (error) { // If git commands fail, fall back to simple file counting but still filter out Git files if (!gitInternalFiles.includes(fileName) && !systemFiles.includes(fileName)) { this.watchStatus.changedFiles.add(filePath); this.watchStatus.filesChanged = this.watchStatus.changedFiles.size; // Log the file change activity only for actual user files this.logActivity('file_change', `File changed: ${fileName}`, filePath); // Update last change info this.watchStatus.lastChange = fileName + ' at ' + new Date().toLocaleTimeString(); // Log the change this.outputChannel.appendLine(`File changed: ${uri.fsPath}`); logger.debug(`File change detected: ${uri.fsPath}`); } this.logActivity('error', 'Git status check failed', error instanceof Error ? error.message : String(error)); } this.updateStatusBar(); this.updateDashboards(); // Clear existing debounce timer if (this.debounceTimer) { clearTimeout(this.debounceTimer); } // Set new debounce timer this.debounceTimer = setTimeout(async () => { try { this.logActivity('ai_analysis', 'Starting AI analysis for changes'); if (config.commitMode === 'intelligent') { await this.handleIntelligentCommit(); } else { // For periodic mode, also use buffer notification const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; if (workspaceFolder) { await this.commitWithBuffer(workspaceFolder.uri.fsPath, config); } } } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); logger.error('Error processing file changes: ' + errorMsg); this.logActivity('error', 'Failed to process file changes', errorMsg); if (config.enableNotifications) { vscode.window.showErrorMessage(`GitCue: Error processing changes - ${errorMsg}`); } } }, config.debounceMs); }; // Listen to all file system events this.fileWatcher.onDidChange(onFileChange); this.fileWatcher.onDidCreate(onFileChange); this.fileWatcher.onDidDelete(onFileChange); // Also listen to workspace file changes for better coverage const workspaceWatcher = vscode.workspace.onDidChangeTextDocument((event) => { if (event.document.uri.scheme === 'file') { onFileChange(event.document.uri); } }); this.context.subscriptions.push(workspaceWatcher); this.isWatching = true; this.watchStatus.isWatching = true; this.watchStatus.filesChanged = 0; this.watchStatus.changedFiles.clear(); this.updateStatusBar(); this.updateDashboards(); // Log watch start activity this.logActivity('watch_start', 'File watching started', `Patterns: ${watchPatterns.join(', ')}`); if (config.enableNotifications) { vscode.window.showInformationMessage('GitCue: Started watching for changes'); } this.outputChannel.appendLine('Started watching for file changes with patterns: ' + watchPatterns.join(', ')); logger.info('File watching started with enhanced detection'); } // Helper method to create diff hash (similar to auto-git library) private createDiffHash(diffText: string): string | null { if (!diffText) return null; // Simple hash function for diff content let hash = 0; for (let i = 0; i < diffText.length; i++) { const char = diffText.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; // Convert to 32-bit integer } return hash.toString(); } private stopWatching() { if (this.fileWatcher) { this.fileWatcher.dispose(); this.fileWatcher = undefined; } if (this.debounceTimer) { clearTimeout(this.debounceTimer); this.debounceTimer = undefined; } // Cancel any pending commits if (this.bufferNotification) { this.cancelBufferedCommit(); } this.isWatching = false; this.watchStatus.isWatching = false; this.watchStatus.filesChanged = 0; this.watchStatus.changedFiles.clear(); this.watchStatus.lastChange = 'None'; this.updateStatusBar(); this.updateDashboards(); // Log watch stop activity this.logActivity('watch_stop', 'File watching stopped'); const config = this.getConfig(); if (config.enableNotifications) { vscode.window.showInformationMessage('GitCue: Stopped watching'); } this.outputChannel.appendLine('Stopped watching for file changes'); } private async handleIntelligentCommit() { const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; if (!workspaceFolder) return; const config = this.getConfig(); if (!config.geminiApiKey) return; // Use buffer notification for intelligent commits await this.commitWithBuffer(workspaceFolder.uri.fsPath, config); } private openDashboard() { const panel = vscode.window.createWebviewPanel( 'gitcueDashboard', 'GitCue Dashboard', vscode.ViewColumn.One, { enableScripts: true, retainContextWhenHidden: true } ); // Add to dashboard panels list this.dashboardPanels.push(panel); panel.webview.html = this.getDashboardHtml(); let panelDisposed = false; // Handle panel disposal panel.onDidDispose(() => { panelDisposed = true; // Remove from dashboard panels list const index = this.dashboardPanels.indexOf(panel); if (index > -1) { this.dashboardPanels.splice(index, 1); } }); // Handle messages from the dashboard panel.webview.onDidReceiveMessage(async (message) => { try { switch (message.action) { case 'toggleWatching': this.toggleWatching(); // Send updated status back to dashboard setTimeout(() => { if (!panelDisposed) { panel.webview.postMessage({ action: 'statusUpdate', data: { isWatching: this.isWatching, config: this.getConfig(), watchStatus: this.watchStatus } }); } }, 100); break; case 'openSettings': this.openSettings(); break; case 'manualCommit': this.commitWithPreview(); break; case 'openTerminal': this.openTerminal(); break; case 'keepAlive': // Dashboard is alive, send status update if (!panelDisposed) { panel.webview.postMessage({ action: 'statusUpdate', data: { isWatching: this.isWatching, config: this.getConfig(), watchStatus: this.watchStatus } }); } break; } } catch (error) { this.outputChannel.appendLine(`Dashboard message error: ${error}`); } }); // Initial status update setTimeout(() => { if (!panelDisposed) { panel.webview.postMessage({ action: 'statusUpdate', data: { isWatching: this.isWatching, config: this.getConfig(), watchStatus: this.watchStatus } }); } }, 500); } private getDashboardHtml(): string { const config = this.getConfig(); return ` <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>GitCue Dashboard</title> <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"> <style> :root { --pr