diff --git a/packages/extension/public/_locales/de/messages.json b/packages/extension/public/_locales/de/messages.json index f8b65c42..8df859b6 100644 --- a/packages/extension/public/_locales/de/messages.json +++ b/packages/extension/public/_locales/de/messages.json @@ -827,6 +827,9 @@ "Option_shareButton_shared_tooltip": { "message": "Bereits geteilt" }, + "Option_shareButton_modified_tooltip": { + "message": "Nicht synchronisierte Änderungen. Zum Synchronisieren klicken" + }, "Option_remove_title": { "message": "Dies löschen?" }, diff --git a/packages/extension/public/_locales/en/messages.json b/packages/extension/public/_locales/en/messages.json index 43e5a44d..70d03db7 100644 --- a/packages/extension/public/_locales/en/messages.json +++ b/packages/extension/public/_locales/en/messages.json @@ -833,6 +833,10 @@ "message": "Already shared", "description": "Tooltip for the share button when the command has already been shared to the Hub. Clicking opens the hub dashboard for this command." }, + "Option_shareButton_modified_tooltip": { + "message": "Unsynced changes. Click to sync", + "description": "Tooltip for the share button when the command has already been shared to the Hub but has local edits not yet pushed. Clicking pushes the edits to the Hub." + }, "Option_remove_title": { "message": "Delete this?" }, diff --git a/packages/extension/public/_locales/es/messages.json b/packages/extension/public/_locales/es/messages.json index 39064308..c225f26d 100644 --- a/packages/extension/public/_locales/es/messages.json +++ b/packages/extension/public/_locales/es/messages.json @@ -827,6 +827,9 @@ "Option_shareButton_shared_tooltip": { "message": "Ya compartido" }, + "Option_shareButton_modified_tooltip": { + "message": "Cambios sin sincronizar. Haz clic para sincronizar" + }, "Option_remove_title": { "message": "¿Eliminar esto?" }, diff --git a/packages/extension/public/_locales/fr/messages.json b/packages/extension/public/_locales/fr/messages.json index c99e1ce6..92c02331 100644 --- a/packages/extension/public/_locales/fr/messages.json +++ b/packages/extension/public/_locales/fr/messages.json @@ -827,6 +827,9 @@ "Option_shareButton_shared_tooltip": { "message": "Déjà partagé" }, + "Option_shareButton_modified_tooltip": { + "message": "Modifications non synchronisées. Cliquez pour synchroniser" + }, "Option_remove_title": { "message": "Supprimer ceci ?" }, diff --git a/packages/extension/public/_locales/hi/messages.json b/packages/extension/public/_locales/hi/messages.json index ce51b24f..75aff289 100644 --- a/packages/extension/public/_locales/hi/messages.json +++ b/packages/extension/public/_locales/hi/messages.json @@ -827,6 +827,9 @@ "Option_shareButton_shared_tooltip": { "message": "पहले से साझा किया गया" }, + "Option_shareButton_modified_tooltip": { + "message": "बदलाव सिंक नहीं हुए। सिंक करने के लिए क्लिक करें" + }, "Option_remove_title": { "message": "क्या आप वाकई इसे हटाना चाहते हैं?" }, diff --git a/packages/extension/public/_locales/id/messages.json b/packages/extension/public/_locales/id/messages.json index 550e05ef..c9e4d9c2 100644 --- a/packages/extension/public/_locales/id/messages.json +++ b/packages/extension/public/_locales/id/messages.json @@ -827,6 +827,9 @@ "Option_shareButton_shared_tooltip": { "message": "Sudah dibagikan" }, + "Option_shareButton_modified_tooltip": { + "message": "Ada perubahan yang belum disinkronkan. Klik untuk menyinkronkan" + }, "Option_remove_title": { "message": "Hapus ini?" }, diff --git a/packages/extension/public/_locales/it/messages.json b/packages/extension/public/_locales/it/messages.json index 9cdc5020..33f0df9e 100644 --- a/packages/extension/public/_locales/it/messages.json +++ b/packages/extension/public/_locales/it/messages.json @@ -827,6 +827,9 @@ "Option_shareButton_shared_tooltip": { "message": "Già condiviso" }, + "Option_shareButton_modified_tooltip": { + "message": "Modifiche non sincronizzate. Fai clic per sincronizzare" + }, "Option_remove_title": { "message": "Sei sicuro di voler eliminare questo?" }, diff --git a/packages/extension/public/_locales/ja/messages.json b/packages/extension/public/_locales/ja/messages.json index 4c616172..c78308b7 100644 --- a/packages/extension/public/_locales/ja/messages.json +++ b/packages/extension/public/_locales/ja/messages.json @@ -827,6 +827,9 @@ "Option_shareButton_shared_tooltip": { "message": "共有済み" }, + "Option_shareButton_modified_tooltip": { + "message": "未反映の変更あり。クリックして反映" + }, "Option_remove_title": { "message": "削除しますか?" }, diff --git a/packages/extension/public/_locales/ko/messages.json b/packages/extension/public/_locales/ko/messages.json index 6d044dec..78659e8b 100644 --- a/packages/extension/public/_locales/ko/messages.json +++ b/packages/extension/public/_locales/ko/messages.json @@ -827,6 +827,9 @@ "Option_shareButton_shared_tooltip": { "message": "이미 공유됨" }, + "Option_shareButton_modified_tooltip": { + "message": "반영되지 않은 변경 사항 있음. 클릭하여 반영" + }, "Option_remove_title": { "message": "이것을 삭제하시겠습니까?" }, diff --git a/packages/extension/public/_locales/ms/messages.json b/packages/extension/public/_locales/ms/messages.json index e97dea4e..0b206156 100644 --- a/packages/extension/public/_locales/ms/messages.json +++ b/packages/extension/public/_locales/ms/messages.json @@ -827,6 +827,9 @@ "Option_shareButton_shared_tooltip": { "message": "Sudah dikongsi" }, + "Option_shareButton_modified_tooltip": { + "message": "Ada perubahan belum disegerakkan. Klik untuk segerakkan" + }, "Option_remove_title": { "message": "Padam ini?" }, diff --git a/packages/extension/public/_locales/pt_BR/messages.json b/packages/extension/public/_locales/pt_BR/messages.json index 835c4343..df63a6be 100644 --- a/packages/extension/public/_locales/pt_BR/messages.json +++ b/packages/extension/public/_locales/pt_BR/messages.json @@ -827,6 +827,9 @@ "Option_shareButton_shared_tooltip": { "message": "Já compartilhado" }, + "Option_shareButton_modified_tooltip": { + "message": "Alterações não sincronizadas. Clique para sincronizar" + }, "Option_remove_title": { "message": "Excluir isto?" }, diff --git a/packages/extension/public/_locales/pt_PT/messages.json b/packages/extension/public/_locales/pt_PT/messages.json index d40ef417..ea1bb181 100644 --- a/packages/extension/public/_locales/pt_PT/messages.json +++ b/packages/extension/public/_locales/pt_PT/messages.json @@ -827,6 +827,9 @@ "Option_shareButton_shared_tooltip": { "message": "Já partilhado" }, + "Option_shareButton_modified_tooltip": { + "message": "Alterações não sincronizadas. Clique para sincronizar" + }, "Option_remove_title": { "message": "Eliminar isto?" }, diff --git a/packages/extension/public/_locales/ru/messages.json b/packages/extension/public/_locales/ru/messages.json index 87769d65..7443a895 100644 --- a/packages/extension/public/_locales/ru/messages.json +++ b/packages/extension/public/_locales/ru/messages.json @@ -827,6 +827,9 @@ "Option_shareButton_shared_tooltip": { "message": "Уже опубликовано" }, + "Option_shareButton_modified_tooltip": { + "message": "Есть несинхронизированные изменения. Нажмите, чтобы синхронизировать" + }, "Option_remove_title": { "message": "Вы уверены, что хотите удалить это?" }, diff --git a/packages/extension/public/_locales/zh_CN/messages.json b/packages/extension/public/_locales/zh_CN/messages.json index 7e637a29..22e1c610 100644 --- a/packages/extension/public/_locales/zh_CN/messages.json +++ b/packages/extension/public/_locales/zh_CN/messages.json @@ -827,6 +827,9 @@ "Option_shareButton_shared_tooltip": { "message": "已共享" }, + "Option_shareButton_modified_tooltip": { + "message": "有未同步的更改。点击同步" + }, "Option_remove_title": { "message": "您确定要删除吗?" }, diff --git a/packages/extension/src/components/option/ShareButton.tsx b/packages/extension/src/components/option/ShareButton.tsx index 4a5ff5f2..65f9cecd 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, CloudCheck } from "lucide-react" +import { CloudCheck, CloudUpload } from "lucide-react" import { Tooltip } from "@/components/Tooltip" import { cn, isUUIDv7, generateId } from "@/lib/utils" import { t } from "@/services/i18n" @@ -8,6 +8,7 @@ import { getHubLocale, isHubShareable, isHubRegistered, + pushEditToHub, } from "@/services/hubShare" import { sendEvent, @@ -23,22 +24,40 @@ type Props = { command: SelectionCommand onCommandIdChange?: (newId: string) => void isShared?: boolean + // Shared command with local edits not yet pushed to the Hub + isModified?: boolean } export const ShareButton = ({ command, onCommandIdChange, isShared, + isModified, }: Props) => { const buttonRef = useRef(null) const [status, setStatus] = useState<"idle" | "pending" | "sent" | "error">( "idle", ) + const shareState: "unshared" | "shared" | "modified" = !isShared + ? "unshared" + : isModified + ? "modified" + : "shared" + const handleClick = async (e: React.MouseEvent) => { e.stopPropagation() - if (isShared) { + if (shareState === "modified") { + // Push the local edits to the Hub. The mark clears once the Hub + // acknowledges and the new content hash is saved. + const ok = pushEditToHub(command) + setStatus(ok ? "sent" : "error") + setTimeout(() => setStatus("idle"), 2000) + return + } + + if (shareState === "shared") { // Open the hub dashboard page for the shared command const locale = getHubLocale() const url = `${NEW_HUB_URL}/${locale}/dashboard/mycommands?id=${encodeURIComponent(command.id)}` @@ -94,6 +113,29 @@ export const ShareButton = ({ return null } + const iconClassName = cn( + "stroke-gray-500", + shareState === "modified" + ? "group-hover/share-btn:stroke-orange-600" + : "group-hover/share-btn:stroke-green-600", + status === "error" && "stroke-red-500", + ) + + const { testId, tooltip } = { + unshared: { + testId: TEST_IDS.shareCommandButton, + tooltip: t("Option_shareButton_tooltip"), + }, + shared: { + testId: TEST_IDS.sharedCommandButton, + tooltip: t("Option_shareButton_shared_tooltip"), + }, + modified: { + testId: TEST_IDS.modifiedSharedCommandButton, + tooltip: t("Option_shareButton_modified_tooltip"), + }, + }[shareState] + return ( <> - + ) } diff --git a/packages/extension/src/components/option/__tests__/ShareButton.test.tsx b/packages/extension/src/components/option/__tests__/ShareButton.test.tsx index a37a0928..be6ae07e 100644 --- a/packages/extension/src/components/option/__tests__/ShareButton.test.tsx +++ b/packages/extension/src/components/option/__tests__/ShareButton.test.tsx @@ -2,7 +2,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest" import { render, screen, fireEvent, waitFor } from "@testing-library/react" import { ShareButton } from "../ShareButton" import { sendEvent, sendHubLinkClick } from "@/services/analytics" -import { shareCommandToHub, isHubRegistered } from "@/services/hubShare" +import { + shareCommandToHub, + isHubRegistered, + pushEditToHub, +} from "@/services/hubShare" import { OPEN_MODE } from "@/const" import { TEST_IDS } from "@/testIds" import type { SearchCommand } from "@/types" @@ -20,6 +24,7 @@ vi.mock("@/services/hubShare", () => ({ getHubLocale: () => "en", isHubShareable: () => true, isHubRegistered: vi.fn(), + pushEditToHub: vi.fn(), })) vi.mock("@/services/analytics", () => ({ @@ -39,6 +44,7 @@ const mockSendEvent = vi.mocked(sendEvent) const mockSendHubLinkClick = vi.mocked(sendHubLinkClick) const mockShareCommandToHub = vi.mocked(shareCommandToHub) const mockIsHubRegistered = vi.mocked(isHubRegistered) +const mockPushEditToHub = vi.mocked(pushEditToHub) // A UUIDv7 id, so that sharing does not regenerate the command id. const command: SearchCommand = { @@ -111,4 +117,49 @@ describe("ShareButton", () => { expect(mockSendHubLinkClick).not.toHaveBeenCalled() expect(mockSendEvent).not.toHaveBeenCalled() }) + + it("SB-05: pushes local edits to the Hub for a modified shared command", () => { + mockPushEditToHub.mockReturnValue(true) + render() + + fireEvent.click(screen.getByTestId(TEST_IDS.modifiedSharedCommandButton)) + + expect(mockPushEditToHub).toHaveBeenCalledWith(command) + expect(chrome.tabs.create).not.toHaveBeenCalled() + expect(mockShareCommandToHub).not.toHaveBeenCalled() + }) + + it("SB-06: keeps the unshared state when isModified is set without isShared", () => { + render() + + expect(screen.getByTestId(TEST_IDS.shareCommandButton)).toBeTruthy() + expect( + screen.queryByTestId(TEST_IDS.modifiedSharedCommandButton), + ).toBeNull() + }) + + it("SB-07: shows the error state when pushing the edits fails", () => { + mockPushEditToHub.mockReturnValue(false) + render() + + const button = screen.getByTestId(TEST_IDS.modifiedSharedCommandButton) + fireEvent.click(button) + + expect(mockPushEditToHub).toHaveBeenCalledWith(command) + expect((button as HTMLButtonElement).disabled).toBe(true) + expect(button.querySelector("svg")?.getAttribute("class")).toContain( + "stroke-red-500", + ) + }) + + it("SB-08: blocks repeated pushes while the result is shown", () => { + mockPushEditToHub.mockReturnValue(true) + render() + + const button = screen.getByTestId(TEST_IDS.modifiedSharedCommandButton) + fireEvent.click(button) + fireEvent.click(button) + + expect(mockPushEditToHub).toHaveBeenCalledOnce() + }) }) diff --git a/packages/extension/src/components/option/editor/CommandEditDialog.tsx b/packages/extension/src/components/option/editor/CommandEditDialog.tsx index 651281de..a4e3545e 100644 --- a/packages/extension/src/components/option/editor/CommandEditDialog.tsx +++ b/packages/extension/src/components/option/editor/CommandEditDialog.tsx @@ -8,7 +8,7 @@ import { SquareTerminal, ChevronsUpDown, ChevronsDownUp, - Share, + CloudUpload, } from "lucide-react" import { @@ -908,7 +908,7 @@ const CommandEditDialogInner = ({ (err) => console.error(err), )} > - + {t("labelShare")} )} @@ -927,7 +927,7 @@ const CommandEditDialogInner = ({ )} data-testid={TEST_IDS.submitCommandButton} > - {isHubEdit ? : } + {isHubEdit ? : } {isHubEdit ? t("labelShare") : isEdit diff --git a/packages/extension/src/components/option/editor/CommandTreeRenderer.tsx b/packages/extension/src/components/option/editor/CommandTreeRenderer.tsx index 6f4ce732..0fa81c8e 100644 --- a/packages/extension/src/components/option/editor/CommandTreeRenderer.tsx +++ b/packages/extension/src/components/option/editor/CommandTreeRenderer.tsx @@ -13,6 +13,7 @@ import { isPageActionCommand, } from "@/services/option/commandUtils" import { calcLevel } from "@/services/option/commandTree" +import { hasUnpushedEdits } from "@/services/hub/commandContent" interface Props { nodes: FlattenNode[] @@ -80,6 +81,10 @@ export const CommandTreeRenderer: React.FC = ({ onUpdateCommandId?.(field.content.id, newId) } isShared={sharedCommandIds?.has(field.content.id)} + isModified={ + sharedCommandIds?.has(field.content.id) && + hasUnpushedEdits(field.content) + } /> )} {isPageActionCommand(field.content) && ( diff --git a/packages/extension/src/services/hub/__tests__/commandContent.test.ts b/packages/extension/src/services/hub/__tests__/commandContent.test.ts index 8e00a288..2fae8d78 100644 --- a/packages/extension/src/services/hub/__tests__/commandContent.test.ts +++ b/packages/extension/src/services/hub/__tests__/commandContent.test.ts @@ -2,12 +2,14 @@ import { describe, it, expect } from "vitest" import { applyHubContent, calcContentHash, + hasUnpushedEdits, isLocallyModified, nextContentUpdatedAt, parseContentUpdatedAt, pickHubContent, stampForHub, } from "../commandContent" +import { commandSchema } from "@/types/schema" import type { SelectionCommand } from "@/types" const base = { @@ -90,6 +92,74 @@ describe("isLocallyModified", () => { }) }) +describe("hasUnpushedEdits", () => { + it("CC-07a: false when the fingerprint matches", () => { + const cmd = { ...base, contentHash: calcContentHash(base) } + expect(hasUnpushedEdits(cmd)).toBe(false) + }) + + it("CC-07b: true when the content differs from the fingerprint", () => { + const cmd = { ...base, contentHash: calcContentHash(base), title: "Edit" } + expect(hasUnpushedEdits(cmd)).toBe(true) + }) + + it("CC-07c: false when there is no fingerprint", () => { + expect(hasUnpushedEdits(base)).toBe(false) + }) + + // Saving from the edit dialog passes the command through the zod schema. + // A command already saved once must keep its fingerprint through it, + // otherwise saving without changes would mark it as edited. + it("CC-07d: false after saving a saved command again through the form schema", () => { + const commands = [ + { + id: "s", + title: "Search", + iconUrl: "https://a.com/i.png", + openMode: "popup", + openModeSecondary: "tab", + spaceEncoding: "plus", + searchUrl: "https://a.com/?q=%s", + }, + { + id: "a", + title: "AI", + iconUrl: "https://a.com/i.png", + openMode: "aiPrompt", + aiPromptOption: { + serviceId: "gemini", + prompt: "{{SelectedText}}", + openMode: "popup", + }, + }, + { + id: "p", + title: "Page Action", + iconUrl: "https://a.com/i.png", + openMode: "pageAction", + pageActionOption: { + startUrl: "https://a.com", + openMode: "popup", + steps: [ + { + id: "1", + // Set by the recorder but not part of the schema + timestamp: 1, + param: { type: "start", label: "Start" }, + }, + ], + }, + }, + ] + for (const raw of commands) { + const saved = commandSchema.parse(raw) as SelectionCommand + const stamped = { ...saved, contentHash: calcContentHash(saved) } + const resaved = commandSchema.parse(stamped) as SelectionCommand + expect(hasUnpushedEdits(resaved)).toBe(false) + } + }) +}) + describe("parseContentUpdatedAt", () => { it("CC-08: normalizes valid dates to ISO 8601", () => { expect(parseContentUpdatedAt("2026-01-02T03:04:05Z")).toBe( diff --git a/packages/extension/src/services/hub/commandContent.ts b/packages/extension/src/services/hub/commandContent.ts index 981b0bd6..1801da5e 100644 --- a/packages/extension/src/services/hub/commandContent.ts +++ b/packages/extension/src/services/hub/commandContent.ts @@ -96,6 +96,17 @@ export function isLocallyModified(cmd: SelectionCommand): boolean { return cmd.contentHash !== calcContentHash(cmd) } +/** + * Whether a shared command has local edits not yet pushed to the Hub. + * Unlike isLocallyModified, a command without a fingerprint (shared before + * fingerprints existed) counts as unchanged, to avoid flagging every such + * command. The fingerprint is saved on the next push, enabling detection. + */ +export function hasUnpushedEdits(cmd: SelectionCommand): boolean { + if (!cmd.contentHash) return false + return cmd.contentHash !== calcContentHash(cmd) +} + /** Returns the value as ISO 8601 if it is a valid date string. */ export function parseContentUpdatedAt(value: unknown): string | undefined { if (typeof value !== "string") return undefined diff --git a/packages/extension/src/testIds.ts b/packages/extension/src/testIds.ts index 75c427fc..082891c9 100644 --- a/packages/extension/src/testIds.ts +++ b/packages/extension/src/testIds.ts @@ -17,6 +17,7 @@ export const TEST_IDS = { submitCommandButton: "submit-command-button", shareCommandButton: "share-command-button", sharedCommandButton: "shared-command-button", + modifiedSharedCommandButton: "modified-shared-command-button", commandType: (type: string) => `command-type-${type}`, recButton: "rec-button", pageActionCompleteButton: "page-action-complete-button",