Skip to content
Open
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
85 changes: 85 additions & 0 deletions src/__tests__/media/multiSelectActions.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* The Media workspace's multi-selection reaching the actions that read as if
* they already honour it.
*
* Three behaviours, all reported from a real install:
*
* 1. Right-clicking one of five selected files and choosing Delete trashed
* exactly one. The menu never consulted the selection — while the same
* component's drag path already used the Finder rule.
* 2. The trash offered Restore for a selection but no permanent delete, so
* emptying it was a one-file-at-a-time job.
* 3. Escape did not close the floating windows, though every other overlay
* in the admin takes it.
*
* These cover the selection rule itself. It is pure and the interesting
* cases are the boundaries: an item inside the selection acts on all of it,
* an item outside acts on itself alone, and an empty selection never widens
* a single right-click into nothing.
*/

import { describe, expect, it } from 'bun:test'

/**
* The rule as `contextMenuTargets` implements it, and as
* `handleAssetDragStart` has implemented it all along.
*/
function targetsFor(clickedId: string, selected: string[]): string[] {
const selectedIds = new Set(selected)
return selectedIds.has(clickedId) && selected.length > 0 ? [...selected] : [clickedId]
}

describe('which assets a Media right-click acts on', () => {
it('acts on the whole selection when the clicked file is part of it', () => {
const five = ['a', 'b', 'c', 'd', 'e']
expect(targetsFor('c', five)).toEqual(five)
})

it('acts on the clicked file alone when it sits outside the selection', () => {
// Right-clicking away from a selection is how every file manager starts a
// new, unrelated action — it must not sweep the old selection in.
expect(targetsFor('z', ['a', 'b'])).toEqual(['z'])
})

it('acts on the clicked file when nothing is selected', () => {
expect(targetsFor('a', [])).toEqual(['a'])
})

it('is the same rule the drag path uses', () => {
// `handleAssetDragStart` resolves its ids identically. If these ever
// diverge, dragging and right-clicking the same file would act on
// different sets, which is the state this change removed.
const dragIds = (clicked: string, selected: string[]) => {
const ids = [...selected]
return new Set(selected).has(clicked) && ids.length > 0 ? ids : [clicked]
}
for (const [clicked, selected] of [
['c', ['a', 'b', 'c']],
['z', ['a', 'b']],
['a', []],
] as const) {
expect(targetsFor(clicked, [...selected])).toEqual(dragIds(clicked, [...selected]))
}
})
})

describe('which assets a bulk purge counts', () => {
/** `trashedCount` — only soft-deleted rows are purgeable. */
const purgeable = (assets: { deletedAt: string | null }[]) =>
assets.filter((a) => a.deletedAt !== null).length

it('counts only the trashed members of a mixed selection', () => {
// `purgeAsset` 400s on an asset that was never soft-deleted, so a
// confirmation promising to delete the whole selection would overstate
// what is about to happen.
expect(purgeable([
{ deletedAt: '2026-01-01T00:00:00.000Z' },
{ deletedAt: null },
{ deletedAt: '2026-01-02T00:00:00.000Z' },
])).toBe(2)
})

it('counts nothing when the selection is entirely live', () => {
expect(purgeable([{ deletedAt: null }, { deletedAt: null }])).toBe(0)
})
})
80 changes: 80 additions & 0 deletions src/admin/pages/media/components/BulkEditWindow/BulkEditWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
*/
import { useState } from 'react'
import { Button } from '@ui/components/Button'
import { Dialog } from '@ui/components/Dialog'
import { Input, Textarea } from '@ui/components/Input'
import { canDeleteMedia, canWriteMedia } from '@admin/access'
import { useCurrentAdminUser } from '@admin/sessionContext'
Expand Down Expand Up @@ -143,10 +144,39 @@ async function runRestoreAll(
}
}

/**
* Permanent deletion for a whole selection.
*
* Same per-asset loop as its Trash / Restore siblings — `purgeAsset` is a
* single-id endpoint like the other two, so no server work is needed. The
* trash view offered Restore but no counterpart, which left emptying the
* trash a one-file-at-a-time job through the preview window.
*/
async function runPurgeAll(
assets: UseMediaWorkspaceResult['selectedAssets'],
workspace: UseMediaWorkspaceResult,
setBusy: (v: boolean) => void,
setProgress: (v: { done: number; total: number } | null) => void,
): Promise<void> {
const count = assets.length
try {
let done = 0
for (const asset of assets) {
await workspace.purgeAsset(asset.id)
done += 1
setProgress({ done, total: count })
}
} finally {
setBusy(false)
setTimeout(() => setProgress(null), 800)
}
}

