Skip to content
Closed
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
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
Expand Down
65 changes: 65 additions & 0 deletions apps/web/src/app/api/pages/batch-move/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { NextResponse } from 'next/server';
import { z } from 'zod/v4';
import { broadcastPageEvent, createPageEventPayload } from '@/lib/websocket';
import { loggers, pageTreeCache } from '@pagespace/lib/server';
import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth';
import { pageService } from '@/services/api';

const AUTH_OPTIONS = { allow: ['jwt', 'mcp'] as const, requireCSRF: true };

const batchMoveSchema = z.object({
pageIds: z.array(z.string()).min(1, 'At least one page ID is required'),
newParentId: z.string().nullable(),
insertionIndex: z.number().int().optional(),
});

export async function POST(request: Request) {
const auth = await authenticateRequestWithOptions(request, AUTH_OPTIONS);
if (isAuthError(auth)) {
return auth.error;
}

try {
const body = await request.json();
const { pageIds, newParentId } = batchMoveSchema.parse(body);

const result = await pageService.batchMovePages(pageIds, newParentId, auth.userId);

if (!result.success) {
return NextResponse.json({ error: result.error }, { status: result.status });
}

// Broadcast events and invalidate caches for all affected drives
for (const driveId of result.driveIds) {
// Broadcast a batch move event
await broadcastPageEvent(
createPageEventPayload(driveId, pageIds[0], 'batch-moved', {
pageIds,
count: result.movedCount,
parentId: result.targetParentId ?? undefined,
})
);

// Invalidate page tree cache
await pageTreeCache.invalidateDriveTree(driveId);
}

return NextResponse.json({
message: `${result.movedCount} pages moved successfully.`,
movedCount: result.movedCount,
targetParentId: result.targetParentId,
});
} catch (error) {
loggers.api.error('Error batch moving pages:', error as Error);

if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.issues[0]?.message || 'Validation failed' },
{ status: 400 }
);
}

const errorMessage = error instanceof Error ? error.message : 'Failed to batch move pages';
return NextResponse.json({ error: errorMessage }, { status: 500 });
}
}
66 changes: 66 additions & 0 deletions apps/web/src/app/api/pages/batch-trash/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { NextResponse } from 'next/server';
import { z } from 'zod/v4';
import { broadcastPageEvent, createPageEventPayload } from '@/lib/websocket';
import { loggers, agentAwarenessCache, pageTreeCache } from '@pagespace/lib/server';
import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth';
import { pageService } from '@/services/api';

const AUTH_OPTIONS = { allow: ['jwt', 'mcp'] as const, requireCSRF: true };

const batchTrashSchema = z.object({
pageIds: z.array(z.string()).min(1, 'At least one page ID is required'),
});

export async function POST(request: Request) {
const auth = await authenticateRequestWithOptions(request, AUTH_OPTIONS);
if (isAuthError(auth)) {
return auth.error;
}

try {
const body = await request.json();
const { pageIds } = batchTrashSchema.parse(body);

const result = await pageService.batchTrashPages(pageIds, auth.userId);

if (!result.success) {
return NextResponse.json({ error: result.error }, { status: result.status });
}

// Broadcast events and invalidate caches for all affected drives
for (const driveId of result.driveIds) {
// Broadcast a batch trash event
await broadcastPageEvent(
createPageEventPayload(driveId, pageIds[0], 'batch-trashed', {
pageIds,
count: result.trashedCount,
})
);

// Invalidate page tree cache
await pageTreeCache.invalidateDriveTree(driveId);

// Invalidate agent awareness cache if AI_CHAT pages were trashed
if (result.hasAIChatPages) {
await agentAwarenessCache.invalidateDriveAgents(driveId);
}
}

return NextResponse.json({
message: `${result.trashedCount} pages moved to trash successfully.`,
trashedCount: result.trashedCount,
});
} catch (error) {
loggers.api.error('Error batch trashing pages:', error as Error);

if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.issues[0]?.message || 'Validation failed' },
{ status: 400 }
);
}

const errorMessage = error instanceof Error ? error.message : 'Failed to batch trash pages';
return NextResponse.json({ error: errorMessage }, { status: 500 });
}
}
64 changes: 64 additions & 0 deletions apps/web/src/components/dialogs/BatchDeleteDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";

interface BatchDeleteDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
pageCount: number;
pageNames?: string[];
}

