e.stopPropagation()}>
diff --git a/src/renderer/inbox/store.ts b/src/renderer/inbox/store.ts
index 3f69d27..78ff106 100644
--- a/src/renderer/inbox/store.ts
+++ b/src/renderer/inbox/store.ts
@@ -102,68 +102,117 @@ export const useInbox = create((set, get) => ({
searchResults: null,
reviewData: null,
async loadInitial() {
+ // v0.3.8 — IPC 실패 시 loading=true 영구 stuck 방지. catch 로 reset.
set({ loading: true });
- const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, settings] = await Promise.all([
- // inbox 탭은 status='active' 만 표시 — loadByView('inbox') 와 동일 path 로 일관성 확보.
- // listNotes 는 deleted_at IS NULL 만 필터 (= active+completed+archived 혼재) 이라 부정확.
- inboxApi.listByStatus('active', { limit: 50 }),
- inboxApi.getContinuity(),
- inboxApi.getPendingCount(),
- inboxApi.getOllamaStatus(),
- inboxApi.getTodayCount(),
- inboxApi.getTrashCount(),
- inboxApi.listExpired(),
- inboxApi.getFailedCount(),
- inboxApi.listRecallCandidate(),
- inboxApi.countsByStatus(),
- inboxApi.getSettings()
- ]);
- set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, ai_enabled: settings.ai_enabled ?? true, loading: false });
+ try {
+ const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, settings] = await Promise.all([
+ // inbox 탭은 status='active' 만 표시 — loadByView('inbox') 와 동일 path 로 일관성 확보.
+ // listNotes 는 deleted_at IS NULL 만 필터 (= active+completed+archived 혼재) 이라 부정확.
+ inboxApi.listByStatus('active', { limit: 50 }),
+ inboxApi.getContinuity(),
+ inboxApi.getPendingCount(),
+ inboxApi.getOllamaStatus(),
+ inboxApi.getTodayCount(),
+ inboxApi.getTrashCount(),
+ inboxApi.listExpired(),
+ inboxApi.getFailedCount(),
+ inboxApi.listRecallCandidate(),
+ inboxApi.countsByStatus(),
+ inboxApi.getSettings()
+ ]);
+ set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, ai_enabled: settings.ai_enabled ?? true, loading: false });
+ } catch (e) {
+ // 첫 launch 의 IPC 실패 (DB migration 실패 / main process 비정상) 시 무한 loading 회피.
+ // 빈 데이터로 진입하면 사용자가 캡처 시도 → 실제 fail 이 표면화 → 재시도 가능.
+ console.error('[inbox] loadInitial failed', e);
+ set({ loading: false });
+ }
},
async refreshMeta() {
- const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, settings] = await Promise.all([
- inboxApi.getContinuity(),
- inboxApi.getPendingCount(),
- inboxApi.getOllamaStatus(),
- inboxApi.getTodayCount(),
- inboxApi.getTrashCount(),
- inboxApi.listExpired(),
- inboxApi.getFailedCount(),
- inboxApi.listRecallCandidate(),
- inboxApi.countsByStatus(),
- inboxApi.getSettings()
- ]);
- set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, ai_enabled: settings.ai_enabled ?? true });
+ try {
+ const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, settings] = await Promise.all([
+ inboxApi.getContinuity(),
+ inboxApi.getPendingCount(),
+ inboxApi.getOllamaStatus(),
+ inboxApi.getTodayCount(),
+ inboxApi.getTrashCount(),
+ inboxApi.listExpired(),
+ inboxApi.getFailedCount(),
+ inboxApi.listRecallCandidate(),
+ inboxApi.countsByStatus(),
+ inboxApi.getSettings()
+ ]);
+ set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, ai_enabled: settings.ai_enabled ?? true });
+ } catch (e) {
+ // refreshMeta 는 background poll/event 에서 자주 호출 → fail 무시 (다음 호출에 회복).
+ console.error('[inbox] refreshMeta failed', e);
+ }
},
upsertNote(note) {
- // trashCount 는 server-authoritative. trashNotes 가 cache-loaded (showTrash=true) 일
- // 때만 trashCount 를 local recompute. 그 외엔 server 값 (refreshMeta) 보존.
- const showTrash = get().showTrash;
- if (note.deletedAt !== null) {
- // trash 노트: notes 에서 제거 + trashNotes 에 upsert
- const cleanNotes = get().notes.filter((n) => n.id !== note.id);
- const ti = get().trashNotes.findIndex((n) => n.id === note.id);
- const nextTrash = get().trashNotes.slice();
- if (ti >= 0) nextTrash[ti] = note;
+ // v0.3.8 — status 가 current view 와 매칭될 때만 notes 에 유지. 그 외엔 제거.
+ // 이전 구현은 trashed 외 모든 status 를 notes 에 누적 → 사용자가 inbox view 에서
+ // 완료/보관 으로 옮긴 노트가 list 에 잔류하는 버그. push-based (setStatus 도 emit) 로
+ // 모든 status 전이가 upsertNote 를 거치므로 view-aware filter 가 필수.
+ //
+ // trashCount/trashNotes 는 server-authoritative. trashNotes 가 cache-loaded
+ // (view='trash') 일 때만 trashCount 를 local recompute. 그 외엔 server 값
+ // (refreshMeta) 보존. searchResults 도 별도로 갱신 (status 변경 시 list 에서 제거).
+ const state = get();
+ const view = state.view;
+ const showTrash = state.showTrash;
+ const viewStatus: 'active' | 'completed' | 'archived' | 'trashed' | null =
+ view === 'inbox' ? 'active' :
+ view === 'completed' ? 'completed' :
+ view === 'archived' ? 'archived' :
+ view === 'trash' ? 'trashed' : null;
+
+ // trashNotes — note.status='trashed' 면 upsert, 아니면 제거.
+ const cleanTrash = state.trashNotes.filter((n) => n.id !== note.id);
+ let nextTrash = cleanTrash;
+ if (note.status === 'trashed') {
+ const ti = state.trashNotes.findIndex((n) => n.id === note.id);
+ nextTrash = cleanTrash.slice();
+ if (ti >= 0) nextTrash.splice(ti, 0, note);
else nextTrash.unshift(note);
- set({
- notes: cleanNotes,
- trashNotes: nextTrash,
- ...(showTrash ? { trashCount: nextTrash.length } : {})
- });
- } else {
- // active 노트: trashNotes 에서 제거 + notes 에 upsert (restore 케이스 포함)
- const cleanTrash = get().trashNotes.filter((n) => n.id !== note.id);
- const i = get().notes.findIndex((n) => n.id === note.id);
- const nextNotes = get().notes.slice();
- if (i >= 0) nextNotes[i] = note;
- else nextNotes.unshift(note);
- set({
- notes: nextNotes,
- trashNotes: cleanTrash,
- ...(showTrash ? { trashCount: cleanTrash.length } : {})
- });
}
+
+ // notes — current view 의 status 와 매칭되는 경우만 유지/upsert.
+ // viewStatus=null (review/settings/검색) 이면 notes 직접 렌더 안 함 → 갱신 skip.
+ const cleanNotes = state.notes.filter((n) => n.id !== note.id);
+ let nextNotes = state.notes;
+ if (viewStatus !== null) {
+ if (note.status === viewStatus) {
+ const i = state.notes.findIndex((n) => n.id === note.id);
+ nextNotes = cleanNotes.slice();
+ if (i >= 0) nextNotes.splice(i, 0, note);
+ else nextNotes.unshift(note);
+ } else {
+ nextNotes = cleanNotes;
+ }
+ }
+
+ // searchResults — null 아니면 동일 패턴으로 갱신 (status 가 current search status 와
+ // 안 맞으면 제거, 맞으면 upsert).
+ let nextSearch = state.searchResults;
+ if (state.searchResults !== null) {
+ const cleanSearch = state.searchResults.filter((n) => n.id !== note.id);
+ if (viewStatus === null || note.status === viewStatus) {
+ // search 가 active 한 view 가 review/settings 면 status filter 없음 → 모두 keep.
+ const i = state.searchResults.findIndex((n) => n.id === note.id);
+ nextSearch = cleanSearch.slice();
+ if (i >= 0) nextSearch.splice(i, 0, note);
+ else nextSearch.unshift(note);
+ } else {
+ nextSearch = cleanSearch;
+ }
+ }
+
+ set({
+ notes: nextNotes,
+ trashNotes: nextTrash,
+ searchResults: nextSearch,
+ ...(showTrash ? { trashCount: nextTrash.length } : {})
+ });
},
removeNote(id) {
const cleanNotes = get().notes.filter((n) => n.id !== id);
@@ -204,13 +253,21 @@ export const useInbox = create((set, get) => ({
if (view === 'review-monthly') void get().loadReview('monthly');
},
async loadByView(view) {
+ // v0.3.8 — IPC 실패 시 stale 한 이전 view 의 notes 가 계속 노출되는 사고 방지.
+ // fail 시 빈 배열로 reset 해서 사용자에게 "비어있음" 으로 표시 (혼동 < stale).
const status =
view === 'trash' ? 'trashed' : view === 'inbox' ? 'active' : view;
- const notes = await inboxApi.listByStatus(status, { limit: 200 });
- if (view === 'trash') {
- set({ trashNotes: notes, trashCount: notes.length });
- } else {
- set({ notes });
+ try {
+ const notes = await inboxApi.listByStatus(status, { limit: 200 });
+ if (view === 'trash') {
+ set({ trashNotes: notes, trashCount: notes.length });
+ } else {
+ set({ notes });
+ }
+ } catch (e) {
+ console.error('[inbox] loadByView failed', view, e);
+ if (view === 'trash') set({ trashNotes: [] });
+ else set({ notes: [] });
}
},
async toggleShowTrash() {
@@ -225,11 +282,12 @@ export const useInbox = create((set, get) => ({
async restoreNote(id) {
await inboxApi.restoreNote(id);
// 낙관적 갱신: main 은 trash/restore 시 pushNoteUpdated 를 보내지 않음
- // (현재 AiWorker.onUpdate 만 push). 자가 반영이 primary 메커니즘.
+ // (현재 AiWorker.onUpdate + setStatus 만 push). 자가 반영이 primary 메커니즘.
// 전제: 호출 시점에 trashNotes 에 노트가 존재 (T14 trash view 한정 호출).
+ // v0.3.8 — status 도 'active' 로 함께 갱신. upsertNote 가 status='trashed' 만 trash 로 라우팅.
const note = get().trashNotes.find((n) => n.id === id);
if (note) {
- get().upsertNote({ ...note, deletedAt: null });
+ get().upsertNote({ ...note, deletedAt: null, status: 'active' });
}
},
async permanentDeleteNote(id) {
@@ -306,14 +364,27 @@ export const useInbox = create((set, get) => ({
const status = view === 'completed' || view === 'archived' || view === 'trash'
? (view === 'trash' ? 'trashed' : view)
: view === 'inbox' ? 'active' : undefined;
- const r = await inboxApi.search(q, status ? { status } : {});
- set({ searchResults: r });
+ try {
+ const r = await inboxApi.search(q, status ? { status } : {});
+ set({ searchResults: r });
+ } catch (e) {
+ // FTS5 query parse error (special char 미escape) / IPC fail → 빈 결과로.
+ console.error('[inbox] searchNotes failed', e);
+ set({ searchResults: [] });
+ }
},
clearSearch() {
set({ searchQuery: '', searchResults: null });
},
async loadReview(period) {
- const data = await inboxApi.reviewAggregate(period);
- set({ reviewData: data });
+ try {
+ const data = await inboxApi.reviewAggregate(period);
+ set({ reviewData: data });
+ } catch (e) {
+ // review IPC fail 시 reviewData=null → ReviewView 의 "불러오는 중…" 영구 표시 회피.
+ // 빈 aggregate 로 set 해서 사용자에게 "0건" 표기.
+ console.error('[inbox] loadReview failed', period, e);
+ set({ reviewData: { totalCount: 0, tagCounts: [], dueProgress: { total: 0, passed: 0, pending: 0 }, recentNotes: [] } });
+ }
}
}));
diff --git a/tests/unit/MoveStatusModal.test.tsx b/tests/unit/MoveStatusModal.test.tsx
index 7e41104..606f068 100644
--- a/tests/unit/MoveStatusModal.test.tsx
+++ b/tests/unit/MoveStatusModal.test.tsx
@@ -137,6 +137,42 @@ describe('MoveStatusModal', () => {
expect(screen.queryByRole('button', { name: '휴지통' })).toBeNull();
});
+ it('Escape key → onClose 호출', () => {
+ const onClose = vi.fn();
+ render(
+
+ );
+ fireEvent.keyDown(document, { key: 'Escape' });
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it('overlay 클릭 → onClose, modal body 클릭 → 무반응', () => {
+ const onClose = vi.fn();
+ render(
+
+ );
+ // body 클릭 (textarea) → onClose 호출 안 됨
+ fireEvent.click(screen.getByRole('textbox'));
+ expect(onClose).not.toHaveBeenCalled();
+ // overlay (dialog) 클릭 → onClose
+ fireEvent.click(screen.getByRole('dialog', { name: '이동' }));
+ expect(onClose).toHaveBeenCalled();
+ });
+
it('빈 사유 → null reason 전달', async () => {
const onMoved = vi.fn();
render(
diff --git a/tests/unit/inboxApi-setStatus.test.ts b/tests/unit/inboxApi-setStatus.test.ts
index bba5969..a74c4fd 100644
--- a/tests/unit/inboxApi-setStatus.test.ts
+++ b/tests/unit/inboxApi-setStatus.test.ts
@@ -83,6 +83,20 @@ describe('inbox:set-status IPC', () => {
expect(r.reason).toBe('invalid status');
expect(mockSetStatus).not.toHaveBeenCalled();
});
+
+ it('emits note:updated to renderer after setStatus (v0.3.8 push-based)', async () => {
+ const send = vi.fn();
+ const win = { webContents: { send }, isDestroyed: () => false } as never;
+ const deps = makeDeps();
+ deps.getInboxWindow = () => win;
+ const updatedNote = { id: 'n1', status: 'completed' };
+ mockFindById.mockReturnValue(updatedNote);
+ registerInboxApi(deps);
+ const handler = handlers['inbox:set-status'];
+ if (handler === undefined) throw new Error('handler not registered');
+ await handler(null, 'n1', 'completed', null);
+ expect(send).toHaveBeenCalledWith('note:updated', updatedNote);
+ });
});
describe('ai:classify-status IPC', () => {
diff --git a/tests/unit/store.trash.test.ts b/tests/unit/store.trash.test.ts
index 7bb173d..e124365 100644
--- a/tests/unit/store.trash.test.ts
+++ b/tests/unit/store.trash.test.ts
@@ -95,6 +95,37 @@ describe('useInbox — trash state (v0.2.3 #4)', () => {
expect(useInbox.getState().notes).toHaveLength(1);
});
+ it('view-aware upsertNote — inbox view 에서 status=completed 노트 push → notes 에서 제거', async () => {
+ const { useInbox } = await import('../../src/renderer/inbox/store.js');
+ // view='inbox' (default), active 노트 upsert
+ useInbox.getState().upsertNote(noteStub('a'));
+ expect(useInbox.getState().notes).toHaveLength(1);
+ // 같은 노트가 completed 로 status 변경 → 현재 view 와 안 맞으므로 notes 에서 제거
+ const completed: Note = { ...noteStub('a'), status: 'completed' };
+ useInbox.getState().upsertNote(completed);
+ expect(useInbox.getState().notes).toHaveLength(0);
+ });
+
+ it('view-aware upsertNote — completed view 에서 active 노트 push → notes 에 추가 안 됨', async () => {
+ const { useInbox } = await import('../../src/renderer/inbox/store.js');
+ useInbox.setState({ view: 'completed' });
+ useInbox.getState().upsertNote(noteStub('a')); // status='active'
+ expect(useInbox.getState().notes).toHaveLength(0);
+ // completed status 면 추가
+ const completed: Note = { ...noteStub('a'), status: 'completed' };
+ useInbox.getState().upsertNote(completed);
+ expect(useInbox.getState().notes).toHaveLength(1);
+ });
+
+ it('view-aware upsertNote — searchResults 가 있을 때 status mismatch → searchResults 에서 제거', async () => {
+ const { useInbox } = await import('../../src/renderer/inbox/store.js');
+ // 이전 test 가 view='completed' 로 설정한 채 끝났을 수 있어 명시적 초기화.
+ useInbox.setState({ view: 'inbox', searchResults: [noteStub('a')] });
+ const completed: Note = { ...noteStub('a'), status: 'completed' };
+ useInbox.getState().upsertNote(completed);
+ expect(useInbox.getState().searchResults).toHaveLength(0);
+ });
+
it('emptyTrash with cancelled confirm leaves trashNotes intact', async () => {
mockApi.emptyTrash.mockResolvedValueOnce({ confirmed: false, count: 0 });
const { useInbox } = await import('../../src/renderer/inbox/store.js');