feat(workspace): trash for notes

Deleting a note (or its folder) moves it to the trash instead of erasing
it. Trashed notes leave the tree, search, export and stats and are hidden
from everyone they were shared with; the owner sees them read-only with a
restore banner. New endpoints list, restore, purge and empty the trash;
restoring recreates a deleted folder. Sidebar entry and mobile menu item
with counter, list view with two-tap permanent delete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 07:57:42 +01:00
1 parent 344f5e4ab2
commit f69f74fdc7
15 files changed
+649 -40

No files matched your search

@@ -36,6 +36,7 @@ import {
TagDetails,
TagSummary,
Team,
TrashedDocument,
UnifiedShare,
User,
} from '../../lib/types';
@@ -53,6 +54,8 @@ import { FormSheet } from '../../components/FormSheet';
import { sendDocOps } from '../../lib/docSync/oneoff';
import { useMobileActions } from '../../context/MobileActionContext';
import { DocumentEditor, DocSaveStatus } from './DocumentEditor';
import { TrashView } from './TrashView';
import { CountBadge } from '../../components/CountBadge';
import { extractHashtags } from '../../lib/hashtags';
interface WorkspaceViewProps {
@@ -63,7 +66,7 @@ interface WorkspaceViewProps {
onOpenTask?: (taskId: string) => void;
}
type WorkspaceSubView = 'editor' | 'tag_detail' | 'todos' | 'tags';
type WorkspaceSubView = 'editor' | 'tag_detail' | 'todos' | 'tags' | 'trash';
interface FolderTreeNode {
name: string;
@@ -307,6 +310,15 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const [targetBlockId, setTargetBlockId] = useState<string | null>(initialBlockId || null);
const [newlyCreatedDocId, setNewlyCreatedDocId] = useState<string | null>(null);
const [subView, setSubView] = useState<WorkspaceSubView>('editor');
const [trash, setTrash] = useState<TrashedDocument[]>([]);
// Bumped after a restore so the open editor reloads the note without the trash banner
const [editorNonce, setEditorNonce] = useState(0);
const reloadTrash = useCallback(() => {
api.getTrash().then(setTrash).catch(() => {});
}, []);
useEffect(() => {
reloadTrash();
}, [reloadTrash]);
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const [tagDetails, setTagDetails] = useState<TagDetails | null>(null);
const [tagNoteDraft, setTagNoteDraft] = useState<string>('');
@@ -466,14 +478,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const delId = msg.payload.id as string;
setDocs((prev) => prev.filter((d) => d.id !== delId));
setSelectedDocId((prev) => (prev === delId ? null : prev));
reloadTrash();
} else if (msg.event === 'WORKSPACE_FOLDERS_UPDATED') {
api.getFolders().then(setFolders).catch(() => {});
api.getSharedFolders().then(setSharedFolders).catch(() => {});
api.getDocuments().then(setDocs).catch(() => {});
reloadTrash();
}
});
return unsub;
}, [currentUser]);
}, [currentUser, reloadTrash]);
const handleCreateDocument = useCallback(
async (folderPath = '') => {
@@ -524,6 +538,12 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
setMobileShowDetail(true);
}, []);
const openTrash = useCallback(() => {
reloadTrash();
setSubView('trash');
setMobileShowDetail(true);
}, [reloadTrash]);
// Register mobile FAB actions
useEffect(() => {
setPrimaryAction(() => handleCreateDocument(''));
@@ -545,6 +565,13 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
icon: <FolderPlus className="w-4 h-4" />,
onClick: () => setShowNewFolderModal(true),
},
{
id: 'trash',
label: 'Papierkorb',
icon: <Trash2 className="w-4 h-4" />,
badge: trash.length,
onClick: openTrash,
},
],
},
]);
@@ -552,7 +579,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
setGroups([]);
setPrimaryAction(undefined);
};
}, [handleCreateDocument, setGroups, setPrimaryAction]);
}, [handleCreateDocument, setGroups, setPrimaryAction, trash.length, openTrash]);
const handleDeleteDocument = useCallback(
async (docId: string) => {
@@ -567,13 +594,47 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
});
setMobileShowDetail(false);
onStatsChange();
reloadTrash();
} catch {
// ignore
}
},
[onStatsChange, selectedDocId]
[onStatsChange, selectedDocId, reloadTrash]
);
const handleOpenTrashedDoc = (docId: string) => {
setSelectedDocId(docId);
setTargetBlockId(null);
setNewlyCreatedDocId(null);
setSubView('editor');
setMobileShowDetail(true);
};
const handleRestoreDocument = useCallback(
async (docId: string) => {
await api.restoreDocument(docId);
const [freshDocs, freshFolders] = await Promise.all([api.getDocuments(), api.getFolders()]);
setDocs(freshDocs);
setFolders(freshFolders);
setEditorNonce((n) => n + 1);
reloadTrash();
onStatsChange();
},
[reloadTrash, onStatsChange]
);
const handlePurgeDocument = async (docId: string) => {
await api.purgeDocument(docId);
if (selectedDocId === docId) setSelectedDocId(null);
reloadTrash();
};
const handleEmptyTrash = async () => {
await api.emptyTrash();
if (selectedDocId && trash.some((t) => t.id === selectedDocId)) setSelectedDocId(null);
reloadTrash();
};
const handleDocMetadataChange = useCallback((updated: Document) => {
setDocs((prev) => prev.map((d) => (d.id === updated.id ? { ...d, ...updated } : d)));
}, []);
@@ -615,6 +676,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}
}
onStatsChange();
reloadTrash();
} catch {
// ignore
}
@@ -1257,7 +1319,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
{/* LEFT MASTER SIDEBAR */}
<aside
className={`${
subView !== 'editor' || isSidebarCollapsed
(subView !== 'editor' && subView !== 'trash') || isSidebarCollapsed
? 'hidden'
: mobileShowDetail
? 'hidden lg:flex'
@@ -1469,6 +1531,23 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
</div>
)}
</div>
{/* Trash at the bottom of the tree */}
<div className="p-2 border-t border-border shrink-0">
<button
type="button"
onClick={openTrash}
className={`w-full flex items-center gap-2 px-2 h-11 sm:h-8 rounded-btn text-xs border transition-colors cursor-pointer ${
subView === 'trash'
? 'bg-panel-elevated text-main font-semibold border-border/80'
: 'border-transparent text-muted hover:text-main hover:bg-panel-elevated'
}`}
>
<Trash2 className="w-3.5 h-3.5" />
<span className="flex-1 text-left">Papierkorb</span>
<CountBadge count={trash.length} />
</button>
</div>
</aside>
{/* RIGHT DETAIL AREA (STRICTLY ISOLATED BY KEY) */}
@@ -1477,9 +1556,21 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
subView !== 'editor' || mobileShowDetail || isSidebarCollapsed ? 'flex' : 'hidden lg:flex'
} flex-1 flex-col min-w-0 h-full overflow-hidden bg-canvas`}
>
{subView === 'editor' && selectedDocId ? (
{subView === 'trash' ? (
<TrashView
items={trash}
onOpen={handleOpenTrashedDoc}
onRestore={handleRestoreDocument}
onPurge={handlePurgeDocument}
onEmpty={handleEmptyTrash}
onBackMobile={() => {
setSubView('editor');
setMobileShowDetail(false);
}}
/>
) : subView === 'editor' && selectedDocId ? (
<DocumentEditor
key={selectedDocId}
key={`${selectedDocId}:${editorNonce}`}
docId={selectedDocId}
currentUser={currentUser}
initialDoc={selectedDocObj}
@@ -1488,6 +1579,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
initialFocusedBlockId={targetBlockId}
onDocMetadataChange={handleDocMetadataChange}
onDeleteDoc={handleDeleteDocument}
onRestoreDoc={handleRestoreDocument}
onOpenShareModal={openDocShareModal}
onSelectTag={handleSelectTag}
onWikiLinkClick={handleWikiLinkClick}