feat: Add realtime git status updates via WebSocket

- Add file watcher on .git directory in terminal server
- Broadcast git-change events to connected clients
- Frontend auto-refreshes when changes detected
- Visual indicator shows realtime connection status
This commit is contained in:
2026-02-14 11:20:55 -06:00
parent 3c401c4c2b
commit 8daf07819b
3 changed files with 166 additions and 7 deletions

View File

@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useGitApi } from '@/composables/git' import { useGitApi } from '@/composables/git'
import { DiffViewer, FileTree, CommitList, BranchSelector, ProjectTree, FileViewer } from '@/components/git' import { DiffViewer, FileTree, CommitList, BranchSelector, ProjectTree, FileViewer } from '@/components/git'
import { endpoints } from '@/config/endpoints'
type TabName = 'status' | 'history' | 'compare' | 'files' type TabName = 'status' | 'history' | 'compare' | 'files'
@@ -42,6 +43,63 @@ const compareHead = ref('')
// Files tab state // Files tab state
const selectedFilePath = ref<string | null>(null) const selectedFilePath = ref<string | null>(null)
// Realtime WebSocket connection
let gitSocket: WebSocket | null = null
const isRealtime = ref(false)
function connectGitWatcher() {
if (gitSocket?.readyState === WebSocket.OPEN) return
gitSocket = new WebSocket(endpoints.terminal)
gitSocket.onopen = () => {
isRealtime.value = true
console.log('[Git] Realtime connected')
}
gitSocket.onmessage = (event) => {
try {
const msg = JSON.parse(event.data)
if (msg.type === 'git-change') {
console.log('[Git] Change detected, refreshing...')
refreshCurrentTab()
}
} catch {
// Ignore non-JSON messages
}
}
gitSocket.onclose = () => {
isRealtime.value = false
console.log('[Git] Realtime disconnected, reconnecting...')
setTimeout(connectGitWatcher, 2000)
}
gitSocket.onerror = () => {
isRealtime.value = false
}
}
function disconnectGitWatcher() {
if (gitSocket) {
gitSocket.close()
gitSocket = null
}
}
async function refreshCurrentTab() {
if (activeTab.value === 'status') {
await fetchStatus()
if (selectedFile.value) {
await fetchDiff({ file: selectedFile.value, staged: selectedStaged.value })
}
} else if (activeTab.value === 'history') {
await fetchLog(30)
} else if (activeTab.value === 'files') {
await fetchFileTree()
}
}
// Load initial data // Load initial data
onMounted(async () => { onMounted(async () => {
await Promise.all([ await Promise.all([
@@ -51,6 +109,12 @@ onMounted(async () => {
]) ])
// Load initial diff // Load initial diff
await fetchDiff() await fetchDiff()
// Connect realtime watcher
connectGitWatcher()
})
onUnmounted(() => {
disconnectGitWatcher()
}) })
// Refresh when tab changes // Refresh when tab changes
@@ -143,11 +207,16 @@ const totalChanges = computed(() => {
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-header"> <div class="sidebar-header">
<h2>Git</h2> <h2>Git</h2>
<button class="refresh-btn" @click="refresh" :disabled="loading" title="Refresh"> <div class="header-actions">
<svg :class="{ spinning: loading }" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <span :class="['realtime-indicator', { connected: isRealtime }]" :title="isRealtime ? 'Realtime: connected' : 'Realtime: disconnected'">
<path d="M21 12a9 9 0 1 1-6.219-8.56" /> <svg width="8" height="8" viewBox="0 0 8 8"><circle cx="4" cy="4" r="4" fill="currentColor"/></svg>
</svg> </span>
</button> <button class="refresh-btn" @click="refresh" :disabled="loading" title="Refresh">
<svg :class="{ spinning: loading }" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
</button>
</div>
</div> </div>
<div class="branch-info"> <div class="branch-info">
@@ -502,6 +571,29 @@ const totalChanges = computed(() => {
margin: 0; margin: 0;
} }
.header-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.realtime-indicator {
color: var(--text-muted);
opacity: 0.5;
transition: all 0.3s;
}
.realtime-indicator.connected {
color: #22c55e;
opacity: 1;
animation: pulse-glow 2s infinite;
}
@keyframes pulse-glow {
0%, 100% { filter: drop-shadow(0 0 2px currentColor); }
50% { filter: drop-shadow(0 0 6px currentColor); }
}
.refresh-btn { .refresh-btn {
width: 28px; width: 28px;
height: 28px; height: 28px;

View File

@@ -1,4 +1,6 @@
import { spawn, type IPty } from '@skitee3000/bun-pty' import { spawn, type IPty } from '@skitee3000/bun-pty'
import { watch, type FSWatcher } from 'fs'
import { join } from 'path'
import { PORT_TERMINAL, WORKING_DIR, SHELL, SHELL_ARGS, DEFAULT_SESSION_ID, MAX_BUFFER_LINES } from '../config' import { PORT_TERMINAL, WORKING_DIR, SHELL, SHELL_ARGS, DEFAULT_SESSION_ID, MAX_BUFFER_LINES } from '../config'
interface TerminalSession { interface TerminalSession {
@@ -239,3 +241,66 @@ export function broadcastClaudeStatus(status: ClaudeStatus, tool?: string) {
console.log(`[Terminal] Claude status broadcast: ${status}${tool ? ` (${tool})` : ''}${clientCount} clients`) console.log(`[Terminal] Claude status broadcast: ${status}${tool ? ` (${tool})` : ''}${clientCount} clients`)
} }
// Git watcher
let gitWatcher: FSWatcher | null = null
let gitDebounceTimer: Timer | null = null
const GIT_DEBOUNCE_MS = 300
function broadcastGitChange() {
const message = JSON.stringify({
type: 'git-change',
timestamp: Date.now()
})
let clientCount = 0
for (const [, session] of sessions) {
for (const ws of session.clients) {
try {
ws.send(message)
clientCount++
} catch {
// Client disconnected
}
}
}
if (clientCount > 0) {
console.log(`[Git] Change detected → ${clientCount} clients notified`)
}
}
export function startGitWatcher() {
const gitDir = join(WORKING_DIR, '.git')
try {
// Watch .git directory recursively
gitWatcher = watch(gitDir, { recursive: true }, (eventType, filename) => {
// Ignore some noisy files
if (filename?.includes('FETCH_HEAD') || filename?.includes('gc.log')) {
return
}
// Debounce to avoid flooding
if (gitDebounceTimer) {
clearTimeout(gitDebounceTimer)
}
gitDebounceTimer = setTimeout(() => {
broadcastGitChange()
}, GIT_DEBOUNCE_MS)
})
console.log(`[Git] Watching ${gitDir} for changes`)
} catch (e: any) {
console.error(`[Git] Failed to watch .git directory: ${e.message}`)
}
}
export function stopGitWatcher() {
if (gitWatcher) {
gitWatcher.close()
gitWatcher = null
console.log('[Git] Watcher stopped')
}
}

View File

@@ -5,7 +5,7 @@
* even when the main server restarts due to code changes. * even when the main server restarts due to code changes.
*/ */
import { startTerminalServer } from './services/terminal' import { startTerminalServer, startGitWatcher } from './services/terminal'
import { WORKING_DIR } from './config' import { WORKING_DIR } from './config'
console.log('') console.log('')
@@ -13,6 +13,7 @@ console.log('='.repeat(50))
console.log('Terminal Server (Independent Process)') console.log('Terminal Server (Independent Process)')
console.log(` WebSocket: ws://localhost:4103`) console.log(` WebSocket: ws://localhost:4103`)
console.log(` Working Dir: ${WORKING_DIR}`) console.log(` Working Dir: ${WORKING_DIR}`)
console.log(` Git Watcher: enabled`)
console.log('') console.log('')
console.log('This process is stable and won\'t restart') console.log('This process is stable and won\'t restart')
console.log('when the main server reloads.') console.log('when the main server reloads.')
@@ -20,3 +21,4 @@ console.log('='.repeat(50))
console.log('') console.log('')
startTerminalServer() startTerminalServer()
startGitWatcher()