diff --git a/src/features/workspaces/components/WorkspaceItemActionDialogs.tsx b/src/features/workspaces/components/WorkspaceItemActionDialogs.tsx index 2c3a9331..6e193c32 100644 --- a/src/features/workspaces/components/WorkspaceItemActionDialogs.tsx +++ b/src/features/workspaces/components/WorkspaceItemActionDialogs.tsx @@ -1,4 +1,4 @@ -import { type ReactNode, useId } from "react"; +import { type ReactNode, useId, useState } from "react"; import { AlertDialog, @@ -19,7 +19,7 @@ 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"; @@ -27,6 +27,7 @@ import { useDeleteWorkspaceItemsMutation, useRenameWorkspaceItemMutation, } from "#/features/workspaces/use-workspace-kernel-items"; +import { getErrorMessage } from "#/lib/error-message"; export function RenameWorkspaceItemDialog({ item, @@ -53,28 +54,38 @@ 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 (
{ - 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. + } }} > @@ -82,9 +93,25 @@ function RenameWorkspaceItemDialogContent({ Update the item name shown in this workspace. - + Name - + setNameDraft(event.target.value)} + required + autoFocus + aria-invalid={renameWorkspaceItemMutation.isError || undefined} + /> + {renameWorkspaceItemMutation.isError ? ( + + {getErrorMessage( + renameWorkspaceItemMutation.error, + "Unable to rename workspace item right now.", + )} + + ) : null} @@ -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?.(); } }} @@ -137,6 +167,15 @@ export function DeleteWorkspaceItemsAlert({ {description} + {deleteWorkspaceItemsMutation.isError ? ( +

+ {getErrorMessage( + deleteWorkspaceItemsMutation.error, + "Unable to delete right now. Please try again.", + )} +

+ ) : null} + Cancel - Delete + {deleteWorkspaceItemsMutation.isError ? "Retry delete" : "Delete"} diff --git a/src/features/workspaces/kernel/workspace-kernel-item-commands.test.ts b/src/features/workspaces/kernel/workspace-kernel-item-commands.test.ts new file mode 100644 index 00000000..725eb14e --- /dev/null +++ b/src/features/workspaces/kernel/workspace-kernel-item-commands.test.ts @@ -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)); + }); +}); diff --git a/src/features/workspaces/kernel/workspace-kernel-item-commands.ts b/src/features/workspaces/kernel/workspace-kernel-item-commands.ts index dd2575d9..098caee5 100644 --- a/src/features/workspaces/kernel/workspace-kernel-item-commands.ts +++ b/src/features/workspaces/kernel/workspace-kernel-item-commands.ts @@ -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; @@ -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({ @@ -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"