diff --git a/web/src/client/components/git-status-badge.ts b/web/src/client/components/git-status-badge.ts index 16e9790e..ad984336 100644 --- a/web/src/client/components/git-status-badge.ts +++ b/web/src/client/components/git-status-badge.ts @@ -5,7 +5,7 @@ * Shows counts for modified, untracked, staged files, and ahead/behind commits. */ import { html, LitElement } from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; +import { customElement, property } from 'lit/decorators.js'; import type { Session } from '../../shared/types.js'; @customElement('git-status-badge') @@ -17,47 +17,6 @@ export class GitStatusBadge extends LitElement { @property({ type: Object }) session: Session | null = null; @property({ type: Boolean }) detailed = false; // Show detailed breakdown - @property({ type: Number }) pollInterval = 5000; // Poll every 5 seconds - - @state() private _isPolling = false; - @state() private _gitModifiedCount = 0; - @state() private _gitUntrackedCount = 0; - @state() private _gitStagedCount = 0; - @state() private _gitAheadCount = 0; - @state() private _gitBehindCount = 0; - - private _pollTimer?: number; - private _visibilityHandler?: () => void; - - connectedCallback() { - super.connectedCallback(); - - // Set up visibility change listener - this._visibilityHandler = () => { - if (!document.hidden) { - this._startPolling(); - } else { - this._stopPolling(); - } - }; - - document.addEventListener('visibilitychange', this._visibilityHandler); - - // Start polling if page is visible and we have a session - if (!document.hidden && this.session?.id) { - this._startPolling(); - } - } - - disconnectedCallback() { - super.disconnectedCallback(); - - // Clean up - this._stopPolling(); - if (this._visibilityHandler) { - document.removeEventListener('visibilitychange', this._visibilityHandler); - } - } updated(changedProperties: Map) { super.updated(changedProperties); @@ -75,75 +34,6 @@ export class GitStatusBadge extends LitElement { newId: this.session?.id, }); } - - // Initialize internal state from session - if (this.session) { - this._gitModifiedCount = this.session.gitModifiedCount ?? 0; - this._gitUntrackedCount = this.session.gitUntrackedCount ?? 0; - this._gitStagedCount = this.session.gitStagedCount ?? 0; - this._gitAheadCount = this.session.gitAheadCount ?? 0; - this._gitBehindCount = this.session.gitBehindCount ?? 0; - } - - if (this.session?.id && !document.hidden) { - this._startPolling(); - } else { - this._stopPolling(); - } - } - - // Handle poll interval changes - if (changedProperties.has('pollInterval') && this._isPolling) { - this._stopPolling(); - this._startPolling(); - } - } - - private async _startPolling() { - if (this._isPolling || !this.session?.id) return; - - this._isPolling = true; - - // Initial fetch - await this._updateGitStatus(); - - // Set up periodic polling - this._pollTimer = window.setInterval(() => { - if (!document.hidden && this.session?.id) { - this._updateGitStatus(); - } - }, this.pollInterval); - } - - private _stopPolling() { - this._isPolling = false; - - if (this._pollTimer) { - window.clearInterval(this._pollTimer); - this._pollTimer = undefined; - } - } - - private async _updateGitStatus() { - if (!this.session?.id) return; - - try { - const response = await fetch(`/api/sessions/${this.session.id}/git-status`); - if (!response.ok) return; - - const status = await response.json(); - - // Update internal state instead of modifying the session object - this._gitModifiedCount = status.modified || 0; - this._gitUntrackedCount = status.untracked || 0; - this._gitStagedCount = status.added || 0; - this._gitAheadCount = status.ahead || 0; - this._gitBehindCount = status.behind || 0; - - // State updates will trigger re-render automatically - } catch (error) { - // Silently ignore errors to avoid disrupting the UI - console.debug('Failed to update git status:', error); } } @@ -155,9 +45,12 @@ export class GitStatusBadge extends LitElement { } const _hasLocalChanges = - this._gitModifiedCount > 0 || this._gitUntrackedCount > 0 || this._gitStagedCount > 0; + (this.session?.gitModifiedCount ?? 0) > 0 || + (this.session?.gitUntrackedCount ?? 0) > 0 || + (this.session?.gitStagedCount ?? 0) > 0; - const _hasRemoteChanges = this._gitAheadCount > 0 || this._gitBehindCount > 0; + const _hasRemoteChanges = + (this.session?.gitAheadCount ?? 0) > 0 || (this.session?.gitBehindCount ?? 0) > 0; // Always show the badge when in a Git repository // Even if there are no changes, users want to see the branch name @@ -186,9 +79,9 @@ export class GitStatusBadge extends LitElement { private renderLocalChanges() { if (!this.session) return null; - const modifiedCount = this._gitModifiedCount; - const untrackedCount = this._gitUntrackedCount; - const stagedCount = this._gitStagedCount; + const modifiedCount = this.session?.gitModifiedCount ?? 0; + const untrackedCount = this.session?.gitUntrackedCount ?? 0; + const stagedCount = this.session?.gitStagedCount ?? 0; const totalChanges = modifiedCount + untrackedCount + stagedCount; if (totalChanges === 0 && !this.detailed) return null; @@ -239,8 +132,8 @@ export class GitStatusBadge extends LitElement { private renderRemoteChanges() { if (!this.session) return null; - const aheadCount = this._gitAheadCount; - const behindCount = this._gitBehindCount; + const aheadCount = this.session?.gitAheadCount ?? 0; + const behindCount = this.session?.gitBehindCount ?? 0; if (aheadCount === 0 && behindCount === 0) return null; diff --git a/web/src/client/components/session-view/connection-manager.ts b/web/src/client/components/session-view/connection-manager.ts index 4e7a1151..3eef3db3 100644 --- a/web/src/client/components/session-view/connection-manager.ts +++ b/web/src/client/components/session-view/connection-manager.ts @@ -18,6 +18,7 @@ export interface StreamConnection { disconnect: () => void; errorHandler?: EventListener; sessionExitHandler?: EventListener; + sessionUpdateHandler?: EventListener; } export class ConnectionManager { @@ -87,6 +88,38 @@ export class ConnectionManager { this.terminal.addEventListener('session-exit', handleSessionExit); + // Listen for session-update events from SSE (git status updates) + const handleSessionUpdate = (event: MessageEvent) => { + try { + const data = JSON.parse(event.data); + logger.debug('Received session-update event:', data); + + if ( + data.type === 'git-status-update' && + this.session && + data.sessionId === this.session.id + ) { + // Update session with new git status + const updatedSession = { + ...this.session, + gitModifiedCount: data.gitModifiedCount, + gitUntrackedCount: data.gitUntrackedCount, + gitStagedCount: data.gitStagedCount, + gitAheadCount: data.gitAheadCount, + gitBehindCount: data.gitBehindCount, + }; + + this.session = updatedSession; + this.onSessionUpdate(updatedSession); + } + } catch (error) { + logger.error('Failed to parse session-update event:', error); + } + }; + + // Add named event listener for session-update events + connection.eventSource.addEventListener('session-update', handleSessionUpdate); + // Wrap the connection to track reconnections const originalEventSource = connection.eventSource; let lastErrorTime = 0; @@ -134,6 +167,7 @@ export class ConnectionManager { ...connection, errorHandler: handleError as EventListener, sessionExitHandler: handleSessionExit as EventListener, + sessionUpdateHandler: handleSessionUpdate as EventListener, }; } @@ -146,6 +180,14 @@ export class ConnectionManager { this.terminal.removeEventListener('session-exit', this.streamConnection.sessionExitHandler); } + // Remove session-update event listener if it exists + if (this.streamConnection.sessionUpdateHandler && this.streamConnection.eventSource) { + this.streamConnection.eventSource.removeEventListener( + 'session-update', + this.streamConnection.sessionUpdateHandler + ); + } + this.streamConnection.disconnect(); this.streamConnection = null; } diff --git a/web/src/server/routes/sessions.ts b/web/src/server/routes/sessions.ts index 90415348..ec57b487 100644 --- a/web/src/server/routes/sessions.ts +++ b/web/src/server/routes/sessions.ts @@ -12,6 +12,7 @@ import type { RemoteRegistry } from '../services/remote-registry.js'; import type { StreamWatcher } from '../services/stream-watcher.js'; import type { TerminalManager } from '../services/terminal-manager.js'; import { detectGitInfo } from '../utils/git-info.js'; +import { getDetailedGitStatus } from '../utils/git-status.js'; import { createLogger } from '../utils/logger.js'; import { resolveAbsolutePath } from '../utils/path-utils.js'; import { generateSessionName } from '../utils/session-naming.js'; @@ -19,7 +20,7 @@ import { createControlMessage, type TerminalSpawnResponse } from '../websocket/c import { controlUnixHandler } from '../websocket/control-unix-handler.js'; const logger = createLogger('sessions'); -const execFile = promisify(require('child_process').execFile); +const _execFile = promisify(require('child_process').execFile); interface SessionRoutesConfig { ptyManager: PtyManager; @@ -440,93 +441,6 @@ export function createSessionRoutes(config: SessionRoutesConfig): Router { } }); - /** - * Get detailed git status including file counts - */ - async function getDetailedGitStatus(workingDir: string) { - try { - const { stdout: statusOutput } = await execFile( - 'git', - ['status', '--porcelain=v1', '--branch'], - { - cwd: workingDir, - timeout: 5000, - env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, - } - ); - - const lines = statusOutput.trim().split('\n'); - const branchLine = lines[0]; - - let aheadCount = 0; - let behindCount = 0; - let modifiedCount = 0; - let untrackedCount = 0; - let stagedCount = 0; - let deletedCount = 0; - - // Parse branch line for ahead/behind info - if (branchLine?.startsWith('##')) { - const aheadMatch = branchLine.match(/\[ahead (\d+)/); - const behindMatch = branchLine.match(/behind (\d+)/); - - if (aheadMatch) { - aheadCount = Number.parseInt(aheadMatch[1], 10); - } - if (behindMatch) { - behindCount = Number.parseInt(behindMatch[1], 10); - } - } - - // Process status lines (skip the branch line) - const statusLines = lines.slice(1); - - for (const line of statusLines) { - if (line.length < 2) continue; - - const indexStatus = line[0]; - const workTreeStatus = line[1]; - - // Staged changes - if (indexStatus !== ' ' && indexStatus !== '?') { - stagedCount++; - } - - // Working tree changes - if (workTreeStatus === 'M') { - modifiedCount++; - } else if (workTreeStatus === 'D' && indexStatus === ' ') { - // Deleted in working tree but not staged - deletedCount++; - } - - // Untracked files - if (indexStatus === '?' && workTreeStatus === '?') { - untrackedCount++; - } - } - - return { - modified: modifiedCount, - untracked: untrackedCount, - added: stagedCount, - deleted: deletedCount, - ahead: aheadCount, - behind: behindCount, - }; - } catch (error) { - logger.debug(`Could not get detailed git status: ${error}`); - return { - modified: 0, - untracked: 0, - added: 0, - deleted: 0, - ahead: 0, - behind: 0, - }; - } - } - // Get git status for a specific session router.get('/sessions/:sessionId/git-status', async (req, res) => { const sessionId = req.params.sessionId; diff --git a/web/src/server/services/git-watcher.ts b/web/src/server/services/git-watcher.ts new file mode 100644 index 00000000..e76cf935 --- /dev/null +++ b/web/src/server/services/git-watcher.ts @@ -0,0 +1,250 @@ +/** + * Git File Watcher Service + * + * Monitors git repositories for file changes and broadcasts git status updates via SSE. + * Simply watches for any file system changes and lets Git determine what's important. + */ + +import * as chokidar from 'chokidar'; +import type { Response } from 'express'; +import { type GitStatusCounts, getDetailedGitStatus } from '../utils/git-status.js'; +import { createLogger } from '../utils/logger.js'; + +const logger = createLogger('git-watcher'); + +interface WatcherInfo { + watcher: chokidar.FSWatcher; + sessionId: string; + workingDir: string; + gitRepoPath: string; + lastStatus?: GitStatusCounts; + debounceTimer?: NodeJS.Timeout; + clients: Set; +} + +export class GitWatcher { + private watchers = new Map(); + + /** + * Start watching git repository for a session + */ + startWatching(sessionId: string, workingDir: string, gitRepoPath: string): void { + // Don't create duplicate watchers + if (this.watchers.has(sessionId)) { + logger.debug(`Git watcher already exists for session ${sessionId}`); + return; + } + + logger.debug(`Starting git watcher for session ${sessionId} at ${gitRepoPath}`); + + // Watch the repository root, but ignore performance-killing directories + const watcher = chokidar.watch(gitRepoPath, { + ignoreInitial: true, + ignored: [ + // Ignore directories that would kill performance + '**/node_modules/**', + '**/.git/objects/**', // Git's object database - huge and changes don't matter + '**/.git/logs/**', // Git's log files - not relevant for status + '**/dist/**', + '**/build/**', + '**/.next/**', + '**/coverage/**', + '**/.turbo/**', + '**/*.log', + ], + // Don't follow symlinks to avoid infinite loops + followSymlinks: false, + // Use native events for better performance + usePolling: false, + // Optimize for performance + awaitWriteFinish: { + stabilityThreshold: 200, + pollInterval: 100, + }, + }); + + const watcherInfo: WatcherInfo = { + watcher, + sessionId, + workingDir, + gitRepoPath, + clients: new Set(), + }; + + // Handle any file system change + const handleChange = (changedPath: string, eventType: string) => { + // Only log significant events to reduce noise + const isGitFile = changedPath.includes('.git'); + if (isGitFile || eventType !== 'change') { + logger.debug(`Git watcher event for session ${sessionId}: ${eventType} ${changedPath}`); + } + + // Clear existing debounce timer + if (watcherInfo.debounceTimer) { + clearTimeout(watcherInfo.debounceTimer); + } + + // Debounce rapid changes + watcherInfo.debounceTimer = setTimeout(() => { + this.checkAndBroadcastStatus(watcherInfo); + }, 300); + }; + + // Listen to all events + watcher.on('all', (eventType, path) => handleChange(path, eventType)); + + watcher.on('error', (error) => { + logger.error(`Git watcher error for session ${sessionId}:`, error); + }); + + this.watchers.set(sessionId, watcherInfo); + + // Get initial status + this.checkAndBroadcastStatus(watcherInfo); + } + + /** + * Add a client to receive git status updates + */ + addClient(sessionId: string, client: Response): void { + const watcherInfo = this.watchers.get(sessionId); + if (!watcherInfo) { + logger.debug(`No git watcher found for session ${sessionId}`); + return; + } + + watcherInfo.clients.add(client); + logger.debug( + `Added SSE client to git watcher for session ${sessionId} (${watcherInfo.clients.size} total)` + ); + + // Send current status to new client + if (watcherInfo.lastStatus) { + this.sendStatusUpdate(client, sessionId, watcherInfo.lastStatus); + } + } + + /** + * Remove a client from git status updates + */ + removeClient(sessionId: string, client: Response): void { + const watcherInfo = this.watchers.get(sessionId); + if (!watcherInfo) { + return; + } + + watcherInfo.clients.delete(client); + logger.debug( + `Removed SSE client from git watcher for session ${sessionId} (${watcherInfo.clients.size} remaining)` + ); + + // If no more clients, stop watching + if (watcherInfo.clients.size === 0) { + this.stopWatching(sessionId); + } + } + + /** + * Stop watching git directory for a session + */ + stopWatching(sessionId: string): void { + const watcherInfo = this.watchers.get(sessionId); + if (!watcherInfo) { + return; + } + + logger.debug(`Stopping git watcher for session ${sessionId}`); + + // Clear debounce timer + if (watcherInfo.debounceTimer) { + clearTimeout(watcherInfo.debounceTimer); + } + + // Close watcher + watcherInfo.watcher.close(); + + // Remove from map + this.watchers.delete(sessionId); + } + + /** + * Check git status and broadcast if changed + */ + private async checkAndBroadcastStatus(watcherInfo: WatcherInfo): Promise { + try { + const status = await getDetailedGitStatus(watcherInfo.workingDir); + + // Check if status has changed + if (this.hasStatusChanged(watcherInfo.lastStatus, status)) { + logger.debug(`Git status changed for session ${watcherInfo.sessionId}:`, status); + watcherInfo.lastStatus = status; + + // Broadcast to all clients + this.broadcastStatusUpdate(watcherInfo, status); + } + } catch (error) { + logger.error(`Failed to get git status for session ${watcherInfo.sessionId}:`, error); + } + } + + /** + * Check if git status has changed + */ + private hasStatusChanged( + oldStatus: GitStatusCounts | undefined, + newStatus: GitStatusCounts + ): boolean { + if (!oldStatus) return true; + + return ( + oldStatus.modified !== newStatus.modified || + oldStatus.untracked !== newStatus.untracked || + oldStatus.staged !== newStatus.staged || + oldStatus.ahead !== newStatus.ahead || + oldStatus.behind !== newStatus.behind + ); + } + + /** + * Broadcast status update to all clients + */ + private broadcastStatusUpdate(watcherInfo: WatcherInfo, status: GitStatusCounts): void { + for (const client of watcherInfo.clients) { + this.sendStatusUpdate(client, watcherInfo.sessionId, status); + } + } + + /** + * Send status update to a specific client + */ + private sendStatusUpdate(client: Response, sessionId: string, status: GitStatusCounts): void { + try { + const event = { + type: 'git-status-update', + sessionId, + gitModifiedCount: status.modified, + gitUntrackedCount: status.untracked, + gitStagedCount: status.staged, + gitAheadCount: status.ahead, + gitBehindCount: status.behind, + }; + + client.write(`event: session-update\ndata: ${JSON.stringify(event)}\n\n`); + } catch (error) { + logger.error(`Failed to send git status update to client:`, error); + } + } + + /** + * Clean up all watchers + */ + cleanup(): void { + logger.debug('Cleaning up all git watchers'); + for (const [sessionId] of this.watchers) { + this.stopWatching(sessionId); + } + } +} + +// Export singleton instance +export const gitWatcher = new GitWatcher(); diff --git a/web/src/server/services/stream-watcher.ts b/web/src/server/services/stream-watcher.ts index ec09a7ab..03701897 100644 --- a/web/src/server/services/stream-watcher.ts +++ b/web/src/server/services/stream-watcher.ts @@ -10,6 +10,7 @@ import { findLastPrunePoint, logPruningDetection, } from '../utils/pruning-detector.js'; +import { gitWatcher } from './git-watcher.js'; const logger = createLogger('stream-watcher'); @@ -217,9 +218,15 @@ export class StreamWatcher { // Start watching for new content this.startWatching(sessionId, streamPath, watcherInfo); + + // Start git watching if this is a git repository + this.startGitWatching(sessionId, response); } else { // Send existing content to new client this.sendExistingContent(sessionId, streamPath, client); + + // Add this client to git watcher + gitWatcher.addClient(sessionId, response); } // Add client to set @@ -256,6 +263,9 @@ export class StreamWatcher { ) ); + // Remove client from git watcher + gitWatcher.removeClient(sessionId, response); + // If no more clients, stop watching if (watcherInfo.clients.size === 0) { logger.log(chalk.yellow(`stopping watcher for session ${sessionId} (no clients)`)); @@ -263,6 +273,9 @@ export class StreamWatcher { watcherInfo.watcher.close(); } this.activeWatchers.delete(sessionId); + + // Stop git watching when no clients remain + gitWatcher.stopWatching(sessionId); } } } @@ -611,6 +624,22 @@ export class StreamWatcher { } } + /** + * Start git watching for a session if it's in a git repository + */ + private async startGitWatching(sessionId: string, response: Response): Promise { + try { + const sessionInfo = this.sessionManager.loadSessionInfo(sessionId); + if (sessionInfo?.gitRepoPath && sessionInfo.workingDir) { + logger.debug(`Starting git watcher for session ${sessionId} at ${sessionInfo.gitRepoPath}`); + await gitWatcher.startWatching(sessionId, sessionInfo.workingDir, sessionInfo.gitRepoPath); + gitWatcher.addClient(sessionId, response); + } + } catch (error) { + logger.error(`Failed to start git watching for session ${sessionId}:`, error); + } + } + /** * Clean up all watchers and listeners */ @@ -626,5 +655,7 @@ export class StreamWatcher { } this.activeWatchers.clear(); } + // Clean up git watchers + gitWatcher.cleanup(); } } diff --git a/web/src/server/utils/git-status.ts b/web/src/server/utils/git-status.ts new file mode 100644 index 00000000..03687f97 --- /dev/null +++ b/web/src/server/utils/git-status.ts @@ -0,0 +1,108 @@ +/** + * Shared Git Status Utilities + * + * Provides a single implementation for parsing git status output + * to avoid duplication across the codebase. + */ + +import { execFile } from 'child_process'; +import { promisify } from 'util'; + +const execFileAsync = promisify(execFile); + +export interface GitStatusCounts { + modified: number; + untracked: number; + staged: number; + deleted: number; + ahead: number; + behind: number; +} + +/** + * Get detailed git status including file counts and ahead/behind info + * @param workingDir The directory to check git status in + * @returns Git status counts or null if not a git repository + */ +export async function getDetailedGitStatus(workingDir: string): Promise { + try { + const { stdout: statusOutput } = await execFileAsync( + 'git', + ['status', '--porcelain=v1', '--branch'], + { + cwd: workingDir, + timeout: 5000, + env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, + } + ); + + const lines = statusOutput.trim().split('\n'); + const branchLine = lines[0]; + + let aheadCount = 0; + let behindCount = 0; + let modifiedCount = 0; + let untrackedCount = 0; + let stagedCount = 0; + let deletedCount = 0; + + // Parse branch line for ahead/behind info + if (branchLine?.startsWith('##')) { + const aheadMatch = branchLine.match(/\[ahead (\d+)/); + const behindMatch = branchLine.match(/behind (\d+)/); + + if (aheadMatch) { + aheadCount = Number.parseInt(aheadMatch[1], 10); + } + if (behindMatch) { + behindCount = Number.parseInt(behindMatch[1], 10); + } + } + + // Parse file statuses + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + if (!line || line.length < 2) continue; + + const indexStatus = line[0]; + const workingStatus = line[1]; + + // Staged files (changes in index) + if (indexStatus !== ' ' && indexStatus !== '?') { + stagedCount++; + } + + // Working directory changes + if (workingStatus === 'M') { + modifiedCount++; + } else if (workingStatus === 'D' && indexStatus === ' ') { + // Deleted in working tree but not staged + deletedCount++; + } + + // Untracked files + if (indexStatus === '?' && workingStatus === '?') { + untrackedCount++; + } + } + + return { + modified: modifiedCount, + untracked: untrackedCount, + staged: stagedCount, + deleted: deletedCount, + ahead: aheadCount, + behind: behindCount, + }; + } catch (_error) { + // Not a git repository or git command failed + return { + modified: 0, + untracked: 0, + staged: 0, + deleted: 0, + ahead: 0, + behind: 0, + }; + } +}