export function BatchDeleteDialog({
isOpen,
onClose,
onConfirm,
pageCount,
pageNames,
}: BatchDeleteDialogProps) {
const showNames = pageNames && pageNames.length > 0 && pageNames.length <= 5;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fix the confusing "and N more..." message when no names are displayed.

When there are more than 5 pages, showNames is false (line 27), so no page names are rendered in lines 40-46. However, lines 47-51 then display "and {pageNames.length - 5} more..." which implies that some names were already shown. This creates a confusing user experience.

Solution 1 (recommended): Display the first 5 names plus "and N more..." when there are more than 5 pages:

-  const showNames = pageNames && pageNames.length > 0 && pageNames.length <= 5;
+  const showNames = pageNames && pageNames.length > 0;
+  const displayNames = showNames ? pageNames.slice(0, 5) : [];
               {showNames && (
                 <ul className="mt-2 list-disc list-inside text-sm text-muted-foreground">
-                  {pageNames.map((name, index) => (
+                  {displayNames.map((name, index) => (
                     <li key={index} className="truncate">{name}</li>
                   ))}
                 </ul>

Solution 2: Change the message to not imply previous names were shown:

               {pageNames && pageNames.length > 5 && (
                 <p className="text-sm text-muted-foreground">
-                  and {pageNames.length - 5} more...
+                  {pageNames.length} pages selected
                 </p>
               )}

Also applies to: 40-51

🤖 Prompt for AI Agents
In apps/web/src/components/dialogs/BatchDeleteDialog.tsx around line 27 (and
adjust rendering at lines 40-51): the current boolean showNames is false when
pageNames.length > 5 which prevents rendering any names while still showing "and
N more...", causing confusion; change the logic to render the first five names
when there are more than five (i.e., compute a displayedNames =
pageNames.slice(0, 5) and set showNames to displayedNames.length > 0), update
the JSX at lines 40-46 to map over displayedNames to show those names, and keep
the "and {pageNames.length - displayedNames.length} more..." text only when
pageNames.length > displayedNames.length; alternatively, if you prefer Solution
2, change the trailing message to "and {pageNames.length} more..." and only show
it when no names are displayed—implement Solution 1 by default.


return (
<AlertDialog open={isOpen} onOpenChange={onClose}>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Wrap the onClose callback to match onOpenChange signature.

The onOpenChange prop expects (open: boolean) => void, but onClose is typed as () => void. While this may work at runtime, it's not type-safe and could cause issues with strict TypeScript configurations.

Apply this diff:

-    <AlertDialog open={isOpen} onOpenChange={onClose}>
+    <AlertDialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<AlertDialog open={isOpen} onOpenChange={onClose}>
<AlertDialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
🤖 Prompt for AI Agents
In apps/web/src/components/dialogs/BatchDeleteDialog.tsx around line 30, the
AlertDialog is passing onClose (type () => void) directly to onOpenChange which
expects (open: boolean) => void; wrap onClose in a handler that matches the
expected signature, e.g. create a const handleOpenChange = (open: boolean) => {
if (!open) onClose?.(); } and pass handleOpenChange to onOpenChange so
TypeScript types align and closing only triggers when open becomes false.

<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Move {pageCount} pages to trash?</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-2">
<p>
This action will move {pageCount} selected page{pageCount !== 1 ? "s" : ""} to the trash.
You can restore them later.
</p>
{showNames && (
<ul className="mt-2 list-disc list-inside text-sm text-muted-foreground">
{pageNames.map((name, index) => (
<li key={index} className="truncate">{name}</li>
))}
</ul>
)}
{pageNames && pageNames.length > 5 && (
<p className="text-sm text-muted-foreground">
and {pageNames.length - 5} more...
</p>
)}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>
Move to Trash
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
84 changes: 78 additions & 6 deletions apps/web/src/components/layout/left-sidebar/page-tree/PageTree.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
"use client";

import { useState, useMemo, useCallback } from "react";
import { useState, useMemo, useCallback, useEffect } from "react";
import { FileIcon } from "lucide-react";
import { patch } from "@/lib/auth/auth-fetch";
import { patch, post } from "@/lib/auth/auth-fetch";
import { TreePage } from "@/hooks/usePageTree";
import { usePageTreeSocket } from "@/hooks/usePageTreeSocket";
import { findNodeAndParent } from "@/lib/tree/tree-utils";
import { Skeleton } from "@/components/ui/skeleton";
import CreatePageDialog from "../CreatePageDialog";
import { useTreeState } from "@/hooks/useUI";
import { useTreeState, usePageSelection } from "@/hooks/useUI";
import { useFileDrop } from "@/hooks/useFileDrop";
import { cn } from "@/lib/utils";
import { SortableTree } from "@/components/ui/sortable-tree";
import { Projection, TreeItem } from "@/lib/tree/sortable-tree";
import { PageTreeItem, DropPosition } from "./PageTreeItem";
import { DragOverlayContent } from "./PageTreeItemContent";
import { KeyedMutator } from "swr";
import { toast } from "sonner";

interface PageTreeProps {
driveId: string;
Expand All @@ -38,6 +40,11 @@ export default function PageTree({
const tree = initialTree ?? fetchedTree;
const mutate = externalMutate ?? internalMutate;
const { expanded: expandedNodes, toggleExpanded } = useTreeState();
const {
selectedPageIds,
setSelection,
clearSelection,
} = usePageSelection();

const [createPageInfo, setCreatePageInfo] = useState<{
isOpen: boolean;
Expand All @@ -63,6 +70,23 @@ export default function PageTree({
onUploadComplete: () => mutate(),
});

// Clear selection when clicking outside the tree
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
// If click is outside the sidebar tree, clear selection
if (!target.closest('[data-tree-node-id]') && !target.closest('[data-slot="context-menu"]')) {
// Only clear if not right-clicking (context menu)
if (e.button === 0) {
clearSelection();
}
}
};

document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [clearSelection]);

// Filter tree by search query
const filterTree = useCallback((nodes: TreePage[], query: string): TreePage[] => {
if (!query) return nodes;
Expand Down Expand Up @@ -103,7 +127,7 @@ export default function PageTree({
return allIds;
}, [displayedTree, expandedNodes]);

// Handle move from SortableTree
// Handle single move from SortableTree
const handleMove = useCallback(
async (activeId: string, _overId: string, projection: Projection) => {
const activeInfo = findNodeAndParent(tree, activeId);
Expand All @@ -116,7 +140,7 @@ export default function PageTree({
const siblings = newParent ? newParent.children : tree;

// Filter out the active item from siblings (it's being moved)
const siblingsWithoutActive = siblings.filter(s => s.id !== activeId);
const siblingsWithoutActive = siblings.filter((s: TreePage) => s.id !== activeId);

// Use projection.insertionIndex for correct position calculation
const insertAt = projection.insertionIndex;
Expand Down Expand Up @@ -159,6 +183,41 @@ export default function PageTree({
[tree, mutate, expandedNodes, toggleExpanded]
);

// Handle multi-move from SortableTree
const handleMultiMove = useCallback(
async (activeIds: string[], _overId: string, projection: Projection) => {
const newParentId = projection.parentId;

// Auto-expand parent if dropping inside
if (newParentId && !expandedNodes.has(newParentId)) {
toggleExpanded(newParentId);
}

const toastId = toast.loading(`Moving ${activeIds.length} pages...`);
try {
await post("/api/pages/batch-move", {
pageIds: activeIds,
newParentId: newParentId,
insertionIndex: projection.insertionIndex,
});
await mutate();
toast.success(`${activeIds.length} pages moved.`, { id: toastId });
} catch (error) {
console.error("Failed to batch move pages:", error);
toast.error("Failed to move pages.", { id: toastId });
}
},
[expandedNodes, toggleExpanded, mutate]
);

// Handle selection change from SortableTree (when dragging unselected item)
const handleSelectionChange = useCallback(
(ids: string[]) => {
setSelection(ids);
},
[setSelection]
);

const handleToggleExpand = useCallback(
(id: string) => {
toggleExpanded(id);
Expand Down Expand Up @@ -274,6 +333,14 @@ export default function PageTree({
[isDraggingFiles, fileDragState, tree, handleFileDrop]
);

// Render drag overlay for multi-select
const renderDragOverlay = useCallback(
({ items, totalCount }: { items: TreePage[]; totalCount: number }) => {
return <DragOverlayContent items={items} totalCount={totalCount} />;
},
[]
);

if (isLoading) {
return (
<div className="p-4 space-y-2">
Expand All @@ -297,8 +364,12 @@ export default function PageTree({
items={displayedTree as SortableTreePage[]}
collapsedIds={collapsedIds}
indentationWidth={24}
selectedIds={selectedPageIds}
onMove={handleMove}
renderItem={({ item, depth, isActive, isOver, dropPosition, projectedDepth, projected, handleProps, wrapperProps }) => (
onMultiMove={handleMultiMove}
onSelectionChange={handleSelectionChange}
renderDragOverlay={renderDragOverlay}
renderItem={({ item, depth, isActive, isOver, dropPosition, projectedDepth, projected, flattenedIds, handleProps, wrapperProps }) => (
<PageTreeItem
item={item as TreePage}
depth={depth}
Expand All @@ -315,6 +386,7 @@ export default function PageTree({
mutate={mutate}
isTrashView={isTrashView}
fileDragState={fileDragState}
flattenedIds={flattenedIds}
/>
)}
/>
Expand Down
Loading