Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/apps/desktop/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,18 @@ type DesktopExportSection =
| 'themes'

type DesktopThemeExportPayload = {
themePreset?: string | null
customThemeId: string | null
customThemes: Record<string, unknown>
backgroundImage?: {
dataUrl: string
name: string
mimeType: string
size: number
updatedAt: number
} | null
backgroundImageOpacity?: number | null
sidebarGrouping?: 'normal' | 'gtd' | null
}

type DesktopExportOptions = {
Expand Down
10 changes: 10 additions & 0 deletions src/apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,8 +229,18 @@ export type DesktopExportSection =
| 'themes'

export type DesktopThemeExportPayload = {
themePreset?: string | null
customThemeId: string | null
customThemes: Record<string, unknown>
backgroundImage?: {
dataUrl: string
name: string
mimeType: string
size: number
updatedAt: number
} | null
backgroundImageOpacity?: number | null
sidebarGrouping?: 'normal' | 'gtd' | null
}

export type DesktopExportOptions = {
Expand Down
10 changes: 10 additions & 0 deletions src/apps/shared/src/desktop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,8 +362,18 @@ export type DesktopExportSection =
| 'themes'

export type DesktopThemeExportPayload = {
themePreset?: string | null
customThemeId: string | null
customThemes: Record<string, unknown>
backgroundImage?: {
dataUrl: string
name: string
mimeType: string
size: number
updatedAt: number
} | null
backgroundImageOpacity?: number | null
sidebarGrouping?: 'normal' | 'gtd' | null
}

export type DesktopExportOptions = {
Expand Down
190 changes: 190 additions & 0 deletions src/apps/web/src/__tests__/advancedSettings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ describe('AdvancedSettings', () => {
return {
...actual,
readLocaleFromStorage: vi.fn(() => 'zh'),
readGtdEnabled: vi.fn(() => false),
writeLocaleToStorage: vi.fn(),
}
})
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -228,6 +235,7 @@ describe('AdvancedSettings', () => {
return {
...actual,
readLocaleFromStorage: vi.fn(() => 'zh'),
readGtdEnabled: vi.fn(() => true),
writeLocaleToStorage: vi.fn(),
}
})
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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<typeof import('../storage')>('../storage')
return {
...actual,
readLocaleFromStorage: vi.fn(() => 'zh'),
readGtdEnabled: vi.fn(() => false),
writeGtdEnabled,
writeLocaleToStorage: vi.fn(),
}
})
vi.doMock('../api', async () => {
const actual = await vi.importActual<typeof import('../api')>('../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: () => <div>connection-settings</div>,
}))
vi.doMock('../components/settings/ModulesSettings', () => ({
ModulesSettings: () => <div>modules-settings</div>,
}))
vi.doMock('../components/settings/ExtensionsSettings', () => ({
ExtensionsSettings: () => <div>extensions-settings</div>,
}))
vi.doMock('../components/settings/UpdateSettings', () => ({
UpdateSettingsContent: () => <div>update-settings</div>,
}))
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<typeof import('@arkloop/shared')>('@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(
<LocaleProvider>
<AdvancedSettings accessToken="token" />
</LocaleProvider>,
)
})
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()
})
})
42 changes: 41 additions & 1 deletion src/apps/web/src/__tests__/appearanceStorage.test.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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()
})
})
18 changes: 2 additions & 16 deletions src/apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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']
Expand Down Expand Up @@ -1221,20 +1220,7 @@ export const Sidebar = memo(function Sidebar({
}, [deleteConfirmThreadId])

useEffect(() => {
const handler = (e: Event) => {
const enabled = (e as CustomEvent<boolean>).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 变更,触发重新渲染
Expand Down
Loading
Loading