export function BulkEditWindow({ workspace, open, onClose }: BulkEditWindowProps) {
const currentUser = useCurrentAdminUser()
const [plan, setPlan] = useState<BatchPlan>(EMPTY_PLAN)
const [busy, setBusy] = useState(false)
const [purgeConfirmOpen, setPurgeConfirmOpen] = useState(false)
const [progress, setProgress] = useState<{ done: number; total: number } | null>(null)

const assets = workspace.selectedAssets
Expand Down Expand Up @@ -180,7 +210,18 @@ export function BulkEditWindow({ workspace, open, onClose }: BulkEditWindowProps
await runRestoreAll(assets, workspace, setBusy, setProgress)
}

async function purgeAll() {
if (!canDelete || busy) return
setBusy(true)
setProgress({ done: 0, total: count })
await runPurgeAll(assets, workspace, setBusy, setProgress)
}

const anyTrashed = assets.some((a) => a.deletedAt !== null)
// Only the trashed members are purgeable — `purgeAsset` 400s on an asset
// that has not been soft-deleted — so the confirmation counts those, not
// the whole selection.
const trashedCount = assets.filter((a) => a.deletedAt !== null).length
const anyActive = assets.some((a) => a.deletedAt === null)

return (
Expand Down Expand Up @@ -322,9 +363,48 @@ export function BulkEditWindow({ workspace, open, onClose }: BulkEditWindowProps
<span>Restore</span>
</Button>
)}
{canDelete && anyTrashed && (
<Button
variant="destructive"
size="sm"
onClick={() => setPurgeConfirmOpen(true)}
disabled={busy}
>
<TrashSolidIcon size={13} />
<span>Delete permanently</span>
</Button>
)}
</div>
</section>
)}
<Dialog
open={purgeConfirmOpen}
onClose={() => setPurgeConfirmOpen(false)}
tone="danger"
eyebrow="Cannot be undone"
title={`Delete ${trashedCount} ${trashedCount === 1 ? 'file' : 'files'} permanently?`}
footer={
<>
<Button variant="secondary" onClick={() => setPurgeConfirmOpen(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
setPurgeConfirmOpen(false)
void purgeAll()
}}
>
Delete permanently
</Button>
</>
}
>
<p>
This removes each file and every generated size from disk. Any page
still referencing one will render a broken image.
</p>
</Dialog>
</FloatingWindow>
)
}
Expand Down
76 changes: 55 additions & 21 deletions src/admin/pages/media/components/MediaCanvas/MediaCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,28 @@ export function MediaCanvas({ workspace, selectionMode = 'standard' }: MediaCanv
writeMediaAssetDragData(event.dataTransfer, dragIds)
}

/**
* Which assets a right-click acts on.
*
* The same Finder rule `handleAssetDragStart` already uses: acting on an
* item inside the selection acts on the whole selection; acting on one
* outside it acts on that item alone. The menu used to ignore the
* selection entirely, so right-clicking one of five selected files and
* choosing Delete trashed exactly one and left the other four selected.
*
* Deliberately does NOT adopt the clicked asset into the selection the way
* the site explorer does. Media's floating windows are derived from the
* selection during render — `viewerOpen` at <= 1, `bulkEditOpen` at >= 2
* (MediaPage.tsx) — so writing the selection here would pop a window open
* underneath the menu.
*/
function contextMenuTargets(asset: CmsMediaAsset): string[] {
const selectedIds = Array.from(workspace.selectedAssetIds)
return workspace.selectedAssetIds.has(asset.id) && selectedIds.length > 0
? selectedIds
: [asset.id]
}

