Hard delete of items in Trash (#9091)

- Close https://github.com/enso-org/cloud-v2/issues/698
- Requires https://github.com/enso-org/cloud-v2/pull/905

# Important Notes
None
This commit is contained in:
somebody1234 2024-03-06 13:12:14 +10:00 committed by GitHub
parent 1a76f6383d
commit 618080b803
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 174 additions and 88 deletions

View File

@ -233,51 +233,54 @@ export default function AssetRow(props: AssetRowProps) {
/* should never change */ setIsAssetPanelTemporarilyVisible,
])
const doDelete = React.useCallback(async () => {
setInsertionVisibility(Visibility.hidden)
if (asset.type === backendModule.AssetType.directory) {
dispatchAssetListEvent({
type: AssetListEventType.closeFolder,
id: asset.id,
// This is SAFE, as this asset is already known to be a directory.
// eslint-disable-next-line no-restricted-syntax
key: item.key as backendModule.DirectoryId,
})
}
try {
dispatchAssetListEvent({ type: AssetListEventType.willDelete, key: item.key })
if (
asset.type === backendModule.AssetType.project &&
backend.type === backendModule.BackendType.local
) {
if (
asset.projectState.type !== backendModule.ProjectState.placeholder &&
asset.projectState.type !== backendModule.ProjectState.closed
) {
await backend.openProject(asset.id, null, asset.title)
}
try {
await backend.closeProject(asset.id, asset.title)
} catch {
// Ignored. The project was already closed.
}
const doDelete = React.useCallback(
async (forever = false) => {
setInsertionVisibility(Visibility.hidden)
if (asset.type === backendModule.AssetType.directory) {
dispatchAssetListEvent({
type: AssetListEventType.closeFolder,
id: asset.id,
// This is SAFE, as this asset is already known to be a directory.
// eslint-disable-next-line no-restricted-syntax
key: item.key as backendModule.DirectoryId,
})
}
await backend.deleteAsset(asset.id, asset.title)
dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key })
} catch (error) {
setInsertionVisibility(Visibility.visible)
toastAndLog(
errorModule.tryGetMessage(error)?.slice(0, -1) ??
`Could not delete ${backendModule.ASSET_TYPE_NAME[asset.type]}`
)
}
}, [
backend,
dispatchAssetListEvent,
asset,
/* should never change */ item.key,
/* should never change */ toastAndLog,
])
try {
dispatchAssetListEvent({ type: AssetListEventType.willDelete, key: item.key })
if (
asset.type === backendModule.AssetType.project &&
backend.type === backendModule.BackendType.local
) {
if (
asset.projectState.type !== backendModule.ProjectState.placeholder &&
asset.projectState.type !== backendModule.ProjectState.closed
) {
await backend.openProject(asset.id, null, asset.title)
}
try {
await backend.closeProject(asset.id, asset.title)
} catch {
// Ignored. The project was already closed.
}
}
await backend.deleteAsset(asset.id, forever, asset.title)
dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key })
} catch (error) {
setInsertionVisibility(Visibility.visible)
toastAndLog(
errorModule.tryGetMessage(error)?.slice(0, -1) ??
`Could not delete ${backendModule.ASSET_TYPE_NAME[asset.type]}`
)
}
},
[
backend,
dispatchAssetListEvent,
asset,
/* should never change */ item.key,
/* should never change */ toastAndLog,
]
)
const doRestore = React.useCallback(async () => {
// Visually, the asset is deleted from the Trash view.
@ -337,7 +340,13 @@ export default function AssetRow(props: AssetRowProps) {
}
case AssetEventType.delete: {
if (event.ids.has(item.key)) {
await doDelete()
await doDelete(false)
}
break
}
case AssetEventType.deleteForever: {
if (event.ids.has(item.key)) {
await doDelete(true)
}
break
}

View File

@ -67,6 +67,7 @@ export default function DataLinkNameColumn(props: DataLinkNameColumnProps) {
case AssetEventType.cancelCut:
case AssetEventType.move:
case AssetEventType.delete:
case AssetEventType.deleteForever:
case AssetEventType.restore:
case AssetEventType.download:
case AssetEventType.downloadSelected:
@ -77,8 +78,8 @@ export default function DataLinkNameColumn(props: DataLinkNameColumnProps) {
case AssetEventType.removeLabels:
case AssetEventType.deleteLabel: {
// Ignored. These events should all be unrelated to secrets.
// `deleteMultiple`, `restoreMultiple`, `download`,
// and `downloadSelected` are handled by `AssetRow`.
// `delete`, `deleteForever`, `restoreMultiple`, `download`, and `downloadSelected`
// are handled by `AssetRow`.
break
}
case AssetEventType.newDataLink: {

View File

@ -81,6 +81,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
case AssetEventType.cancelCut:
case AssetEventType.move:
case AssetEventType.delete:
case AssetEventType.deleteForever:
case AssetEventType.restore:
case AssetEventType.download:
case AssetEventType.downloadSelected:
@ -91,8 +92,8 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
case AssetEventType.removeLabels:
case AssetEventType.deleteLabel: {
// Ignored. These events should all be unrelated to directories.
// `deleteMultiple`, `restoreMultiple`, `download`,
// and `downloadSelected` are handled by `AssetRow`.
// `delete`, `deleteForever`, `restore`, `download`, and `downloadSelected`
// are handled by`AssetRow`.
break
}
case AssetEventType.newFolder: {

View File

@ -66,6 +66,7 @@ export default function FileNameColumn(props: FileNameColumnProps) {
case AssetEventType.cancelCut:
case AssetEventType.move:
case AssetEventType.delete:
case AssetEventType.deleteForever:
case AssetEventType.restore:
case AssetEventType.download:
case AssetEventType.downloadSelected:
@ -76,7 +77,7 @@ export default function FileNameColumn(props: FileNameColumnProps) {
case AssetEventType.removeLabels:
case AssetEventType.deleteLabel: {
// Ignored. These events should all be unrelated to projects.
// `deleteMultiple`, `restoreMultiple`, `download`, and `downloadSelected`
// `delete`, `deleteForever`, `restoreMultiple`, `download`, and `downloadSelected`
// are handled by `AssetRow`.
break
}

View File

@ -234,6 +234,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
case AssetEventType.cancelCut:
case AssetEventType.move:
case AssetEventType.delete:
case AssetEventType.deleteForever:
case AssetEventType.restore:
case AssetEventType.download:
case AssetEventType.downloadSelected:
@ -243,9 +244,9 @@ export default function ProjectIcon(props: ProjectIconProps) {
case AssetEventType.addLabels:
case AssetEventType.removeLabels:
case AssetEventType.deleteLabel: {
// Ignored. Any missing project-related events should be handled by
// `ProjectNameColumn`. `deleteMultiple`, `restoreMultiple`, `download`,
// and `downloadSelected` are handled by `AssetRow`.
// Ignored. Any missing project-related events should be handled by `ProjectNameColumn`.
// `delete`, `deleteForever`, `restore`, `download`, and `downloadSelected`
// are handled by`AssetRow`.
break
}
case AssetEventType.openProject: {

View File

@ -102,6 +102,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
case AssetEventType.cancelCut:
case AssetEventType.move:
case AssetEventType.delete:
case AssetEventType.deleteForever:
case AssetEventType.restore:
case AssetEventType.download:
case AssetEventType.downloadSelected:
@ -112,8 +113,8 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
case AssetEventType.removeLabels:
case AssetEventType.deleteLabel: {
// Ignored. Any missing project-related events should be handled by `ProjectIcon`.
// `deleteMultiple`, `restoreMultiple`, `download`, and `downloadSelected`
// are handled by `AssetRow`.
// `delete`, `deleteForever`, `restore`, `download`, and `downloadSelected`
// are handled by`AssetRow`.
break
}
case AssetEventType.newProject: {

View File

@ -64,6 +64,7 @@ export default function SecretNameColumn(props: SecretNameColumnProps) {
case AssetEventType.cancelCut:
case AssetEventType.move:
case AssetEventType.delete:
case AssetEventType.deleteForever:
case AssetEventType.restore:
case AssetEventType.download:
case AssetEventType.downloadSelected:
@ -74,8 +75,8 @@ export default function SecretNameColumn(props: SecretNameColumnProps) {
case AssetEventType.removeLabels:
case AssetEventType.deleteLabel: {
// Ignored. These events should all be unrelated to secrets.
// `deleteMultiple`, `restoreMultiple`, `download`,
// and `downloadSelected` are handled by `AssetRow`.
// `delete`, `deleteForever`, `restore`, `download`, and `downloadSelected`
// are handled by`AssetRow`.
break
}
case AssetEventType.newSecret: {

View File

@ -19,6 +19,7 @@ enum AssetEventType {
cancelCut = 'cancel-cut',
move = 'move',
delete = 'delete',
deleteForever = 'delete-forever',
restore = 'restore',
download = 'download',
downloadSelected = 'download-selected',

View File

@ -13,6 +13,7 @@ enum AssetListEventType {
move = 'move',
willDelete = 'will-delete',
delete = 'delete',
emptyTrash = 'empty-trash',
removeSelf = 'remove-self',
}

View File

@ -38,6 +38,7 @@ interface AssetEvents {
readonly cancelCut: AssetCancelCutEvent
readonly move: AssetMoveEvent
readonly delete: AssetDeleteEvent
readonly deleteForever: AssetDeleteForeverEvent
readonly restore: AssetRestoreEvent
readonly download: AssetDownloadEvent
readonly downloadSelected: AssetDownloadSelectedEvent
@ -134,6 +135,11 @@ export interface AssetDeleteEvent extends AssetBaseEvent<AssetEventType.delete>
readonly ids: ReadonlySet<backendModule.AssetId>
}
/** A signal to delete assets forever. */
export interface AssetDeleteForeverEvent extends AssetBaseEvent<AssetEventType.deleteForever> {
readonly ids: ReadonlySet<backendModule.AssetId>
}
/** A signal to restore assets from trash. */
export interface AssetRestoreEvent extends AssetBaseEvent<AssetEventType.restore> {
readonly ids: ReadonlySet<backendModule.AssetId>

View File

@ -36,6 +36,7 @@ interface AssetListEvents {
readonly move: AssetListMoveEvent
readonly willDelete: AssetListWillDeleteEvent
readonly delete: AssetListDeleteEvent
readonly emptyTrash: AssetListEmptyTrashEvent
readonly removeSelf: AssetListRemoveSelfEvent
}
@ -127,6 +128,9 @@ interface AssetListDeleteEvent extends AssetListBaseEvent<AssetListEventType.del
readonly key: backend.AssetId
}
/** A signal to permanently delete all files in Trash. */
interface AssetListEmptyTrashEvent extends AssetListBaseEvent<AssetListEventType.emptyTrash> {}
/** A signal for a file to remove itself from the asset list, without being deleted. */
interface AssetListRemoveSelfEvent extends AssetListBaseEvent<AssetListEventType.removeSelf> {
readonly id: backend.AssetId

View File

@ -98,6 +98,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
},
[/* should never change */ setItem]
)
return category === Category.trash ? (
!ownsThisAsset ? null : (
<ContextMenus hidden={hidden} key={asset.id} event={event}>
@ -108,10 +109,23 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
label="Restore From Trash"
doAction={() => {
unsetModal()
dispatchAssetEvent({
type: AssetEventType.restore,
ids: new Set([asset.id]),
})
dispatchAssetEvent({ type: AssetEventType.restore, ids: new Set([asset.id]) })
}}
/>
<MenuEntry
hidden={hidden}
action="delete"
label="Delete Forever"
doAction={() => {
setModal(
<ConfirmDeleteModal
actionText={`delete the ${asset.type} '${asset.title}' forever`}
doDelete={() => {
const ids = new Set([asset.id])
dispatchAssetEvent({ type: AssetEventType.deleteForever, ids })
}}
/>
)
}}
/>
</ContextMenu>

View File

@ -1727,11 +1727,20 @@ export default function AssetsTable(props: AssetsTableProps) {
deleteAsset(event.key)
break
}
case AssetListEventType.emptyTrash: {
if (category !== Category.trash) {
toastAndLog('Can only empty trash when in Trash')
} else if (assetTree.children != null) {
const ids = new Set(assetTree.children.map(child => child.item.id))
// This is required to prevent an infinite loop,
window.setTimeout(() => {
dispatchAssetEvent({ type: AssetEventType.deleteForever, ids })
})
}
break
}
case AssetListEventType.removeSelf: {
dispatchAssetEvent({
type: AssetEventType.removeSelf,
id: event.id,
})
dispatchAssetEvent({ type: AssetEventType.removeSelf, id: event.id })
break
}
case AssetListEventType.closeFolder: {

View File

@ -86,6 +86,7 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
)
return selfPermission?.permission === permissions.PermissionAction.own
}).every(isOwner => isOwner))
// This is not a React component even though it contains JSX.
// eslint-disable-next-line no-restricted-syntax
const doDeleteAll = () => {
@ -104,15 +105,7 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
)
}
}
// This is not a React component even though it contains JSX.
// eslint-disable-next-line no-restricted-syntax
const doRestoreAll = () => {
unsetModal()
dispatchAssetEvent({
type: AssetEventType.restore,
ids: selectedKeys,
})
}
if (category === Category.trash) {
return selectedKeys.size === 0 ? (
<></>
@ -123,8 +116,29 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
hidden={hidden}
action="undelete"
label="Restore All From Trash"
doAction={doRestoreAll}
doAction={() => {
unsetModal()
dispatchAssetEvent({ type: AssetEventType.restore, ids: selectedKeys })
}}
/>
{isCloud && (
<MenuEntry
hidden={hidden}
action="delete"
label="Delete All Forever"
doAction={() => {
setModal(
<ConfirmDeleteModal
actionText={`delete ${selectedKeys.size} selected ${pluralized} forever`}
doDelete={() => {
clearSelectedKeys()
dispatchAssetEvent({ type: AssetEventType.deleteForever, ids: selectedKeys })
}}
/>
)
}}
/>
)}
</ContextMenu>
</ContextMenus>
)

View File

@ -185,6 +185,10 @@ export default function Drive(props: DriveProps) {
[backend, user, rootDirectoryId, toastAndLog, /* should never change */ dispatchAssetListEvent]
)
const doEmptyTrash = React.useCallback(() => {
dispatchAssetListEvent({ type: AssetListEventType.emptyTrash })
}, [/* should never change */ dispatchAssetListEvent])
const doCreateProject = React.useCallback(
(
templateId: string | null = null,
@ -363,6 +367,7 @@ export default function Drive(props: DriveProps) {
<DriveBar
category={category}
canDownloadFiles={canDownloadFiles}
doEmptyTrash={doEmptyTrash}
doCreateProject={doCreateProject}
doUploadFiles={doUploadFiles}
doCreateDirectory={doCreateDirectory}

View File

@ -19,6 +19,7 @@ import Category from '#/layouts/CategorySwitcher/Category'
import Button from '#/components/Button'
import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
import UpsertDataLinkModal from '#/modals/UpsertDataLinkModal'
import UpsertSecretModal from '#/modals/UpsertSecretModal'
@ -34,6 +35,7 @@ import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
export interface DriveBarProps {
readonly category: Category
readonly canDownloadFiles: boolean
readonly doEmptyTrash: () => void
readonly doCreateProject: () => void
readonly doCreateDirectory: () => void
readonly doCreateSecret: (name: string, value: string) => void
@ -45,7 +47,7 @@ export interface DriveBarProps {
/** Displays the current directory path and permissions, upload and download buttons,
* and a column display mode switcher. */
export default function DriveBar(props: DriveBarProps) {
const { category, canDownloadFiles, doCreateProject, doCreateDirectory } = props
const { category, canDownloadFiles, doEmptyTrash, doCreateProject, doCreateDirectory } = props
const { doCreateSecret, doCreateDataLink, doUploadFiles, dispatchAssetEvent } = props
const { backend } = backendProvider.useBackend()
const { setModal, unsetModal } = modalProvider.useSetModal()
@ -72,7 +74,23 @@ export default function DriveBar(props: DriveBarProps) {
})
}, [backend.type, doCreateDirectory, doCreateProject, /* should never change */ inputBindings])
return (
return category === Category.trash ? (
<div className="flex h-8 py-0.5">
<div className="flex gap-2.5">
<button
className="flex items-center bg-frame rounded-full h-8 px-2.5"
onClick={event => {
event.stopPropagation()
setModal(
<ConfirmDeleteModal actionText="all trashed items forever" doDelete={doEmptyTrash} />
)
}}
>
<span className="font-semibold whitespace-nowrap leading-5 h-6 py-px">Clear Trash</span>
</button>
</div>
</div>
) : (
<div className="flex h-8 py-0.5">
<div className="flex gap-2.5">
<button
@ -169,11 +187,7 @@ export default function DriveBar(props: DriveBarProps) {
disabled={!canDownloadFiles}
image={DataDownloadIcon}
alt="Download Files"
error={
category === Category.trash
? 'You cannot download files from Trash.'
: 'You currently can only download files.'
}
error="You currently can only download files."
disabledOpacityClassName="opacity-20"
onClick={event => {
event.stopPropagation()

View File

@ -54,8 +54,6 @@ export default function ConfirmDeleteModal(props: ConfirmDeleteModalProps) {
}}
onSubmit={event => {
event.preventDefault()
// Consider not calling `onSubmit()` here to make it harder to accidentally
// delete an important asset.
onSubmit()
}}
>

View File

@ -1079,7 +1079,7 @@ export default abstract class Backend {
title: string | null
): Promise<void>
/** Delete an arbitrary asset. */
abstract deleteAsset(assetId: AssetId, title: string | null): Promise<void>
abstract deleteAsset(assetId: AssetId, force: boolean, title: string | null): Promise<void>
/** Restore an arbitrary asset from the trash. */
abstract undoDeleteAsset(assetId: AssetId, title: string | null): Promise<void>
/** Copy an arbitrary asset to another directory. */

View File

@ -269,7 +269,11 @@ export default class LocalBackend extends Backend {
/** Delete an arbitrary asset.
* @throws An error if the JSON-RPC call fails. */
override async deleteAsset(assetId: backend.AssetId, title: string | null): Promise<void> {
override async deleteAsset(
assetId: backend.AssetId,
_force: boolean,
title: string | null
): Promise<void> {
// This is SAFE, as the only asset type on the local backend is projects.
// eslint-disable-next-line no-restricted-syntax
const projectId = assetId as backend.ProjectId

View File

@ -440,8 +440,9 @@ export default class RemoteBackend extends Backend {
/** Delete an arbitrary asset.
* @throws An error if a non-successful status code (not 200-299) was received. */
override async deleteAsset(assetId: backendModule.AssetId, title: string | null) {
const path = remoteBackendPaths.deleteAssetPath(assetId)
override async deleteAsset(assetId: backendModule.AssetId, force: boolean, title: string | null) {
const paramsString = new URLSearchParams([['force', String(force)]]).toString()
const path = remoteBackendPaths.deleteAssetPath(assetId) + '?' + paramsString
const response = await this.delete(path)
if (!responseIsSuccessful(response)) {
const name = title != null ? `'${title}'` : `asset with ID '${assetId}'`