mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-28 15:46:58 +09:00
Right-click an instance in the Navigator and choose **Copy link to
instance** to share its full Builder URL. Opening it restores the page
and the same instance occurrence, including descendants of shared Slots.
The shared menu also exposes the action on the canvas.
Copying and opening a link use the same existing
`getDeepLinkedInstanceSelection` function, moved unchanged from
`use-switch-page.ts` into `shared/instance-utils/link.ts`, alongside URL
creation and the existing instance URL parser. It validates the instance
path and page access. The context menu and pre-publish “Show element”
links both call `getInstanceLink(instanceSelector)`. It lives in
`shared/instance-utils/link.ts` and owns validation and the complete URL
setup: project, page, full selector, origin, share token, editor mode,
and safe mode, using the existing `builderUrl`. There is no separate
link-permission implementation.
The link keeps the current share access and editor mode, clears
unrelated scroll anchors, and shows clipboard success or failure. Body
is supported; Global Root and multiple selections are unavailable.
Instance-only navigation now preserves loaded Builder data: the route
comparison recognizes the `instance` parameter as well as the older
`instanceId`. A functional regression test confirms that changing the
target does not change the data URL comparison, while changing share
access does.
MCP guidance explains how to extract the target ID from the link for
`inspect-instance`, verify the project and shared Slot context, and
avoid guessing when an instance no longer exists. This does not add a
URL-taking MCP endpoint.
1. [x] Add the menu action and reuse complete instance-link construction
with pre-publish links.
2. [x] Share existing instance-link validation between copying and
opening links.
3. [x] Update the Navigator and MCP guidance; regenerate CLI
documentation.
4. [x] Extend the existing functional test to round-trip a shared Slot
URL, including share access and editor mode. Verify it fails without the
instance parameter and passes after restoration.
5. [x] Remove the React/clipboard test harness and duplicate
permission-test matrix. Confirm no fixture inputs or outputs are
affected.
Validation: 28 focused Builder tests, Builder typecheck, repository
lint, and `git diff --check` pass on this revision. The earlier CLI
documentation checks passed and their inputs are unchanged. An earlier
GPT-5.4 mini subagent at low reasoning passed a focused native MCP test
against commit 072a8dc534: given only a link and text-edit request, it
changed the correct heading among three identical-looking headings,
leaving all other fixture records unchanged. It used four MCP calls with
broad instance/text reads. That agent test used an isolated local
fixture; it was not a production or shared-Slot agent test. Screenshots
and the full agent evaluation suite were not run.
Closes #4610
Ref #4364
178 lines
4.4 KiB
TypeScript
178 lines
4.4 KiB
TypeScript
import type { AUTH_PROVIDERS } from "~/shared/session";
|
|
import { getAssetUploadApiUrl } from "@webstudio-is/sdk/runtime";
|
|
import { getAuthorizationServerOrigin } from "./origins";
|
|
import type { BuilderMode } from "../nano-states/misc";
|
|
|
|
const searchParams = (params: Record<string, string | undefined | null>) => {
|
|
const searchParams = new URLSearchParams();
|
|
for (const [key, value] of Object.entries(params)) {
|
|
if (typeof value === "string") {
|
|
searchParams.set(key, value);
|
|
}
|
|
}
|
|
const asString = searchParams.toString();
|
|
return asString === "" ? "" : `?${asString}`;
|
|
};
|
|
|
|
export type BuilderLinkParams = {
|
|
pageId?: string;
|
|
instanceSelector?: readonly string[];
|
|
authToken?: string;
|
|
pageHash?: string;
|
|
mode?: BuilderMode;
|
|
safemode?: boolean;
|
|
};
|
|
|
|
export const builderPath = ({
|
|
pageId,
|
|
instanceSelector,
|
|
authToken,
|
|
pageHash,
|
|
mode,
|
|
safemode = false,
|
|
}: BuilderLinkParams = {}) => {
|
|
return `/${searchParams({
|
|
pageId,
|
|
instance: instanceSelector?.join(","),
|
|
authToken,
|
|
pageHash,
|
|
mode,
|
|
safemode: safemode ? "true" : undefined,
|
|
})}`;
|
|
};
|
|
|
|
export const builderUrl = ({
|
|
projectId,
|
|
origin,
|
|
...link
|
|
}: BuilderLinkParams & {
|
|
projectId: string;
|
|
origin: string;
|
|
}) => {
|
|
const authServerOrigin = getAuthorizationServerOrigin(origin);
|
|
|
|
const url = new URL(builderPath(link), authServerOrigin);
|
|
|
|
const fragments = url.host.split(".");
|
|
if (fragments.length <= 3) {
|
|
fragments.splice(0, 0, "p-" + projectId);
|
|
} else {
|
|
// staging | development branches
|
|
fragments[0] = "p-" + projectId + "-dot-" + fragments[0];
|
|
}
|
|
|
|
url.host = fragments.join(".");
|
|
|
|
return url.href;
|
|
};
|
|
|
|
export const dashboardPath = (
|
|
view: "templates" | "search" | "projects" = "projects"
|
|
) => {
|
|
if (view === "projects") {
|
|
return `/dashboard`;
|
|
}
|
|
return `/dashboard/${view}`;
|
|
};
|
|
|
|
export const dashboardUrl = (props: { origin: string }) => {
|
|
const authServerOrigin = getAuthorizationServerOrigin(props.origin);
|
|
|
|
return `${authServerOrigin}/dashboard`;
|
|
};
|
|
|
|
export const cloneProjectUrl = (props: {
|
|
origin: string;
|
|
sourceAuthToken: string;
|
|
}) => {
|
|
const authServerOrigin = getAuthorizationServerOrigin(props.origin);
|
|
|
|
const searchParams = new URLSearchParams();
|
|
searchParams.set("projectToCloneAuthToken", props.sourceAuthToken);
|
|
|
|
return `${authServerOrigin}/dashboard?${searchParams.toString()}`;
|
|
};
|
|
|
|
export const loginPath = (params: {
|
|
error?: (typeof AUTH_PROVIDERS)[keyof typeof AUTH_PROVIDERS];
|
|
message?: string;
|
|
returnTo?: string;
|
|
}) => `/login${searchParams(params)}`;
|
|
|
|
export const logoutPath = () => "/logout";
|
|
export const restLogoutPath = () => "/dashboard-logout";
|
|
|
|
export const planSubscriptionPath = (subscriptionId?: string) => {
|
|
const urlSearchParams = new URLSearchParams();
|
|
urlSearchParams.set("return_url", window.location.href);
|
|
if (subscriptionId) {
|
|
urlSearchParams.set("subscription", subscriptionId);
|
|
}
|
|
|
|
return `/builder-payments/billing-portal/sessions?${urlSearchParams.toString()}`;
|
|
};
|
|
|
|
export const authCallbackPath = ({
|
|
provider,
|
|
}: {
|
|
provider: "google" | "github";
|
|
}) => `/auth/${provider}/callback`;
|
|
|
|
export const authPath = ({
|
|
provider,
|
|
}: {
|
|
provider: "google" | "github" | "dev";
|
|
}) => `/auth/${provider}`;
|
|
|
|
export const restAssetsUploadPath = ({
|
|
name,
|
|
width,
|
|
height,
|
|
}: {
|
|
name: string;
|
|
width?: number | undefined;
|
|
height?: number | undefined;
|
|
}) => {
|
|
const urlSearchParams = new URLSearchParams();
|
|
if (width !== undefined) {
|
|
urlSearchParams.set("width", String(width));
|
|
}
|
|
if (height !== undefined) {
|
|
urlSearchParams.set("height", String(height));
|
|
}
|
|
|
|
const query = urlSearchParams.toString();
|
|
if (query !== "") {
|
|
return `${getAssetUploadApiUrl(name)}?${query}`;
|
|
}
|
|
|
|
return getAssetUploadApiUrl(name);
|
|
};
|
|
|
|
export const getCanvasUrl = () => {
|
|
return `/canvas`;
|
|
};
|
|
|
|
export const restResourcesLoader = ({ diagnostics = false } = {}) =>
|
|
`/rest/resources-loader${diagnostics ? "?diagnostics=true" : ""}`;
|
|
|
|
export const marketplacePath = (method: string) =>
|
|
`/builder/marketplace/${method}`;
|
|
|
|
// Editor navigation does not change the Builder data loaded by the route.
|
|
export const areBuilderDataUrlsEqual = (currentUrl: URL, nextUrl: URL) => {
|
|
const current = new URL(currentUrl);
|
|
const next = new URL(nextUrl);
|
|
for (const param of [
|
|
"pageId",
|
|
"instance",
|
|
"instanceId",
|
|
"mode",
|
|
"pageHash",
|
|
]) {
|
|
current.searchParams.delete(param);
|
|
next.searchParams.delete(param);
|
|
}
|
|
return current.href === next.href;
|
|
};
|