mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-23 18:17:44 +02:00
Merge pull request #74 from multica-ai/feat/native-file-watcher
feat: replace chokidar with native fs.watch for file tree monitoring
This commit is contained in:
@@ -18,12 +18,14 @@ import { IPC_CHANNELS } from '../shared/ipc-channels'
|
||||
import type { PermissionResponse } from '../shared/electron-api'
|
||||
import { PermissionManager } from './permission'
|
||||
import { createUpdater, AutoUpdater } from './updater'
|
||||
import { FileWatcher } from './watcher'
|
||||
|
||||
// Global instances
|
||||
let conductor: Conductor
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let permissionManager: PermissionManager
|
||||
let updater: AutoUpdater
|
||||
let fileWatcher: FileWatcher
|
||||
|
||||
function createWindow(): BrowserWindow {
|
||||
const window = new BrowserWindow({
|
||||
@@ -130,8 +132,14 @@ app.whenReady().then(async () => {
|
||||
permissionManager.handlePermissionResponse(response)
|
||||
})
|
||||
|
||||
// Initialize file watcher
|
||||
fileWatcher = new FileWatcher({
|
||||
debounceMs: 300,
|
||||
getMainWindow: () => mainWindow
|
||||
})
|
||||
|
||||
// Register IPC handlers
|
||||
registerIPCHandlers(conductor)
|
||||
registerIPCHandlers(conductor, fileWatcher)
|
||||
|
||||
mainWindow = createWindow()
|
||||
|
||||
@@ -180,3 +188,10 @@ app.on('window-all-closed', () => {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
// Clean up file watcher on quit
|
||||
app.on('will-quit', () => {
|
||||
if (fileWatcher) {
|
||||
fileWatcher.unwatchAll()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import { DEFAULT_AGENTS } from '../config/defaults'
|
||||
import { checkAgents, checkAgent } from '../utils/agent-check'
|
||||
import { installAgent } from '../utils/agent-install'
|
||||
import type { Conductor } from '../conductor/Conductor'
|
||||
import type { FileWatcher } from '../watcher'
|
||||
import type {
|
||||
ListSessionsOptions,
|
||||
MulticaSession,
|
||||
@@ -89,7 +90,7 @@ function extractErrorMessage(err: unknown): string {
|
||||
return String(err)
|
||||
}
|
||||
|
||||
export function registerIPCHandlers(conductor: Conductor): void {
|
||||
export function registerIPCHandlers(conductor: Conductor, fileWatcher: FileWatcher): void {
|
||||
// --- Agent handlers (per-session) ---
|
||||
|
||||
ipcMain.handle(
|
||||
@@ -442,6 +443,23 @@ export function registerIPCHandlers(conductor: Conductor): void {
|
||||
}
|
||||
)
|
||||
|
||||
// --- File watcher handlers ---
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.FS_WATCH_START,
|
||||
async (_event, sessionId: string, directory: string) => {
|
||||
if (!isValidPath(directory)) {
|
||||
console.warn(`[IPC] Invalid watch path rejected: ${directory}`)
|
||||
throw new Error('Invalid directory path')
|
||||
}
|
||||
fileWatcher.watch(sessionId, directory)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.FS_WATCH_STOP, async (_event, sessionId: string) => {
|
||||
fileWatcher.unwatch(sessionId)
|
||||
})
|
||||
|
||||
// Terminal: Run command in a new terminal window
|
||||
ipcMain.handle(IPC_CHANNELS.TERMINAL_RUN, async (_event, command: string) => {
|
||||
if (!command || typeof command !== 'string') {
|
||||
|
||||
281
src/main/watcher/FileWatcher.ts
Normal file
281
src/main/watcher/FileWatcher.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* FileWatcher - Manages native fs.watch file system watchers for sessions
|
||||
*
|
||||
* Uses native fs.watch with recursive: true to leverage platform-specific
|
||||
* efficient watching mechanisms:
|
||||
* - macOS: FSEvents (kernel-level API, no per-file descriptor needed)
|
||||
* - Linux: inotify
|
||||
* - Windows: ReadDirectoryChangesW
|
||||
*
|
||||
* This avoids the EMFILE (too many open files) error that can occur with
|
||||
* chokidar when watching large directories with many subdirectories.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Create watchers for session working directories
|
||||
* - Debounce file change events
|
||||
* - Filter out ignored directories (node_modules, .git, etc.)
|
||||
* - Notify renderer process of changes
|
||||
* - Clean up watchers when sessions close
|
||||
*/
|
||||
import { watch, existsSync, type FSWatcher } from 'fs'
|
||||
import path from 'path'
|
||||
import type { BrowserWindow } from 'electron'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
|
||||
interface FileWatcherOptions {
|
||||
debounceMs?: number
|
||||
getMainWindow: () => BrowserWindow | null
|
||||
}
|
||||
|
||||
// Directories to ignore - checked by path component
|
||||
const IGNORED_DIRS = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'dist',
|
||||
'build',
|
||||
'.next',
|
||||
'.cache',
|
||||
'coverage',
|
||||
'.vscode',
|
||||
'.idea',
|
||||
'venv',
|
||||
'__pycache__',
|
||||
'target',
|
||||
'.gradle',
|
||||
'out',
|
||||
'.turbo',
|
||||
'.svn',
|
||||
'.hg'
|
||||
])
|
||||
|
||||
// Files to ignore by name
|
||||
const IGNORED_FILES = new Set(['.DS_Store', 'Thumbs.db'])
|
||||
|
||||
const DEFAULT_DEBOUNCE_MS = 100
|
||||
|
||||
export class FileWatcher {
|
||||
private watchers: Map<string, FSWatcher> = new Map() // directory -> FSWatcher
|
||||
private sessionToDirectory: Map<string, string> = new Map() // sessionId -> directory
|
||||
private directoryToSessions: Map<string, Set<string>> = new Map() // directory -> sessionIds
|
||||
private debounceTimers: Map<string, NodeJS.Timeout> = new Map() // debounceKey -> timer
|
||||
private debounceMs: number
|
||||
private getMainWindow: () => BrowserWindow | null
|
||||
|
||||
constructor(options: FileWatcherOptions) {
|
||||
this.debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS
|
||||
this.getMainWindow = options.getMainWindow
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a relative path should be ignored based on its components
|
||||
*/
|
||||
private shouldIgnore(relativePath: string): boolean {
|
||||
const parts = relativePath.split(path.sep)
|
||||
// Check if any path component is an ignored directory
|
||||
for (const part of parts) {
|
||||
if (IGNORED_DIRS.has(part)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Check if the filename is ignored
|
||||
const filename = parts[parts.length - 1]
|
||||
if (filename && IGNORED_FILES.has(filename)) {
|
||||
return true
|
||||
}
|
||||
// Ignore log files
|
||||
if (filename && filename.endsWith('.log')) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounce a handler by key
|
||||
*/
|
||||
private debounce(key: string, handler: () => void): void {
|
||||
const existing = this.debounceTimers.get(key)
|
||||
if (existing) {
|
||||
clearTimeout(existing)
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
this.debounceTimers.delete(key)
|
||||
handler()
|
||||
}, this.debounceMs)
|
||||
|
||||
this.debounceTimers.set(key, timer)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start watching a directory for a session
|
||||
*/
|
||||
watch(sessionId: string, directory: string): void {
|
||||
// Normalize directory path
|
||||
const normalizedDir = path.normalize(directory)
|
||||
|
||||
// Check if directory exists (reference: Craft Agents pattern)
|
||||
if (!existsSync(normalizedDir)) {
|
||||
console.warn(`[FileWatcher] Directory does not exist: ${normalizedDir}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Track session -> directory mapping
|
||||
this.sessionToDirectory.set(sessionId, normalizedDir)
|
||||
|
||||
// Add to directory -> sessions mapping
|
||||
let sessions = this.directoryToSessions.get(normalizedDir)
|
||||
if (!sessions) {
|
||||
sessions = new Set()
|
||||
this.directoryToSessions.set(normalizedDir, sessions)
|
||||
}
|
||||
sessions.add(sessionId)
|
||||
|
||||
// Check if we already have a watcher for this directory
|
||||
if (this.watchers.has(normalizedDir)) {
|
||||
console.log(`[FileWatcher] Session ${sessionId} joined existing watcher for ${normalizedDir}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Create new watcher
|
||||
console.log(`[FileWatcher] Creating watcher for ${normalizedDir}`)
|
||||
|
||||
try {
|
||||
// Use native fs.watch with recursive: true
|
||||
// On macOS, this uses FSEvents which doesn't have EMFILE issues
|
||||
const watcher = watch(normalizedDir, { recursive: true }, (eventType, filename) => {
|
||||
// filename can be null in some edge cases
|
||||
if (!filename) return
|
||||
|
||||
// Check if this path should be ignored
|
||||
if (this.shouldIgnore(filename)) {
|
||||
return
|
||||
}
|
||||
|
||||
const fullPath = path.join(normalizedDir, filename)
|
||||
|
||||
// Use directory + filename as debounce key for per-file debouncing
|
||||
const debounceKey = `${normalizedDir}:${filename}`
|
||||
|
||||
this.debounce(debounceKey, () => {
|
||||
this.notifyChange(normalizedDir, eventType, fullPath)
|
||||
})
|
||||
})
|
||||
|
||||
watcher.on('error', (error) => {
|
||||
const errCode = (error as NodeJS.ErrnoException).code
|
||||
console.error(`[FileWatcher] Error watching ${normalizedDir}:`, error)
|
||||
|
||||
// If EMFILE error, clean up this watcher to prevent spam
|
||||
// (Reference: Craft Agents just logs errors, but we add cleanup for safety)
|
||||
if (errCode === 'EMFILE') {
|
||||
console.warn(`[FileWatcher] EMFILE error, closing watcher for ${normalizedDir}`)
|
||||
// Clear pending debounce timers for this directory
|
||||
for (const [key, timer] of this.debounceTimers) {
|
||||
if (key.startsWith(`${normalizedDir}:`)) {
|
||||
clearTimeout(timer)
|
||||
this.debounceTimers.delete(key)
|
||||
}
|
||||
}
|
||||
watcher.close()
|
||||
this.watchers.delete(normalizedDir)
|
||||
}
|
||||
})
|
||||
|
||||
this.watchers.set(normalizedDir, watcher)
|
||||
} catch (error) {
|
||||
console.error(`[FileWatcher] Failed to create watcher for ${normalizedDir}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop watching a directory for a session
|
||||
*/
|
||||
unwatch(sessionId: string): void {
|
||||
const directory = this.sessionToDirectory.get(sessionId)
|
||||
if (!directory) return
|
||||
|
||||
this.sessionToDirectory.delete(sessionId)
|
||||
|
||||
const sessions = this.directoryToSessions.get(directory)
|
||||
if (sessions) {
|
||||
sessions.delete(sessionId)
|
||||
|
||||
// If no more sessions watching this directory, close the watcher
|
||||
if (sessions.size === 0) {
|
||||
this.directoryToSessions.delete(directory)
|
||||
|
||||
// Clear any pending debounce timers for this directory
|
||||
for (const [key, timer] of this.debounceTimers) {
|
||||
if (key.startsWith(`${directory}:`)) {
|
||||
clearTimeout(timer)
|
||||
this.debounceTimers.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
const watcher = this.watchers.get(directory)
|
||||
if (watcher) {
|
||||
console.log(`[FileWatcher] Closing watcher for ${directory}`)
|
||||
watcher.close()
|
||||
this.watchers.delete(directory)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all watchers
|
||||
*/
|
||||
unwatchAll(): void {
|
||||
// Clear all debounce timers
|
||||
for (const timer of this.debounceTimers.values()) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
this.debounceTimers.clear()
|
||||
|
||||
// Close all watchers
|
||||
for (const [directory, watcher] of this.watchers) {
|
||||
console.log(`[FileWatcher] Closing watcher for ${directory}`)
|
||||
watcher.close()
|
||||
}
|
||||
this.watchers.clear()
|
||||
this.sessionToDirectory.clear()
|
||||
this.directoryToSessions.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify renderer of file change
|
||||
*/
|
||||
private notifyChange(directory: string, eventType: string, filePath: string): void {
|
||||
const mainWindow = this.getMainWindow()
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||
|
||||
const sessions = this.directoryToSessions.get(directory)
|
||||
if (!sessions || sessions.size === 0) return
|
||||
|
||||
// Map fs.watch eventType to our event types
|
||||
// fs.watch only provides 'rename' (add/delete) and 'change' (modify)
|
||||
const normalizedEventType = eventType === 'rename' ? 'change' : eventType
|
||||
|
||||
// Send change notification with affected session IDs
|
||||
mainWindow.webContents.send(IPC_CHANNELS.FS_FILE_CHANGED, {
|
||||
directory,
|
||||
eventType: normalizedEventType,
|
||||
path: filePath,
|
||||
sessionIds: Array.from(sessions)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get watching status for a session
|
||||
*/
|
||||
isWatching(sessionId: string): boolean {
|
||||
return this.sessionToDirectory.has(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get directory being watched for a session
|
||||
*/
|
||||
getWatchedDirectory(sessionId: string): string | undefined {
|
||||
return this.sessionToDirectory.get(sessionId)
|
||||
}
|
||||
}
|
||||
1
src/main/watcher/index.ts
Normal file
1
src/main/watcher/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { FileWatcher } from './FileWatcher'
|
||||
@@ -86,6 +86,20 @@ const electronAPI: ElectronAPI = {
|
||||
detectApps: () => ipcRenderer.invoke(IPC_CHANNELS.FS_DETECT_APPS),
|
||||
openWith: (options: OpenWithOptions) => ipcRenderer.invoke(IPC_CHANNELS.FS_OPEN_WITH, options),
|
||||
|
||||
// File watcher
|
||||
startFileWatch: (sessionId: string, directory: string) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.FS_WATCH_START, sessionId, directory),
|
||||
stopFileWatch: (sessionId: string) => ipcRenderer.invoke(IPC_CHANNELS.FS_WATCH_STOP, sessionId),
|
||||
onFileChanged: (callback) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, changeEvent: unknown): void => {
|
||||
callback(changeEvent as Parameters<typeof callback>[0])
|
||||
}
|
||||
ipcRenderer.on(IPC_CHANNELS.FS_FILE_CHANGED, listener)
|
||||
return (): void => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.FS_FILE_CHANGED, listener)
|
||||
}
|
||||
},
|
||||
|
||||
// Event listeners
|
||||
onAgentMessage: (callback) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, message: unknown): void => {
|
||||
|
||||
@@ -358,7 +358,7 @@ export function FileTree({ rootPath, directoryExists = true }: FileTreeProps): R
|
||||
expandedPathsRef.current = expandedPaths
|
||||
}, [expandedPaths])
|
||||
|
||||
// Refresh file tree when files change (from agent tool calls)
|
||||
// Refresh file tree when files change (from file system watcher)
|
||||
useEffect(() => {
|
||||
// Skip on initial mount (already loaded above)
|
||||
if (isInitialMount.current) {
|
||||
|
||||
@@ -18,11 +18,6 @@ import { useCommandStore } from '../stores/commandStore'
|
||||
import { toast } from 'sonner'
|
||||
import { getErrorMessage } from '../utils/error'
|
||||
|
||||
// ACP standard tool kinds that modify files (used for Codex and other agents)
|
||||
const FILE_MODIFYING_KINDS = new Set(['edit', 'write', 'delete', 'execute'])
|
||||
// Actual tool names from _meta.claudeCode.toolName (used for Claude Code)
|
||||
const FILE_MODIFYING_TOOL_NAMES = new Set(['write', 'edit', 'bash', 'notebookedit'])
|
||||
|
||||
// Auth commands for each agent
|
||||
const AGENT_AUTH_COMMANDS: Record<string, string> = {
|
||||
'claude-code': 'claude login',
|
||||
@@ -80,10 +75,6 @@ export interface AppActions {
|
||||
}
|
||||
|
||||
export function useApp(): AppState & AppActions {
|
||||
// Store toolCallId -> kind mapping for file tree refresh
|
||||
// This is needed because tool_call_update events don't include kind
|
||||
const toolKindMapRef = useRef<Map<string, string>>(new Map())
|
||||
|
||||
// Track pending session selection to handle rapid switching
|
||||
const pendingSessionRef = useRef<string | null>(null)
|
||||
|
||||
@@ -111,7 +102,7 @@ export function useApp(): AppState & AppActions {
|
||||
? runningSessionsStatus.processingSessionIds.includes(currentSession.id)
|
||||
: false
|
||||
|
||||
// Note: File tree refresh is triggered by tool completion (see onAgentMessage handler below)
|
||||
// Note: File tree refresh is handled by file system watcher (see fs:file-changed handler below)
|
||||
// No need for periodic refresh - it causes performance issues
|
||||
|
||||
// Load sessions on mount
|
||||
@@ -152,12 +143,53 @@ export function useApp(): AppState & AppActions {
|
||||
}
|
||||
}, [currentSessionId])
|
||||
|
||||
// Subscribe to file system change events (runs once on mount)
|
||||
useEffect(() => {
|
||||
const handleFileChange = useFileChangeStore.getState().handleFileChange
|
||||
|
||||
const unsubFileChange = window.electronAPI.onFileChanged((event) => {
|
||||
// Notify the store of file changes for each affected session
|
||||
for (const sessionId of event.sessionIds) {
|
||||
handleFileChange(sessionId)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubFileChange()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Start/stop file watching when current session changes
|
||||
useEffect(() => {
|
||||
const setWatchedSession = useFileChangeStore.getState().setWatchedSession
|
||||
|
||||
if (currentSession) {
|
||||
// Start watching the session's working directory
|
||||
setWatchedSession(currentSession.id)
|
||||
window.electronAPI
|
||||
.startFileWatch(currentSession.id, currentSession.workingDirectory)
|
||||
.catch((err) => {
|
||||
console.error('[useApp] Failed to start file watch:', err)
|
||||
})
|
||||
} else {
|
||||
setWatchedSession(null)
|
||||
}
|
||||
|
||||
// Cleanup: stop watching when session changes or component unmounts
|
||||
return () => {
|
||||
if (currentSession) {
|
||||
window.electronAPI.stopFileWatch(currentSession.id).catch((err) => {
|
||||
console.error('[useApp] Failed to stop file watch:', err)
|
||||
})
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Intentional: only re-run when id or workingDirectory changes
|
||||
}, [currentSession?.id, currentSession?.workingDirectory])
|
||||
|
||||
// Subscribe to agent events (persistent subscription - only runs once on mount)
|
||||
// Uses refs to access current session ID, avoiding race condition where subscription
|
||||
// is being recreated while events are arriving
|
||||
useEffect(() => {
|
||||
// Get triggerRefresh from store for file change detection
|
||||
const triggerRefresh = useFileChangeStore.getState().triggerRefresh
|
||||
// Get setAvailableCommands from store for command updates
|
||||
const setAvailableCommands = useCommandStore.getState().setAvailableCommands
|
||||
|
||||
@@ -170,36 +202,7 @@ export function useApp(): AppState & AppActions {
|
||||
return
|
||||
}
|
||||
|
||||
// Check for file-modifying tool completion to trigger FileTree refresh
|
||||
const update = message.update
|
||||
const status = update?.status?.toLowerCase() || ''
|
||||
const toolCallId = update?.toolCallId
|
||||
|
||||
// Get tool name from _meta.claudeCode.toolName (Claude Code specific)
|
||||
const meta = update?._meta as { claudeCode?: { toolName?: string } } | undefined
|
||||
const toolName = meta?.claudeCode?.toolName?.toLowerCase() || ''
|
||||
|
||||
// Handle tool_call event: store toolCallId -> kind mapping
|
||||
// This is needed because tool_call_update events don't include kind (for Codex etc.)
|
||||
if (update?.sessionUpdate === 'tool_call' && toolCallId && update?.kind) {
|
||||
const kind = update.kind.toLowerCase()
|
||||
toolKindMapRef.current.set(toolCallId, kind)
|
||||
}
|
||||
|
||||
// Handle tool_call_update event: check if we should trigger refresh
|
||||
if (update?.sessionUpdate === 'tool_call_update' && toolCallId) {
|
||||
// Get kind from our stored mapping (for Codex) or from toolName (for Claude Code)
|
||||
const storedKind = toolKindMapRef.current.get(toolCallId) || ''
|
||||
const isFileModifying =
|
||||
FILE_MODIFYING_KINDS.has(storedKind) || FILE_MODIFYING_TOOL_NAMES.has(toolName)
|
||||
const isCompleted = status === 'completed' || status === 'failed'
|
||||
|
||||
if (isFileModifying && isCompleted) {
|
||||
triggerRefresh()
|
||||
// Clean up the mapping
|
||||
toolKindMapRef.current.delete(toolCallId)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle available_commands_update event: update slash commands store
|
||||
if (update?.sessionUpdate === 'available_commands_update') {
|
||||
@@ -250,11 +253,6 @@ export function useApp(): AppState & AppActions {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Clear toolKindMap when session changes to avoid stale data
|
||||
useEffect(() => {
|
||||
toolKindMapRef.current.clear()
|
||||
}, [currentSessionId])
|
||||
|
||||
// Actions
|
||||
const loadSessions = useCallback(async () => {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* File change state management
|
||||
* Tracks file changes from agent tool calls to trigger FileTree refresh
|
||||
* Tracks file changes from the file system watcher (native fs.watch)
|
||||
*/
|
||||
import { create } from 'zustand'
|
||||
|
||||
@@ -8,14 +8,31 @@ interface FileChangeStore {
|
||||
// Counter that increments when files change - used to trigger refresh
|
||||
refreshCounter: number
|
||||
|
||||
// Trigger a file refresh
|
||||
triggerRefresh: () => void
|
||||
// Active session being watched
|
||||
watchedSessionId: string | null
|
||||
|
||||
// Handle file system change event from watcher
|
||||
handleFileChange: (sessionId: string) => void
|
||||
|
||||
// Set watched session
|
||||
setWatchedSession: (sessionId: string | null) => void
|
||||
}
|
||||
|
||||
export const useFileChangeStore = create<FileChangeStore>((set) => ({
|
||||
export const useFileChangeStore = create<FileChangeStore>((set, get) => ({
|
||||
refreshCounter: 0,
|
||||
watchedSessionId: null,
|
||||
|
||||
triggerRefresh: () => {
|
||||
set((state) => ({ refreshCounter: state.refreshCounter + 1 }))
|
||||
handleFileChange: (sessionId: string) => {
|
||||
const state = get()
|
||||
// Only trigger refresh if the change is for the watched session
|
||||
if (state.watchedSessionId === sessionId) {
|
||||
set((state) => ({
|
||||
refreshCounter: state.refreshCounter + 1
|
||||
}))
|
||||
}
|
||||
},
|
||||
|
||||
setWatchedSession: (sessionId: string | null) => {
|
||||
set({ watchedSessionId: sessionId })
|
||||
}
|
||||
}))
|
||||
|
||||
15
src/shared/electron-api.d.ts
vendored
15
src/shared/electron-api.d.ts
vendored
@@ -116,6 +116,16 @@ export interface OpenWithOptions {
|
||||
appId: string
|
||||
}
|
||||
|
||||
// File watcher types
|
||||
// Note: fs.watch only provides 'rename' (add/delete) and 'change' (modify)
|
||||
// We normalize 'rename' to 'change' for simplicity
|
||||
export interface FileChangeEvent {
|
||||
directory: string
|
||||
eventType: 'change' | 'rename'
|
||||
path: string
|
||||
sessionIds: string[]
|
||||
}
|
||||
|
||||
// Agent installation
|
||||
export type InstallStep = 'check-npm' | 'install-cli' | 'install-acp'
|
||||
|
||||
@@ -201,6 +211,11 @@ export interface ElectronAPI {
|
||||
detectApps(): Promise<DetectedApp[]>
|
||||
openWith(options: OpenWithOptions): Promise<void>
|
||||
|
||||
// File watcher
|
||||
startFileWatch(sessionId: string, directory: string): Promise<void>
|
||||
stopFileWatch(sessionId: string): Promise<void>
|
||||
onFileChanged(callback: (event: FileChangeEvent) => void): () => void
|
||||
|
||||
// Event listeners (return unsubscribe function)
|
||||
onAgentMessage(callback: (message: AgentMessage) => void): () => void
|
||||
onAgentStatus(callback: (status: RunningSessionsStatus) => void): () => void
|
||||
|
||||
@@ -54,6 +54,11 @@ export const IPC_CHANNELS = {
|
||||
FS_DETECT_APPS: 'fs:detect-apps',
|
||||
FS_OPEN_WITH: 'fs:open-with',
|
||||
|
||||
// File watcher
|
||||
FS_WATCH_START: 'fs:watch-start',
|
||||
FS_WATCH_STOP: 'fs:watch-stop',
|
||||
FS_FILE_CHANGED: 'fs:file-changed',
|
||||
|
||||
// Terminal
|
||||
TERMINAL_RUN: 'terminal:run',
|
||||
|
||||
|
||||
Reference in New Issue
Block a user