diff --git a/packages/extension/package.json b/packages/extension/package.json index f5d31567..8e3fa722 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -48,7 +48,7 @@ "embla-carousel-react": "^8.6.0", "get-xpath": "^3.3.0", "lottie-web": "^5.12.2", - "lucide-react": "^0.483.0", + "lucide-react": "^1.16.0", "platform": "^1.3.6", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/packages/extension/src/components/option/ShareButton.tsx b/packages/extension/src/components/option/ShareButton.tsx index 54d201e7..d9da856d 100644 --- a/packages/extension/src/components/option/ShareButton.tsx +++ b/packages/extension/src/components/option/ShareButton.tsx @@ -1,5 +1,5 @@ import { useState, useRef } from "react" -import { Share } from "lucide-react" +import { Share, CloudCheck } from "lucide-react" import { Tooltip } from "@/components/Tooltip" import { cn, isUUIDv7, generateId } from "@/lib/utils" import { t } from "@/services/i18n" @@ -39,7 +39,7 @@ export const ShareButton = ({ if (isShared) { // Open the hub dashboard page for the shared command const locale = getHubLocale() - const url = `${NEW_HUB_URL}/${locale}/dashboard/commands?id=${encodeURIComponent(command.id)}` + const url = `${NEW_HUB_URL}/${locale}/dashboard/mycommands?id=${encodeURIComponent(command.id)}` chrome.tabs.create({ url }) return } @@ -71,19 +71,27 @@ export const ShareButton = ({ className={cn( "outline-gray-200 p-2 rounded-md transition hover:bg-green-100 hover:scale-125 group/share-btn", "disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none", - isShared && "bg-green-50", )} onClick={handleClick} ref={buttonRef} > - + {!isShared ? ( + + ) : ( + + )} { const sharedCommandIds = useSharedCommandIds() - const { getValues, setValue } = useFormContext() - const commandArray = useFieldArray({ name: "commands", control: control, @@ -306,22 +300,9 @@ export const CommandList = ({ control }: CommandListProps) => { } const handleUpdateCommandId = (commandId: string, newId: string) => { - // Update command ID - const idx = commandArray.fields.findIndex((f) => f.id === commandId) - if (idx >= 0) { - commandArray.update(idx, { ...commandArray.fields[idx], id: newId }) - } - // Update any shortcuts that reference this command ID - const currentShortcuts: ShortcutCommand[] = - getValues("shortcuts.shortcuts") ?? [] - if (currentShortcuts.some((s) => s.commandId === commandId)) { - setValue( - "shortcuts.shortcuts", - currentShortcuts.map((s) => - s.commandId === commandId ? { ...s, commandId: newId } : s, - ), - ) - } + Settings.updateCommandId(commandId, newId).catch((err) => { + console.error("[handleUpdateCommandId] Failed to update command ID:", err) + }) } const commandRemove = (idx: number) => { diff --git a/packages/extension/src/components/option/editor/ShortcutList.tsx b/packages/extension/src/components/option/editor/ShortcutList.tsx index 438c8623..2fef9bb0 100644 --- a/packages/extension/src/components/option/editor/ShortcutList.tsx +++ b/packages/extension/src/components/option/editor/ShortcutList.tsx @@ -1,10 +1,5 @@ import { useEffect, useState, useMemo } from "react" -import { - Control, - useFieldArray, - useWatch, - useFormContext, -} from "react-hook-form" +import { useFieldArray, useWatch, useFormContext } from "react-hook-form" import { Keyboard, SquareArrowOutUpRight } from "lucide-react" import { SelectField } from "@/components/option/field/SelectField" import type { SelectOptionType } from "@/components/option/field/SelectField" @@ -30,7 +25,7 @@ import { INSERT, toInsertTemplate } from "@/services/pageAction" const t = (key: string, p?: string[]) => _t(`Option_${key}`, p) type ShortcutListProps = { - control: Control + control: any } const isTextSelectionOnly = (command: Command) => { diff --git a/packages/extension/src/hooks/option/useSharedCommandIds.ts b/packages/extension/src/hooks/option/useSharedCommandIds.ts index 15a6e43e..c6725dbc 100644 --- a/packages/extension/src/hooks/option/useSharedCommandIds.ts +++ b/packages/extension/src/hooks/option/useSharedCommandIds.ts @@ -13,8 +13,10 @@ export function useSharedCommandIds(): Set { useEffect(() => { let cancelled = false + let currentHubUser: HubUser | null = null const fetchIds = async (hubUser: HubUser | null) => { + currentHubUser = hubUser if (cancelled) return if (!hubUser) { setSharedIds(new Set()) @@ -35,9 +37,16 @@ export function useSharedCommandIds(): Set { (newVal) => fetchIds(newVal), ) + // Re-fetch after a command is successfully shared to the hub + const unsubscribeSharedAt = Storage.addListener( + LOCAL_STORAGE_KEY.HUB_SHARED_AT, + () => fetchIds(currentHubUser), + ) + return () => { cancelled = true unsubscribe() + unsubscribeSharedAt() } }, []) diff --git a/packages/extension/src/services/hub/background.test.ts b/packages/extension/src/services/hub/background.test.ts index a2367bc3..be8e6da1 100644 --- a/packages/extension/src/services/hub/background.test.ts +++ b/packages/extension/src/services/hub/background.test.ts @@ -23,11 +23,11 @@ vi.mock("@/services/storage", () => ({ set: vi.fn(), updateCommands: vi.fn(), }, - LOCAL_STORAGE_KEY: { HUB_USER: "hubUser" }, + LOCAL_STORAGE_KEY: { HUB_USER: "hubUser", HUB_SHARED_AT: "hubSharedAt" }, })) vi.mock("@/services/settings/settings", () => ({ - Settings: { addCommands: vi.fn() }, + Settings: { addCommands: vi.fn(), updateCommandId: vi.fn() }, })) vi.mock("@/services/analytics", () => ({ @@ -120,6 +120,7 @@ beforeEach(() => { resetEditSession() vi.mocked(Storage.updateCommands).mockResolvedValue(true) vi.mocked(Settings.addCommands).mockResolvedValue(true) + vi.mocked(Settings.updateCommandId).mockResolvedValue(undefined) vi.mocked(Storage.setCommands).mockResolvedValue(true) vi.mocked(Storage.set).mockResolvedValue(true) vi.mocked(sendEvent).mockResolvedValue(undefined as any) @@ -1003,7 +1004,7 @@ describe("shareCommandToHub", () => { expect(mockPort.postMessage).not.toHaveBeenCalled() }) - it("SH-07: valid port sends share-command and removes listener on ack", async () => { + it("SH-07: valid port sends share-command; ack stops timer but keeps listener", async () => { vi.useFakeTimers() vi.mocked(chrome.tabs.create).mockImplementation((_opts, cb) => { cb?.({ id: 42 } as chrome.tabs.Tab) @@ -1040,7 +1041,153 @@ describe("shareCommandToHub", () => { }) expect(chrome.runtime.onConnectExternal.removeListener).toHaveBeenCalled() + // ack stops the retry timer but keeps the message listener alive capturedOnMessage?.({ type: "share-command-ack" }) + expect(mockPort.onMessage.removeListener).not.toHaveBeenCalled() + + vi.useRealTimers() + }) + + it("SH-08: share-command-submitted updates HUB_SHARED_AT and removes listener", async () => { + vi.useFakeTimers() + vi.mocked(chrome.tabs.create).mockImplementation((_opts, cb) => { + cb?.({ id: 42 } as chrome.tabs.Tab) + return Promise.resolve({ id: 42 } as chrome.tabs.Tab) + }) + const response = vi.fn() + shareCommandToHub(param, sender, response) + + await Promise.resolve() + + const portConnectListener = vi.mocked( + chrome.runtime.onConnectExternal.addListener, + ).mock.calls[0][0] + + let capturedOnMessage: ((msg: unknown) => void) | undefined + const mockPort = { + name: "hub-share", + sender: { tab: { id: 42 } }, + postMessage: vi.fn(), + onMessage: { + addListener: vi.fn((fn) => { + capturedOnMessage = fn + }), + removeListener: vi.fn(), + }, + } + portConnectListener(mockPort as any) + + vi.advanceTimersByTime(100) + + capturedOnMessage?.({ type: "share-command-ack" }) + expect(mockPort.onMessage.removeListener).not.toHaveBeenCalled() + expect(Storage.set).not.toHaveBeenCalledWith( + LOCAL_STORAGE_KEY.HUB_SHARED_AT, + expect.any(Number), + ) + + capturedOnMessage?.({ type: "share-command-submitted", commandId: "cmd-1" }) + expect(mockPort.onMessage.removeListener).toHaveBeenCalled() + await vi.waitFor(() => + expect(Storage.set).toHaveBeenCalledWith( + LOCAL_STORAGE_KEY.HUB_SHARED_AT, + expect.any(Number), + ), + ) + + vi.useRealTimers() + }) + + it("SH-09: DUPLICATE_COMMAND_ID awaits updateCommandId before re-sending", async () => { + vi.useFakeTimers() + vi.mocked(chrome.tabs.create).mockImplementation((_opts, cb) => { + cb?.({ id: 42 } as chrome.tabs.Tab) + return Promise.resolve({ id: 42 } as chrome.tabs.Tab) + }) + const response = vi.fn() + shareCommandToHub(param, sender, response) + + await Promise.resolve() + + const portConnectListener = vi.mocked( + chrome.runtime.onConnectExternal.addListener, + ).mock.calls[0][0] + + let capturedOnMessage: ((msg: unknown) => Promise) | undefined + const mockPort = { + name: "hub-share", + sender: { tab: { id: 42 } }, + postMessage: vi.fn(), + onMessage: { + addListener: vi.fn((fn) => { + capturedOnMessage = fn + }), + removeListener: vi.fn(), + }, + } + portConnectListener(mockPort as any) + vi.advanceTimersByTime(100) + + // Simulate DUPLICATE_COMMAND_ID ack + await capturedOnMessage?.({ + type: "share-command-ack", + errorCode: "DUPLICATE_COMMAND_ID", + }) + + expect(Settings.updateCommandId).toHaveBeenCalledTimes(1) + // postMessage for retry must happen after updateCommandId resolves + expect(mockPort.postMessage).toHaveBeenLastCalledWith({ + type: "share-command", + command: expect.objectContaining({ + id: expect.not.stringMatching(param.id), + }), + }) + expect(mockPort.onMessage.removeListener).not.toHaveBeenCalled() + + vi.useRealTimers() + }) + + it("SH-10: DUPLICATE_COMMAND_ID stops share if updateCommandId fails", async () => { + vi.useFakeTimers() + vi.mocked(chrome.tabs.create).mockImplementation((_opts, cb) => { + cb?.({ id: 42 } as chrome.tabs.Tab) + return Promise.resolve({ id: 42 } as chrome.tabs.Tab) + }) + vi.mocked(Settings.updateCommandId).mockRejectedValue( + new Error("update failed"), + ) + const response = vi.fn() + shareCommandToHub(param, sender, response) + + await Promise.resolve() + + const portConnectListener = vi.mocked( + chrome.runtime.onConnectExternal.addListener, + ).mock.calls[0][0] + + let capturedOnMessage: ((msg: unknown) => Promise) | undefined + const mockPort = { + name: "hub-share", + sender: { tab: { id: 42 } }, + postMessage: vi.fn(), + onMessage: { + addListener: vi.fn((fn) => { + capturedOnMessage = fn + }), + removeListener: vi.fn(), + }, + } + portConnectListener(mockPort as any) + vi.advanceTimersByTime(100) + + // Simulate DUPLICATE_COMMAND_ID ack with updateCommandId failure + await capturedOnMessage?.({ + type: "share-command-ack", + errorCode: "DUPLICATE_COMMAND_ID", + }) + + // Must not send new command since local storage update failed + expect(mockPort.postMessage).toHaveBeenCalledTimes(1) // only the initial retry send expect(mockPort.onMessage.removeListener).toHaveBeenCalled() vi.useRealTimers() @@ -1080,7 +1227,7 @@ describe("pushEditToHub", () => { expect(chrome.tabs.create).toHaveBeenCalledWith( { url: expect.stringContaining( - "hub.example.com/en/dashboard/commands?id=cmd-1", + "hub.example.com/en/dashboard/mycommands?id=cmd-1", ), }, expect.any(Function), diff --git a/packages/extension/src/services/hub/background.ts b/packages/extension/src/services/hub/background.ts index c934a715..0b8c5885 100644 --- a/packages/extension/src/services/hub/background.ts +++ b/packages/extension/src/services/hub/background.ts @@ -18,6 +18,7 @@ import { } from "@/services/analytics" import { PopupOption } from "@/services/option/defaultSettings" import { + generateId, isSearchCommand, isPageActionCommand, isAiPromptCommand, @@ -78,6 +79,10 @@ export const shareCommandToHub = ( const share = async () => { try { + let currentParam = param + let idRegenerateCount = 0 + const MAX_ID_REGENERATE = 3 + // Use a named function expression so the handler can remove itself via // `portConnect` (inner self-reference, always valid inside the handler). // The outer `onPortConnect` variable is used for cleanup in error paths @@ -93,9 +98,59 @@ export const shareCommandToHub = ( port.onMessage.removeListener(onMessage) } - const onMessage = (msg: unknown) => { - if ((msg as { type?: string })?.type === "share-command-ack") { - cleanup() + const onMessage = async (msg: unknown) => { + const type = (msg as { type?: string })?.type + + if (type === "share-command-ack") { + const ack = msg as { type: string; errorCode?: string } + + if ( + ack.errorCode === "DUPLICATE_COMMAND_ID" && + idRegenerateCount < MAX_ID_REGENERATE + ) { + idRegenerateCount++ + clearInterval(timer) + + const oldId = currentParam.id + const newId = generateId() + + try { + await Settings.updateCommandId(oldId, newId) + } catch (err) { + console.error( + "[shareCommandToHub] Failed to update command ID:", + err, + ) + port.onMessage.removeListener(onMessage) + return + } + + currentParam = { ...currentParam, id: newId } + port.postMessage({ type: "share-command", command: currentParam }) + return + } + + // Stop retry timer now that ack is received + clearInterval(timer) + + if (ack.errorCode) { + // Error: stop listening + port.onMessage.removeListener(onMessage) + } + // Success: keep listener to await share-command-submitted + return + } + + if (type === "share-command-submitted") { + port.onMessage.removeListener(onMessage) + Storage.set(LOCAL_STORAGE_KEY.HUB_SHARED_AT, Date.now()).catch( + (err) => { + console.error( + "[shareCommandToHub] Failed to update hubSharedAt:", + err, + ) + }, + ) } } port.onMessage.addListener(onMessage) @@ -110,7 +165,7 @@ export const shareCommandToHub = ( ) return } - port.postMessage({ type: "share-command", command: param }) + port.postMessage({ type: "share-command", command: currentParam }) }, RETRY_INTERVAL_MS) } // Register listener before tab creation so the Hub page can connect immediately on load @@ -605,7 +660,7 @@ export const pushEditToHub = ( const tab = await new Promise((resolve) => chrome.tabs.create( { - url: `${NEW_HUB_URL}/${param.locale}/dashboard/commands?id=${encodeURIComponent(param.id)}`, + url: `${NEW_HUB_URL}/${param.locale}/dashboard/mycommands?id=${encodeURIComponent(param.id)}`, }, resolve, ), diff --git a/packages/extension/src/services/settings/settings.ts b/packages/extension/src/services/settings/settings.ts index 3d30e625..eb7afdc3 100644 --- a/packages/extension/src/services/settings/settings.ts +++ b/packages/extension/src/services/settings/settings.ts @@ -186,6 +186,20 @@ export const Settings = { return true }, + updateCommandId: async (oldId: string, newId: string): Promise => { + const commands = await Storage.getCommands() + await Storage.setCommands( + commands.map((c) => (c.id === oldId ? { ...c, id: newId } : c)), + ) + const shortcuts = await Storage.get(STORAGE_KEY.SHORTCUTS) + await Storage.set(STORAGE_KEY.SHORTCUTS, { + ...shortcuts, + shortcuts: (shortcuts?.shortcuts ?? []).map((s) => + s.commandId === oldId ? { ...s, commandId: newId } : s, + ), + }) + }, + reset: async () => { await Storage.set(STORAGE_KEY.USER, DefaultSettings) await Storage.setCommands(getDefaultCommands(getUILanguage())) @@ -305,8 +319,7 @@ const migrate0_11_5 = (data: SettingsType): SettingsType => { data.commands = data.commands.map((c) => { if (c.id.length === 36) return c c.id = - DefaultCommands.find((dc) => dc.title === c.title)?.id ?? - generateId() + DefaultCommands.find((dc) => dc.title === c.title)?.id ?? generateId() return c }) diff --git a/packages/extension/src/services/storage/const.ts b/packages/extension/src/services/storage/const.ts index 767326e2..e449e871 100644 --- a/packages/extension/src/services/storage/const.ts +++ b/packages/extension/src/services/storage/const.ts @@ -17,6 +17,7 @@ export enum LOCAL_STORAGE_KEY { DAILY_COMMANDS_BACKUP = "dailyCommandsBackup", WEEKLY_COMMANDS_BACKUP = "weeklyCommandsBackup", HUB_USER = "hubUser", + HUB_SHARED_AT = "hubSharedAt", } export enum SESSION_STORAGE_KEY { diff --git a/packages/hub/eslint.config.mjs b/packages/hub/eslint.config.mjs index 60e186d6..f39a3003 100644 --- a/packages/hub/eslint.config.mjs +++ b/packages/hub/eslint.config.mjs @@ -5,7 +5,13 @@ import tseslint from "typescript-eslint" export default tseslint.config( ...rootConfig, { - ignores: [".next/**", "out/**", "scripts/**", "*.config.*"], + ignores: [ + ".next/**", + "out/**", + "scripts/**", + "*.config.*", + "next-env.d.ts", + ], }, { files: ["**/*.{ts,tsx,js,jsx}"], diff --git a/yarn.lock b/yarn.lock index 27d31c30..66695f3e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5536,10 +5536,10 @@ lucide-react@^0.468.0: resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.468.0.tgz#830c1bfd905575ddd23b832baa420c87db166910" integrity sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA== -lucide-react@^0.483.0: - version "0.483.0" - resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.483.0.tgz#036c538173a8c310ac6b7d6af150543e5ecdf116" - integrity sha512-WldsY17Qb/T3VZdMnVQ9C3DDIP7h1ViDTHVdVGnLZcvHNg30zH/MTQ04RTORjexoGmpsXroiQXZ4QyR0kBy0FA== +lucide-react@^1.16.0: + version "1.16.0" + resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-1.16.0.tgz#8c19a757982ed28e28afd0da9a950d3152e4b7e5" + integrity sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ== lz-string@^1.5.0: version "1.5.0"