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:
@@ -1,7 +1,8 @@
|
||||
<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 { DiffViewer, FileTree, CommitList, BranchSelector, ProjectTree, FileViewer } from '@/components/git'
|
||||
import { endpoints } from '@/config/endpoints'
|
||||
|
||||
type TabName = 'status' | 'history' | 'compare' | 'files'
|
||||
|
||||
@@ -42,6 +43,63 @@ const compareHead = ref('')
|
||||
// Files tab state
|
||||
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
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
@@ -51,6 +109,12 @@ onMounted(async () => {
|
||||
])
|
||||
// Load initial diff
|
||||
await fetchDiff()
|
||||
// Connect realtime watcher
|
||||
connectGitWatcher()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
disconnectGitWatcher()
|
||||
})
|
||||
|
||||
// Refresh when tab changes
|
||||
@@ -143,11 +207,16 @@ const totalChanges = computed(() => {
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2>Git</h2>
|
||||
<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 class="header-actions">
|
||||
<span :class="['realtime-indicator', { connected: isRealtime }]" :title="isRealtime ? 'Realtime: connected' : 'Realtime: disconnected'">
|
||||
<svg width="8" height="8" viewBox="0 0 8 8"><circle cx="4" cy="4" r="4" fill="currentColor"/></svg>
|
||||
</span>
|
||||
<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 class="branch-info">
|
||||
@@ -502,6 +571,29 @@ const totalChanges = computed(() => {
|
||||
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 {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
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'
|
||||
|
||||
interface TerminalSession {
|
||||
@@ -239,3 +241,66 @@ export function broadcastClaudeStatus(status: ClaudeStatus, tool?: string) {
|
||||
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* 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'
|
||||
|
||||
console.log('')
|
||||
@@ -13,6 +13,7 @@ console.log('='.repeat(50))
|
||||
console.log('Terminal Server (Independent Process)')
|
||||
console.log(` WebSocket: ws://localhost:4103`)
|
||||
console.log(` Working Dir: ${WORKING_DIR}`)
|
||||
console.log(` Git Watcher: enabled`)
|
||||
console.log('')
|
||||
console.log('This process is stable and won\'t restart')
|
||||
console.log('when the main server reloads.')
|
||||
@@ -20,3 +21,4 @@ console.log('='.repeat(50))
|
||||
console.log('')
|
||||
|
||||
startTerminalServer()
|
||||
startGitWatcher()
|
||||
|
||||
Reference in New Issue
Block a user