diff --git a/src/renderer/components/ScriptsView/ScriptsView.tsx b/src/renderer/components/ScriptsView/ScriptsView.tsx index 6a85fcd..3d58642 100644 --- a/src/renderer/components/ScriptsView/ScriptsView.tsx +++ b/src/renderer/components/ScriptsView/ScriptsView.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'; import MonacoEditor from '@monaco-editor/react'; import type { ScriptData } from '../../../main/shared/electronApi'; import { useAppStore } from '../../store'; +import { BDS_EVENT_SCRIPTS_CHANGED, dispatchWindowEvent } from '../../utils'; import { getPythonRuntimeManager } from '../../python/runtimeManagerInstance'; import { useI18n } from '../../i18n'; import { showToast } from '../Toast'; @@ -197,9 +198,7 @@ export const ScriptsView: React.FC = ({ scriptId }) => { setScriptContent(updated.content || ''); const normalizedExisting = toFunctionSlug(updated.slug || updated.title || ''); setIsSlugManuallyEdited(normalizedExisting !== toFunctionSlug(updated.title || '')); - if (typeof window.dispatchEvent === 'function') { - window.dispatchEvent(new CustomEvent('bds:scripts-changed')); - } + dispatchWindowEvent(BDS_EVENT_SCRIPTS_CHANGED); } finally { setIsSaving(false); } @@ -217,9 +216,7 @@ export const ScriptsView: React.FC = ({ scriptId }) => { return; } closeTab(script.id); - if (typeof window.dispatchEvent === 'function') { - window.dispatchEvent(new CustomEvent('bds:scripts-changed')); - } + dispatchWindowEvent(BDS_EVENT_SCRIPTS_CHANGED); } catch (error) { console.error('Failed to delete script:', error); showToast.error(t('sidebar.scripts.deleteFailed')); diff --git a/src/renderer/components/Sidebar/Sidebar.tsx b/src/renderer/components/Sidebar/Sidebar.tsx index e9336fd..bd54ee5 100644 --- a/src/renderer/components/Sidebar/Sidebar.tsx +++ b/src/renderer/components/Sidebar/Sidebar.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useAppStore, PostData, MediaData } from '../../store'; import { showToast } from '../Toast'; -import { getContrastColor, groupPostsByStatus, loadTagColorMap } from '../../utils'; +import { BDS_EVENT_SCRIPTS_CHANGED, dispatchWindowEvent, getContrastColor, groupPostsByStatus, loadTagColorMap } from '../../utils'; import type { ChatConversation, ImportDefinitionData } from '../../types/electron'; import { GitSidebar } from '../GitSidebar/GitSidebar'; import { scrollToSettingsSection, SettingsCategory } from '../SettingsView/SettingsView'; @@ -1598,7 +1598,7 @@ const ScriptsList: React.FC = () => { } = useProjectScopedSidebarData[number]>({ load: loadScripts, activeProjectId, - refreshEventName: 'bds:scripts-changed', + refreshEventName: BDS_EVENT_SCRIPTS_CHANGED, }); const handleCreateScript = async () => { @@ -1619,9 +1619,7 @@ const ScriptsList: React.FC = () => { { id: created.id, title: created.title, updatedAt: created.updatedAt }, ...prev.filter((script) => script.id !== created.id), ]); - if (typeof window.dispatchEvent === 'function') { - window.dispatchEvent(new CustomEvent('bds:scripts-changed')); - } + dispatchWindowEvent(BDS_EVENT_SCRIPTS_CHANGED); openScriptTab(openTab, created.id, 'pin'); void reloadScripts(); } catch (error) { @@ -1640,9 +1638,7 @@ const ScriptsList: React.FC = () => { } setScripts((prev) => prev.filter((script) => script.id !== scriptId)); closeTab(scriptId); - if (typeof window.dispatchEvent === 'function') { - window.dispatchEvent(new CustomEvent('bds:scripts-changed')); - } + dispatchWindowEvent(BDS_EVENT_SCRIPTS_CHANGED); } catch (error) { console.error('Failed to delete script:', error); showToast.error(t('sidebar.scripts.deleteFailed')); diff --git a/src/renderer/components/Sidebar/useProjectScopedSidebarData.ts b/src/renderer/components/Sidebar/useProjectScopedSidebarData.ts index 664a10a..175be09 100644 --- a/src/renderer/components/Sidebar/useProjectScopedSidebarData.ts +++ b/src/renderer/components/Sidebar/useProjectScopedSidebarData.ts @@ -1,9 +1,10 @@ import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from 'react'; +import { addWindowEventListener, type BdsWindowEventName } from '../../utils'; interface ProjectScopedSidebarDataOptions { load: () => Promise; activeProjectId?: string; - refreshEventName?: string; + refreshEventName?: BdsWindowEventName; } interface ProjectScopedSidebarDataResult { @@ -53,18 +54,11 @@ export function useProjectScopedSidebarData(options: ProjectScopedSidebar return; } - if (typeof window.addEventListener !== 'function' || typeof window.removeEventListener !== 'function') { - return; - } - - const handleRefreshEvent = () => { + const unsubscribe = addWindowEventListener(refreshEventName, () => { void reload(); - }; + }); - window.addEventListener(refreshEventName, handleRefreshEvent); - return () => { - window.removeEventListener(refreshEventName, handleRefreshEvent); - }; + return unsubscribe; }, [refreshEventName, reload]); return { diff --git a/src/renderer/utils/index.ts b/src/renderer/utils/index.ts index 4c9915f..e68a8a9 100644 --- a/src/renderer/utils/index.ts +++ b/src/renderer/utils/index.ts @@ -4,3 +4,4 @@ export { unescapeMacroSyntax } from './markdownEscape'; export { groupPostsByStatus, type GroupedPosts, type PostStatus } from './postGrouping'; export { loadTabsForProject, saveTabsForProject } from './tabPersistence'; export { buildTagColorMap, loadTagColorMap } from './tagColors'; +export { BDS_EVENT_SCRIPTS_CHANGED, addWindowEventListener, dispatchWindowEvent, type BdsWindowEventName } from './windowEvents'; diff --git a/src/renderer/utils/windowEvents.ts b/src/renderer/utils/windowEvents.ts new file mode 100644 index 0000000..5a9cff3 --- /dev/null +++ b/src/renderer/utils/windowEvents.ts @@ -0,0 +1,34 @@ +export const BDS_EVENT_SCRIPTS_CHANGED = 'bds:scripts-changed' as const; + +export type BdsWindowEventName = + | typeof BDS_EVENT_SCRIPTS_CHANGED + | 'bds:site-validation-updated'; + +export function addWindowEventListener( + eventName: BdsWindowEventName, + handler: (event: CustomEvent) => void, +): () => void { + if (typeof window.addEventListener !== 'function' || typeof window.removeEventListener !== 'function') { + return () => {}; + } + + const listener = (event: Event) => { + handler(event as CustomEvent); + }; + + window.addEventListener(eventName, listener as EventListener); + return () => { + window.removeEventListener(eventName, listener as EventListener); + }; +} + +export function dispatchWindowEvent( + eventName: BdsWindowEventName, + detail?: TDetail, +): boolean { + if (typeof window.dispatchEvent !== 'function') { + return false; + } + + return window.dispatchEvent(new CustomEvent(eventName, { detail })); +} diff --git a/tests/renderer/utils/windowEvents.test.ts b/tests/renderer/utils/windowEvents.test.ts new file mode 100644 index 0000000..083af63 --- /dev/null +++ b/tests/renderer/utils/windowEvents.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { addWindowEventListener, dispatchWindowEvent } from '../../../src/renderer/utils/windowEvents'; + +describe('windowEvents utils', () => { + beforeEach(() => { + vi.clearAllMocks(); + + const listeners = new Map void>>(); + (window as any).addEventListener = vi.fn((type: string, listener: (event: Event) => void) => { + if (!listeners.has(type)) { + listeners.set(type, new Set()); + } + listeners.get(type)?.add(listener); + }); + (window as any).removeEventListener = vi.fn((type: string, listener: (event: Event) => void) => { + listeners.get(type)?.delete(listener); + }); + (window as any).dispatchEvent = vi.fn((event: Event) => { + listeners.get(event.type)?.forEach((listener) => listener(event)); + return true; + }); + }); + + it('dispatches custom events with detail payload', () => { + const listener = vi.fn(); + addWindowEventListener('bds:scripts-changed', listener); + + const dispatched = dispatchWindowEvent('bds:scripts-changed', { scriptId: 'script-1' }); + + expect(dispatched).toBe(true); + expect(listener).toHaveBeenCalledTimes(1); + const event = listener.mock.calls[0][0] as CustomEvent<{ scriptId: string }>; + expect(event.detail).toEqual({ scriptId: 'script-1' }); + }); + + it('returns no-op unsubscribe when listener APIs are unavailable', () => { + const originalAdd = (window as any).addEventListener; + const originalRemove = (window as any).removeEventListener; + (window as any).addEventListener = undefined; + (window as any).removeEventListener = undefined; + + const unsubscribe = addWindowEventListener('bds:scripts-changed', vi.fn()); + expect(typeof unsubscribe).toBe('function'); + expect(() => unsubscribe()).not.toThrow(); + + (window as any).addEventListener = originalAdd; + (window as any).removeEventListener = originalRemove; + }); + + it('subscribes and unsubscribes listeners', () => { + const listener = vi.fn(); + const unsubscribe = addWindowEventListener('bds:scripts-changed', listener); + + dispatchWindowEvent('bds:scripts-changed'); + expect(listener).toHaveBeenCalledTimes(1); + + unsubscribe(); + dispatchWindowEvent('bds:scripts-changed'); + expect(listener).toHaveBeenCalledTimes(1); + }); +});