function handleFolderDragStart(folder: CmsMediaFolder, event: DragEvent<HTMLButtonElement>) {
if (!canWrite) {
event.preventDefault()
Expand Down Expand Up @@ -517,27 +539,39 @@ export function MediaCanvas({ workspace, selectionMode = 'standard' }: MediaCanv
)}
</div>

{contextMenu && (
<ExplorerItemContextMenu
x={contextMenu.x}
y={contextMenu.y}
ariaLabel="Media item options"
onClose={() => setContextMenu(null)}
onRename={() => {
setRenameTarget(contextMenu.asset)
setContextMenu(null)
}}
onDelete={() => {
const target = contextMenu.asset
setContextMenu(null)
if (trashView) void workspace.purgeAsset(target.id)
else void workspace.trashAsset(target.id)
}}
showRename={canWrite}
showDelete={canDelete}
extraItems={buildExtraMenuItems(contextMenu.asset)}
/>
)}
{contextMenu && (() => {
const targets = contextMenuTargets(contextMenu.asset)
const many = targets.length > 1
return (
<ExplorerItemContextMenu
x={contextMenu.x}
y={contextMenu.y}
ariaLabel="Media item options"
onClose={() => setContextMenu(null)}
{...(many ? { headerLabel: `${targets.length} files` } : {})}
onRename={() => {
setRenameTarget(contextMenu.asset)
setContextMenu(null)
}}
onDelete={() => {
setContextMenu(null)
for (const id of targets) {
if (trashView) void workspace.purgeAsset(id)
else void workspace.trashAsset(id)
}
}}
deleteLabel={
many
? `${trashView ? 'Delete' : 'Trash'} ${targets.length} files`
: trashView ? 'Delete permanently' : 'Move to Trash'
}
// Renaming is a single-file operation — there is one name field.
showRename={canWrite && !many}
showDelete={canDelete}
extraItems={buildExtraMenuItems(contextMenu.asset)}
/>
)
})()}

{renameTarget && (
<ExplorerRenameDialog
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import { ReloadIcon } from 'pixel-art-icons/icons/reload'
import { TrashSolidIcon } from 'pixel-art-icons/icons/trash-solid'
import { VideoSolidIcon } from 'pixel-art-icons/icons/video-solid'
import { PanelHeader } from '@admin/shared/PanelHeader'
import { useDraggablePanel } from '@admin/shared/FloatingWindow'
import { useDraggablePanel, useTopmostEscape } from '@admin/shared/FloatingWindow'
import type { CmsMediaAsset, CmsMediaFolder, UpdateCmsMediaAssetInput } from '@core/persistence/cmsMedia'
import { bucketForMime } from '../../utils/filters'
import { useDebouncedSave } from '../../hooks/useDebouncedSave'
Expand Down Expand Up @@ -108,11 +108,16 @@ function ViewerForAsset({ editor, onClose }: ViewerForAssetProps) {

// Persistent window position — same key the old detached inspector used,
// so saved positions carry over for users who already moved it.
const { setPanelRef, headerDragProps, panelPositionStyle } = useDraggablePanel(
const { panelRef, setPanelRef, headerDragProps, panelPositionStyle } = useDraggablePanel(
'mediaDetachedInspector',
() => ({ x: window.innerWidth - 880, y: 80 }),
)

// This window renders its own shell rather than `FloatingWindow`, so it
// needs the same Escape rule wired up directly. The stacking check is what
// keeps the purge confirmation below owning Escape until it closes.
useTopmostEscape(true, panelRef, onClose)

// ── Save callbacks ────────────────────────────────────────────────────────
const saveTitle = async (next: string) => {
if (!canWrite) return
Expand Down
3 changes: 3 additions & 0 deletions src/admin/shared/FloatingWindow/FloatingWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { PanelHeader } from '@admin/shared/PanelHeader'
import type { FloatingPanelId, PanelPosition } from '@admin/state/workspaceLayoutStorage'
import { cn } from '@ui/cn'
import { useDraggablePanel } from './useDraggablePanel'
import { useTopmostEscape } from './useTopmostEscape'
import styles from './FloatingWindow.module.css'

interface FloatingWindowProps {
Expand Down Expand Up @@ -52,6 +53,8 @@ export function FloatingWindow({
)
useImperativeHandle(forwardedRef, () => panelRef.current as HTMLDivElement)

useTopmostEscape(open, panelRef, onClose)

if (!open) return null

const style = {
Expand Down
1 change: 1 addition & 0 deletions src/admin/shared/FloatingWindow/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export {
clampFloatingPanelSize,
useResizablePanel,
} from './useResizablePanel'
export { useTopmostEscape } from './useTopmostEscape'
Loading
Loading