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">
|
<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;
|
||||||
|
|||||||
@@ -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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user