diff --git a/backend/eslint.config.js b/backend/eslint.config.js index 19671e83..76cb27e9 100644 --- a/backend/eslint.config.js +++ b/backend/eslint.config.js @@ -28,4 +28,10 @@ export default defineConfig([ 'no-useless-escape': 'warn', }, }, + { + files: ['test/**/*.ts'], + rules: { + '@typescript-eslint/no-explicit-any': 'off', + }, + }, ]) \ No newline at end of file diff --git a/backend/src/db/migrations.ts b/backend/src/db/migrations.ts index 8a765671..3f2af61c 100644 --- a/backend/src/db/migrations.ts +++ b/backend/src/db/migrations.ts @@ -163,7 +163,8 @@ function migrateGitTokenToCredentials(db: Database): void { continue } - const { gitToken: _, ...rest } = parsed + const { gitToken: _gitToken, ...rest } = parsed + void _gitToken const migrated = { ...rest, gitCredentials: [{ diff --git a/backend/src/index.ts b/backend/src/index.ts index a4b644e7..7ce32e83 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -201,7 +201,7 @@ try { logger.error('Failed to initialize workspace:', error) } -app.route('/api/auth', createAuthRoutes(auth, db)) +app.route('/api/auth', createAuthRoutes(auth)) app.route('/api/auth-info', createAuthInfoRoutes(auth, db)) app.route('/api/health', createHealthRoutes(db)) diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index f30b728b..19c24251 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -5,7 +5,7 @@ import { ENV } from '@opencode-manager/shared/config/env' import { logger } from '../utils/logger' import { hashPassword } from 'better-auth/crypto' -export function createAuthRoutes(auth: AuthInstance, _db: Database) { +export function createAuthRoutes(auth: AuthInstance): Hono { const app = new Hono() app.all('/*', async (c) => { diff --git a/backend/src/services/archive.ts b/backend/src/services/archive.ts index 301ff94c..86573c39 100644 --- a/backend/src/services/archive.ts +++ b/backend/src/services/archive.ts @@ -4,6 +4,11 @@ import { readdir, stat, unlink } from 'fs/promises' import path from 'path' import os from 'os' import { logger } from '../utils/logger' +import { getReposPath } from '@opencode-manager/shared/config/env' + +function resolvePath(userPath: string): string { + return path.isAbsolute(userPath) ? userPath : path.join(getReposPath(), userPath) +} export interface ArchiveOptions { includeGit?: boolean @@ -57,14 +62,14 @@ async function getIgnoredPaths(gitRoot: string, targetPath: string, paths: strin const { spawn } = await import('child_process') const targetRelativeToRoot = path.relative(gitRoot, targetPath) - console.log('[getIgnoredPaths] gitRoot:', gitRoot, 'targetPath:', targetPath, 'targetRelativeToRoot:', targetRelativeToRoot) + logger.debug('[getIgnoredPaths] gitRoot:', gitRoot, 'targetPath:', targetPath, 'targetRelativeToRoot:', targetRelativeToRoot) const relativePaths = paths.map(p => { const relativeToTarget = p return targetRelativeToRoot ? path.join(targetRelativeToRoot, relativeToTarget) : relativeToTarget }) - console.log('[getIgnoredPaths] First 5 relativePaths:', relativePaths.slice(0, 5)) + logger.debug('[getIgnoredPaths] First 5 relativePaths:', relativePaths.slice(0, 5)) return new Promise((resolve) => { const ignored = new Set() @@ -83,9 +88,9 @@ async function getIgnoredPaths(gitRoot: string, targetPath: string, paths: strin proc.stdin?.end() proc.on('close', (code) => { - console.log('[getIgnoredPaths] git check-ignore exited with code:', code) + logger.debug('[getIgnoredPaths] git check-ignore exited with code:', code) const ignoredFullPaths = stdout.split('\n').filter(p => p.trim()) - console.log('[getIgnoredPaths] Raw ignored count:', ignoredFullPaths.length, 'first 5:', ignoredFullPaths.slice(0, 5)) + logger.debug('[getIgnoredPaths] Raw ignored count:', ignoredFullPaths.length, 'first 5:', ignoredFullPaths.slice(0, 5)) const targetRelativeToRoot = path.relative(gitRoot, targetPath) for (const fullPath of ignoredFullPaths) { @@ -99,7 +104,7 @@ async function getIgnoredPaths(gitRoot: string, targetPath: string, paths: strin ignored.add(relativePath) } } - console.log('[getIgnoredPaths] Final ignored set size:', ignored.size) + logger.debug('[getIgnoredPaths] Final ignored set size:', ignored.size) resolve(ignored) }) @@ -200,6 +205,7 @@ async function filterIgnoredPaths(targetPath: string, allPaths: string[], option } export async function createRepoArchive(repoPath: string, options?: ArchiveOptions): Promise { + repoPath = resolvePath(repoPath) const repoName = path.basename(repoPath) const tempFile = path.join(os.tmpdir(), `${repoName}-${Date.now()}.zip`) @@ -237,6 +243,7 @@ export async function createRepoArchive(repoPath: string, options?: ArchiveOptio } export async function createDirectoryArchive(directoryPath: string, archiveName?: string, options?: ArchiveOptions): Promise { + directoryPath = resolvePath(directoryPath) const dirName = archiveName || path.basename(directoryPath) const tempFile = path.join(os.tmpdir(), `${dirName}-${Date.now()}.zip`) @@ -293,34 +300,35 @@ export async function getArchiveSize(filePath: string): Promise { } export async function getIgnoredPathsList(directoryPath: string): Promise { - console.log('[getIgnoredPathsList] Starting for:', directoryPath) + directoryPath = resolvePath(directoryPath) + logger.debug('[getIgnoredPathsList] Starting for:', directoryPath) const gitRoot = await findGitRoot(directoryPath) - console.log('[getIgnoredPathsList] Git root:', gitRoot) + logger.debug('[getIgnoredPathsList] Git root:', gitRoot) if (!gitRoot) { - console.log('[getIgnoredPathsList] No git root found') + logger.debug('[getIgnoredPathsList] No git root found') const hasGitDir = await collectFiles(directoryPath).then( paths => paths.some(p => p.startsWith('.git/') || p === '.git') ).catch(() => false) if (hasGitDir) { - console.log('[getIgnoredPathsList] Has .git dir, returning [.git/]') + logger.debug('[getIgnoredPathsList] Has .git dir, returning [.git/]') return ['.git/'] } - console.log('[getIgnoredPathsList] No .git dir, returning []') + logger.debug('[getIgnoredPathsList] No .git dir, returning []') return [] } const allPaths = await collectFiles(directoryPath) - console.log('[getIgnoredPathsList] Collected', allPaths.length, 'paths') + logger.debug('[getIgnoredPathsList] Collected', allPaths.length, 'paths') const ignoredSet = new Set() const batchSize = 1000 for (let i = 0; i < allPaths.length; i += batchSize) { const batch = allPaths.slice(i, i + batchSize) - console.log('[getIgnoredPathsList] Checking batch', i, 'to', i + batch.length) + logger.debug('[getIgnoredPathsList] Checking batch', i, 'to', i + batch.length) const ignored = await getIgnoredPaths(gitRoot, directoryPath, batch) - console.log('[getIgnoredPathsList] Batch ignored count:', ignored.size) + logger.debug('[getIgnoredPathsList] Batch ignored count:', ignored.size) for (const p of ignored) { ignoredSet.add(p) if (p.endsWith('/')) { @@ -331,7 +339,7 @@ export async function getIgnoredPathsList(directoryPath: string): Promise() @@ -358,14 +366,14 @@ export async function getIgnoredPathsList(directoryPath: string): Promise true).catch(() => false) - console.log('[getIgnoredPathsList] .git exists:', gitDirExists) + logger.debug('[getIgnoredPathsList] .git exists:', gitDirExists) if (gitDirExists && !ignoredDirs.some(p => p.startsWith('.git'))) { ignoredDirs.push('.git/') } ignoredDirs.sort() - console.log('[getIgnoredPathsList] Final result:', ignoredDirs) + logger.debug('[getIgnoredPathsList] Final result:', ignoredDirs) return ignoredDirs } diff --git a/backend/test/routes/files.test.ts b/backend/test/routes/files.test.ts index 301dc3b5..23aaa5ad 100644 --- a/backend/test/routes/files.test.ts +++ b/backend/test/routes/files.test.ts @@ -4,7 +4,7 @@ import { Hono } from 'hono' import type { ReadStream } from 'fs' import * as fileService from '../../src/services/files' import * as archiveService from '../../src/services/archive' -import type { FileInfo, ChunkedFileInfo, PatchOperation } from '@opencode-manager/shared' +import type { FileInfo, ChunkedFileInfo } from '@opencode-manager/shared' interface FileUploadResult { name: string @@ -47,7 +47,6 @@ vi.mock('../../src/services/archive', () => ({ })) const getFile = fileService.getFile as MockedFunction -const getRawFileContent = fileService.getRawFileContent as MockedFunction const getFileRange = fileService.getFileRange as MockedFunction const uploadFile = fileService.uploadFile as MockedFunction const createFileOrFolder = fileService.createFileOrFolder as MockedFunction @@ -58,7 +57,6 @@ const applyFilePatches = fileService.applyFilePatches as MockedFunction const getArchiveSize = archiveService.getArchiveSize as MockedFunction const getArchiveStream = archiveService.getArchiveStream as MockedFunction -const deleteArchive = archiveService.deleteArchive as MockedFunction describe('File Routes', () => { let app: Hono @@ -81,7 +79,7 @@ describe('File Routes', () => { getArchiveSize.mockResolvedValue(1024) getArchiveStream.mockReturnValue(mockStream) - const response = await app.request('/api/files/test-repo/src/download-zip') + await app.request('/api/files/test-repo/src/download-zip') expect(createDirectoryArchive).toHaveBeenCalledWith('test-repo/src', undefined, { includeGit: false, @@ -497,15 +495,6 @@ describe('File Routes', () => { }) describe('Path Traversal Protection', () => { - const mockFileInfo: FileInfo = { - name: 'test.txt', - path: 'safe-path/test.txt', - isDirectory: false, - size: 100, - mimeType: 'text/plain', - content: '', - lastModified: new Date(), - } it('should reject path with ../ segments via service error', async () => { const error = { message: 'Path traversal detected', statusCode: 403 } diff --git a/backend/test/routes/tts.test.ts b/backend/test/routes/tts.test.ts index 7504e7a0..2b3e561d 100644 --- a/backend/test/routes/tts.test.ts +++ b/backend/test/routes/tts.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import * as fs from 'fs/promises' -import { logger } from '../../src/utils/logger' vi.mock('fs/promises', () => ({ mkdir: vi.fn(), @@ -27,7 +26,6 @@ vi.mock('../../src/utils/logger', () => ({ const mockMkdir = fs.mkdir as any const mockReadFile = fs.readFile as any -const mockWriteFile = fs.writeFile as any const mockReaddir = fs.readdir as any const mockStat = fs.stat as any const mockUnlink = fs.unlink as any @@ -36,29 +34,12 @@ import { createTTSRoutes, cleanupExpiredCache, getCacheStats, generateCacheKey, describe('TTS Routes', () => { let mockDb: any - let ttsApp: any - let mockSettingsService: any beforeEach(() => { vi.clearAllMocks() mockDb = {} as any - mockSettingsService = { - getSettings: vi.fn().mockReturnValue({ - preferences: { - tts: { - enabled: true, - apiKey: 'test-key', - endpoint: 'https://api.openai.com/v1/audio/speech', - voice: 'alloy', - model: 'tts-1', - speed: 1.0, - }, - }, - }), - } - - ttsApp = createTTSRoutes(mockDb) + createTTSRoutes(mockDb) }) describe('generateCacheKey', () => { diff --git a/backend/test/services/git/GitBranchService.test.ts b/backend/test/services/git/GitBranchService.test.ts index 8ff5a820..087a0473 100644 --- a/backend/test/services/git/GitBranchService.test.ts +++ b/backend/test/services/git/GitBranchService.test.ts @@ -218,12 +218,10 @@ describe('GitBranchService', () => { it('handles getBranchStatus failure for current branch gracefully', async () => { const mockRepo = { id: 1, fullPath: '/path/to/repo' } getRepoByIdMock.mockReturnValue(mockRepo as any) - let revListCalls = 0 executeCommandMock.mockImplementation((args) => { if (args.includes('rev-parse')) return Promise.resolve('main') if (args.includes('branch')) return Promise.resolve('* main abc123 Initial commit') if (args.includes('rev-list')) { - revListCalls++ return Promise.reject(new Error('No upstream')) } return Promise.resolve('') diff --git a/backend/test/services/git/GitLogService.test.ts b/backend/test/services/git/GitLogService.test.ts index 8fb96751..defc3730 100644 --- a/backend/test/services/git/GitLogService.test.ts +++ b/backend/test/services/git/GitLogService.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { describe, it, expect, vi, beforeEach, type MockedFunction } from 'vitest' vi.mock('bun:sqlite', () => ({ diff --git a/backend/test/utils/helpers.test.ts b/backend/test/utils/helpers.test.ts index 55d30295..8a9ead66 100644 --- a/backend/test/utils/helpers.test.ts +++ b/backend/test/utils/helpers.test.ts @@ -3,28 +3,24 @@ import { describe, it, expect } from 'vitest' describe('Process Helper Functions', () => { describe('mapProcessState', () => { it('should map "running" state correctly', () => { - const state = 'running' const mapped = 'running' as const expect(mapped).toBe('running') }) it('should map "stopped" state to "stopped"', () => { - const state = 'stopped' const mapped = 'stopped' as const expect(mapped).toBe('stopped') }) it('should map "starting" state to "starting"', () => { - const state = 'starting' const mapped = 'starting' as const expect(mapped).toBe('starting') }) it('should map unknown states to "error"', () => { - const state = 'unknown' const mapped = 'error' as const expect(mapped).toBe('error') @@ -62,28 +58,24 @@ describe('Process Helper Functions', () => { describe('validateRepoUrl', () => { it('should accept valid GitHub HTTPS URLs', () => { - const url = 'https://github.com/user/repo' const isValid = true expect(isValid).toBe(true) }) it('should accept valid GitHub SSH URLs', () => { - const url = 'git@github.com:user/repo.git' const isValid = true expect(isValid).toBe(true) }) it('should reject invalid URLs', () => { - const url = 'not-a-url' const isValid = false expect(isValid).toBe(false) }) it('should accept GitLab URLs', () => { - const url = 'https://gitlab.com/user/repo' const isValid = true expect(isValid).toBe(true) @@ -92,11 +84,6 @@ describe('Process Helper Functions', () => { describe('sanitizeEnvVars', () => { it('should filter out undefined values', () => { - const env = { - DEFINED: 'value', - UNDEFINED: undefined - } - const sanitized = ['DEFINED=value'] expect(sanitized).not.toContain('UNDEFINED') diff --git a/frontend/src/components/session/QuestionPrompt.tsx b/frontend/src/components/session/QuestionPrompt.tsx index 73f2e3d1..8046cc5b 100644 --- a/frontend/src/components/session/QuestionPrompt.tsx +++ b/frontend/src/components/session/QuestionPrompt.tsx @@ -88,6 +88,22 @@ export function QuestionPrompt({ question, onReply, onReject }: QuestionPromptPr }) }, []) + const handleExpandOther = useCallback((questionIndex: number) => { + if (!isMultiSelect) { + setAnswers(prev => { + const updated = [...prev] + updated[questionIndex] = [] + return updated + }) + } + setConfirmedCustoms(prev => { + const updated = [...prev] + updated[questionIndex] = '' + return updated + }) + setExpandedOther(questionIndex) + }, [isMultiSelect]) + const confirmCustomInput = useCallback((questionIndex: number) => { const value = customInputs[questionIndex]?.trim() if (!value) { @@ -125,6 +141,14 @@ export function QuestionPrompt({ question, onReply, onReject }: QuestionPromptPr } }, [customInputs, confirmedCustoms, questions, isSingleSelect, goToNext, handleSubmitSingle]) + const handleNext = useCallback(() => { + if (expandedOther === currentIndex) { + confirmCustomInput(currentIndex) + } else { + goToNext() + } + }, [expandedOther, currentIndex, confirmCustomInput, goToNext]) + const handleSubmit = async () => { setIsSubmitting(true) try { @@ -194,7 +218,7 @@ export function QuestionPrompt({ question, onReply, onReject }: QuestionPromptPr -
+
{isConfirmStep ? ( selectOption(currentIndex, label)} - onExpandOther={() => setExpandedOther(currentIndex)} + onExpandOther={() => handleExpandOther(currentIndex)} onCustomInputChange={(value) => handleCustomInput(currentIndex, value)} onConfirmCustomInput={() => confirmCustomInput(currentIndex)} onCollapseOther={() => setExpandedOther(null)} @@ -267,11 +291,11 @@ export function QuestionPrompt({ question, onReply, onReject }: QuestionPromptPr ) : ( ) @@ -315,6 +339,7 @@ function QuestionStep({ useEffect(() => { if (expandedOther && textareaRef.current) { textareaRef.current.focus() + textareaRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }) } }, [expandedOther]) @@ -388,7 +413,7 @@ function QuestionStep({
@@ -421,24 +446,6 @@ function QuestionStep({ } }} /> -
- - -
)} diff --git a/frontend/src/contexts/EventContext.tsx b/frontend/src/contexts/EventContext.tsx index 2977817c..6707aa1c 100644 --- a/frontend/src/contexts/EventContext.tsx +++ b/frontend/src/contexts/EventContext.tsx @@ -6,7 +6,7 @@ import { OpenCodeClient } from '@/api/opencode' import { listRepos } from '@/api/repos' import type { PermissionRequest, PermissionResponse, QuestionRequest, SSEEvent } from '@/api/types' import { showToast } from '@/lib/toast' -import { subscribeToSSE } from '@/lib/sseManager' +import { subscribeToSSE, addSSEDirectory } from '@/lib/sseManager' import { OPENCODE_API_ENDPOINT } from '@/config' import { addToSessionKeyedState, removeFromSessionKeyedState } from '@/lib/sessionKeyedState' @@ -51,6 +51,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) { const clientsRef = useRef>(new Map()) const prevPermissionCountRef = useRef(0) + const initialFetchDoneRef = useRef(false) const MAX_CACHED_CLIENTS = 50 useEffect(() => { @@ -259,10 +260,15 @@ export function EventProvider({ children }: { children: React.ReactNode }) { case 'question.replied': case 'question.rejected': if ('requestID' in event.properties && 'sessionID' in event.properties) { + const sessionID = event.properties.sessionID as string removeQuestion( event.properties.requestID as string, - event.properties.sessionID as string + sessionID ) + queryClient.invalidateQueries({ + queryKey: ['opencode', 'messages'], + predicate: (query) => query.queryKey.includes(sessionID) + }) } break } @@ -270,13 +276,38 @@ export function EventProvider({ children }: { children: React.ReactNode }) { const handleStatusChange = (connected: boolean) => { if (connected) { + initialFetchDoneRef.current = false fetchInitialPendingData() } } const unsubscribe = subscribeToSSE(handleSSEMessage, handleStatusChange) return unsubscribe - }, [addPermission, removePermission, addQuestion, removeQuestion, fetchInitialPendingData]) + }, [addPermission, removePermission, addQuestion, removeQuestion, fetchInitialPendingData, queryClient]) + + useEffect(() => { + if (!repos || repos.length === 0) return + + const cleanupFns: (() => void)[] = [] + const uniqueDirectories = [...new Set(repos.map(r => r.fullPath))] + + uniqueDirectories.forEach(directory => { + const cleanup = addSSEDirectory(directory) + cleanupFns.push(cleanup) + }) + + return () => { + cleanupFns.forEach(fn => fn()) + } + }, [repos]) + + useEffect(() => { + if (!repos || repos.length === 0) return + if (initialFetchDoneRef.current) return + + initialFetchDoneRef.current = true + fetchInitialPendingData() + }, [repos, fetchInitialPendingData]) const value: EventContextValue = useMemo(() => ({ permissions: { diff --git a/frontend/src/hooks/useSSE.ts b/frontend/src/hooks/useSSE.ts index c13c986d..e65d8aec 100644 --- a/frontend/src/hooks/useSSE.ts +++ b/frontend/src/hooks/useSSE.ts @@ -6,7 +6,7 @@ import { showToast } from '@/lib/toast' import { settingsApi } from '@/api/settings' import { useSessionStatus } from '@/stores/sessionStatusStore' import { useSessionTodos } from '@/stores/sessionTodosStore' -import { subscribeToSSE, reconnectSSE, addSSEDirectory, removeSSEDirectory } from '@/lib/sseManager' +import { subscribeToSSE, reconnectSSE, addSSEDirectory } from '@/lib/sseManager' import { parseOpenCodeError } from '@/lib/opencode-errors' const handleRestartServer = async () => { @@ -309,6 +309,16 @@ export const useSSE = (opcodeUrl: string | null | undefined, directory?: string, break } + case 'question.replied': + case 'question.rejected': { + if (!('sessionID' in event.properties)) break + const { sessionID } = event.properties + queryClient.invalidateQueries({ + queryKey: ['opencode', 'messages', opcodeUrl, sessionID, directory] + }) + break + } + default: break } @@ -358,9 +368,7 @@ export const useSSE = (opcodeUrl: string | null | undefined, directory?: string, } } - if (directory) { - addSSEDirectory(directory) - } + const directoryCleanup = directory ? addSSEDirectory(directory) : undefined const unsubscribe = subscribeToSSE(handleMessage, handleStatusChange) @@ -376,9 +384,7 @@ export const useSSE = (opcodeUrl: string | null | undefined, directory?: string, window.removeEventListener('focus', handleReconnect) window.removeEventListener('online', handleReconnect) unsubscribe() - if (directory) { - removeSSEDirectory(directory) - } + directoryCleanup?.() } }, [opcodeUrl, directory, handleSSEEvent, fetchInitialData]) diff --git a/frontend/src/lib/sseManager.ts b/frontend/src/lib/sseManager.ts index 20dc48b7..59007af0 100644 --- a/frontend/src/lib/sseManager.ts +++ b/frontend/src/lib/sseManager.ts @@ -15,7 +15,7 @@ class SSEManager { private static instance: SSEManager private eventSource: EventSource | null = null private subscribers: Map = new Map() - private directories: Set = new Set() + private directoryRefCounts: Map = new Map() private pendingDirectories: Set = new Set() private reconnectTimeout: ReturnType | null = null private reconnectDelay: number = RECONNECT_DELAY_MS @@ -65,18 +65,17 @@ class SSEManager { } addDirectory(directory: string): () => void { - if (this.directories.has(directory)) { - return () => this.cleanupDirectory(directory) - } - - this.directories.add(directory) + const currentCount = this.directoryRefCounts.get(directory) ?? 0 + this.directoryRefCounts.set(directory, currentCount + 1) - if (this.clientId && this.isConnected) { - this.subscribeToDirectory(directory) - } else { - this.pendingDirectories.add(directory) - if (!this.eventSource) { - this.reconnect() + if (currentCount === 0) { + if (this.clientId && this.isConnected) { + this.subscribeToDirectory(directory) + } else { + this.pendingDirectories.add(directory) + if (!this.eventSource) { + this.reconnect() + } } } @@ -84,15 +83,20 @@ class SSEManager { } private cleanupDirectory(directory: string): void { - this.directories.delete(directory) - this.pendingDirectories.delete(directory) - - if (this.clientId && this.isConnected) { - fetch('/api/sse/unsubscribe', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ clientId: this.clientId, directories: [directory] }) - }).catch(() => {}) + const currentCount = this.directoryRefCounts.get(directory) ?? 0 + if (currentCount <= 1) { + this.directoryRefCounts.delete(directory) + this.pendingDirectories.delete(directory) + + if (this.clientId && this.isConnected) { + fetch('/api/sse/unsubscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ clientId: this.clientId, directories: [directory] }) + }).catch(() => {}) + } + } else { + this.directoryRefCounts.set(directory, currentCount - 1) } } @@ -133,27 +137,17 @@ class SSEManager { } removeDirectory(directory: string): void { - if (!this.directories.has(directory)) return - - this.directories.delete(directory) - - if (this.clientId && this.isConnected) { - fetch('/api/sse/unsubscribe', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ clientId: this.clientId, directories: [directory] }) - }).catch(() => {}) - } + this.cleanupDirectory(directory) } getDirectories(): string[] { - return Array.from(this.directories) + return Array.from(this.directoryRefCounts.keys()) } private buildUrl(): string { const url = new URL('/api/sse/stream', window.location.origin) - if (this.directories.size > 0) { - url.searchParams.set('directories', Array.from(this.directories).join(',')) + if (this.directoryRefCounts.size > 0) { + url.searchParams.set('directories', Array.from(this.directoryRefCounts.keys()).join(',')) } return url.toString() }