From 110f1583438748dfa474b037d4deea40cae7cd4c Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Wed, 23 Sep 2026 08:18:05 +0900 Subject: [PATCH 1/6] fix: fall back to default icon colors when resolveIconColors fails An outdated service worker can answer resolveIconColors with null, which left the menu stuck in the loading state. Render unanswered icons with the default colors instead, while still leaving them uncached so the next menu retries. Co-Authored-By: Claude Opus 5.5 --- .../hooks/useSettingsWithImageCache.test.tsx | 36 +++++++++++++++++-- .../src/hooks/useSettingsWithImageCache.ts | 16 +++++++-- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/packages/extension/src/hooks/useSettingsWithImageCache.test.tsx b/packages/extension/src/hooks/useSettingsWithImageCache.test.tsx index 3d1fbd20..206d239b 100644 --- a/packages/extension/src/hooks/useSettingsWithImageCache.test.tsx +++ b/packages/extension/src/hooks/useSettingsWithImageCache.test.tsx @@ -298,9 +298,12 @@ describe("useSettingsWithImageCache", () => { mockIpcSend.mockResolvedValueOnce([]).mockResolvedValueOnce([false]) const firstRender = renderHook(() => useSettingsWithImageCache()) - await waitFor(() => expect(mockIpcSend).toHaveBeenCalledTimes(1)) - expect(firstRender.result.current.loading).toBe(true) - expect(firstRender.result.current.commands).toEqual([]) + await waitFor(() => expect(firstRender.result.current.loading).toBe(false)) + expect(mockIpcSend).toHaveBeenCalledTimes(1) + // Falls back to the default colors instead of blocking the menu. + expect(firstRender.result.current.commands).toEqual([ + { ...command, preserveOriginalColor: false }, + ]) firstRender.unmount() const secondRender = renderHook(() => useSettingsWithImageCache()) @@ -312,6 +315,33 @@ describe("useSettingsWithImageCache", () => { ]) }) + it("US-30-a: should fall back to the default colors when IPC returns null", async () => { + const command = { + id: "null-response", + openMode: OPEN_MODE.POPUP, + title: "Null response", + iconUrl: "http://null.example.com/icon.png", + } + const folder = { + id: "null-folder", + title: "Null folder", + iconUrl: "http://null.example.com/folder.png", + } + + mockSections({ folders: [folder] }, [command as Command]) + mockIpcSend.mockResolvedValueOnce(null as any) + + const { result } = renderHook(() => useSettingsWithImageCache()) + await waitFor(() => expect(result.current.loading).toBe(false)) + + expect(result.current.commands).toEqual([ + { ...command, preserveOriginalColor: false }, + ]) + expect(result.current.folders).toEqual([ + { ...folder, preserveOriginalColor: false }, + ]) + }) + it("US-31: should use the AI service URL for AI Prompt icon preservation", async () => { const chatgptUrl = getAiServicesFallback().find( (s) => s.id === "chatgpt", diff --git a/packages/extension/src/hooks/useSettingsWithImageCache.ts b/packages/extension/src/hooks/useSettingsWithImageCache.ts index cc8c7777..80a405a9 100644 --- a/packages/extension/src/hooks/useSettingsWithImageCache.ts +++ b/packages/extension/src/hooks/useSettingsWithImageCache.ts @@ -74,9 +74,16 @@ const getIconTargetUrl = (command: Command) => /** * Resolves every query, asking the service worker only about the ones it has * not answered yet. Returns null until the answers for `queries` are in. + * + * When the service worker fails to answer (e.g. an outdated worker that does + * not know the command), the unanswered queries fall back to `false` so the + * menu still renders with the default (recolored) icons. */ function usePreservedIconColors(queries: IconColorQuery[]): boolean[] | null { const [, setResolvedVersion] = useState(0) + const [failedQueries, setFailedQueries] = useState( + null, + ) useEffect(() => { let active = true @@ -89,6 +96,9 @@ function usePreservedIconColors(queries: IconColorQuery[]): boolean[] | null { BgCommand.resolveIconColors, unknown, ) + if (!Array.isArray(results)) { + throw new Error(`Unexpected icon color response: ${results}`) + } if (results.length !== unknown.length) { throw new Error( `Unexpected icon color response length: expected ${unknown.length}, got ${results.length}`, @@ -103,6 +113,8 @@ function usePreservedIconColors(queries: IconColorQuery[]): boolean[] | null { } if (!active) return if (queries.some((q) => !answerCache.has(queryKey(q)))) { + // Render with the default colors rather than blocking the menu. + setFailedQueries(queries) return } setResolvedVersion((version) => version + 1) @@ -115,10 +127,10 @@ function usePreservedIconColors(queries: IconColorQuery[]): boolean[] | null { }, [queries]) if (queries.some((q) => !answerCache.has(queryKey(q)))) { - return null + if (failedQueries !== queries) return null } - return queries.map((q) => answerCache.get(queryKey(q)) as boolean) + return queries.map((q) => answerCache.get(queryKey(q)) ?? false) } /** From 345a16da4e52e153711c5b9ab5332d48c9f1b20c Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Wed, 23 Sep 2026 08:50:26 +0900 Subject: [PATCH 2/6] refactor: split ImportExport into service, hook and view - Move React-independent import/export/backup logic to services/settings/importExport.ts - Add useImportExport hook that only holds state and wires service calls - Keep ImportExport.tsx focused on rendering - Add unit tests for the importExport service Co-Authored-By: Claude Opus 5.5 --- .../src/components/option/ImportExport.tsx | 530 +++-------------- .../src/hooks/option/useImportExport.ts | 77 +++ .../services/settings/importExport.test.ts | 538 ++++++++++++++++++ .../src/services/settings/importExport.ts | 266 +++++++++ 4 files changed, 975 insertions(+), 436 deletions(-) create mode 100644 packages/extension/src/hooks/option/useImportExport.ts create mode 100644 packages/extension/src/services/settings/importExport.test.ts create mode 100644 packages/extension/src/services/settings/importExport.ts diff --git a/packages/extension/src/components/option/ImportExport.tsx b/packages/extension/src/components/option/ImportExport.tsx index 3b9b218a..1eb478a9 100644 --- a/packages/extension/src/components/option/ImportExport.tsx +++ b/packages/extension/src/components/option/ImportExport.tsx @@ -1,360 +1,65 @@ -import { useState, useRef, useEffect } from "react" +import { useState } from "react" import { Dialog } from "./Dialog" -import type { UserSettings, Caches, ShortcutSettings } from "@/types" - -import { Storage, STORAGE_KEY, LOCAL_STORAGE_KEY } from "@/services/storage" -import { - DailyBackupManager, - WeeklyBackupManager, - LegacyBackupManager, -} from "@/services/storage/backupManager" -import { Settings, migrate } from "@/services/settings/settings" -import { enhancedSettings } from "@/services/settings/enhancedSettings" -import { CACHE_SECTIONS } from "@/services/settings/settingsCache" -import { isBase64, isUrl } from "@/lib/utils" -import { APP_ID } from "@/const" import { t } from "@/services/i18n" import { Download, Upload, Undo2, RotateCcw } from "lucide-react" import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" -import type { BackupData } from "@/services/storage/backupManager" import { TEST_IDS } from "@/testIds" +import { useImportExport } from "@/hooks/option/useImportExport" +import { + BACKUP_TYPES, + isCheckingBackups, + hasAvailableBackup, + getAvailableBackups, +} from "@/services/settings/importExport" +import type { BackupType } from "@/services/settings/importExport" import css from "./Option.module.css" -// Backup type constants -const BACKUP_TYPES = { - LEGACY: "legacy", - DAILY: "daily", - WEEKLY: "weekly", -} as const - -// Backup status constants -const BACKUP_STATUS = { - CHECKING: "checking", - AVAILABLE: "available", - NONE: "none", -} as const - -type BackupType = (typeof BACKUP_TYPES)[keyof typeof BACKUP_TYPES] -type BackupStatus = (typeof BACKUP_STATUS)[keyof typeof BACKUP_STATUS] - -function getTimestamp() { - const date = new Date() - const year = date.getFullYear() - const month = (date.getMonth() + 1).toString().padStart(2, "0") - const day = date.getDate().toString().padStart(2, "0") - const hours = date.getHours().toString().padStart(2, "0") - const minutes = date.getMinutes().toString().padStart(2, "0") - return `${year}${month}${day}_${hours}${minutes}` +const getBackupTypeLabel = (type: BackupType) => { + switch (type) { + case BACKUP_TYPES.LEGACY: + return t("Option_RestoreFromBackup_legacy") + case BACKUP_TYPES.DAILY: + return t("Option_RestoreFromBackup_daily") + case BACKUP_TYPES.WEEKLY: + return t("Option_RestoreFromBackup_weekly") + } } export function ImportExport() { const [resetDialog, setResetDialog] = useState(false) const [importDialog, setImportDialog] = useState(false) const [restoreDialog, setRestoreDialog] = useState(false) - const [importJson, setImportJson] = useState() - const [backupData, setBackupData] = useState<{ - [BACKUP_TYPES.LEGACY]: { - status: BackupStatus - info: { - timestamp: number - commandCount: number - folderCount?: number - } | null - } - [BACKUP_TYPES.DAILY]: { - status: BackupStatus - info: { - timestamp: number - commandCount: number - folderCount?: number - } | null - } - [BACKUP_TYPES.WEEKLY]: { - status: BackupStatus - info: { - timestamp: number - commandCount: number - folderCount?: number - } | null - } - }>({ - [BACKUP_TYPES.LEGACY]: { status: BACKUP_STATUS.CHECKING, info: null }, - [BACKUP_TYPES.DAILY]: { status: BACKUP_STATUS.CHECKING, info: null }, - [BACKUP_TYPES.WEEKLY]: { status: BACKUP_STATUS.CHECKING, info: null }, - }) - const [selectedBackupType, setSelectedBackupType] = useState( - BACKUP_TYPES.LEGACY, - ) - const inputFile = useRef(null) - - // Check backup status on initialization - useEffect(() => { - checkBackupStatus() - }, []) - - const checkBackupStatus = async () => { - try { - const newBackupData = { ...backupData } - - // 1. Check legacy backup - const legacyBackup = await Storage.get( - LOCAL_STORAGE_KEY.COMMANDS_BACKUP, - ) - - if ( - legacyBackup && - legacyBackup.commands && - Array.isArray(legacyBackup.commands) - ) { - newBackupData[BACKUP_TYPES.LEGACY] = { - status: BACKUP_STATUS.AVAILABLE, - info: { - timestamp: legacyBackup.timestamp, - commandCount: legacyBackup.commands.length, - folderCount: Array.isArray(legacyBackup.folders) - ? legacyBackup.folders.length - : 0, - }, - } - } else { - newBackupData[BACKUP_TYPES.LEGACY] = { - status: BACKUP_STATUS.NONE, - info: null, - } - } - - // 2. Check daily backup - const dailyBackupManager = new DailyBackupManager() - const dailyBackup = await dailyBackupManager.getLastBackupData() - - if ( - dailyBackup && - dailyBackup.commands && - Array.isArray(dailyBackup.commands) - ) { - newBackupData[BACKUP_TYPES.DAILY] = { - status: BACKUP_STATUS.AVAILABLE, - info: { - timestamp: dailyBackup.timestamp, - commandCount: dailyBackup.commands.length, - folderCount: Array.isArray(dailyBackup.folders) - ? dailyBackup.folders.length - : 0, - }, - } - } else { - newBackupData[BACKUP_TYPES.DAILY] = { - status: BACKUP_STATUS.NONE, - info: null, - } - } - - // 3. Check weekly backup - const weeklyBackupManager = new WeeklyBackupManager() - const weeklyBackup = await weeklyBackupManager.getLastBackupData() - - if ( - weeklyBackup && - weeklyBackup.commands && - Array.isArray(weeklyBackup.commands) - ) { - newBackupData[BACKUP_TYPES.WEEKLY] = { - status: BACKUP_STATUS.AVAILABLE, - info: { - timestamp: weeklyBackup.timestamp, - commandCount: weeklyBackup.commands.length, - folderCount: Array.isArray(weeklyBackup.folders) - ? weeklyBackup.folders.length - : 0, - }, - } - } else { - newBackupData[BACKUP_TYPES.WEEKLY] = { - status: BACKUP_STATUS.NONE, - info: null, - } - } - - setBackupData(newBackupData) - - // Set default selection to first available backup - if ( - newBackupData[BACKUP_TYPES.LEGACY].status === BACKUP_STATUS.AVAILABLE - ) { - setSelectedBackupType(BACKUP_TYPES.LEGACY) - } else if ( - newBackupData[BACKUP_TYPES.DAILY].status === BACKUP_STATUS.AVAILABLE - ) { - setSelectedBackupType(BACKUP_TYPES.DAILY) - } else if ( - newBackupData[BACKUP_TYPES.WEEKLY].status === BACKUP_STATUS.AVAILABLE - ) { - setSelectedBackupType(BACKUP_TYPES.WEEKLY) - } - } catch (error) { - console.error("Failed to check backup status:", error) - setBackupData({ - [BACKUP_TYPES.LEGACY]: { status: BACKUP_STATUS.NONE, info: null }, - [BACKUP_TYPES.DAILY]: { status: BACKUP_STATUS.NONE, info: null }, - [BACKUP_TYPES.WEEKLY]: { status: BACKUP_STATUS.NONE, info: null }, - }) - } - } - - const handleReset = () => { - setResetDialog(true) - } + const { + importJson, + backupStatus, + selectedBackupType, + setSelectedBackupType, + reset, + exportSettings, + selectImportFile, + runImport, + restore, + } = useImportExport() + const availableBackups = getAvailableBackups(backupStatus) const handleResetClose = (ret: boolean) => { - if (ret) { - Settings.reset().then(() => location.reload()) - } + if (ret) reset() setResetDialog(false) } - const handleExport = async () => { - const data = await Storage.get(STORAGE_KEY.USER) - data.commands = await Storage.getCommands() - data.shortcuts = await Storage.get(STORAGE_KEY.SHORTCUTS) - - // for back compatibility - // cache key to image data url - const caches = (await enhancedSettings.getSection( - CACHE_SECTIONS.CACHES, - true, - )) as Caches - for (const c of data.commands) { - if (!c.iconUrl) continue - if (isBase64(c.iconUrl) || isUrl(c.iconUrl)) continue - if (caches?.images?.[c.iconUrl]) { - c.iconUrl = caches.images[c.iconUrl] - } - } - - const text = JSON.stringify(data, null, 2) - const blob = new Blob([text], { type: "text/plain" }) - const url = URL.createObjectURL(blob) - const a = document.createElement("a") - document.body.appendChild(a) - a.download = `${APP_ID}_${getTimestamp()}.json` - a.href = url - a.click() - a.remove() - URL.revokeObjectURL(url) - } - - const handleImport = () => { - if (inputFile == null || inputFile.current == null) return - const files = inputFile.current.files - if (files == null) return - for (let i = 0; i < files.length; i++) { - const file = files[i] - const reader = new FileReader() - reader.onload = (e) => { - if (e.target != null) { - const text = e.target.result as string - const json = JSON.parse(text) - setImportJson(json) - } - } - reader.readAsText(file) - } + const handleImportFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) selectImportFile(file) } const handleImportClose = (ret: boolean) => { - if (ret && importJson != null) { - ;(async () => { - const { - commandExecutionCount = 0, - hasShownReviewRequest = false, - hasDismissedPromptHistoryBanner = false, - hasShownHubShareToast = false, - hasShownOnboarding = false, - } = await enhancedSettings.get() - const data = await migrate({ - ...importJson, - commandExecutionCount, - hasShownReviewRequest, - hasDismissedPromptHistoryBanner, - hasShownHubShareToast, - hasShownOnboarding, - stars: [], - }) - await Settings.set(data) - location.reload() - })() - } + if (ret) runImport() setImportDialog(false) } - const handleRestore = async () => { - setRestoreDialog(true) - } - const handleRestoreClose = (ret: boolean) => { - if (ret) { - ;(async () => { - try { - let backupCommands: any[] = [] - - if (selectedBackupType === BACKUP_TYPES.LEGACY) { - // Restore from legacy backup - const manager = new LegacyBackupManager() - const data = await manager.restoreFromBackup() - backupCommands = data.commands - - if (data.folders && data.folders.length > 0) { - // Restore folders to settings - const currentSettings = await enhancedSettings.get() - await Settings.set({ - ...currentSettings, - folders: data.folders, - }) - } - } else if (selectedBackupType === BACKUP_TYPES.DAILY) { - // Restore from daily backup - const dailyBackupManager = new DailyBackupManager() - const dailyData = await dailyBackupManager.restoreFromBackup() - backupCommands = dailyData.commands - - if (dailyData.folders && dailyData.folders.length > 0) { - // Restore folders to settings - const currentSettings = await enhancedSettings.get() - await Settings.set({ - ...currentSettings, - folders: dailyData.folders, - }) - } - } else if (selectedBackupType === BACKUP_TYPES.WEEKLY) { - // Restore from weekly backup - const weeklyBackupManager = new WeeklyBackupManager() - const weeklyData = await weeklyBackupManager.restoreFromBackup() - backupCommands = weeklyData.commands - - if (weeklyData.folders && weeklyData.folders.length > 0) { - // Restore folders to settings - const currentSettings = await enhancedSettings.get() - await Settings.set({ - ...currentSettings, - folders: weeklyData.folders, - }) - } - } - - if (backupCommands.length > 0) { - // Save restored commands - await Storage.setCommands(backupCommands) - location.reload() - } else { - alert(t("Option_RestoreFromBackup_failed")) - } - } catch (error) { - console.error("Failed to restore from backup:", error) - alert("Failed to restore from backup.") - } - })() - } + if (ret) restore() setRestoreDialog(false) } @@ -376,7 +81,7 @@ export function ImportExport() { {t("Option_Import")}