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
2 changes: 1 addition & 1 deletion packages/extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
30 changes: 19 additions & 11 deletions packages/extension/src/components/option/ShareButton.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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}
>
<Share
className={cn(
"stroke-gray-500 group-hover/share-btn:stroke-green-600",
isShared && "stroke-green-600",
status === "error" && "stroke-red-500",
)}
size={16}
/>
{!isShared ? (
<Share
className={cn(
"stroke-gray-500 group-hover/share-btn:stroke-green-600",
status === "error" && "stroke-red-500",
)}
size={16}
/>
) : (
<CloudCheck
className={cn(
"stroke-gray-500 group-hover/share-btn:stroke-green-600",
status === "error" && "stroke-red-500",
)}
size={16}
/>
)}
</button>
<Tooltip
positionElm={buttonRef.current}
Expand Down
31 changes: 6 additions & 25 deletions packages/extension/src/components/option/editor/CommandList.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from "react"
import { useFieldArray, useFormContext } from "react-hook-form"
import { useFieldArray } from "react-hook-form"

import {
DndContext,
Expand Down Expand Up @@ -28,12 +28,7 @@ import {

import { ANALYTICS_EVENTS, sendEvent } from "@/services/analytics"
import { SCREEN, COMMAND_TYPE, OPEN_MODE_TYPE_MAP } from "@/const"
import type {
Command,
CommandFolder,
SelectionCommand,
ShortcutCommand,
} from "@/types"
import type { Command, CommandFolder, SelectionCommand } from "@/types"

// Imported services and hooks
import {
Expand All @@ -48,6 +43,7 @@ import {
} from "@/services/option/commandUtils"
import { isValidDrop } from "@/services/option/dragAndDrop"
import { editCommandToHub } from "@/services/hubShare"
import { Settings } from "@/services/settings/settings"
import { useCommandActions } from "@/hooks/option/useCommandActions"
import { useCommandDragDrop } from "@/hooks/option/useCommandDragDrop"
import { useSharedCommandIds } from "@/hooks/option/useSharedCommandIds"
Expand Down Expand Up @@ -124,8 +120,6 @@ export const CommandList = ({ control }: CommandListProps) => {

const sharedCommandIds = useSharedCommandIds()

const { getValues, setValue } = useFormContext()

const commandArray = useFieldArray<CommandsSchemaType, "commands", "_id">({
name: "commands",
control: control,
Expand Down Expand Up @@ -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) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -30,7 +25,7 @@ import { INSERT, toInsertTemplate } from "@/services/pageAction"
const t = (key: string, p?: string[]) => _t(`Option_${key}`, p)

type ShortcutListProps = {
control: Control<any>
control: any
}

const isTextSelectionOnly = (command: Command) => {
Expand Down
9 changes: 9 additions & 0 deletions packages/extension/src/hooks/option/useSharedCommandIds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ export function useSharedCommandIds(): Set<string> {

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())
Expand All @@ -35,9 +37,16 @@ export function useSharedCommandIds(): Set<string> {
(newVal) => fetchIds(newVal),
)

// Re-fetch after a command is successfully shared to the hub
const unsubscribeSharedAt = Storage.addListener<number>(
LOCAL_STORAGE_KEY.HUB_SHARED_AT,
() => fetchIds(currentHubUser),
)

return () => {
cancelled = true
unsubscribe()
unsubscribeSharedAt()
}
}, [])

Expand Down
155 changes: 151 additions & 4 deletions packages/extension/src/services/hub/background.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<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)

// 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<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)

// 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()
Expand Down Expand Up @@ -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),
Expand Down
Loading
Loading