From f0bc73bdac9d8b8fb56dc39029acb715d5ed41e1 Mon Sep 17 00:00:00 2001 From: Oleg Isonen Date: Sat, 25 Apr 2026 19:25:34 +0100 Subject: [PATCH 01/49] feat: Onboard video in dashboard (#5725) --- apps/builder/app/dashboard/dashboard.tsx | 2 +- .../builder/app/dashboard/welcome/welcome.tsx | 80 +++---------------- apps/builder/app/shared/help.tsx | 6 ++ 3 files changed, 17 insertions(+), 71 deletions(-) diff --git a/apps/builder/app/dashboard/dashboard.tsx b/apps/builder/app/dashboard/dashboard.tsx index 29beb5d1099e..aae8b8c3d9b9 100644 --- a/apps/builder/app/dashboard/dashboard.tsx +++ b/apps/builder/app/dashboard/dashboard.tsx @@ -337,7 +337,7 @@ export const Dashboard = () => { /> , - label: "Watch video tutorials", - href: "https://wstd.us/101", - }, - { - icon: , - label: "Read the docs", - href: "https://docs.webstudio.is/", - }, - { - icon: , - label: "Join the community on Discord", - href: "https://wstd.us/community", - }, -]; - -const socialItems = [ - { icon: , label: "X", href: "https://x.com/getwebstudio" }, - { - icon: , - label: "Bluesky", - href: "https://bsky.app/profile/webstudio.is", - }, - { - icon: , - label: "Facebook", - href: "https://www.facebook.com/getwebstudio1/", - }, - { - icon: , - label: "Reddit", - href: "https://www.reddit.com/r/webstudio/", - }, -]; - export const Welcome = ({ currentWorkspaceId, }: { @@ -87,30 +41,16 @@ export const Welcome = ({ )} - - {guideItems.map(({ icon, label, href }) => ( - - {icon} - - {label} - - - ))} - - Follow for updates on: - {socialItems.map(({ icon, label, href }) => ( - - {icon} - - ))} - - + ); diff --git a/apps/builder/app/shared/help.tsx b/apps/builder/app/shared/help.tsx index 4c3cee52ff5b..dd637468acdb 100644 --- a/apps/builder/app/shared/help.tsx +++ b/apps/builder/app/shared/help.tsx @@ -5,6 +5,7 @@ import { XLogoIcon, BlueskyIcon, FacebookIcon, + LinkedinIcon, RedditIcon, } from "@webstudio-is/icons"; @@ -20,6 +21,11 @@ export const socialLinks = [ url: "https://www.facebook.com/webstudiois", icon: , }, + { + label: "LinkedIn", + url: "https://www.linkedin.com/company/getwebstudio/", + icon: , + }, { label: "Reddit", url: "https://www.reddit.com/r/webstudio/", From e9a2733691c9b92a8a35bc30299575167b26d9b8 Mon Sep 17 00:00:00 2001 From: Oleg Isonen Date: Mon, 27 Apr 2026 13:51:49 +0100 Subject: [PATCH 02/49] fix: Fix asset limit count visible assets (#5731) --- .../builder/shared/assets/upload-assets.tsx | 4 + apps/builder/app/routes/rest.assets.tsx | 15 ++- packages/asset-uploader/src/upload.test.ts | 100 ++++++++++++++++++ packages/asset-uploader/src/upload.ts | 37 +++++-- 4 files changed, 141 insertions(+), 15 deletions(-) create mode 100644 packages/asset-uploader/src/upload.test.ts diff --git a/apps/builder/app/builder/shared/assets/upload-assets.tsx b/apps/builder/app/builder/shared/assets/upload-assets.tsx index 245bd7f13f4d..a6ace1c75692 100644 --- a/apps/builder/app/builder/shared/assets/upload-assets.tsx +++ b/apps/builder/app/builder/shared/assets/upload-assets.tsx @@ -145,6 +145,7 @@ const deduplicateAssetName = (name: string, existingNames: Set) => { const uploadAsset = async ({ authToken, projectId, + assetId, fileOrUrl, assetType, onCompleted, @@ -152,6 +153,7 @@ const uploadAsset = async ({ }: { authToken: undefined | string; projectId: string; + assetId: Asset["id"]; fileOrUrl: File | URL; assetType: AssetType; onCompleted: (data: AssetActionResponse) => void; @@ -162,6 +164,7 @@ const uploadAsset = async ({ const fileName = getFileName(fileOrUrl); const metaFormData = new FormData(); + metaFormData.append("assetId", assetId); metaFormData.append("projectId", projectId); metaFormData.append("type", assetType); // sanitizeS3Key here is just because of https://github.com/remix-run/remix/issues/4443 @@ -319,6 +322,7 @@ const processUpload = async ( await uploadAsset({ authToken, projectId, + assetId, fileOrUrl: fileData.source === "file" ? fileData.file : new URL(fileData.url), assetType: fileData.type, diff --git a/apps/builder/app/routes/rest.assets.tsx b/apps/builder/app/routes/rest.assets.tsx index 6e27c35f9bb0..1e5c25a25a7f 100644 --- a/apps/builder/app/routes/rest.assets.tsx +++ b/apps/builder/app/routes/rest.assets.tsx @@ -34,14 +34,21 @@ export const action = async (props: ActionFunctionArgs) => { if (request.method === "POST") { const formData = await request.formData(); - const projectId = formData.get("projectId") as string; - const type = formData.get("type") as string; - const filename = formData.get("filename") as string; - if (projectId === null || type === null || filename === null) { + const assetId = formData.get("assetId"); + const projectId = formData.get("projectId"); + const type = formData.get("type"); + const filename = formData.get("filename"); + if ( + typeof assetId !== "string" || + typeof projectId !== "string" || + typeof type !== "string" || + typeof filename !== "string" + ) { throw Error("Project id, asset id or filename are missing"); } const name = await createUploadName( { + assetId, projectId, type, filename, diff --git a/packages/asset-uploader/src/upload.test.ts b/packages/asset-uploader/src/upload.test.ts new file mode 100644 index 000000000000..2bc83d74381d --- /dev/null +++ b/packages/asset-uploader/src/upload.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from "vitest"; +import { + createTestServer, + db, + empty, + json, + testContext, +} from "@webstudio-is/postgrest/testing"; +import type { AppContext } from "@webstudio-is/trpc-interface/index.server"; +import { createUploadName } from "./upload"; + +const server = createTestServer(); + +const createContext = (): AppContext => + ({ + ...testContext, + authorization: { type: "user", userId: "user-1" }, + getOwnerPlanFeatures: () => Promise.resolve({}), + }) as unknown as AppContext; + +const ownershipHandler = db.get("Project", ({ request }) => { + const url = new URL(request.url); + if (url.searchParams.has("userId")) { + return json({ id: url.searchParams.get("id")?.replace("eq.", "") }); + } + return json(null); +}); + +describe("createUploadName", () => { + test("counts assets instead of uploaded files for the project limit", async () => { + let insertedFile: unknown; + + server.use( + ownershipHandler, + db.head("Asset", ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get("projectId")).toBe("eq.project-1"); + expect(url.searchParams.get("file.status")).toBe("eq.UPLOADED"); + return empty({ headers: { "Content-Range": "*/90" } }); + }), + db.head("File", ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get("uploaderProjectId")).toBe("eq.project-1"); + expect(url.searchParams.get("status")).toBe("eq.UPLOADING"); + return empty({ headers: { "Content-Range": "*/0" } }); + }), + db.post("File", async ({ request }) => { + insertedFile = await request.json(); + return empty({ status: 201 }); + }), + db.post("Asset", async ({ request }) => { + expect(await request.json()).toEqual({ + id: "asset-1", + projectId: "project-1", + name: expect.stringMatching(/^photo_.+\.png$/), + }); + return empty({ status: 201 }); + }) + ); + + const name = await createUploadName( + { + assetId: "asset-1", + projectId: "project-1", + type: "image/png", + filename: "photo.png", + maxAssetsPerProject: 350, + }, + createContext() + ); + + expect(name).toMatch(/^photo_.+\.png$/); + expect(insertedFile).toMatchObject({ + name, + status: "UPLOADING", + uploaderProjectId: "project-1", + }); + }); + + test("throws when uploaded assets and recent uploads reach the limit", async () => { + server.use( + ownershipHandler, + db.head("Asset", () => empty({ headers: { "Content-Range": "*/349" } })), + db.head("File", () => empty({ headers: { "Content-Range": "*/1" } })) + ); + + await expect( + createUploadName( + { + assetId: "asset-2", + projectId: "project-2", + type: "image/png", + filename: "photo.png", + maxAssetsPerProject: 350, + }, + createContext() + ) + ).rejects.toThrow("The maximum number of assets per project is 350."); + }); +}); diff --git a/packages/asset-uploader/src/upload.ts b/packages/asset-uploader/src/upload.ts index 993407457b8f..bb26fde1b754 100644 --- a/packages/asset-uploader/src/upload.ts +++ b/packages/asset-uploader/src/upload.ts @@ -10,6 +10,7 @@ import { sanitizeS3Key } from "./utils/sanitize-s3-key"; import { formatAsset } from "./utils/format-asset"; type UploadData = { + assetId: Asset["id"]; projectId: string; type: string; filename: string; @@ -22,7 +23,7 @@ export const createUploadName = async ( data: UploadData, context: AppContext ): Promise => { - const { projectId, maxAssetsPerProject, type, filename } = data; + const { assetId, projectId, maxAssetsPerProject, type, filename } = data; const canEdit = await authorizeProject.hasProjectPermit( { projectId, permit: "edit" }, context @@ -39,12 +40,11 @@ export const createUploadName = async ( * than UPLOADING_STALE_TIMEOUT milliseconds ago **/ - const uploadedCount = await context.postgrest.client - .from("File") - .select("*", { count: "exact", head: true }) - .eq("isDeleted", false) - .eq("uploaderProjectId", projectId) - .eq("status", "UPLOADED"); + const assetCount = await context.postgrest.client + .from("Asset") + .select("id, file:File!inner(status)", { count: "exact", head: true }) + .eq("projectId", projectId) + .eq("file.status", "UPLOADED"); const uploadingCount = await context.postgrest.client .from("File") @@ -57,11 +57,11 @@ export const createUploadName = async ( new Date(Date.now() - UPLOADING_STALE_TIMEOUT).toISOString() ); - const count = (uploadedCount.count ?? 0) + (uploadingCount.count ?? 0); + const count = (assetCount.count ?? 0) + (uploadingCount.count ?? 0); if (count >= maxAssetsPerProject) { /** - * Here is right to write `Max ${MAX_ASSETS_PER_PROJECT}` but see the comment below, + * Here is right to write `Max ${maxAssetsPerProject}` but see the comment below, * it's probable that the user can exceed the limit a little bit. * So it can be a little bit strange that the limit is 5 but the user already has 7. **/ @@ -73,7 +73,7 @@ export const createUploadName = async ( /** * Create a temporary "UPLOADING" asset, so it can be counted in the next query * Assumptions: - * - it's possible to create more assets than MAX_ASSETS_PER_PROJECT, + * - it's possible to create more assets than maxAssetsPerProject, * but for now we assume that the time since the `count` query above and the `create` query below is negligible, * and some kind of rate limiting exists on API. * Also no locking exists in Prisma, and no raw query locking like @@ -81,7 +81,7 @@ export const createUploadName = async ( **/ const name = getUniqueFilename(sanitizeS3Key(filename)); - await context.postgrest.client.from("File").insert({ + const fileInsert = await context.postgrest.client.from("File").insert({ name, status: "UPLOADING", // store content type in related field @@ -89,6 +89,20 @@ export const createUploadName = async ( size: 0, uploaderProjectId: projectId, }); + if (fileInsert.error) { + throw new Error(fileInsert.error.message); + } + + const assetInsert = await context.postgrest.client.from("Asset").insert({ + id: assetId, + projectId, + name, + }); + if (assetInsert.error) { + await context.postgrest.client.from("File").delete().eq("name", name); + throw new Error(assetInsert.error.message); + } + return name; }; @@ -146,6 +160,7 @@ export const uploadFile = async ( file: file.data, }); } catch (error) { + await context.postgrest.client.from("Asset").delete().eq("name", name); await context.postgrest.client.from("File").delete().eq("name", name); throw error; From 9570c4e0914367e1199c9ddfe45d112e4b5bb4dd Mon Sep 17 00:00:00 2001 From: Shrek <122366903+shreyanshkotak@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:02:28 -0400 Subject: [PATCH 03/49] feat: escape to exit preview mode (#5730) ## Description 1. Related to #5501. This lets user escape exit preview mode when triggered from the builder UI, while preserving canvas-originated escape behavior so preview mode remains active when the canvas should handle the key event. ## Steps for reproduction 1. Open the builder and switch to preview mode. 2. Press Escape from the builder UI. 3. Expect preview mode to exit and the sidebar panel to return to auto. 4. Switch to preview mode again. 5. Press Escape while the focused inside the canvas. 6. Expect preview mode to stay active and canvas/native page behavior to be preserved. ## Code Review - [ ] hi @kof, I need you to do - conceptual review (architecture, feature-correctness) - detailed review (read every line) - test it on preview ## Before requesting a review - [x] made a self-review - [ ] added inline comments where things may be not obvious (the "why", not "what") ## Before merging - [x] tested locally and on preview environment (preview dev login: 0000) - [x] updated [test cases](https://github.com/webstudio-is/webstudio/blob/main/apps/builder/docs/test-cases.md) document - [x] added tests - [ ] if any new env variables are added, added them to `.env` file --- apps/builder/app/builder/shared/commands.ts | 17 ++- .../app/shared/commands-emitter.test.ts | 125 ++++++++++++++++++ apps/builder/app/shared/commands-emitter.ts | 8 +- apps/builder/docs/test-cases.md | 9 ++ 4 files changed, 154 insertions(+), 5 deletions(-) create mode 100644 apps/builder/app/shared/commands-emitter.test.ts diff --git a/apps/builder/app/builder/shared/commands.ts b/apps/builder/app/builder/shared/commands.ts index e36ac094302d..7f9f455a3ccf 100644 --- a/apps/builder/app/builder/shared/commands.ts +++ b/apps/builder/app/builder/shared/commands.ts @@ -8,6 +8,7 @@ import { createCommandsEmitter, type Command } from "~/shared/commands-emitter"; import { $editingItemSelector, $isDesignMode, + $isPreviewMode, toggleBuilderMode, $project, } from "~/shared/nano-states"; @@ -75,6 +76,15 @@ const makeBreakpointCommand = ( }, }); +const exitPreviewModeFromNonCanvasSource = (source: string) => { + if (source === "canvas") { + return; + } + + setActiveSidebarPanel("auto"); + toggleBuilderMode("preview"); +}; + export const { emitCommand, subscribeCommands } = createCommandsEmitter({ source: "builder", externalCommands: [ @@ -99,7 +109,12 @@ export const { emitCommand, subscribeCommands } = createCommandsEmitter({ defaultHotkeys: ["escape"], // radix check event.defaultPrevented before invoking callbacks preventDefault: false, - handler: () => { + handler: (source) => { + if ($isPreviewMode.get()) { + exitPreviewModeFromNonCanvasSource(source); + return; + } + const { publish } = $publisher.get(); publish?.({ type: "cancelCurrentDrag" }); }, diff --git a/apps/builder/app/shared/commands-emitter.test.ts b/apps/builder/app/shared/commands-emitter.test.ts new file mode 100644 index 000000000000..3773ed7be42d --- /dev/null +++ b/apps/builder/app/shared/commands-emitter.test.ts @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { $commandMetas, createCommandsEmitter } from "./commands-emitter"; + +type CommandPayload = { + source: string; + name: string; +}; + +const pubsubMock = vi.hoisted(() => { + let publisher: { publish?: (action: unknown) => void } = {}; + const subscribers = new Map< + string, + Array<(payload: CommandPayload) => void> + >(); + + return { + getPublisher: () => publisher, + setPublisher: (next: { publish?: (action: unknown) => void }) => { + publisher = next; + }, + subscribe: (type: string, callback: (payload: CommandPayload) => void) => { + const callbacks = subscribers.get(type) ?? []; + callbacks.push(callback); + subscribers.set(type, callbacks); + return () => { + subscribers.set( + type, + (subscribers.get(type) ?? []).filter((item) => item !== callback) + ); + }; + }, + emit: (type: string, payload: CommandPayload) => { + for (const callback of subscribers.get(type) ?? []) { + callback(payload); + } + }, + reset: () => { + publisher = {}; + subscribers.clear(); + }, + }; +}); + +vi.mock("~/shared/pubsub", () => ({ + $publisher: { + get: pubsubMock.getPublisher, + set: pubsubMock.setPublisher, + }, + subscribe: pubsubMock.subscribe, +})); + +vi.mock("~/shared/sync/sync-stores", () => ({ + clientSyncStore: { + register: vi.fn(), + createTransaction: vi.fn( + ( + stores: Array<{ get: () => unknown }>, + callback: (value: unknown) => void + ) => { + callback(stores[0].get()); + } + ), + }, +})); + +const createTestCommand = (handler: (source: string) => void = vi.fn()) => + createCommandsEmitter({ + source: "builder", + commands: [ + { + name: "testCommand", + handler, + }, + ], + }); + +describe("createCommandsEmitter", () => { + afterEach(() => { + pubsubMock.reset(); + $commandMetas.set(new Map()); + }); + + test("passes own source to handlers when emitting directly", () => { + const calls: string[] = []; + const { emitCommand } = createTestCommand((source) => { + calls.push(source); + }); + + emitCommand("testCommand"); + + expect(calls).toEqual(["builder"]); + }); + + test("publishes commands with own source", () => { + const publish = vi.fn(); + pubsubMock.setPublisher({ publish }); + const { emitCommand } = createTestCommand(); + + emitCommand("testCommand"); + + expect(publish).toHaveBeenCalledWith({ + type: "command", + payload: { + source: "builder", + name: "testCommand", + }, + }); + }); + + test("passes published source to subscribed command handlers", () => { + const calls: string[] = []; + const { subscribeCommands } = createTestCommand((source) => { + calls.push(source); + }); + + const unsubscribe = subscribeCommands(); + pubsubMock.emit("command", { + source: "canvas", + name: "testCommand", + }); + unsubscribe(); + + expect(calls).toEqual(["canvas"]); + }); +}); diff --git a/apps/builder/app/shared/commands-emitter.ts b/apps/builder/app/shared/commands-emitter.ts index c617ce0d26ac..b7c2905659da 100644 --- a/apps/builder/app/shared/commands-emitter.ts +++ b/apps/builder/app/shared/commands-emitter.ts @@ -30,7 +30,7 @@ export type CommandMeta = { keepCommandPanelOpen?: boolean; }; -type CommandHandler = () => void; +type CommandHandler = (source: string) => void; /** * Command can be registered by builder, canvas or plugin @@ -124,7 +124,7 @@ export const createCommandsEmitter = ({ }, }); } else { - commandHandlers.get(name)?.(); + commandHandlers.get(name)?.(source); } }; @@ -143,8 +143,8 @@ export const createCommandsEmitter = ({ }); } - const unsubscribePubsub = subscribe("command", ({ name }) => { - commandHandlers.get(name)?.(); + const unsubscribePubsub = subscribe("command", ({ name, source }) => { + commandHandlers.get(name)?.(source); }); const handleKeyDown = (event: KeyboardEvent) => { let emitted = false; diff --git a/apps/builder/docs/test-cases.md b/apps/builder/docs/test-cases.md index b47e5f4d8ce1..15f2d12fa55e 100644 --- a/apps/builder/docs/test-cases.md +++ b/apps/builder/docs/test-cases.md @@ -193,3 +193,12 @@ - The name should change in the pages list as well (with a small delay) - Change the page path - Reload browser tab and open the page settings again and make sure your changes are persisted + +1. Preview mode + + - Enter preview mode from the top bar + - Press `escape` while focus is outside the canvas + - Check that preview mode exits and the builder UI is shown + - Enter preview mode again + - Press `escape` while interacting with the canvas + - Check that preview mode stays active and the canvas handles the key press From 7cfaf5533bc8e9cc7987ac110cf768399aa70b16 Mon Sep 17 00:00:00 2001 From: Oleg Isonen Date: Mon, 27 Apr 2026 20:47:41 +0100 Subject: [PATCH 04/49] fix: Prevent orphan domains (#5732) --- packages/domain/src/db/domain.test.ts | 140 +++++++++++++++++++++++++- packages/domain/src/db/domain.ts | 67 ++++++++++++ 2 files changed, 204 insertions(+), 3 deletions(-) diff --git a/packages/domain/src/db/domain.test.ts b/packages/domain/src/db/domain.test.ts index 8b13b8ad8e33..068fb4b63e3c 100644 --- a/packages/domain/src/db/domain.test.ts +++ b/packages/domain/src/db/domain.test.ts @@ -7,18 +7,31 @@ import { testContext, } from "@webstudio-is/postgrest/testing"; import type { AppContext } from "@webstudio-is/trpc-interface/index.server"; -import { countTotalDomains, create } from "./domain"; +import { countTotalDomains, create, remove } from "./domain"; const server = createTestServer(); -const createContext = (): AppContext => +const createContext = (overrides: Partial = {}): AppContext => ({ ...testContext, authorization: { type: "user", userId: "owner-1" }, getOwnerPlanFeatures: () => Promise.resolve({}), domain: {}, - deployment: {}, + deployment: { + deploymentTrpc: { + unpublish: { + mutate: () => Promise.resolve({ success: true }), + }, + }, + env: { + BUILDER_ORIGIN: "https://apps.webstudio.is", + GITHUB_REF_NAME: "main", + GITHUB_SHA: undefined, + PUBLISHER_HOST: "wstd.io", + }, + }, entri: {}, + ...overrides, }) as unknown as AppContext; /** @@ -124,3 +137,124 @@ describe("create — domain limit guard (msw)", () => { expect(result).toEqual({ success: true }); }); }); + +// --------------------------------------------------------------------------- +// remove +// Detaches the domain only after unpublishing and marks unreferenced domains +// inactive so they do not remain ACTIVE orphans. +// --------------------------------------------------------------------------- + +describe("remove (msw)", () => { + test("unpublishes, detaches, and marks an unreferenced domain inactive", async () => { + let unpublishedDomain: string | undefined; + let projectDomainDeleted = false; + let domainUpdate: unknown; + + server.use( + projectHandler, + db.get("ProjectDomain", ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get("domainId")).toBe("eq.domain-id-1"); + expect(url.searchParams.get("projectId")).toBe("eq.proj-1"); + return json({ + domain: { + id: "domain-id-1", + domain: "example.com", + }, + }); + }), + db.get("Build", () => json([])), + db.delete("ProjectDomain", () => { + projectDomainDeleted = true; + return empty({ status: 204 }); + }), + db.head("ProjectDomain", () => + empty({ headers: { "Content-Range": "*/0" } }) + ), + db.patch("Domain", async ({ request }) => { + domainUpdate = await request.json(); + return json({ id: "domain-id-1" }); + }) + ); + + const result = await remove( + { projectId: "proj-1", domainId: "domain-id-1" }, + createContext({ + deployment: { + deploymentTrpc: { + publish: { + mutate: () => Promise.resolve({ success: true }), + }, + unpublish: { + mutate: ({ domain }: { domain: string }) => { + unpublishedDomain = domain; + return Promise.resolve({ success: true }); + }, + }, + }, + env: { + BUILDER_ORIGIN: "https://apps.webstudio.is", + GITHUB_REF_NAME: "main", + GITHUB_SHA: undefined, + PUBLISHER_HOST: "wstd.io", + }, + }, + }) + ); + + expect(result).toEqual({ success: true }); + expect(unpublishedDomain).toBe("example.com"); + expect(projectDomainDeleted).toBe(true); + expect(domainUpdate).toEqual({ + status: "INITIALIZING", + error: "Removed from project", + txtRecord: null, + }); + }); + + test("does not detach when unpublish fails", async () => { + let projectDomainDeleted = false; + + server.use( + projectHandler, + db.get("ProjectDomain", () => + json({ + domain: { + id: "domain-id-1", + domain: "example.com", + }, + }) + ), + db.delete("ProjectDomain", () => { + projectDomainDeleted = true; + return empty({ status: 204 }); + }) + ); + + const result = await remove( + { projectId: "proj-1", domainId: "domain-id-1" }, + createContext({ + deployment: { + deploymentTrpc: { + publish: { + mutate: () => Promise.resolve({ success: true }), + }, + unpublish: { + mutate: () => + Promise.resolve({ success: false, error: "Cloudflare failed" }), + }, + }, + env: { + BUILDER_ORIGIN: "https://apps.webstudio.is", + GITHUB_REF_NAME: "main", + GITHUB_SHA: undefined, + PUBLISHER_HOST: "wstd.io", + }, + }, + }) + ); + + expect(result).toEqual({ success: false, error: "Cloudflare failed" }); + expect(projectDomainDeleted).toBe(false); + }); +}); diff --git a/packages/domain/src/db/domain.ts b/packages/domain/src/db/domain.ts index 5ae62c0977b6..e1d08d5070f3 100644 --- a/packages/domain/src/db/domain.ts +++ b/packages/domain/src/db/domain.ts @@ -5,6 +5,7 @@ import { createErrorResponse, } from "@webstudio-is/trpc-interface/index.server"; import * as projectApi from "@webstudio-is/project/index.server"; +import { unpublishBuild } from "@webstudio-is/project-build/index.server"; import { validateDomain } from "./validate"; import { cnameFromUserId } from "./cname-from-user-id"; import type { Project } from "@webstudio-is/project"; @@ -196,6 +197,48 @@ export const remove = async ( throw new Error("You don't have access to delete this project domains"); } + const projectDomainResult = await context.postgrest.client + .from("ProjectDomain") + .select("domain:Domain(id, domain)") + .eq("domainId", props.domainId) + .eq("projectId", props.projectId) + .single(); + + if (projectDomainResult.error) { + return createErrorResponse(projectDomainResult.error); + } + + const domain = projectDomainResult.data.domain; + if (domain === null) { + return createErrorResponse("Domain not found"); + } + + const deploymentResult = await context.deployment.deploymentTrpc.unpublish + .mutate({ + domain: domain.domain, + }) + .catch((error) => createErrorResponse(error)); + + if ( + deploymentResult.success === false && + deploymentResult.error !== "NOT_IMPLEMENTED" + ) { + return deploymentResult; + } + + await unpublishBuild( + { projectId: props.projectId, domain: domain.domain }, + context + ).catch((error) => { + if ( + error instanceof Error && + error.message === `Domain ${domain.domain} is not published` + ) { + return; + } + throw error; + }); + const deleteResult = await context.postgrest.client .from("ProjectDomain") .delete() @@ -206,6 +249,30 @@ export const remove = async ( return createErrorResponse(deleteResult.error); } + const remainingDomains = await context.postgrest.client + .from("ProjectDomain") + .select("domainId", { count: "exact", head: true }) + .eq("domainId", props.domainId); + + if (remainingDomains.error) { + return createErrorResponse(remainingDomains.error); + } + + if (remainingDomains.count === 0) { + const updateResult = await context.postgrest.client + .from("Domain") + .update({ + status: "INITIALIZING", + error: "Removed from project", + txtRecord: null, + }) + .eq("id", props.domainId); + + if (updateResult.error) { + return createErrorResponse(updateResult.error); + } + } + return { success: true }; }; From b006947a852e612b8dd91dd2ce01bf2abbd3f64b Mon Sep 17 00:00:00 2001 From: Oleg Isonen Date: Mon, 27 Apr 2026 22:19:21 +0100 Subject: [PATCH 05/49] feat: WIP realtime collaboration to builder (#5726) https://github.com/webstudio-is/webstudio/issues/46 --- apps/builder/app/builder/builder.tsx | 4 +- .../builder/features/address-bar.stories.tsx | 5 +- .../app/builder/features/address-bar.tsx | 2 +- .../breakpoints-selector.stories.tsx | 2 +- .../breakpoints/breakpoints-selector.tsx | 2 +- .../command-panel/command-panel.stories.tsx | 12 +- .../groups/breakpoints-group.tsx | 2 +- .../command-panel/groups/components-group.tsx | 2 +- .../command-panel/groups/convert-group.tsx | 10 +- .../groups/duplicate-tokens-group.tsx | 7 +- .../command-panel/groups/instances-group.tsx | 11 +- .../command-panel/groups/pages-group.tsx | 3 +- .../command-panel/groups/tags-group.tsx | 10 +- .../command-panel/groups/wrap-group.test.tsx | 14 +- .../command-panel/groups/wrap-group.tsx | 10 +- .../command-panel/shared/instance-list.tsx | 22 +- .../features/components/components.tsx | 2 +- .../builder/features/footer/breadcrumbs.tsx | 3 +- .../features/marketplace/templates.tsx | 2 +- .../features/navigator/css-preview.tsx | 2 +- .../features/navigator/navigator-tree.tsx | 9 +- .../features/pages/page-settings.stories.tsx | 2 +- .../builder/features/pages/page-settings.tsx | 9 +- .../builder/features/pages/page-utils.test.ts | 12 +- .../app/builder/features/pages/page-utils.ts | 12 +- .../app/builder/features/pages/pages.tsx | 5 +- .../app/builder/features/publish/publish.tsx | 51 +- .../controls/resource-control.tsx | 11 +- .../settings-panel/controls/tag-control.tsx | 10 +- .../settings-panel/property-label.tsx | 2 +- .../animation/set-css-property.ts | 2 +- .../animation/subject-select.tsx | 4 +- .../match-media-breakpoints.test.ts | 10 +- .../props-section/props-section.stories.tsx | 14 +- .../props-section/props-section.tsx | 4 +- .../props-section/use-props-logic.ts | 6 +- .../settings-panel/resource-panel.tsx | 10 +- .../settings-panel/settings-section.tsx | 2 +- .../features/settings-panel/shared.tsx | 4 +- .../settings-panel/variable-popover.tsx | 8 +- .../variables-section.stories.tsx | 5 +- .../settings-panel/variables-section.tsx | 8 +- .../features/style-panel/property-label.tsx | 11 +- .../sections/advanced/advanced.tsx | 2 +- .../style-panel/sections/advanced/stores.ts | 2 +- .../backdrop-filter.stories.tsx | 17 +- .../background-content.stories.tsx | 17 +- .../backgrounds/backgrounds.stories.tsx | 17 +- .../sections/borders/borders.stories.tsx | 17 +- .../box-shadows/box-shadows.stories.tsx | 17 +- .../sections/filter/filter.stories.tsx | 17 +- .../sections/flex-child/flex-child.tsx | 3 +- .../sections/grid-child/grid-child.tsx | 3 +- .../style-panel/sections/layout/layout.tsx | 2 +- .../sections/layout/shared/grid-generator.tsx | 10 +- .../sections/layout/shared/grid-settings.tsx | 2 +- .../sections/outline/outline.stories.tsx | 17 +- .../position/inset-control.stories.tsx | 15 +- .../transitions/transitions.stories.tsx | 17 +- .../style-panel/shared/instances-kv.ts | 2 +- .../features/style-panel/shared/model.tsx | 10 +- .../style-panel/shared/repeated-style.test.ts | 17 +- .../style-panel/shared/use-style-data.test.ts | 17 +- .../style-panel/shared/use-style-data.ts | 6 +- .../features/style-panel/style-panel.tsx | 2 +- .../style-panel/style-source-section.test.ts | 23 +- .../style-panel/style-source-section.tsx | 8 +- .../app/builder/features/sync-status.tsx | 6 +- .../block-editor-context-menu.tsx | 2 +- .../canvas-instance-context-menu.tsx | 2 +- .../workspace/canvas-tools/canvas-tools.tsx | 4 +- .../collaborative-cursors.test.ts | 160 ++++++ .../canvas-tools/collaborative-cursors.tsx | 245 ++++++++++ .../outline/block-instance-outline.tsx | 4 +- .../canvas-tools/outline/block-utils.ts | 6 +- .../collaborative-instance-outline.tsx | 61 ++- .../outline/hovered-instance-outline.tsx | 2 +- .../canvas-tools/outline/outline.tsx | 8 +- .../outline/selected-instance-outline.tsx | 8 +- .../workspace/canvas-tools/resize-handles.tsx | 7 +- .../builder/features/workspace/workspace.tsx | 2 +- apps/builder/app/builder/inspector.tsx | 2 +- .../shared/asset-manager/asset-info.tsx | 47 +- .../asset-manager/asset-manager.stories.tsx | 2 +- .../asset-manager/delete-unused-assets.tsx | 2 +- .../builder/shared/assets/delete-assets.ts | 2 +- .../builder/shared/assets/replace-asset.ts | 2 +- .../builder/shared/assets/upload-assets.tsx | 4 +- .../app/builder/shared/assets/use-assets.tsx | 3 +- .../app/builder/shared/calc-canvas-width.ts | 7 +- apps/builder/app/builder/shared/commands.ts | 5 +- .../builder/shared/data-variable-utils.tsx | 4 +- .../builder/shared/instance-context-menu.tsx | 4 +- .../builder/shared/style-source-actions.tsx | 10 +- .../builder/shared/topbar-layout.stories.tsx | 4 +- apps/builder/app/builder/shared/topbar.tsx | 2 +- .../app/builder/shared/url-pattern.test.ts | 2 +- apps/builder/app/canvas/canvas.tsx | 11 +- .../features/build-mode/block-template.tsx | 3 +- .../app/canvas/features/build-mode/block.tsx | 9 +- .../text-editor/text-editor.stories.tsx | 8 +- .../features/text-editor/text-editor.tsx | 8 +- .../webstudio-component.tsx | 11 +- apps/builder/app/canvas/grid-guide-utils.ts | 21 +- apps/builder/app/canvas/inflator.ts | 11 +- apps/builder/app/canvas/instance-hovering.ts | 23 +- apps/builder/app/canvas/instance-selected.ts | 26 +- apps/builder/app/canvas/instance-selection.ts | 13 +- apps/builder/app/canvas/interceptor.ts | 10 +- apps/builder/app/canvas/shared/commands.ts | 8 +- apps/builder/app/canvas/shared/styles.test.ts | 19 + apps/builder/app/canvas/shared/styles.ts | 85 +++- .../app/canvas/shared/use-drag-drop.ts | 3 +- apps/builder/app/dashboard/projects/colors.ts | 6 - apps/builder/app/dashboard/projects/tags.tsx | 10 +- apps/builder/app/env/env.static.ts | 1 + apps/builder/app/env/vite-env.d.ts | 1 + apps/builder/app/routes/rest.build-version.ts | 21 + apps/builder/app/routes/rest.patch.ts | 459 ------------------ apps/builder/app/routes/trpc.$.ts | 12 +- .../app/services/build-router.server.ts | 190 ++++++++ .../app/services/trcp-router.server.ts | 2 + apps/builder/app/shared/awareness.test.tsx | 170 ++++++- apps/builder/app/shared/awareness.ts | 381 ++++++--------- .../breakpoints/select-breakpoint-by-order.ts | 3 +- apps/builder/app/shared/builder-data.ts | 4 +- .../builder/app/shared/context.server.test.ts | 104 ++++ apps/builder/app/shared/context.server.ts | 17 +- apps/builder/app/shared/copy-paste.test.tsx | 2 +- .../shared/copy-paste/plugin-html.test.tsx | 14 +- .../shared/copy-paste/plugin-instance.test.ts | 11 +- .../app/shared/copy-paste/plugin-instance.ts | 11 +- .../plugin-webflow/plugin-webflow.test.tsx | 10 +- .../plugin-webflow/plugin-webflow.ts | 2 +- .../app/shared/instance-utils.test.tsx | 56 +-- apps/builder/app/shared/instance-utils.ts | 76 ++- .../app/shared/nano-states/breakpoints.ts | 4 - apps/builder/app/shared/nano-states/canvas.ts | 2 +- .../app/shared/nano-states/components.ts | 21 +- apps/builder/app/shared/nano-states/index.ts | 17 - .../shared/nano-states/instance-selection.ts | 16 + .../app/shared/nano-states/instances.ts | 160 +++++- apps/builder/app/shared/nano-states/misc.ts | 22 +- apps/builder/app/shared/nano-states/pages.ts | 53 +- .../app/shared/nano-states/props.test.tsx | 16 +- apps/builder/app/shared/nano-states/props.ts | 18 +- apps/builder/app/shared/page-utils.test.tsx | 2 +- apps/builder/app/shared/page-utils.ts | 2 +- .../app/shared/pages/use-switch-page.ts | 6 +- apps/builder/app/shared/polly/backoff.test.ts | 141 ------ .../app/shared/polly/polling-client.ts | 2 +- .../project-settings/section-general.tsx | 4 +- .../app/shared/router-utils/path-utils.ts | 14 +- apps/builder/app/shared/sync-client.test.ts | 14 +- apps/builder/app/shared/sync-client.ts | 82 ++-- .../app/shared/sync/collab-sync.test.ts | 373 ++++++++++++++ .../app/shared/sync/command-queue.test.ts | 34 +- apps/builder/app/shared/sync/command-queue.ts | 21 +- .../app/shared/sync/multiplayer-client.ts | 106 ++++ .../app/shared/sync/multiplayer-sync.test.ts | 301 ++++++++++++ .../app/shared/sync/multiplayer-sync.ts | 192 ++++++++ .../sync/pages-patch-normalizer.test.ts | 242 +++++++++ .../app/shared/sync/pages-patch-normalizer.ts | 161 ++++++ .../sync/patch/patch-auth.server.test.ts | 189 ++++++++ .../shared/sync/patch/patch-auth.server.ts | 115 +++++ .../sync/patch/patch-normalize.server.test.ts | 140 ++++++ .../sync/patch/patch-normalize.server.ts | 80 +++ .../sync/patch/patch-service.server.test.ts | 237 +++++++++ .../shared/sync/patch/patch-service.server.ts | 251 ++++++++++ .../app/shared/sync/project-queue.test.ts | 229 +++++---- apps/builder/app/shared/sync/project-queue.ts | 177 +++---- .../app/shared/sync/singleplayer-client.ts | 44 ++ apps/builder/app/shared/sync/sync-client.ts | 137 ++++-- .../app/shared/sync/sync-stores.test.ts | 123 +++++ apps/builder/app/shared/sync/sync-stores.ts | 164 ++++++- apps/builder/app/shared/system.test.ts | 2 +- apps/builder/app/shared/system.ts | 5 +- apps/builder/app/shared/tailwind/tailwind.ts | 2 +- apps/builder/app/shared/trpc/trpc-client.ts | 22 +- apps/builder/package.json | 1 + apps/builder/tsconfig.json | 1 + apps/builder/vite.config.ts | 43 +- packages/asset-uploader/package.json | 6 +- .../asset-uploader/src/asset-patch-core.ts | 189 ++++++++ packages/asset-uploader/src/db/load.ts | 38 +- packages/asset-uploader/src/delete.ts | 48 +- packages/asset-uploader/src/index.server.ts | 1 + packages/asset-uploader/src/patch.test.ts | 196 ++++++++ packages/asset-uploader/src/patch.ts | 83 +--- packages/css-engine/src/color.ts | 2 +- packages/design-system/package.json | 3 + .../src/components/color-picker.test.ts | 42 +- .../src/components/color-picker.tsx | 50 +- packages/feature-flags/src/flags.ts | 1 + packages/multiplayer-protocol/package.json | 24 + .../multiplayer-protocol/src/index.test.ts | 56 +++ packages/multiplayer-protocol/src/index.ts | 148 ++++++ packages/multiplayer-protocol/tsconfig.json | 7 + packages/project-build/package.json | 3 +- packages/project-build/src/db/build-parser.ts | 44 ++ packages/project-build/src/db/build.ts | 38 +- packages/project-build/src/index.ts | 4 + packages/project/package.json | 6 + packages/project/src/db/build-patch-core.ts | 303 ++++++++++++ .../src/db/build-patch-permissions.test.ts | 42 ++ .../project/src/db/build-patch-permissions.ts | 18 + packages/project/src/db/build-patch.test.ts | 296 +++++++++++ packages/project/src/db/build-patch.ts | 90 ++++ packages/project/src/index.server.ts | 3 + packages/react-sdk/src/remix.test.ts | 2 +- packages/sdk/package.json | 5 +- packages/sdk/src/router-paths.test.ts | 10 - packages/sync-client/package.json | 36 ++ packages/sync-client/src/backoff.test.ts | 123 +++++ .../sync-client/src}/backoff.ts | 37 +- packages/sync-client/src/index.ts | 57 +++ .../src/multiplayer/collab-state.test.ts | 8 + .../src/multiplayer/collab-state.ts | 4 + .../multiplayer/collaborator-colors.test.ts | 73 +++ .../src/multiplayer/collaborator-colors.ts | 57 +++ .../multiplayer-retry-tracker.test.ts | 337 +++++++++++++ .../multiplayer/multiplayer-retry-tracker.ts | 298 ++++++++++++ .../src/multiplayer/websocket-emitter.test.ts | 300 ++++++++++++ .../src/multiplayer/websocket-emitter.ts | 264 ++++++++++ packages/sync-client/src/sync-status.test.ts | 37 ++ packages/sync-client/src/sync-status.ts | 17 + .../src/transaction-completion.test.ts | 47 ++ .../sync-client/src/transaction-completion.ts | 74 +++ packages/sync-client/src/types.ts | 30 ++ packages/sync-client/tsconfig.json | 7 + pnpm-lock.yaml | 274 +++++++++-- 231 files changed, 8726 insertions(+), 2149 deletions(-) create mode 100644 apps/builder/app/builder/features/workspace/canvas-tools/collaborative-cursors.test.ts create mode 100644 apps/builder/app/builder/features/workspace/canvas-tools/collaborative-cursors.tsx delete mode 100644 apps/builder/app/dashboard/projects/colors.ts create mode 100644 apps/builder/app/routes/rest.build-version.ts delete mode 100644 apps/builder/app/routes/rest.patch.ts create mode 100644 apps/builder/app/services/build-router.server.ts create mode 100644 apps/builder/app/shared/context.server.test.ts create mode 100644 apps/builder/app/shared/nano-states/instance-selection.ts delete mode 100644 apps/builder/app/shared/polly/backoff.test.ts create mode 100644 apps/builder/app/shared/sync/collab-sync.test.ts create mode 100644 apps/builder/app/shared/sync/multiplayer-client.ts create mode 100644 apps/builder/app/shared/sync/multiplayer-sync.test.ts create mode 100644 apps/builder/app/shared/sync/multiplayer-sync.ts create mode 100644 apps/builder/app/shared/sync/pages-patch-normalizer.test.ts create mode 100644 apps/builder/app/shared/sync/pages-patch-normalizer.ts create mode 100644 apps/builder/app/shared/sync/patch/patch-auth.server.test.ts create mode 100644 apps/builder/app/shared/sync/patch/patch-auth.server.ts create mode 100644 apps/builder/app/shared/sync/patch/patch-normalize.server.test.ts create mode 100644 apps/builder/app/shared/sync/patch/patch-normalize.server.ts create mode 100644 apps/builder/app/shared/sync/patch/patch-service.server.test.ts create mode 100644 apps/builder/app/shared/sync/patch/patch-service.server.ts create mode 100644 apps/builder/app/shared/sync/singleplayer-client.ts create mode 100644 apps/builder/app/shared/sync/sync-stores.test.ts create mode 100644 packages/asset-uploader/src/asset-patch-core.ts create mode 100644 packages/multiplayer-protocol/package.json create mode 100644 packages/multiplayer-protocol/src/index.test.ts create mode 100644 packages/multiplayer-protocol/src/index.ts create mode 100644 packages/multiplayer-protocol/tsconfig.json create mode 100644 packages/project-build/src/db/build-parser.ts create mode 100644 packages/project/src/db/build-patch-core.ts create mode 100644 packages/project/src/db/build-patch-permissions.test.ts create mode 100644 packages/project/src/db/build-patch-permissions.ts create mode 100644 packages/project/src/db/build-patch.test.ts create mode 100644 packages/project/src/db/build-patch.ts create mode 100644 packages/sync-client/package.json create mode 100644 packages/sync-client/src/backoff.test.ts rename {apps/builder/app/shared/polly => packages/sync-client/src}/backoff.ts (58%) create mode 100644 packages/sync-client/src/index.ts create mode 100644 packages/sync-client/src/multiplayer/collab-state.test.ts create mode 100644 packages/sync-client/src/multiplayer/collab-state.ts create mode 100644 packages/sync-client/src/multiplayer/collaborator-colors.test.ts create mode 100644 packages/sync-client/src/multiplayer/collaborator-colors.ts create mode 100644 packages/sync-client/src/multiplayer/multiplayer-retry-tracker.test.ts create mode 100644 packages/sync-client/src/multiplayer/multiplayer-retry-tracker.ts create mode 100644 packages/sync-client/src/multiplayer/websocket-emitter.test.ts create mode 100644 packages/sync-client/src/multiplayer/websocket-emitter.ts create mode 100644 packages/sync-client/src/sync-status.test.ts create mode 100644 packages/sync-client/src/sync-status.ts create mode 100644 packages/sync-client/src/transaction-completion.test.ts create mode 100644 packages/sync-client/src/transaction-completion.ts create mode 100644 packages/sync-client/src/types.ts create mode 100644 packages/sync-client/tsconfig.json diff --git a/apps/builder/app/builder/builder.tsx b/apps/builder/app/builder/builder.tsx index a78943243a79..e58525484af2 100644 --- a/apps/builder/app/builder/builder.tsx +++ b/apps/builder/app/builder/builder.tsx @@ -27,8 +27,6 @@ import { $authPermit, $authToken, $isPreviewMode, - $pages, - $project, subscribeResources, $authTokenPermissions, $isDesignMode, @@ -38,6 +36,7 @@ import { $stagingUsername, $stagingPassword, } from "~/shared/nano-states"; +import { $project } from "~/shared/sync/data-stores"; import { $settings, type Settings } from "./shared/client-settings"; import { builderUrl, getCanvasUrl } from "~/shared/router-utils"; import { BlockingAlerts } from "./features/blocking-alerts"; @@ -52,6 +51,7 @@ import { $isCloneDialogOpen, $loadingState, } from "./shared/nano-states"; +import { $pages } from "~/shared/sync/data-stores"; import { CloneProjectDialog } from "~/shared/clone-project"; import type { TokenPermissions } from "@webstudio-is/authorization-token"; import { useToastErrors } from "~/shared/error/toast-error"; diff --git a/apps/builder/app/builder/features/address-bar.stories.tsx b/apps/builder/app/builder/features/address-bar.stories.tsx index 79dbb55dbe5f..c652a8173b6a 100644 --- a/apps/builder/app/builder/features/address-bar.stories.tsx +++ b/apps/builder/app/builder/features/address-bar.stories.tsx @@ -7,7 +7,8 @@ import { TopbarLayout } from "~/builder/shared/topbar-layout"; import { AddressBarPopover } from "./address-bar"; import { $dataSources, $pages } from "~/shared/sync/data-stores"; import { registerContainers } from "~/shared/sync/sync-stores"; -import { $awareness, $selectedPage } from "~/shared/awareness"; +import { $selectedPage } from "~/shared/nano-states"; +import { $selectedPageId } from "~/shared/nano-states"; import { $currentSystem } from "~/shared/system"; registerContainers(); @@ -67,7 +68,7 @@ export default { } satisfies Meta; export const AddressBar: StoryFn = () => { - $awareness.set({ pageId: "dynamicId" }); + $selectedPageId.set("dynamicId"); return ( { useEffect(() => { diff --git a/apps/builder/app/builder/features/command-panel/groups/breakpoints-group.tsx b/apps/builder/app/builder/features/command-panel/groups/breakpoints-group.tsx index b1bfa9c011c4..7b642c7daca9 100644 --- a/apps/builder/app/builder/features/command-panel/groups/breakpoints-group.tsx +++ b/apps/builder/app/builder/features/command-panel/groups/breakpoints-group.tsx @@ -9,10 +9,10 @@ import { computed } from "nanostores"; import { compareMedia } from "@webstudio-is/css-engine"; import type { Breakpoint } from "@webstudio-is/sdk"; import { - $breakpoints, $selectedBreakpoint, $selectedBreakpointId, } from "~/shared/nano-states"; +import { $breakpoints } from "~/shared/sync/data-stores"; import { closeCommandPanel, $isCommandPanelOpen } from "../command-state"; import type { BaseOption } from "../shared/types"; import { setCanvasWidth } from "~/builder/shared/calc-canvas-width"; diff --git a/apps/builder/app/builder/features/command-panel/groups/components-group.tsx b/apps/builder/app/builder/features/command-panel/groups/components-group.tsx index bdc2fffdb62e..4a4a007f7c0b 100644 --- a/apps/builder/app/builder/features/command-panel/groups/components-group.tsx +++ b/apps/builder/app/builder/features/command-panel/groups/components-group.tsx @@ -19,7 +19,7 @@ import { insertWebstudioFragmentAt, } from "~/shared/instance-utils"; import { humanizeString } from "~/shared/string-utils"; -import { $selectedPage } from "~/shared/awareness"; +import { $selectedPage } from "~/shared/nano-states"; import { getInstanceLabel, InstanceIcon, diff --git a/apps/builder/app/builder/features/command-panel/groups/convert-group.tsx b/apps/builder/app/builder/features/command-panel/groups/convert-group.tsx index fd97c5f1ce41..3e7480b0c686 100644 --- a/apps/builder/app/builder/features/command-panel/groups/convert-group.tsx +++ b/apps/builder/app/builder/features/command-panel/groups/convert-group.tsx @@ -13,12 +13,10 @@ import { import { matchSorter } from "match-sorter"; import { computed } from "nanostores"; import { elementComponent, tags } from "@webstudio-is/sdk"; -import { - $instances, - $props, - $registeredComponentMetas, -} from "~/shared/nano-states"; -import { $selectedInstancePath } from "~/shared/awareness"; +import { $registeredComponentMetas } from "~/shared/nano-states"; +import { $instances } from "~/shared/sync/data-stores"; +import { $props } from "~/shared/sync/data-stores"; +import { $selectedInstancePath } from "~/shared/nano-states"; import { getInstanceLabel, InstanceIcon, diff --git a/apps/builder/app/builder/features/command-panel/groups/duplicate-tokens-group.tsx b/apps/builder/app/builder/features/command-panel/groups/duplicate-tokens-group.tsx index 0c8ae332eb4a..21c8755a8961 100644 --- a/apps/builder/app/builder/features/command-panel/groups/duplicate-tokens-group.tsx +++ b/apps/builder/app/builder/features/command-panel/groups/duplicate-tokens-group.tsx @@ -17,12 +17,9 @@ import { useSelectedAction, } from "@webstudio-is/design-system"; import type { Instance, StyleSource } from "@webstudio-is/sdk"; -import { - $styleSources, - $styles, - $breakpoints, -} from "~/shared/sync/data-stores"; +import { $styleSources } from "~/shared/sync/data-stores"; import { $selectedStyleSources } from "~/shared/nano-states"; +import { $styles, $breakpoints } from "~/shared/sync/data-stores"; import { findDuplicateTokens } from "~/shared/style-source-utils"; import { $styleSourceUsages } from "~/builder/shared/style-source-actions"; import { InstanceList, showInstance } from "../shared/instance-list"; diff --git a/apps/builder/app/builder/features/command-panel/groups/instances-group.tsx b/apps/builder/app/builder/features/command-panel/groups/instances-group.tsx index f6644ea9625d..b84bc44fe4ff 100644 --- a/apps/builder/app/builder/features/command-panel/groups/instances-group.tsx +++ b/apps/builder/app/builder/features/command-panel/groups/instances-group.tsx @@ -11,7 +11,11 @@ import { parseComponentName } from "@webstudio-is/sdk"; import type { Instance } from "@webstudio-is/sdk"; import { $instances, $pages } from "~/shared/sync/data-stores"; import { getInstanceLabel } from "~/builder/shared/instance-label"; -import { $awareness, findAwarenessByInstanceId } from "~/shared/awareness"; +import { + $selectedPageId, + $selectedInstanceSelector, +} from "~/shared/nano-states"; +import { findPageAndSelectorByInstanceId } from "~/shared/instance-utils"; import { closeCommandPanel, $isCommandPanelOpen } from "../command-state"; import type { BaseOption } from "../shared/types"; import { setActiveSidebarPanel } from "~/builder/shared/nano-states"; @@ -79,13 +83,14 @@ export const InstancesGroup = ({ options }: { options: InstanceOption[] }) => { const pages = $pages.get(); const instances = $instances.get(); if (pages && instances) { - const awareness = findAwarenessByInstanceId( + const awareness = findPageAndSelectorByInstanceId( pages, instances, instance.id ); if (awareness) { - $awareness.set(awareness); + $selectedPageId.set(awareness.pageId); + $selectedInstanceSelector.set(awareness.instanceSelector); setActiveSidebarPanel("auto"); } } diff --git a/apps/builder/app/builder/features/command-panel/groups/pages-group.tsx b/apps/builder/app/builder/features/command-panel/groups/pages-group.tsx index 85a7c81a7353..c6e0d0a9d54c 100644 --- a/apps/builder/app/builder/features/command-panel/groups/pages-group.tsx +++ b/apps/builder/app/builder/features/command-panel/groups/pages-group.tsx @@ -9,7 +9,8 @@ import { computed } from "nanostores"; import type { Page } from "@webstudio-is/sdk"; import { $pages } from "~/shared/sync/data-stores"; import { $editingPageId } from "~/shared/nano-states"; -import { $selectedPage, selectPage } from "~/shared/awareness"; +import { $selectedPage } from "~/shared/nano-states"; +import { selectPage } from "~/shared/nano-states"; import { setActiveSidebarPanel } from "~/builder/shared/nano-states"; import { closeCommandPanel, $isCommandPanelOpen } from "../command-state"; import type { BaseOption } from "../shared/types"; diff --git a/apps/builder/app/builder/features/command-panel/groups/tags-group.tsx b/apps/builder/app/builder/features/command-panel/groups/tags-group.tsx index b95a71d62693..f6aad5936cd2 100644 --- a/apps/builder/app/builder/features/command-panel/groups/tags-group.tsx +++ b/apps/builder/app/builder/features/command-panel/groups/tags-group.tsx @@ -9,13 +9,11 @@ import { import { computed } from "nanostores"; import { elementComponent, tags } from "@webstudio-is/sdk"; import type { Instance } from "@webstudio-is/sdk"; -import { - $instances, - $props, - $registeredComponentMetas, -} from "~/shared/nano-states"; +import { $registeredComponentMetas } from "~/shared/nano-states"; +import { $instances } from "~/shared/sync/data-stores"; +import { $props } from "~/shared/sync/data-stores"; import { insertWebstudioFragmentAt } from "~/shared/instance-utils"; -import { $selectedInstancePath } from "~/shared/awareness"; +import { $selectedInstancePath } from "~/shared/nano-states"; import { InstanceIcon } from "~/builder/shared/instance-label"; import { isTreeSatisfyingContentModel } from "~/shared/content-model"; import { closeCommandPanel, $isCommandPanelOpen } from "../command-state"; diff --git a/apps/builder/app/builder/features/command-panel/groups/wrap-group.test.tsx b/apps/builder/app/builder/features/command-panel/groups/wrap-group.test.tsx index 9661b2776879..fa8238b541a2 100644 --- a/apps/builder/app/builder/features/command-panel/groups/wrap-group.test.tsx +++ b/apps/builder/app/builder/features/command-panel/groups/wrap-group.test.tsx @@ -4,14 +4,12 @@ import * as baseMetas from "@webstudio-is/sdk-components-react/metas"; import * as animationMetas from "@webstudio-is/sdk-components-animation/metas"; import { createDefaultPages } from "@webstudio-is/project-build"; import { $, renderData } from "@webstudio-is/template"; -import { - $instances, - $pages, - $props, - $registeredComponentMetas, -} from "~/shared/nano-states"; +import { $registeredComponentMetas } from "~/shared/nano-states"; +import { $instances } from "~/shared/sync/data-stores"; +import { $pages, $props } from "~/shared/sync/data-stores"; import { registerContainers } from "~/shared/sync/sync-stores"; -import { $awareness, selectInstance } from "~/shared/awareness"; +import { $selectedPageId } from "~/shared/nano-states"; +import { selectInstance } from "~/shared/nano-states"; import { __testing__ } from "./wrap-group"; const { canWrapInstance } = __testing__; @@ -25,7 +23,7 @@ const metas = new Map( beforeEach(() => { $registeredComponentMetas.set(metas); $pages.set(createDefaultPages({ rootInstanceId: "" })); - $awareness.set({ pageId: "" }); + $selectedPageId.set(""); }); describe("canWrapInstance for components", () => { diff --git a/apps/builder/app/builder/features/command-panel/groups/wrap-group.tsx b/apps/builder/app/builder/features/command-panel/groups/wrap-group.tsx index 209f982c9484..ff3ff139511e 100644 --- a/apps/builder/app/builder/features/command-panel/groups/wrap-group.tsx +++ b/apps/builder/app/builder/features/command-panel/groups/wrap-group.tsx @@ -19,12 +19,10 @@ import type { Props, WsComponentMeta, } from "@webstudio-is/sdk"; -import { - $instances, - $props, - $registeredComponentMetas, -} from "~/shared/nano-states"; -import { $selectedInstancePath } from "~/shared/awareness"; +import { $registeredComponentMetas } from "~/shared/nano-states"; +import { $instances } from "~/shared/sync/data-stores"; +import { $props } from "~/shared/sync/data-stores"; +import { $selectedInstancePath } from "~/shared/nano-states"; import { getInstanceLabel, InstanceIcon, diff --git a/apps/builder/app/builder/features/command-panel/shared/instance-list.tsx b/apps/builder/app/builder/features/command-panel/shared/instance-list.tsx index 36dac6710cb1..61b826adfa2c 100644 --- a/apps/builder/app/builder/features/command-panel/shared/instance-list.tsx +++ b/apps/builder/app/builder/features/command-panel/shared/instance-list.tsx @@ -18,9 +18,12 @@ import { $instances, $pages } from "~/shared/sync/data-stores"; import { getInstanceLabel } from "~/builder/shared/instance-label"; import { buildInstancePath } from "~/shared/instance-utils"; import { $commandContent } from "~/builder/features/command-panel/command-state"; -import { findAwarenessByInstanceId } from "~/shared/awareness"; -import { $awareness } from "~/shared/awareness"; +import { findPageAndSelectorByInstanceId } from "~/shared/instance-utils"; import { $activeInspectorPanel } from "~/builder/shared/nano-states"; +import { + $selectedPageId, + $selectedInstanceSelector, +} from "~/shared/nano-states"; import { useAutoSelectFirstItem } from "./auto-select"; import { InstancePathFooter } from "./instance-path-footer"; @@ -53,7 +56,11 @@ export const InstanceList = ({ continue; } const path = buildInstancePath(instanceId, pages, instances); - const awareness = findAwarenessByInstanceId(pages, instances, instanceId); + const awareness = findPageAndSelectorByInstanceId( + pages, + instances, + instanceId + ); const page = pages.pages.find((p) => p.id === awareness.pageId); usedInInstances.push({ label: getInstanceLabel(instance), @@ -154,8 +161,13 @@ export const showInstance = ( if (pagesData === undefined) { return; } - const awareness = findAwarenessByInstanceId(pagesData, instances, instanceId); - $awareness.set(awareness); + const { pageId, instanceSelector } = findPageAndSelectorByInstanceId( + pagesData, + instances, + instanceId + ); + $selectedPageId.set(pageId); + $selectedInstanceSelector.set(instanceSelector); if (panel !== undefined) { $activeInspectorPanel.set(panel); } diff --git a/apps/builder/app/builder/features/components/components.tsx b/apps/builder/app/builder/features/components/components.tsx index 85abecdfcd13..6696c5032a14 100644 --- a/apps/builder/app/builder/features/components/components.tsx +++ b/apps/builder/app/builder/features/components/components.tsx @@ -36,7 +36,7 @@ import { insertWebstudioFragmentAt, } from "~/shared/instance-utils"; import type { Publish } from "~/shared/pubsub"; -import { $selectedPage } from "~/shared/awareness"; +import { $selectedPage } from "~/shared/nano-states"; import { mapGroupBy } from "~/shared/shim"; import { getInstanceLabel, diff --git a/apps/builder/app/builder/features/footer/breadcrumbs.tsx b/apps/builder/app/builder/features/footer/breadcrumbs.tsx index b8df1553540e..9e21c93c14f5 100644 --- a/apps/builder/app/builder/features/footer/breadcrumbs.tsx +++ b/apps/builder/app/builder/features/footer/breadcrumbs.tsx @@ -3,7 +3,8 @@ import { useStore } from "@nanostores/react"; import { ChevronRightIcon } from "@webstudio-is/icons"; import { theme, Button, Flex, Text } from "@webstudio-is/design-system"; import { $textEditingInstanceSelector } from "~/shared/nano-states"; -import { $selectedInstancePath, selectInstance } from "~/shared/awareness"; +import { $selectedInstancePath } from "~/shared/nano-states"; +import { selectInstance } from "~/shared/nano-states"; import { getInstanceLabel } from "~/builder/shared/instance-label"; export const Breadcrumbs = () => { diff --git a/apps/builder/app/builder/features/marketplace/templates.tsx b/apps/builder/app/builder/features/marketplace/templates.tsx index e6e2f3a1a403..a128f66db626 100644 --- a/apps/builder/app/builder/features/marketplace/templates.tsx +++ b/apps/builder/app/builder/features/marketplace/templates.tsx @@ -35,7 +35,7 @@ import { builderApi } from "~/shared/builder-api"; import { insertPageCopyMutable } from "~/shared/page-utils"; import { Card } from "./card"; import type { MarketplaceOverviewItem } from "~/shared/marketplace/types"; -import { selectPage } from "~/shared/awareness"; +import { selectPage } from "~/shared/nano-states"; const isBody = (instance: Instance) => instance.component === "Body" || diff --git a/apps/builder/app/builder/features/navigator/css-preview.tsx b/apps/builder/app/builder/features/navigator/css-preview.tsx index 1afe573927e5..7736025bf407 100644 --- a/apps/builder/app/builder/features/navigator/css-preview.tsx +++ b/apps/builder/app/builder/features/navigator/css-preview.tsx @@ -12,7 +12,7 @@ import { CollapsibleSection } from "~/builder/shared/collapsible-section"; import { highlightCss } from "~/shared/code-highlight"; import type { ComputedStyleDecl } from "~/shared/style-object-model"; import { $computedStyleDeclarations } from "~/builder/features/style-panel/shared/model"; -import { $selectedInstance } from "~/shared/awareness"; +import { $selectedInstance } from "~/shared/nano-states"; const preStyle = css(textVariants.mono, { margin: 0, diff --git a/apps/builder/app/builder/features/navigator/navigator-tree.tsx b/apps/builder/app/builder/features/navigator/navigator-tree.tsx index 517dc4c177fd..e18232edbfbe 100644 --- a/apps/builder/app/builder/features/navigator/navigator-tree.tsx +++ b/apps/builder/app/builder/features/navigator/navigator-tree.tsx @@ -40,27 +40,26 @@ import { $blockChildOutline, $editingItemSelector, $hoveredInstanceSelector, - $instances, $isContentMode, - $props, $propsIndex, $propValuesByInstanceSelector, $registeredComponentMetas, - $selectedInstanceSelector, getIndexedInstanceId, type ItemDropTarget, $propValuesByInstanceSelectorWithMemoryProps, } from "~/shared/nano-states"; +import { $instances, $props } from "~/shared/sync/data-stores"; import { isDescendantOrSelf, type InstanceSelector } from "~/shared/tree-utils"; import { serverSyncStore } from "~/shared/sync/sync-stores"; import { reparentInstance, toggleInstanceShow } from "~/shared/instance-utils"; import { emitCommand } from "~/builder/shared/commands"; import { useContentEditable } from "~/shared/dom-hooks"; import { + $selectedInstanceSelector, $selectedPage, getInstanceKey, - selectInstance, -} from "~/shared/awareness"; +} from "~/shared/nano-states"; +import { selectInstance } from "~/shared/nano-states"; import { findClosestContainer, isRichTextContent, diff --git a/apps/builder/app/builder/features/pages/page-settings.stories.tsx b/apps/builder/app/builder/features/pages/page-settings.stories.tsx index 6fe9538a23f8..9846d301a315 100644 --- a/apps/builder/app/builder/features/pages/page-settings.stories.tsx +++ b/apps/builder/app/builder/features/pages/page-settings.stories.tsx @@ -1,4 +1,4 @@ -import { $pages } from "~/shared/nano-states/pages"; +import { $pages } from "~/shared/sync/data-stores"; import { PageSettings as PageSettingsComponent } from "./page-settings"; import { Grid, diff --git a/apps/builder/app/builder/features/pages/page-settings.tsx b/apps/builder/app/builder/features/pages/page-settings.tsx index 859a86701719..fd3c8d35e0fc 100644 --- a/apps/builder/app/builder/features/pages/page-settings.tsx +++ b/apps/builder/app/builder/features/pages/page-settings.tsx @@ -68,15 +68,14 @@ import { } from "@webstudio-is/icons"; import { useIds } from "~/shared/form-utils"; import { - $assets, - $instances, - $pages, $publishedOrigin, - $project, $permissions, $isDesignMode, } from "~/shared/nano-states"; +import { $assets } from "~/shared/sync/data-stores"; +import { $project } from "~/shared/sync/data-stores"; import { $openProjectSettings } from "~/shared/nano-states/project-settings"; +import { $instances, $pages } from "~/shared/sync/data-stores"; import { BindingControl, BindingPopover, @@ -92,7 +91,7 @@ import { validatePathnamePattern, } from "~/builder/shared/url-pattern"; import { useUnmount } from "~/shared/hook-utils/use-mount"; -import { selectInstance } from "~/shared/awareness"; +import { selectInstance } from "~/shared/nano-states"; import { computeExpression } from "~/shared/data-variables"; import { $currentSystem } from "~/shared/system"; import { Card } from "../marketplace/card"; diff --git a/apps/builder/app/builder/features/pages/page-utils.test.ts b/apps/builder/app/builder/features/pages/page-utils.test.ts index ab86ef7b90b4..96628ed5fb34 100644 --- a/apps/builder/app/builder/features/pages/page-utils.test.ts +++ b/apps/builder/app/builder/features/pages/page-utils.test.ts @@ -23,15 +23,15 @@ import { reparentPageOrFolderMutable, deletePageMutable, } from "./page-utils"; +import { $dataSourceVariables } from "~/shared/nano-states"; import { - $dataSourceVariables, $dataSources, $pages, $project, $resources, -} from "~/shared/nano-states"; +} from "~/shared/sync/data-stores"; import { registerContainers } from "~/shared/sync/sync-stores"; -import { $awareness } from "~/shared/awareness"; +import { $selectedPageId } from "~/shared/nano-states"; import { updateCurrentSystem } from "~/shared/system"; import { $resourcesCache, getResourceKey } from "~/shared/resources"; @@ -464,7 +464,7 @@ test("page root scope should rely on selected page", () => { meta: {}, }); $pages.set(pages); - $awareness.set({ pageId: "pageId" }); + $selectedPageId.set("pageId"); $dataSources.set( toMap([ { @@ -506,7 +506,7 @@ test("page root scope should use variable and resource values", () => { homePageId: "homePageId", }) ); - $awareness.set({ pageId: "homePageId" }); + $selectedPageId.set("homePageId"); $dataSources.set( toMap([ { @@ -574,7 +574,7 @@ test("page root scope should provide page system variable value", () => { systemDataSourceId: "systemId", }) ); - $awareness.set({ pageId: "homePageId" }); + $selectedPageId.set("homePageId"); $dataSources.set( toMap([ { diff --git a/apps/builder/app/builder/features/pages/page-utils.ts b/apps/builder/app/builder/features/pages/page-utils.ts index 6e9515b6231f..7fea31b8dce7 100644 --- a/apps/builder/app/builder/features/pages/page-utils.ts +++ b/apps/builder/app/builder/features/pages/page-utils.ts @@ -21,18 +21,16 @@ import { deleteInstanceMutable, updateWebstudioData, } from "~/shared/instance-utils"; -import { - $dataSources, - $pages, - $variableValuesByInstanceSelector, -} from "~/shared/nano-states"; +import { $variableValuesByInstanceSelector } from "~/shared/nano-states"; +import { $dataSources } from "~/shared/sync/data-stores"; +import { $pages } from "~/shared/sync/data-stores"; import { insertPageCopyMutable } from "~/shared/page-utils"; import { $selectedPage, getInstanceKey, getInstancePath, - selectPage, -} from "~/shared/awareness"; +} from "~/shared/nano-states"; +import { selectPage } from "~/shared/nano-states"; /** * When page or folder needs to be deleted or moved to a different parent, diff --git a/apps/builder/app/builder/features/pages/pages.tsx b/apps/builder/app/builder/features/pages/pages.tsx index fc8e606eb3aa..9f69f98cd5e6 100644 --- a/apps/builder/app/builder/features/pages/pages.tsx +++ b/apps/builder/app/builder/features/pages/pages.tsx @@ -36,8 +36,8 @@ import { $editingPageId, $isContentMode, $isDesignMode, - $pages, } from "~/shared/nano-states"; +import { $pages } from "~/shared/sync/data-stores"; import { getAllChildrenAndSelf, reparentOrphansMutable, @@ -66,7 +66,8 @@ import { import { atom, computed } from "nanostores"; import { isPathnamePattern } from "~/builder/shared/url-pattern"; import { updateWebstudioData } from "~/shared/instance-utils"; -import { $selectedPage, selectPage } from "~/shared/awareness"; +import { $selectedPage } from "~/shared/nano-states"; +import { selectPage } from "~/shared/nano-states"; const ItemSuffix = ({ isParentSelected, diff --git a/apps/builder/app/builder/features/publish/publish.tsx b/apps/builder/app/builder/features/publish/publish.tsx index 4c1be4628761..c846cf6506f5 100644 --- a/apps/builder/app/builder/features/publish/publish.tsx +++ b/apps/builder/app/builder/features/publish/publish.tsx @@ -43,28 +43,25 @@ import { } from "@webstudio-is/design-system"; import { validateProjectDomain, type Project } from "@webstudio-is/project"; import { - $awareness, $selectedPagePath, - findAwarenessByInstanceId, - type Awareness, -} from "~/shared/awareness"; + $selectedInstanceSelector, +} from "~/shared/nano-states"; +import { findPageAndSelectorByInstanceId } from "~/shared/instance-utils"; +import { $selectedPageId } from "~/shared/nano-states"; import { $authTokenPermissions, - $dataSources, $editingPageId, - $instances, - $pages, - $project, $publishedOrigin, $permissions, $stagingUsername, $stagingPassword, - $publisherHost, } from "~/shared/nano-states"; +import { $publisherHost } from "~/shared/sync/data-stores"; import { $publishDialog, setActiveSidebarPanel, } from "../../shared/nano-states"; +import { $project } from "~/shared/sync/data-stores"; import { Domains, PENDING_TIMEOUT, getPublishStatusAndText } from "./domains"; import { CollapsibleDomainSection } from "./collapsible-domain-section"; import { @@ -83,6 +80,7 @@ import { isPathnamePattern, type Templates } from "@webstudio-is/sdk"; import { DomainCheckbox, domainToPublishName } from "./domain-checkbox"; import { CopyToClipboard } from "~/shared/copy-to-clipboard"; import { $openProjectSettings } from "~/shared/nano-states/project-settings"; +import { $dataSources, $instances, $pages } from "~/shared/sync/data-stores"; import { RelativeTime } from "~/builder/shared/relative-time"; import cmsUpgradeBanner from "~/shared/cms-upgrade-banner.svg?url"; @@ -327,7 +325,11 @@ const $restrictedFeatures = computed( const features = new Map< string, | undefined - | { awareness?: Awareness; view?: "pageSettings"; info?: ReactNode } + | { + navigate?: { pageId: string; instanceSelector: string[] }; + view?: "pageSettings"; + info?: ReactNode; + } >(); if (pages === undefined) { return features; @@ -342,16 +344,16 @@ const $restrictedFeatures = computed( if (!permissions.allowDynamicData) { // pages with dynamic paths for (const page of [pages.homePage, ...pages.pages]) { - const awareness = { + const navigate = { pageId: page.id, instanceSelector: [page.rootInstanceId], }; // allow catch all for 404 pages on free plan if (isPathnamePattern(page.path) && page.path !== "/*") { - features.set("Dynamic path", { awareness, view: "pageSettings" }); + features.set("Dynamic path", { navigate, view: "pageSettings" }); } if (page.meta.redirect && page.meta.redirect !== `""`) { - features.set("Redirect", { awareness, view: "pageSettings" }); + features.set("Redirect", { navigate, view: "pageSettings" }); } } // has resource variables @@ -359,7 +361,11 @@ const $restrictedFeatures = computed( if (dataSource.type === "resource") { const instanceId = dataSource.scopeInstanceId ?? ""; features.set("Resource variable", { - awareness: findAwarenessByInstanceId(pages, instances, instanceId), + navigate: findPageAndSelectorByInstanceId( + pages, + instances, + instanceId + ), }); } } @@ -410,7 +416,11 @@ const Publish = ({ restrictedFeatures: Map< string, | undefined - | { awareness?: Awareness; view?: "pageSettings"; info?: ReactNode } + | { + navigate?: { pageId: string; instanceSelector: string[] }; + view?: "pageSettings"; + info?: ReactNode; + } >; }) => { const { maxDailyPublishesPerUser } = useStore($permissions); @@ -850,18 +860,21 @@ const UpgradeBanner = ({ hasCustomDomains }: { hasCustomDomains: boolean }) => { Following Pro features are used: {Array.from(restrictedFeatures).map( - ([message, { awareness, view, info } = {}], index) => ( + ([message, { navigate, view, info } = {}], index) => (
  • - {awareness ? ( + {navigate ? (