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,
};