diff --git a/apps/builder/app/builder/features/topbar/menu/menu.tsx b/apps/builder/app/builder/features/topbar/menu/menu.tsx index 63287a246053..ca5957c3c0c8 100644 --- a/apps/builder/app/builder/features/topbar/menu/menu.tsx +++ b/apps/builder/app/builder/features/topbar/menu/menu.tsx @@ -142,6 +142,12 @@ export const Menu = () => { + emitCommand("save")}> + Save + + + + emitCommand("togglePreviewMode")}> Preview diff --git a/apps/builder/app/builder/shared/commands.ts b/apps/builder/app/builder/shared/commands.ts index f0633e63d436..7e1d5853414d 100644 --- a/apps/builder/app/builder/shared/commands.ts +++ b/apps/builder/app/builder/shared/commands.ts @@ -56,6 +56,7 @@ import { denormalizeSrcProps } from "~/shared/copy-paste/asset-upload"; import { getInstanceLabel } from "./instance-label"; import { $instanceTags } from "../features/style-panel/shared/model"; import { reactPropsToStandardAttributes } from "@webstudio-is/react-sdk"; +import { isSyncIdle } from "./sync/sync-server"; export const $styleSourceInputElement = atom(); @@ -631,6 +632,22 @@ export const { emitCommand, subscribeCommands } = createCommandsEmitter({ }, }, + { + name: "save", + defaultHotkeys: ["meta+s", "ctrl+s"], + handler: async () => { + toast.dismiss("save-success"); + try { + await isSyncIdle(); + toast.success("Project saved successfully", { id: "save-success" }); + } catch (error) { + if (error instanceof Error) { + toast.error(error.message); + } + } + }, + }, + { name: "openCommandPanel", hidden: true, diff --git a/apps/builder/app/builder/shared/sync/sync-server.ts b/apps/builder/app/builder/shared/sync/sync-server.ts index 23ce01201f7a..8aabb582b6eb 100644 --- a/apps/builder/app/builder/shared/sync/sync-server.ts +++ b/apps/builder/app/builder/shared/sync/sync-server.ts @@ -324,6 +324,38 @@ export class ServerSyncStorage implements SyncStorage { } } +/** + * Promisify idle state of the queue for a one-off notification when everything is saved. + */ +export const isSyncIdle = () => { + return new Promise((resolve, reject) => { + const handle = (status: QueueStatus) => { + if (status.status === "idle") { + resolve(status); + return true; + } + if (status.status === "fatal") { + reject( + new Error( + "Synchronization is in fatal state. Please reload the page or check your internet connection." + ) + ); + return true; + } + return false; + }; + const status = $queueStatus.get(); + + if (handle(status) === false) { + const unsubscribe = $queueStatus.subscribe((status) => { + if (handle(status)) { + unsubscribe(); + } + }); + } + }); +}; + type SyncServerProps = { projectId: Project["id"]; authPermit: AuthPermit; diff --git a/packages/design-system/src/components/toast.tsx b/packages/design-system/src/components/toast.tsx index 17d9c07dfd2c..d83bd9acc5f0 100644 --- a/packages/design-system/src/components/toast.tsx +++ b/packages/design-system/src/components/toast.tsx @@ -391,4 +391,5 @@ export const toast = { hotToast.custom(value, options), success: (value: JSX.Element | string, options?: Options) => hotToast.success(value, options), + dismiss: hotToast.dismiss, };