diff --git a/src/main/ipc/settingsApi.ts b/src/main/ipc/settingsApi.ts index e4f6b0d..1d51f7f 100644 --- a/src/main/ipc/settingsApi.ts +++ b/src/main/ipc/settingsApi.ts @@ -8,6 +8,23 @@ import type { ExportService } from '../services/ExportService.js'; import type { ImportService } from '../services/ImportService.js'; import type { SyncService } from '../services/SyncService.js'; import type { TelemetryService } from '../services/TelemetryService.js'; +import { getInboxWindow as getInboxWindowSingleton } from '../windows/inboxWindow.js'; + +/** + * 외부 (트레이 / second-instance / 기타 main 프로세스 호출자) 에서 inbox 창에 view 전환을 + * 요청하는 진입점. 창이 숨겨져 있으면 show + focus 후 'inbox:navigate' IPC 이벤트를 + * renderer 로 전달. + * + * Task 13 (v0.2.7) — 트레이 "설정..." 메뉴 wiring 은 Task 16 에서 본 함수 호출. + */ +export function navigateInbox(view: 'inbox' | 'trash' | 'settings'): void { + const win = getInboxWindowSingleton(); + if (win && !win.isDestroyed()) { + if (!win.isVisible()) win.show(); + win.focus(); + win.webContents.send('inbox:navigate', view); + } +} export interface SettingsIpcDeps { backup: BackupService; diff --git a/src/preload/index.ts b/src/preload/index.ts index 65e06ca..f71adca 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -52,6 +52,12 @@ const api: InklingApi = { ipcRenderer.on('inbox:openOllamaSettings', handler); return () => ipcRenderer.removeListener('inbox:openOllamaSettings', handler); }, + // v0.2.7 Task 13 — 외부 (트레이) 에서 view 전환 요청 listener. + onNavigate: (cb: (view: 'inbox' | 'trash' | 'settings') => void) => { + const listener = (_e: unknown, view: 'inbox' | 'trash' | 'settings') => cb(view); + ipcRenderer.on('inbox:navigate', listener); + return () => ipcRenderer.off('inbox:navigate', listener); + }, // v0.2.7 자동 실행 (임시 채널 — Task 22 에서 settings:autostart-state / settings:autostart-set 으로 rename) getAutostart: () => ipcRenderer.invoke('settings:get-autostart'), setAutostart: (open: boolean) => ipcRenderer.invoke('settings:set-autostart', open), diff --git a/src/renderer/inbox/App.tsx b/src/renderer/inbox/App.tsx index 91a11fc..85ed039 100644 --- a/src/renderer/inbox/App.tsx +++ b/src/renderer/inbox/App.tsx @@ -37,9 +37,20 @@ export function App(): React.ReactElement { useInbox.setState({ ollamaStatus: status }); }); const unsubOllamaSettings = inboxApi.onOpenOllamaSettings(() => setOllamaSettingsOpen(true)); + const unsubNav = inboxApi.onNavigate((view) => { + if (view === 'settings') { + useInbox.getState().setShowSettings(true); + } else if (view === 'inbox') { + useInbox.getState().setShowSettings(false); + if (useInbox.getState().showTrash) void useInbox.getState().toggleShowTrash(); + } else if (view === 'trash') { + useInbox.getState().setShowSettings(false); + if (!useInbox.getState().showTrash) void useInbox.getState().toggleShowTrash(); + } + }); const onFocus = () => { void refreshMeta(); }; window.addEventListener('focus', onFocus); - return () => { unsubNote(); unsubOllama(); unsubOllamaSettings(); window.removeEventListener('focus', onFocus); }; + return () => { unsubNote(); unsubOllama(); unsubOllamaSettings(); unsubNav(); window.removeEventListener('focus', onFocus); }; // onOllamaStatus 콜백은 useInbox.setState 직접 호출 — store reference 가 안정적이라 // deps array 에 추가 불필요. mount 시 1회 구독 + unmount 시 해제. }, [loadInitial, refreshMeta, upsertNote]); diff --git a/src/shared/types.ts b/src/shared/types.ts index 5be2325..7260b0a 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -92,6 +92,8 @@ export interface InboxApi { loadOllamaSettings(): Promise<{ endpoint: string; model: string } | null>; saveOllamaSettings(v: { endpoint: string; model: string }): Promise<{ ok: true } | { ok: false; reason: string }>; onOpenOllamaSettings(cb: () => void): () => void; + // v0.2.7 Task 13 — 외부 (트레이 등) 에서 view 전환 요청 구독. + onNavigate(cb: (view: 'inbox' | 'trash' | 'settings') => void): () => void; // v0.2.7 자동 실행 (임시 채널 — Task 22 에서 정식 이름으로 rename) getAutostart(): Promise<{ openAtLogin: boolean }>; setAutostart(open: boolean): Promise<{ openAtLogin: boolean }>; diff --git a/tests/unit/App.test.tsx b/tests/unit/App.test.tsx index 947cf1f..5e65be9 100644 --- a/tests/unit/App.test.tsx +++ b/tests/unit/App.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { describe, it, expect, vi, beforeEach } from 'vitest'; import '@testing-library/jest-dom/vitest'; -import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; vi.mock('../../src/renderer/inbox/api.js', () => ({ inboxApi: { @@ -20,6 +20,7 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({ onNoteUpdated: vi.fn(() => () => undefined), onOllamaStatus: vi.fn(() => () => undefined), onOpenOllamaSettings: vi.fn(() => () => undefined), + onNavigate: vi.fn(() => () => undefined), // 4 섹션 mounted 시 호출되는 stub loadOllamaSettings: vi.fn(async () => ({ endpoint: '', model: '' })), saveOllamaSettings: vi.fn(async () => ({ ok: true })), @@ -39,6 +40,7 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({ import { App } from '../../src/renderer/inbox/App'; import { useInbox } from '../../src/renderer/inbox/store'; +import { inboxApi } from '../../src/renderer/inbox/api.js'; describe('App — settings view', () => { beforeEach(() => { @@ -58,4 +60,19 @@ describe('App — settings view', () => { fireEvent.click(await screen.findByLabelText('설정 열기')); expect(useInbox.getState().showSettings).toBe(true); }); + + it('inbox:navigate "settings" event sets showSettings=true', async () => { + const navHandlers: Array<(view: 'inbox' | 'trash' | 'settings') => void> = []; + vi.mocked(inboxApi.onNavigate).mockImplementation((cb) => { + navHandlers.push(cb); + return () => { + const i = navHandlers.indexOf(cb); + if (i >= 0) navHandlers.splice(i, 1); + }; + }); + render(); + await waitFor(() => expect(navHandlers.length).toBeGreaterThan(0)); + navHandlers.forEach((h) => h('settings')); + await waitFor(() => expect(useInbox.getState().showSettings).toBe(true)); + }); });