Skip to content
Draft
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
66 changes: 54 additions & 12 deletions src/features/workspaces/components/WorkspaceItemActionDialogs.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type ReactNode, useId } from "react";
import { type ReactNode, useId, useState } from "react";

import {
AlertDialog,
Expand All @@ -19,14 +19,15 @@ import {
DialogHeader,
DialogTitle,
} from "#/components/ui/dialog";
import { Field, FieldGroup, FieldLabel } from "#/components/ui/field";
import { Field, FieldError, FieldGroup, FieldLabel } from "#/components/ui/field";
import { Input } from "#/components/ui/input";
import { getWorkspaceDescendantIds } from "#/features/workspaces/model/tree";
import type { WorkspaceItem } from "#/features/workspaces/model/types";
import {
useDeleteWorkspaceItemsMutation,
useRenameWorkspaceItemMutation,
} from "#/features/workspaces/use-workspace-kernel-items";
import { getErrorMessage } from "#/lib/error-message";

export function RenameWorkspaceItemDialog({
item,
Expand All @@ -53,38 +54,64 @@ function RenameWorkspaceItemDialogContent({
}) {
const nameInputId = useId();
const renameWorkspaceItemMutation = useRenameWorkspaceItemMutation();
// Controlled so a failed rename keeps the user's typed value in place for
// correction/retry instead of React resetting the form back to the old name.
const [nameDraft, setNameDraft] = useState(item.name);

return (
<DialogContent>
<form
className="grid gap-6"
action={(formData) => {
const rawName = formData.get("name");
const name = (typeof rawName === "string" ? rawName : "").trim();
action={async () => {
const name = nameDraft.trim();

if (!name) {
return;
}

if (name !== item.name) {
renameWorkspaceItemMutation.mutate({
if (name === item.name) {
onOpenChange(false);
return;
}

try {
await renameWorkspaceItemMutation.mutateAsync({
workspaceId: item.workspaceId,
itemId: item.id,
name,
});
}

onOpenChange(false);
onOpenChange(false);
} catch {
// Keep the dialog open so the error surfaces inline and the
// user can correct the name and retry instead of losing input.
}
}}
>
<DialogHeader>
<DialogTitle>Rename item</DialogTitle>
<DialogDescription>Update the item name shown in this workspace.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field>
<Field data-invalid={renameWorkspaceItemMutation.isError || undefined}>
<FieldLabel htmlFor={nameInputId}>Name</FieldLabel>
<Input id={nameInputId} name="name" defaultValue={item.name} required autoFocus />
<Input
id={nameInputId}
name="name"
value={nameDraft}
onChange={(event) => setNameDraft(event.target.value)}
required
autoFocus
aria-invalid={renameWorkspaceItemMutation.isError || undefined}
/>
{renameWorkspaceItemMutation.isError ? (
<FieldError>
{getErrorMessage(
renameWorkspaceItemMutation.error,
"Unable to rename workspace item right now.",
)}
</FieldError>
) : null}
</Field>
</FieldGroup>
<DialogFooter>
Expand Down Expand Up @@ -127,6 +154,9 @@ export function DeleteWorkspaceItemsAlert({
onOpenChange={onOpenChange}
onOpenChangeComplete={(nextOpen) => {
if (!nextOpen) {
// Clear any prior failure so reopening starts from a clean state
// rather than surfacing a stale error and a "Retry delete" label.
deleteWorkspaceItemsMutation.reset();
onClosed?.();
}
}}
Expand All @@ -137,6 +167,15 @@ export function DeleteWorkspaceItemsAlert({
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>

{deleteWorkspaceItemsMutation.isError ? (
<p role="alert" className="text-destructive text-sm">
{getErrorMessage(
deleteWorkspaceItemsMutation.error,
"Unable to delete right now. Please try again.",
)}
</p>
) : null}

<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
Expand All @@ -149,6 +188,9 @@ export function DeleteWorkspaceItemsAlert({
return;
}

// Only close on success. On failure the mutation surfaces the
// error inline (above) and re-enables this button so the user
// can retry instead of being wedged with no way forward.
deleteWorkspaceItemsMutation.mutate(
{
workspaceId,
Expand All @@ -163,7 +205,7 @@ export function DeleteWorkspaceItemsAlert({
);
}}
>
Delete
{deleteWorkspaceItemsMutation.isError ? "Retry delete" : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, expect, it, vi } from "vitest";

import { WorkspaceKernelItemCommands } from "#/features/workspaces/kernel/workspace-kernel-item-commands";
import type { KernelItemRow } from "#/features/workspaces/kernel/workspace-kernel-rows";

function makeItemRow(id: string): KernelItemRow {
return {
id,
parent_id: null,
type: "document",
name: id,
color: null,
metadata_json: "{}",
sort_order: 0,
shell_path: `/${id}`,
created_at: 0,
updated_at: 0,
deleted_at: null,
};
}

describe("WorkspaceKernelItemCommands.deleteItems", () => {
it("streams file removals in bounded batches instead of all at once", async () => {
const itemIds = Array.from({ length: 9 }, (_, index) => `item-${index}`);
const rowsById = new Map(itemIds.map((id) => [id, makeItemRow(id)]));

let inFlight = 0;
let maxInFlight = 0;
const rm = vi.fn(async () => {
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
await new Promise((resolve) => setTimeout(resolve, 1));
inFlight -= 1;
});

const store = {
assertActiveItem: (id: string) => rowsById.get(id),
getItemRowIncludingDeleted: (id: string) => rowsById.get(id) ?? null,
getDescendantIds: () => [],
softDeleteItems: vi.fn(),
};
const relations = { deleteRelationsForItems: vi.fn() };
const events = { commit: vi.fn(() => ({ id: "event" })) };
const workspace = { rm };

const commands = new WorkspaceKernelItemCommands({
events: events as never,
relations: relations as never,
sql: (() => []) as never,
store: store as never,
workspace: workspace as never,
workspaceId: () => "workspace",
});

const { result } = await commands.deleteItems({ itemIds });

expect(rm).toHaveBeenCalledTimes(9);
expect(maxInFlight).toBeLessThanOrEqual(4);
expect(maxInFlight).toBeGreaterThan(1);
expect(result.deletedItemIds).toEqual(itemIds);
expect(store.softDeleteItems).toHaveBeenCalledWith(itemIds, expect.any(Number));
});
});
34 changes: 26 additions & 8 deletions src/features/workspaces/kernel/workspace-kernel-item-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ import {
} from "#/features/workspaces/model/workspace-item-colors";
import type { WorkspaceCommandResult } from "#/features/workspaces/realtime/messages";

/**
* Number of workspace file removals to run concurrently when deleting items.
*
* A bulk delete can expand into a large number of nested rows. Firing every
* `workspace.rm` at once overwhelms the Durable Object storage budget and trips
* memory-limit / storage-timeout resets, so we stream the removals in bounded
* batches instead of loading them all at once.
*/
const workspaceItemDeleteConcurrency = 4;

export class WorkspaceKernelItemCommands {
private readonly events: WorkspaceKernelEventBus;
private readonly relations: WorkspaceKernelRelations;
Expand Down Expand Up @@ -250,14 +260,7 @@ export class WorkspaceKernelItemCommands {

this.store.softDeleteItems(deleteIds, Date.now());
this.relations.deleteRelationsForItems(deleteIds);
await Promise.all(
rowsToRemove.map((row) =>
this.workspace.rm(row.shell_path, {
recursive: true,
force: true,
}),
),
);
await this.removeWorkspaceItemFiles(rowsToRemove);

const result = { itemIds: rootIds, deletedItemIds: deleteIds };
const event = this.events.commit({
Expand Down Expand Up @@ -342,6 +345,21 @@ export class WorkspaceKernelItemCommands {
);
}

private async removeWorkspaceItemFiles(rows: KernelItemRow[]) {
for (let start = 0; start < rows.length; start += workspaceItemDeleteConcurrency) {
const batch = rows.slice(start, start + workspaceItemDeleteConcurrency);

await Promise.all(
batch.map((row) =>
this.workspace.rm(row.shell_path, {
recursive: true,
force: true,
}),
),
);
}
}

private commitItemEvent(input: {
type:
| "workspace.item.renamed"
Expand Down
Loading