diff --git a/apps/mark/src-tauri/capabilities/default.json b/apps/mark/src-tauri/capabilities/default.json index 7cd99d65..74b20594 100644 --- a/apps/mark/src-tauri/capabilities/default.json +++ b/apps/mark/src-tauri/capabilities/default.json @@ -10,6 +10,7 @@ "core:window:allow-close", "clipboard-manager:allow-write-text", "window-state:default", - "store:default" + "store:default", + "core:window:allow-set-badge-count" ] } diff --git a/apps/mark/src/App.svelte b/apps/mark/src/App.svelte index 616ad0a9..b76296de 100644 --- a/apps/mark/src/App.svelte +++ b/apps/mark/src/App.svelte @@ -287,6 +287,20 @@ // Cache the BLOX_ENV value once at startup (process-level, never changes). initBloxEnv(); + // Restore persisted unread state and sync the dock badge. + try { + await projectStateStore.initFromStore(); + // If we restored into a project that was unread, mark it as read now + if ( + navigation.selectedProjectId && + projectStateStore.isUnread(navigation.selectedProjectId) + ) { + projectStateStore.markAsRead(navigation.selectedProjectId); + } + } catch (e) { + console.error('Failed to restore unread state:', e); + } + try { await initializeShortcutBindings(); } catch (e) { diff --git a/apps/mark/src/lib/features/layout/navigation.svelte.ts b/apps/mark/src/lib/features/layout/navigation.svelte.ts index 5c081d20..0a4844f0 100644 --- a/apps/mark/src/lib/features/layout/navigation.svelte.ts +++ b/apps/mark/src/lib/features/layout/navigation.svelte.ts @@ -50,13 +50,15 @@ export async function initNavigation(): Promise { // Validate the project still exists before navigating to it try { const projects = await commands.listProjects(); - const exists = projects.some((p) => p.id === lastProjectId); - if (exists) { + const existingIds = new Set(projects.map((p) => p.id)); + if (existingIds.has(lastProjectId)) { navigation.selectedProjectId = lastProjectId; } else { // Project was deleted — clear the stale value await setStoreValue(LAST_PROJECT_STORE_KEY, null); } + // Remove unread entries for projects that no longer exist + await projectStateStore.pruneDeletedProjects(existingIds); } catch { // If we can't list projects (e.g. store error), stay on home console.warn('[Navigation] Could not verify last project, falling back to home'); diff --git a/apps/mark/src/lib/stores/projectState.svelte.ts b/apps/mark/src/lib/stores/projectState.svelte.ts index 46506e04..2804cc03 100644 --- a/apps/mark/src/lib/stores/projectState.svelte.ts +++ b/apps/mark/src/lib/stores/projectState.svelte.ts @@ -9,12 +9,17 @@ * - The user navigates to that project * * Running sessions are tracked to show spinners on project cards + * Unread state is persisted to disk and the macOS dock badge is kept in sync. * * Note: Session-to-project lookups are now delegated to the unified sessionRegistry */ +import { getCurrentWindow } from '@tauri-apps/api/window'; +import { getStoreValue, setStoreValue } from '../shared/persistentStore'; import { sessionRegistry, type SessionType } from './sessionRegistry.svelte'; +const UNREAD_PROJECTS_STORE_KEY = 'unread-projects'; + interface ProjectState { unread: boolean; runningSessions: Set; // Set of session IDs currently running in this project @@ -84,6 +89,7 @@ class ProjectStateStore { const state = this.getOrCreateState(projectId); state.unread = false; this.version++; // Trigger reactivity + this.persistAndBadge(); } /** @@ -94,6 +100,7 @@ class ProjectStateStore { const state = this.getOrCreateState(projectId); state.unread = true; this.version++; // Trigger reactivity + this.persistAndBadge(); } /** @@ -190,6 +197,79 @@ class ProjectStateStore { } return types; } + + /** + * Get the number of projects currently in the unread state. + */ + getUnreadCount(): number { + this.version; + let count = 0; + for (const state of this.states.values()) { + if (state.unread) count++; + } + return count; + } + + /** + * Persist the current set of unread project IDs to disk and update the dock badge. + */ + private persistAndBadge(): void { + const ids: string[] = []; + for (const [id, state] of this.states) { + if (state.unread) ids.push(id); + } + setStoreValue(UNREAD_PROJECTS_STORE_KEY, ids); + getCurrentWindow() + .setBadgeCount(ids.length || undefined) + .catch(() => { + // setBadgeCount may be unsupported on some platforms — ignore + }); + } + + /** + * Restore unread state from the persistent store and sync the badge. + * Must be called after initPersistentStore(). + */ + async initFromStore(): Promise { + const ids = await getStoreValue(UNREAD_PROJECTS_STORE_KEY); + if (ids && ids.length > 0) { + for (const id of ids) { + const state = this.getOrCreateState(id); + state.unread = true; + } + this.version++; + getCurrentWindow() + .setBadgeCount(ids.length) + .catch(() => {}); + } + } + + /** + * Remove unread entries for projects that no longer exist. + * Operates directly on persisted data so it works regardless of + * whether initFromStore() has run yet. + */ + async pruneDeletedProjects(existingProjectIds: Set): Promise { + const ids = await getStoreValue(UNREAD_PROJECTS_STORE_KEY); + if (!ids || ids.length === 0) return; + + const kept = ids.filter((id) => existingProjectIds.has(id)); + if (kept.length === ids.length) return; + + await setStoreValue(UNREAD_PROJECTS_STORE_KEY, kept); + + // Also update in-memory state if it has been initialized + for (const id of ids) { + if (!existingProjectIds.has(id)) { + const state = this.states.get(id); + if (state) state.unread = false; + } + } + this.version++; + getCurrentWindow() + .setBadgeCount(kept.length || undefined) + .catch(() => {}); + } } export const projectStateStore = new ProjectStateStore();