diff --git a/src/apps/desktop/src/main/ipc.ts b/src/apps/desktop/src/main/ipc.ts index c5cd26a87..87da0402a 100644 --- a/src/apps/desktop/src/main/ipc.ts +++ b/src/apps/desktop/src/main/ipc.ts @@ -42,8 +42,18 @@ type DesktopExportSection = | 'themes' type DesktopThemeExportPayload = { + themePreset?: string | null customThemeId: string | null customThemes: Record + backgroundImage?: { + dataUrl: string + name: string + mimeType: string + size: number + updatedAt: number + } | null + backgroundImageOpacity?: number | null + sidebarGrouping?: 'normal' | 'gtd' | null } type DesktopExportOptions = { diff --git a/src/apps/desktop/src/preload/index.ts b/src/apps/desktop/src/preload/index.ts index 66d0a8a2d..97b029c47 100644 --- a/src/apps/desktop/src/preload/index.ts +++ b/src/apps/desktop/src/preload/index.ts @@ -229,8 +229,18 @@ export type DesktopExportSection = | 'themes' export type DesktopThemeExportPayload = { + themePreset?: string | null customThemeId: string | null customThemes: Record + backgroundImage?: { + dataUrl: string + name: string + mimeType: string + size: number + updatedAt: number + } | null + backgroundImageOpacity?: number | null + sidebarGrouping?: 'normal' | 'gtd' | null } export type DesktopExportOptions = { diff --git a/src/apps/shared/src/desktop.ts b/src/apps/shared/src/desktop.ts index 22e8d2ff1..c2914c8ea 100644 --- a/src/apps/shared/src/desktop.ts +++ b/src/apps/shared/src/desktop.ts @@ -362,8 +362,18 @@ export type DesktopExportSection = | 'themes' export type DesktopThemeExportPayload = { + themePreset?: string | null customThemeId: string | null customThemes: Record + backgroundImage?: { + dataUrl: string + name: string + mimeType: string + size: number + updatedAt: number + } | null + backgroundImageOpacity?: number | null + sidebarGrouping?: 'normal' | 'gtd' | null } export type DesktopExportOptions = { diff --git a/src/apps/web/src/__tests__/advancedSettings.test.tsx b/src/apps/web/src/__tests__/advancedSettings.test.tsx index 51e0a38cb..5c3e71226 100644 --- a/src/apps/web/src/__tests__/advancedSettings.test.tsx +++ b/src/apps/web/src/__tests__/advancedSettings.test.tsx @@ -102,6 +102,7 @@ describe('AdvancedSettings', () => { return { ...actual, readLocaleFromStorage: vi.fn(() => 'zh'), + readGtdEnabled: vi.fn(() => false), writeLocaleToStorage: vi.fn(), } }) @@ -140,10 +141,16 @@ describe('AdvancedSettings', () => { })) vi.doMock('../contexts/AppearanceContext', () => ({ useAppearance: () => ({ + themePreset: 'default', + setThemePreset: vi.fn(), customThemeId: null, customThemes: {}, saveCustomTheme: vi.fn(), setActiveCustomTheme: vi.fn(), + backgroundImage: null, + setBackgroundImage: vi.fn(() => true), + backgroundImageOpacity: 40, + setBackgroundImageOpacity: vi.fn(), }), })) vi.doMock('@arkloop/shared', async () => { @@ -228,6 +235,7 @@ describe('AdvancedSettings', () => { return { ...actual, readLocaleFromStorage: vi.fn(() => 'zh'), + readGtdEnabled: vi.fn(() => true), writeLocaleToStorage: vi.fn(), } }) @@ -255,10 +263,22 @@ describe('AdvancedSettings', () => { })) vi.doMock('../contexts/AppearanceContext', () => ({ useAppearance: () => ({ + themePreset: 'background-image', + setThemePreset: vi.fn(), customThemeId: null, customThemes: {}, saveCustomTheme: vi.fn(), setActiveCustomTheme: vi.fn(), + backgroundImage: { + dataUrl: 'data:image/png;base64,Ymc=', + name: 'bg.png', + mimeType: 'image/png', + size: 2, + updatedAt: 1234, + }, + setBackgroundImage: vi.fn(() => true), + backgroundImageOpacity: 55, + setBackgroundImageOpacity: vi.fn(), }), })) vi.doMock('@arkloop/shared', async () => { @@ -311,5 +331,175 @@ describe('AdvancedSettings', () => { '自定义主题', ]) expect(exportDataBundle).not.toHaveBeenCalled() + + const exportLabel = exportButton!.textContent?.trim() + const confirmExportButton = Array.from(document.body.querySelectorAll('button')) + .filter((button) => button.textContent?.trim() === exportLabel) + .at(-1) + expect(confirmExportButton).toBeTruthy() + + await act(async () => { + confirmExportButton!.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + await flushEffects() + + expect(exportDataBundle).toHaveBeenCalledWith(expect.objectContaining({ + sections: expect.arrayContaining(['themes']), + themes: expect.objectContaining({ + themePreset: 'background-image', + backgroundImage: expect.objectContaining({ + dataUrl: 'data:image/png;base64,Ymc=', + name: 'bg.png', + }), + backgroundImageOpacity: 55, + sidebarGrouping: 'gtd', + }), + })) + }) + + it('导入主题外观校验失败时不写入部分主题状态', async () => { + const saveCustomTheme = vi.fn() + const setBackgroundImage = vi.fn(() => true) + const setBackgroundImageOpacity = vi.fn() + const setThemePreset = vi.fn() + const setActiveCustomTheme = vi.fn() + const writeGtdEnabled = vi.fn() + const desktopApi = { + advanced: { + getOverview: vi.fn().mockResolvedValue({ + appName: 'Arkloop', + appVersion: '1.2.3', + githubUrl: 'https://github.com/qqqqqf-q/Arkloop', + telegramUrl: null, + iconDataUrl: null, + configPath: '/tmp/config.json', + dataDir: '/tmp/data', + logsDir: '/tmp/logs', + sqlitePath: '/tmp/data.db', + links: [], + status: [], + usage: null, + }), + chooseDataFolder: vi.fn().mockResolvedValue('/tmp/export'), + exportDataBundle: vi.fn().mockResolvedValue({ ok: true, filePath: '/tmp/export/bundle' }), + importDataBundle: vi.fn().mockResolvedValue({ + ok: true, + importedFrom: '/tmp/import', + themes: { + themePreset: 'background-image', + customThemeId: 'theme-1', + customThemes: { + 'theme-1': { id: 'theme-1', name: 'Theme 1' }, + }, + backgroundImage: { dataUrl: 12 }, + backgroundImageOpacity: 55, + sidebarGrouping: 'gtd', + }, + }), + listLogs: vi.fn().mockResolvedValue({ entries: [] }), + }, + config: { + get: vi.fn().mockResolvedValue({ + network: { + proxyEnabled: false, + requestTimeoutMs: 30000, + retryCount: 1, + }, + }), + set: vi.fn().mockResolvedValue({ ok: true }), + }, + } + + vi.doMock('../storage', async () => { + const actual = await vi.importActual('../storage') + return { + ...actual, + readLocaleFromStorage: vi.fn(() => 'zh'), + readGtdEnabled: vi.fn(() => false), + writeGtdEnabled, + writeLocaleToStorage: vi.fn(), + } + }) + vi.doMock('../api', async () => { + const actual = await vi.importActual('../api') + return { + ...actual, + getMyUsage: vi.fn().mockResolvedValue(null), + getMyDailyUsage: vi.fn().mockResolvedValue([]), + getMyHourlyUsage: vi.fn().mockResolvedValue([]), + getMyUsageByModel: vi.fn().mockResolvedValue([]), + } + }) + vi.doMock('../components/settings/ConnectionSettings', () => ({ + ConnectionSettings: () =>
connection-settings
, + })) + vi.doMock('../components/settings/ModulesSettings', () => ({ + ModulesSettings: () =>
modules-settings
, + })) + vi.doMock('../components/settings/ExtensionsSettings', () => ({ + ExtensionsSettings: () =>
extensions-settings
, + })) + vi.doMock('../components/settings/UpdateSettings', () => ({ + UpdateSettingsContent: () =>
update-settings
, + })) + vi.doMock('../contexts/AppearanceContext', () => ({ + useAppearance: () => ({ + themePreset: 'default', + setThemePreset, + customThemeId: null, + customThemes: {}, + saveCustomTheme, + setActiveCustomTheme, + backgroundImage: null, + setBackgroundImage, + backgroundImageOpacity: 40, + setBackgroundImageOpacity, + }), + })) + vi.doMock('@arkloop/shared', async () => { + const actual = await vi.importActual('@arkloop/shared') + return { + ...actual, + useToast: () => ({ addToast }), + } + }) + vi.doMock('@arkloop/shared/desktop', () => ({ + getDesktopApi: () => desktopApi, + })) + + const { AdvancedSettings } = await import('../components/settings/AdvancedSettings') + const { LocaleProvider } = await import('../contexts/LocaleContext') + + await act(async () => { + root!.render( + + + , + ) + }) + await flushEffects() + + const dataButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent?.includes('数据')) + expect(dataButton).toBeTruthy() + + await act(async () => { + dataButton!.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + await flushEffects() + + const importButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent?.includes('导入数据')) + expect(importButton).toBeTruthy() + + await act(async () => { + importButton!.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + await flushEffects() + + expect(saveCustomTheme).not.toHaveBeenCalled() + expect(setBackgroundImage).not.toHaveBeenCalled() + expect(setBackgroundImageOpacity).not.toHaveBeenCalled() + expect(setThemePreset).not.toHaveBeenCalled() + expect(setActiveCustomTheme).not.toHaveBeenCalled() + expect(writeGtdEnabled).not.toHaveBeenCalled() }) }) diff --git a/src/apps/web/src/__tests__/appearanceStorage.test.ts b/src/apps/web/src/__tests__/appearanceStorage.test.ts index efbe5c01e..3eea7d2e7 100644 --- a/src/apps/web/src/__tests__/appearanceStorage.test.ts +++ b/src/apps/web/src/__tests__/appearanceStorage.test.ts @@ -1,12 +1,15 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ThemeBackgroundImage } from '../themes/types' import { readBackgroundImageFromStorage, readBackgroundImageOpacityFromStorage, + readGtdEnabled, readThemePresetFromStorage, + subscribeGtdEnabled, writeBackgroundImageToStorage, writeBackgroundImageOpacityToStorage, + writeGtdEnabled, writeThemePresetToStorage, } from '../storage' @@ -105,4 +108,41 @@ describe('appearance storage', () => { writeThemePresetToStorage('background-image') expect(readThemePresetFromStorage()).toBe('background-image') }) + + it('同步 GTD 分组状态变更', () => { + const observed: boolean[] = [] + const unsubscribe = subscribeGtdEnabled((enabled) => observed.push(enabled)) + + writeGtdEnabled(true) + expect(readGtdEnabled()).toBe(true) + expect(observed).toEqual([true]) + + writeGtdEnabled(false) + expect(readGtdEnabled()).toBe(false) + expect(observed).toEqual([true, false]) + + unsubscribe() + }) + + it('storage 写入失败时仍广播 GTD 分组变更', () => { + const storage = createMemoryStorage() + const failingStorage = { + ...storage, + setItem: vi.fn(() => { + throw new Error('quota') + }), + } as Storage + Object.defineProperty(globalThis, 'localStorage', { value: failingStorage, configurable: true }) + Object.defineProperty(window, 'localStorage', { value: failingStorage, configurable: true }) + + const observed: boolean[] = [] + const unsubscribe = subscribeGtdEnabled((enabled) => observed.push(enabled)) + + writeGtdEnabled(true) + + expect(observed).toEqual([true]) + expect(readGtdEnabled()).toBe(false) + + unsubscribe() + }) }) diff --git a/src/apps/web/src/components/Sidebar.tsx b/src/apps/web/src/components/Sidebar.tsx index 9d65cbd9d..be77a475a 100644 --- a/src/apps/web/src/components/Sidebar.tsx +++ b/src/apps/web/src/components/Sidebar.tsx @@ -39,7 +39,7 @@ import { readGtdSomedayThreadIds, writeGtdSomedayThreadIds, readGtdArchivedThreadIds, writeGtdArchivedThreadIds, readPinnedThreadIds, writePinnedThreadIds, - readGtdEnabled, readExpandedProjectPaths, writeExpandedProjectPaths, + readGtdEnabled, readExpandedProjectPaths, subscribeGtdEnabled, writeExpandedProjectPaths, clearThreadWorkFolder, readThreadWorkFolder, writeThreadWorkFolder, clearWorkFolder, writeWorkFolder, } from '../storage' @@ -61,7 +61,6 @@ const PROJECT_GROUP_SECONDARY_PAGE_SIZE = 2 const PROJECT_GROUP_LABEL_WEIGHT = 'var(--c-sidebar-thread-weight)' const SIDEBAR_ROW_TEXT_SIZE = '13.5px' const SIDEBAR_ROW_LINE_HEIGHT = '20px' -const GTD_ENABLED_STORAGE_KEY = 'arkloop:web:gtd_enabled' const DRAG_START_DISTANCE_PX = 3 const DRAG_LONG_PRESS_DELAY_MS = 180 const GTD_BUCKETS: readonly GtdBucket[] = ['inbox', 'todo', 'waiting', 'someday', 'archived'] @@ -1221,20 +1220,7 @@ export const Sidebar = memo(function Sidebar({ }, [deleteConfirmThreadId]) useEffect(() => { - const handler = (e: Event) => { - const enabled = (e as CustomEvent).detail - setGtdEnabled(enabled) - } - const storageHandler = (e: StorageEvent) => { - if (e.key !== GTD_ENABLED_STORAGE_KEY) return - setGtdEnabled(e.newValue === 'true') - } - window.addEventListener('arkloop:gtd-enabled-changed', handler) - window.addEventListener('storage', storageHandler) - return () => { - window.removeEventListener('arkloop:gtd-enabled-changed', handler) - window.removeEventListener('storage', storageHandler) - } + return subscribeGtdEnabled(setGtdEnabled) }, []) // 监听 work folder 变更,触发重新渲染 diff --git a/src/apps/web/src/components/settings/AdvancedSettings.tsx b/src/apps/web/src/components/settings/AdvancedSettings.tsx index 10f5e9eb1..89007a8a4 100644 --- a/src/apps/web/src/components/settings/AdvancedSettings.tsx +++ b/src/apps/web/src/components/settings/AdvancedSettings.tsx @@ -32,7 +32,8 @@ import type { MeDailyUsageItem, MeHourlyUsageItem, MeModelUsageItem, MeUsageSumm import { getMyDailyUsage, getMyHourlyUsage, getMyUsage, getMyUsageByModel } from '../../api' import { useAppearance } from '../../contexts/AppearanceContext' import { useLocale } from '../../contexts/LocaleContext' -import type { ThemeDefinition } from '../../themes/types' +import type { ThemeBackgroundImage, ThemeDefinition, ThemePreset } from '../../themes/types' +import { readGtdEnabled, writeGtdEnabled } from '../../storage' import { SettingsSection } from './_SettingsSection' import { SettingsSectionHeader } from './_SettingsSectionHeader' import { settingsInputCls } from './_SettingsInput' @@ -78,6 +79,7 @@ const DESKTOP_EXPORT_SECTIONS: DesktopExportSection[] = [ const AGENT_IMPORT_ITEM_KEYS = ['identity', 'skills', 'mcp', 'providers'] as const const AGENT_IMPORT_SOURCE_ORDER: ImportSourceKind[] = ['openclaw', 'hermes'] +const THEME_PRESETS: readonly ThemePreset[] = ['default', 'terra', 'github', 'nord', 'catppuccin', 'tokyo-night', 'retina-burn', 'background-image', 'custom'] function createDefaultAgentImportSelection(): Record { return { @@ -95,6 +97,51 @@ function createAgentImportSelections(sources: AgentImportDiscovery[]): AgentImpo }, {}) } +function isThemePreset(value: unknown): value is ThemePreset { + return typeof value === 'string' && THEME_PRESETS.includes(value as ThemePreset) +} + +type ThemeBackgroundImageImport = + | { valid: true; value: ThemeBackgroundImage | null } + | { valid: false } + +function normalizeThemeBackgroundImage(value: unknown): ThemeBackgroundImageImport { + if (value === null) return { valid: true, value: null } + if (!value || typeof value !== 'object') return { valid: false } + const image = value as Record + if ( + typeof image.dataUrl !== 'string' || + typeof image.name !== 'string' || + typeof image.mimeType !== 'string' || + typeof image.size !== 'number' || + typeof image.updatedAt !== 'number' + ) { + return { valid: false } + } + return { + valid: true, + value: { + dataUrl: image.dataUrl, + name: image.name, + mimeType: image.mimeType, + size: image.size, + updatedAt: image.updatedAt, + }, + } +} + +function applySidebarGrouping(value: unknown): void { + if (value !== 'normal' && value !== 'gtd') return + writeGtdEnabled(value === 'gtd') +} + +function collectImportedCustomThemes(value: unknown): ThemeDefinition[] { + if (!value || typeof value !== 'object') return [] + return Object.values(value).filter((item): item is ThemeDefinition => ( + !!item && typeof item === 'object' && 'id' in item && typeof item.id === 'string' + )) +} + function formatUsd(value: number) { return `$${value.toFixed(4)}` } @@ -962,7 +1009,18 @@ function DataPane({ onReloadOverview }: { onReloadOverview: () => Promise const ob = t.onboarding const api = getDesktopApi() const { addToast } = useToast() - const { customThemeId, customThemes, saveCustomTheme, setActiveCustomTheme } = useAppearance() + const { + themePreset, + setThemePreset, + customThemeId, + customThemes, + saveCustomTheme, + setActiveCustomTheme, + backgroundImage, + setBackgroundImage, + backgroundImageOpacity, + setBackgroundImageOpacity, + } = useAppearance() const [actionLoading, setActionLoading] = useState<'choose' | 'export' | 'import' | null>(null) const [actionError, setActionError] = useState('') const [exportDialogOpen, setExportDialogOpen] = useState(false) @@ -1034,8 +1092,12 @@ function DataPane({ onReloadOverview }: { onReloadOverview: () => Promise const result = await api.advanced.exportDataBundle({ sections: selectedSections, themes: { + themePreset, customThemeId, customThemes: selectedSections.includes('themes') ? customThemes : {}, + backgroundImage: selectedSections.includes('themes') ? backgroundImage : null, + backgroundImageOpacity: selectedSections.includes('themes') ? backgroundImageOpacity : null, + sidebarGrouping: selectedSections.includes('themes') ? (readGtdEnabled() ? 'gtd' : 'normal') : null, }, }) if (result.canceled) { @@ -1050,7 +1112,7 @@ function DataPane({ onReloadOverview }: { onReloadOverview: () => Promise } finally { setActionLoading(null) } - }, [api, addToast, customThemeId, customThemes, ds.advancedExportCanceled, ds.advancedExportDone, selectedSections, t.requestFailed]) + }, [api, addToast, backgroundImage, backgroundImageOpacity, customThemeId, customThemes, ds.advancedExportCanceled, ds.advancedExportDone, selectedSections, t.requestFailed, themePreset]) const handleImport = useCallback(async () => { if (!api?.advanced) return @@ -1062,15 +1124,45 @@ function DataPane({ onReloadOverview }: { onReloadOverview: () => Promise addToast(ds.advancedImportCanceled, 'neutral') return } - if (result.themes?.customThemes && typeof result.themes.customThemes === 'object') { - for (const value of Object.values(result.themes.customThemes)) { - if (value && typeof value === 'object' && 'id' in value && typeof value.id === 'string') { - saveCustomTheme(value as ThemeDefinition) - } + const importedThemes = result.themes + const importedCustomThemes = collectImportedCustomThemes(importedThemes?.customThemes) + let hasImportedBackground = false + let importedBackground: ThemeBackgroundImage | null = null + if (importedThemes && 'backgroundImage' in importedThemes) { + const backgroundResult = normalizeThemeBackgroundImage(importedThemes.backgroundImage) + if (!backgroundResult.valid) { + throw new Error(t.requestFailed) } - if (result.themes.customThemeId) { - setActiveCustomTheme(result.themes.customThemeId) + importedBackground = backgroundResult.value + hasImportedBackground = true + } + const importedBackgroundOpacity = typeof importedThemes?.backgroundImageOpacity === 'number' && Number.isFinite(importedThemes.backgroundImageOpacity) + ? importedThemes.backgroundImageOpacity + : null + const importedSidebarGrouping = importedThemes?.sidebarGrouping + const importedThemePreset = importedThemes?.themePreset + const importedCustomThemeId = importedThemes?.customThemeId + + if (hasImportedBackground && !setBackgroundImage(importedBackground)) { + throw new Error(t.requestFailed) + } + for (const theme of importedCustomThemes) { + saveCustomTheme(theme) + } + if (importedBackgroundOpacity !== null) { + setBackgroundImageOpacity(importedBackgroundOpacity) + } + applySidebarGrouping(importedSidebarGrouping) + if (isThemePreset(importedThemePreset)) { + if (importedThemePreset === 'custom' && importedCustomThemeId) { + setActiveCustomTheme(importedCustomThemeId) + } else if (importedThemePreset === 'background-image') { + setThemePreset('background-image') + } else { + setThemePreset(importedThemePreset) } + } else if (importedCustomThemeId) { + setActiveCustomTheme(importedCustomThemeId) } addToast(ds.advancedImportDone, 'success') await onReloadOverview() @@ -1079,7 +1171,7 @@ function DataPane({ onReloadOverview }: { onReloadOverview: () => Promise } finally { setActionLoading(null) } - }, [api, addToast, ds.advancedImportCanceled, ds.advancedImportDone, onReloadOverview, saveCustomTheme, setActiveCustomTheme, t.requestFailed]) + }, [api, addToast, ds.advancedImportCanceled, ds.advancedImportDone, onReloadOverview, saveCustomTheme, setActiveCustomTheme, setBackgroundImage, setBackgroundImageOpacity, setThemePreset, t.requestFailed]) const selectedAgentImportSource = selectedAgentImport ? agentImportSources.find((source) => source.kind === selectedAgentImport) ?? null diff --git a/src/apps/web/src/components/settings/AppearanceSettings.tsx b/src/apps/web/src/components/settings/AppearanceSettings.tsx index 85b1873d1..079526439 100644 --- a/src/apps/web/src/components/settings/AppearanceSettings.tsx +++ b/src/apps/web/src/components/settings/AppearanceSettings.tsx @@ -1,11 +1,11 @@ -import React, { useState } from 'react' +import React, { useEffect, useState } from 'react' import type { LucideIcon } from 'lucide-react' import { Monitor, Sun, Moon } from 'lucide-react' import type { Locale } from '../../locales' import type { Theme } from '@arkloop/shared/contexts/theme' import { useLocale } from '../../contexts/LocaleContext' import { useTheme } from '../../contexts/ThemeContext' -import { readGtdEnabled, writeGtdEnabled } from '../../storage' +import { readGtdEnabled, subscribeGtdEnabled, writeGtdEnabled } from '../../storage' import { FontSettings } from './FontSettings' import { ThemePresetPicker } from './ThemePresetPicker' import { ThemeColorEditor } from './ThemeColorEditor' @@ -267,6 +267,8 @@ export function SidebarGroupingPicker({ showLabel = true }: { showLabel?: boolea const [gtdEnabled, setGtdEnabled] = useState(() => readGtdEnabled()) const [hoveredValue, setHoveredValue] = useState(null) + useEffect(() => subscribeGtdEnabled(setGtdEnabled), []) + const options: { value: boolean; label: string; Preview: () => React.JSX.Element }[] = [ { value: false, label: t.sidebarGroupingNormal, Preview: NormalPreview }, { value: true, label: t.sidebarGroupingGtd, Preview: GtdPreview }, @@ -294,11 +296,6 @@ export function SidebarGroupingPicker({ showLabel = true }: { showLabel?: boolea if (gtdEnabled === value) return setGtdEnabled(value) writeGtdEnabled(value) - window.dispatchEvent(new CustomEvent('arkloop:gtd-enabled-changed', { detail: value })) - window.dispatchEvent(new StorageEvent('storage', { - key: 'arkloop:web:gtd_enabled', - newValue: String(value), - })) }} className="flex flex-col items-center gap-2" > diff --git a/src/apps/web/src/storage.ts b/src/apps/web/src/storage.ts index 171c9f6ac..d28c32818 100644 --- a/src/apps/web/src/storage.ts +++ b/src/apps/web/src/storage.ts @@ -2486,22 +2486,48 @@ export function writeBackgroundImageOpacityToStorage(opacity: number): void { // -- Sidebar View & GTD -- -const GTD_ENABLED_KEY = 'arkloop:web:gtd_enabled' +export const GTD_ENABLED_STORAGE_KEY = 'arkloop:web:gtd_enabled' +const GTD_ENABLED_CHANGED_EVENT = 'arkloop:gtd-enabled-changed' export function readGtdEnabled(): boolean { if (!canUseLocalStorage()) return false try { - return localStorage.getItem(GTD_ENABLED_KEY) === 'true' + return localStorage.getItem(GTD_ENABLED_STORAGE_KEY) === 'true' } catch { return false } } export function writeGtdEnabled(v: boolean): void { - if (!canUseLocalStorage()) return - try { - localStorage.setItem(GTD_ENABLED_KEY, String(v)) - } catch { /* ignore */ } + if (canUseLocalStorage()) { + try { + localStorage.setItem(GTD_ENABLED_STORAGE_KEY, String(v)) + } catch { /* ignore */ } + } + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent(GTD_ENABLED_CHANGED_EVENT, { detail: v })) + } +} + +export function subscribeGtdEnabled(listener: (enabled: boolean) => void): () => void { + if (typeof window === 'undefined') return () => {} + + const customHandler = (event: Event) => { + const enabled = (event as CustomEvent).detail + listener(typeof enabled === 'boolean' ? enabled : readGtdEnabled()) + } + const storageHandler = (event: StorageEvent) => { + if (event.key !== GTD_ENABLED_STORAGE_KEY) return + listener(event.newValue === 'true') + } + + window.addEventListener(GTD_ENABLED_CHANGED_EVENT, customHandler) + window.addEventListener('storage', storageHandler) + + return () => { + window.removeEventListener(GTD_ENABLED_CHANGED_EVENT, customHandler) + window.removeEventListener('storage', storageHandler) + } } const GTD_INBOX_THREAD_IDS_KEY = 'arkloop:web:gtd_inbox_thread_ids'