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
530 changes: 94 additions & 436 deletions packages/extension/src/components/option/ImportExport.tsx

Large diffs are not rendered by default.

82 changes: 82 additions & 0 deletions packages/extension/src/hooks/option/useImportExport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import { renderHook, waitFor, act } from "@testing-library/react"
import { useImportExport } from "./useImportExport"
import {
checkBackupStatus,
readSettingsFile,
INITIAL_BACKUP_STATUS,
} from "@/services/settings/importExport"

vi.mock("@/services/settings/importExport", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/services/settings/importExport")>()
return {
...actual,
checkBackupStatus: vi.fn(),
readSettingsFile: vi.fn(),
}
})

const mockCheckBackupStatus = vi.mocked(checkBackupStatus)
const mockReadSettingsFile = vi.mocked(readSettingsFile)

describe("useImportExport", () => {
let alertSpy: ReturnType<typeof vi.spyOn>
let consoleSpy: ReturnType<typeof vi.spyOn>

beforeEach(() => {
vi.clearAllMocks()
mockCheckBackupStatus.mockResolvedValue(INITIAL_BACKUP_STATUS)
alertSpy = vi.spyOn(window, "alert").mockImplementation(() => {})
consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {})
})

afterEach(() => {
alertSpy.mockRestore()
consoleSpy.mockRestore()
})

const renderImportExport = async () => {
const hook = renderHook(() => useImportExport())
await waitFor(() => expect(mockCheckBackupStatus).toHaveBeenCalled())
return hook
}

describe("selectImportFile", () => {
it("UIE-01: should set importJson when the file is valid", async () => {
const json = { folders: [] } as any
mockReadSettingsFile.mockResolvedValue(json)
const { result } = await renderImportExport()

await act(() => result.current.selectImportFile(new File([""], "a.json")))

expect(result.current.importJson).toBe(json)
expect(alertSpy).not.toHaveBeenCalled()
})

it("UIE-02: should alert and not throw when the file is invalid", async () => {
mockReadSettingsFile.mockRejectedValue(new SyntaxError("invalid json"))
const { result } = await renderImportExport()

await expect(
act(() => result.current.selectImportFile(new File([""], "a.json"))),
).resolves.not.toThrow()

expect(result.current.importJson).toBeUndefined()
expect(alertSpy).toHaveBeenCalledWith("Failed to read settings file.")
})

it("UIE-03: should clear the previous file when an invalid file is selected", async () => {
mockReadSettingsFile
.mockResolvedValueOnce({ folders: [] } as any)
.mockRejectedValueOnce(new SyntaxError("invalid json"))
const { result } = await renderImportExport()

await act(() => result.current.selectImportFile(new File([""], "a.json")))
expect(result.current.importJson).toBeDefined()

await act(() => result.current.selectImportFile(new File([""], "b.json")))
expect(result.current.importJson).toBeUndefined()
})
})
})
84 changes: 84 additions & 0 deletions packages/extension/src/hooks/option/useImportExport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { useState, useEffect } from "react"
import type { UserSettings } from "@/types"
import { t } from "@/services/i18n"
import {
BACKUP_TYPES,
INITIAL_BACKUP_STATUS,
checkBackupStatus,
getDefaultBackupType,
resetSettings,
exportSettings,
readSettingsFile,
importSettings,
restoreFromBackup,
} from "@/services/settings/importExport"
import type {
BackupType,
BackupStatusMap,
} from "@/services/settings/importExport"

export function useImportExport() {
const [importJson, setImportJson] = useState<UserSettings>()
const [backupStatus, setBackupStatus] = useState<BackupStatusMap>(
INITIAL_BACKUP_STATUS,
)
const [selectedBackupType, setSelectedBackupType] = useState<BackupType>(
BACKUP_TYPES.LEGACY,
)

// Check backup status on initialization
useEffect(() => {
checkBackupStatus().then((statusMap) => {
setBackupStatus(statusMap)
const defaultType = getDefaultBackupType(statusMap)
if (defaultType) setSelectedBackupType(defaultType)
})
}, [])

const reset = async () => {
await resetSettings()
location.reload()
}

const selectImportFile = async (file: File) => {
try {
setImportJson(await readSettingsFile(file))
} catch (error) {
// Clear the previously selected file so that it is not imported by mistake.
setImportJson(undefined)
console.error("Failed to read settings file:", error)
alert("Failed to read settings file.")
}
}

const runImport = async () => {
if (importJson == null) return
await importSettings(importJson)
location.reload()
}

const restore = async () => {
try {
if (await restoreFromBackup(selectedBackupType)) {
location.reload()
} else {
alert(t("Option_RestoreFromBackup_failed"))
}
} catch (error) {
console.error("Failed to restore from backup:", error)
alert("Failed to restore from backup.")
}
}

return {
importJson,
backupStatus,
selectedBackupType,
setSelectedBackupType,
reset,
exportSettings,
selectImportFile,
runImport,
restore,
}
}
36 changes: 33 additions & 3 deletions packages/extension/src/hooks/useSettingsWithImageCache.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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",
Expand Down
16 changes: 14 additions & 2 deletions packages/extension/src/hooks/useSettingsWithImageCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<IconColorQuery[] | null>(
null,
)

useEffect(() => {
let active = true
Expand All @@ -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}`,
Expand All @@ -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)
Expand All @@ -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)
}

/**
Expand Down
Loading
Loading