feat(workspace): deleted folders go to the trash as a whole, after a confirmation

A deleted personal folder is kept in trashed_folders with its subfolders;
its notes are trashed and tied to it. The trash lists the folder as one
entry with its note count; restoring brings back the folder tree (empty
subfolders too) and all notes, deleting for good, emptying the trash and
the notes trash delay remove it with its notes. Folders in shared folders
keep the old way.

Deleting a folder now asks first, in a small popover at the click
(ConfirmPopover, documented in ui-patterns).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-11-02 19:36:22 +01:00
1 parent 9dcfa6c27e
commit b26b07cfea
18 files changed
+564 -25

No files matched your search

@@ -57,6 +57,7 @@ import { JournalCalendar } from './JournalCalendar';
import { isJournalEnabled, readWorkspaceSession, todayISO, writeWorkspaceSession } from '../../lib/journal';
import { MergeDialog } from './MergeDialog';
import { CountBadge } from '../../components/CountBadge';
import { ConfirmPopover } from '../../components/ConfirmPopover';
import { EmojiIconButton } from '../../components/EmojiIconButton';
import { LazyMount } from '../../components/LazyMount';
import { extractHashtags } from '../../lib/hashtags';
@@ -97,7 +98,8 @@ interface FolderTreeItemProps {
handleCreateDocument: (folderPath?: string) => void;
handleOpenNewFolder: (parentPath?: string, e?: React.MouseEvent) => void;
handleShareFolderClick: (folderPath: string, existingSf?: SharedFolder) => void;
handleDeleteFolder: (folderPath: string) => void;
// Asks first (popover at the given point), then moves the folder to the trash
handleDeleteFolder: (folderPath: string, at: { x: number; y: number }) => void;
onSelectDoc: (id: string) => void;
// Dropping a note onto another one offers to merge them
docDropProps: (targetId: string) => React.HTMLAttributes<HTMLDivElement>;
@@ -218,7 +220,10 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
type="button"
onClick={(e) => {
e.stopPropagation();
handleDeleteFolder(node.fullPath);
// Keyboard clicks have no pointer position: open at the button instead
const r = e.currentTarget.getBoundingClientRect();
const byPointer = e.clientX !== 0 || e.clientY !== 0;
handleDeleteFolder(node.fullPath, byPointer ? { x: e.clientX, y: e.clientY } : { x: r.left, y: r.bottom });
}}
className="p-1 h-6 w-6 flex items-center justify-center text-faint hover:text-status-err transition-colors cursor-pointer"
title="Ordner löschen"
@@ -736,6 +741,22 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
reloadTrash();
};
// Trash entries: a note, or a deleted folder that comes back (or goes) with all of its notes
const handleRestoreTrashItem = async (item: TrashedDocument) => {
if (item.kind !== 'folder') return handleRestoreDocument(item.id);
await api.restoreFolder(item.id);
const [freshDocs, freshFolders] = await Promise.all([api.getDocuments(), api.getFolders()]);
setDocs(freshDocs);
setFolders(freshFolders);
reloadTrash();
onStatsChange();
};
const handlePurgeTrashItem = async (item: TrashedDocument) => {
if (item.kind !== 'folder') return handlePurgeDocument(item.id);
await api.purgeFolder(item.id);
reloadTrash();
};
const handleEmptyTrash = async () => {
await api.emptyTrash();
if (selectedDocId && trash.some((t) => t.id === selectedDocId)) setSelectedDocId(null);
@@ -770,6 +791,11 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}
};
// Deleting a folder asks first, in a small popover where the click was
const [pendingFolderDelete, setPendingFolderDelete] = useState<{ path: string; x: number; y: number } | null>(null);
const requestDeleteFolder = (path: string, at: { x: number; y: number }) => setPendingFolderDelete({ path, ...at });
const closeFolderDelete = useCallback(() => setPendingFolderDelete(null), []);
const handleDeleteFolder = async (folderPath: string) => {
try {
const res = await api.deleteFolder(folderPath);
@@ -2031,7 +2057,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
handleCreateDocument={handleCreateDocument}
handleOpenNewFolder={handleOpenNewFolder}
handleShareFolderClick={handleShareFolderClick}
handleDeleteFolder={handleDeleteFolder}
handleDeleteFolder={requestDeleteFolder}
onSelectDoc={(id) => {
setSelectedDocId(id);
setTargetBlockId(null);
@@ -2190,8 +2216,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
<TrashView
items={trash}
onOpen={handleOpenTrashedDoc}
onRestore={handleRestoreDocument}
onPurge={handlePurgeDocument}
onRestore={handleRestoreTrashItem}
onPurge={handlePurgeTrashItem}
onEmpty={handleEmptyTrash}
onBackMobile={() => {
setSubView('editor');
@@ -2872,6 +2898,33 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
</FormSheet>
)}
{pendingFolderDelete &&
(() => {
const { path } = pendingFolderDelete;
const name = path.split('/').pop() || path;
const notes = docs.filter((d) => d.folderPath === path || (d.folderPath || '').startsWith(`${path}/`)).length;
return (
<ConfirmPopover
x={pendingFolderDelete.x}
y={pendingFolderDelete.y}
danger
confirmLabel="In den Papierkorb"
message={
<>
Ordner <strong>„{name}“</strong>
{notes > 0 ? ` mit ${notes === 1 ? '1 Notiz' : `${notes} Notizen`}` : ''} löschen? Er kommt mit allen
Unterordnern in den Papierkorb und lässt sich dort wiederherstellen.
</>
}
onCancel={closeFolderDelete}
onConfirm={() => {
closeFolderDelete();
void handleDeleteFolder(path);
}}
/>
);
})()}
{mergeDialog && (
<MergeDialog
docs={docs}