Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 51 additions & 17 deletions apps/builder/app/builder/features/assets/assets.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
IconButton,
PanelTitle,
Separator,
Tooltip,
} from "@webstudio-is/design-system";
import { BrushCleaningIcon, NewFolderIcon } from "@webstudio-is/icons";
import { BrushCleaningIcon, PlusIcon } from "@webstudio-is/icons";
import { useRef, useState } from "react";
import { useStore } from "@nanostores/react";
import { isTextFileAsset } from "@webstudio-is/sdk";
Expand All @@ -17,6 +21,7 @@ import { $assets } from "~/shared/sync/data-stores";
import type { Publish } from "~/shared/pubsub";
import { useImageAssetCanvasDrag } from "./use-image-asset-canvas-drag";
import { TextFileEditor } from "~/builder/features/text-file-editor/text-file-editor";
import { CreateTextFileDialog } from "~/builder/features/text-file-editor/create-text-file-dialog";
import { getAssetUrl } from "~/builder/shared/assets/asset-utils";

export const AssetsPanel = ({
Expand All @@ -27,9 +32,15 @@ export const AssetsPanel = ({
}) => {
const [folderId, setFolderId] = useState<string>();
const [createFolderOpen, setCreateFolderOpen] = useState(false);
const [createTextFileOpen, setCreateTextFileOpen] = useState(false);
const [openedTextAssetId, setOpenedTextAssetId] = useState<string>();
const uploadRef = useRef<AssetUploadHandle>(null);
const authPermit = useStore($authPermit);
const addActions = {
upload: () => uploadRef.current?.open(),
createFile: () => setCreateTextFileOpen(true),
createFolder: () => setCreateFolderOpen(true),
};
const openAsset = (assetId: string) => {
const asset = $assets.get().get(assetId);
if (asset === undefined) {
Expand All @@ -48,24 +59,46 @@ export const AssetsPanel = ({
useImageAssetCanvasDrag(publish);
return (
<>
<AssetUpload
ref={uploadRef}
type="file"
folderId={folderId}
showTrigger={false}
/>
<PanelTitle
suffix={
<>
<Tooltip content="Create folder">
<Tooltip content="Delete unused assets">
<IconButton
disabled={authPermit === "view"}
aria-label="Create asset folder"
onClick={() => setCreateFolderOpen(true)}
aria-label="Delete unused assets"
onClick={openDeleteUnusedAssetsDialog}
>
<NewFolderIcon />
</IconButton>
</Tooltip>
<Tooltip content="Delete unused assets">
<IconButton onClick={openDeleteUnusedAssetsDialog}>
<BrushCleaningIcon />
</IconButton>
</Tooltip>
<AssetUpload ref={uploadRef} type="file" folderId={folderId} />
<DropdownMenu>
<Tooltip content="Add asset">
<DropdownMenuTrigger asChild>
<IconButton
disabled={authPermit === "view"}
aria-label="Add asset"
>
<PlusIcon />
</IconButton>
</DropdownMenuTrigger>
</Tooltip>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={addActions.upload}>
Upload
</DropdownMenuItem>
<DropdownMenuItem onSelect={addActions.createFile}>
Create text file
</DropdownMenuItem>
<DropdownMenuItem onSelect={addActions.createFolder}>
Create folder
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
}
>
Expand All @@ -78,12 +111,7 @@ export const AssetsPanel = ({
onOpen={openAsset}
canManageFolders={authPermit !== "view"}
panelActions={{
...(authPermit === "view"
? {}
: {
createFolder: () => setCreateFolderOpen(true),
upload: () => uploadRef.current?.open(),
}),
...(authPermit === "view" ? {} : addActions),
deleteUnusedAssets: openDeleteUnusedAssetsDialog,
}}
/>
Expand All @@ -92,6 +120,12 @@ export const AssetsPanel = ({
onOpenChange={setCreateFolderOpen}
currentFolderId={folderId}
/>
<CreateTextFileDialog
open={createTextFileOpen}
folderId={folderId}
onOpenChange={setCreateTextFileOpen}
onCreated={setOpenedTextAssetId}
/>
{openedTextAssetId !== undefined && (
<TextFileEditor
key={openedTextAssetId}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { expect, test } from "vitest";
import type { Asset } from "@webstudio-is/sdk";
import { getTextFileNameError } from "./create-text-file-dialog";

const existing: Asset = {
id: "existing",
projectId: "project",
name: "readme_hash.md",
folderId: "docs",
type: "file",
format: "md",
size: 0,
createdAt: "2026-07-20T00:00:00.000Z",
meta: {},
};

test("accepts supported text names and rejects invalid or duplicate names", () => {
expect(
getTextFileNameError({ name: "data.json", assets: [existing] })
).toBeUndefined();
expect(getTextFileNameError({ name: "image.png", assets: [] })).toBe(
"Use a supported editable text extension."
);
expect(getTextFileNameError({ name: ".md", assets: [] })).toBe(
"Use a supported editable text extension."
);
expect(getTextFileNameError({ name: "bad/name.md", assets: [] })).toBe(
"Enter a valid file name."
);
expect(
getTextFileNameError({
name: "readme.md",
assets: [existing],
})
).toBe("A file with this name already exists.");
});

test("compares global complete display names after an asset is renamed", () => {
expect(
getTextFileNameError({
name: "guide.md",
assets: [{ ...existing, filename: "guide", folderId: "other" }],
})
).toBe("A file with this name already exists.");
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { useLayoutEffect, useState, type KeyboardEvent } from "react";
import isValidFilename from "valid-filename";
import {
Button,
Dialog,
DialogContent,
DialogTitle,
Flex,
Grid,
InputField,
Label,
Text,
theme,
} from "@webstudio-is/design-system";
import {
formatAssetName,
getFileExtension,
getMimeTypeByExtension,
isTextFileAsset,
type Asset,
} from "@webstudio-is/sdk";
import { $assets } from "~/shared/sync/data-stores";
import { uploadSingleAsset } from "~/builder/shared/assets/upload-assets";

export const getTextFileNameError = ({
name,
assets,
}: {
name: string;
assets: Iterable<Asset>;
}) => {
if (isValidFilename(name) === false) {
return "Enter a valid file name.";
}
if (
isTextFileAsset({ format: getFileExtension(name)?.toLowerCase() ?? "" }) ===
false
) {
return "Use a supported editable text extension.";
}
for (const asset of assets) {
if (formatAssetName(asset) === name) {
return "A file with this name already exists.";
}
}
};

const createTextFile = async ({
name,
folderId,
}: {
name: string;
folderId?: string;
}): Promise<Asset | undefined> => {
const format = getFileExtension(name)?.toLowerCase() ?? "";
if (isTextFileAsset({ format }) === false) {
return;
}
const file = new File([""], name, {
type: getMimeTypeByExtension(format),
});
return uploadSingleAsset("file", file, { folderId });
};

const stopEscapePropagation = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.stopPropagation();
}
};

export const CreateTextFileDialog = ({
open,
folderId,
onOpenChange,
onCreated,
}: {
open: boolean;
folderId?: string;
onOpenChange: (open: boolean) => void;
onCreated: (assetId: string) => void;
}) => {
const [name, setName] = useState("untitled.md");
const [error, setError] = useState<string>();
const [creating, setCreating] = useState(false);

useLayoutEffect(() => {
if (open) {
setName("untitled.md");
setError(undefined);
setCreating(false);
}
}, [open]);

const normalizedName = name.trim();
const submit = async () => {
if (creating) {
return;
}
const validationError = getTextFileNameError({
name: normalizedName,
assets: $assets.get().values(),
});
setError(validationError);
if (validationError !== undefined) {
return;
}
setCreating(true);
try {
const asset = await createTextFile({ name: normalizedName, folderId });
if (asset === undefined) {
setError("The file could not be created.");
return;
}
onOpenChange(false);
onCreated(asset.id);
} catch (error) {
setError(
error instanceof Error
? error.message
: "The file could not be created."
);
} finally {
setCreating(false);
}
};

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
minWidth={360}
aria-describedby={undefined}
onKeyDown={stopEscapePropagation}
>
<DialogTitle>New text file</DialogTitle>
<Grid gap={3} css={{ padding: theme.panel.padding }}>
<Grid gap={1}>
<Label htmlFor="asset-text-file-name">File name</Label>
<InputField
id="asset-text-file-name"
autoFocus
disabled={creating}
value={name}
color={error === undefined ? undefined : "error"}
onChange={(event) => {
setName(event.target.value);
setError(undefined);
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
void submit();
}
}}
/>
{error !== undefined && (
<Text color="destructive" variant="tiny">
{error}
</Text>
)}
</Grid>
<Flex justify="end">
<Button disabled={creating} onClick={() => void submit()}>
{creating ? "Creating…" : "Create file"}
</Button>
</Flex>
</Grid>
</DialogContent>
</Dialog>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ test("uses one ordered command model for context and dropdown menus", () => {
open: action,
settings: action,
createFolder: action,
createFile: action,
upload: action,
cut: action,
copy: action,
Expand All @@ -21,6 +22,7 @@ test("uses one ordered command model for context and dropdown menus", () => {

expect(items.map(({ name }) => name)).toEqual([
"createFolder",
"createFile",
"upload",
"open",
"settings",
Expand Down Expand Up @@ -60,13 +62,15 @@ test("orders panel actions independently of unavailable item actions", () => {
const action = vi.fn();
const items = getAssetManagerItemMenuItems({
createFolder: action,
createFile: action,
upload: action,
paste: action,
deleteUnusedAssets: action,
});

expect(items.map(({ name }) => name)).toEqual([
"createFolder",
"createFile",
"upload",
"paste",
"deleteUnusedAssets",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export type AssetManagerItemActions = Partial<
| "download"
| "replace"
| "createFolder"
| "createFile"
| "upload"
| "deleteUnusedAssets"
| "delete",
Expand All @@ -46,6 +47,7 @@ type ItemDefinition = {

const itemDefinitions: readonly ItemDefinition[] = [
{ name: "createFolder", label: "Create folder" },
{ name: "createFile", label: "Create text file" },
{ name: "upload", label: "Upload asset" },
{ name: "open", label: "Open" },
{ name: "settings", label: "Settings" },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ type AssetManagerProps = FolderNavigationProps & {
panelActions?: Partial<
Pick<
AssetManagerItemActions,
"createFolder" | "upload" | "deleteUnusedAssets"
"createFolder" | "createFile" | "upload" | "deleteUnusedAssets"
>
>;
};
Expand Down
Loading
Loading