Merge branch 'feat/sidebar-explorer' into dev

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 22:37:17 +01:00
commit a9d0e49159
2 files changed
+261 -105

No files matched your search

+96 -105
View File
@@ -911,7 +911,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
);
const rootDocs = useMemo(
() => docs.filter((d) => !d.folderPath),
() => docs.filter((d) => !d.folderPath && !d.isPinned),
[docs]
);
@@ -1330,114 +1330,105 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
{/* Scrollable Navigation Tree */}
<div className="flex-1 overflow-y-auto p-2 space-y-3">
{/* Pinned Notes */}
{pinnedDocs.length > 0 && (
{pinnedDocs.length === 0 && folderTree.length === 0 && rootDocs.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted">Noch keine Notizen</div>
) : (
<div>
<div className="px-2 mb-1 flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-faint">
<Pin className="w-3 h-3 text-status-warn" />
<span>Angeheftet</span>
</div>
<div className="space-y-0.5">
{pinnedDocs.map((d) => (
<div
key={`pin-${d.id}`}
draggable
onDragStart={(e) => handleDocDragStart(e, d.id)}
onClick={() => {
setSelectedDocId(d.id);
setSubView('editor');
setMobileShowDetail(true);
}}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
selectedDocId === d.id && subView === 'editor'
? 'bg-panel-elevated text-main font-semibold border-border/80'
: 'border-transparent text-main hover:bg-panel-elevated'
}`}
>
<span>{d.icon || '📝'}</span>
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
</div>
))}
</div>
{/* Pinned Notes */}
{pinnedDocs.length > 0 && (
<div
className={`space-y-0.5 ${
folderTree.length > 0 || rootDocs.length > 0
? 'border-b border-border pb-1.5 mb-1.5'
: ''
}`}
>
{pinnedDocs.map((d) => (
<div
key={`pin-${d.id}`}
draggable
onDragStart={(e) => handleDocDragStart(e, d.id)}
onClick={() => {
setSelectedDocId(d.id);
setSubView('editor');
setMobileShowDetail(true);
}}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
selectedDocId === d.id && subView === 'editor'
? 'bg-panel-elevated text-main font-semibold border-border/80'
: 'border-transparent text-main hover:bg-panel-elevated'
}`}
>
<span className="shrink-0">{d.icon || '📝'}</span>
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
{d.sharedWith && d.sharedWith.length > 0 && (
<Users className="w-3 h-3 text-status-ok shrink-0" />
)}
<Pin className="w-3 h-3 text-muted shrink-0" />
</div>
))}
</div>
)}
{/* Folders and Root Documents */}
{(folderTree.length > 0 || rootDocs.length > 0) && (
<div className="space-y-0.5">
{folderTree.map((node) => (
<FolderTreeItem
key={node.fullPath}
node={node}
depth={0}
expandedFolders={expandedFolders}
setExpandedFolders={setExpandedFolders}
selectedDocId={selectedDocId}
subView={subView}
dragOverFolder={dragOverFolder}
setDragOverFolder={setDragOverFolder}
handleDropOnFolder={handleDropOnFolder}
handleFolderDragStart={handleFolderDragStart}
handleDocDragStart={handleDocDragStart}
handleCreateDocument={handleCreateDocument}
handleOpenNewFolder={handleOpenNewFolder}
handleShareFolderClick={handleShareFolderClick}
handleDeleteFolder={handleDeleteFolder}
onSelectDoc={(id) => {
setSelectedDocId(id);
setTargetBlockId(null);
setNewlyCreatedDocId(null);
setSubView('editor');
setMobileShowDetail(true);
}}
/>
))}
{rootDocs.map((d) => (
<div
key={d.id}
draggable
onDragStart={(e) => handleDocDragStart(e, d.id)}
onClick={() => {
setSelectedDocId(d.id);
setSubView('editor');
setMobileShowDetail(true);
}}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
selectedDocId === d.id && subView === 'editor'
? 'bg-panel-elevated text-main font-semibold border-border/80'
: 'border-transparent text-main hover:bg-panel-elevated'
}`}
>
<span className="shrink-0">{d.icon || '📝'}</span>
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
{d.sharedWith && d.sharedWith.length > 0 && (
<Users className="w-3 h-3 text-status-ok shrink-0" />
)}
</div>
))}
</div>
)}
</div>
)}
{/* Folders Tree with Recursive Hierarchical Nesting */}
{folderTree.length > 0 && (
<div>
<div className="px-2 mb-1 text-[10px] font-bold uppercase tracking-wider text-faint">
Ordner
</div>
<div className="space-y-0.5">
{folderTree.map((node) => (
<FolderTreeItem
key={node.fullPath}
node={node}
depth={0}
expandedFolders={expandedFolders}
setExpandedFolders={setExpandedFolders}
selectedDocId={selectedDocId}
subView={subView}
dragOverFolder={dragOverFolder}
setDragOverFolder={setDragOverFolder}
handleDropOnFolder={handleDropOnFolder}
handleFolderDragStart={handleFolderDragStart}
handleDocDragStart={handleDocDragStart}
handleCreateDocument={handleCreateDocument}
handleOpenNewFolder={handleOpenNewFolder}
handleShareFolderClick={handleShareFolderClick}
handleDeleteFolder={handleDeleteFolder}
onSelectDoc={(id) => {
setSelectedDocId(id);
setTargetBlockId(null);
setNewlyCreatedDocId(null);
setSubView('editor');
setMobileShowDetail(true);
}}
/>
))}
</div>
</div>
)}
{/* Root Documents */}
<div>
<div className="px-2 mb-1 text-[10px] font-bold uppercase tracking-wider text-faint">
Notizen (Hauptebene)
</div>
{rootDocs.length === 0 ? (
<div className="px-2 py-2 text-xs text-faint italic">
Keine Notizen auf der Hauptebene.
</div>
) : (
<div className="space-y-0.5">
{rootDocs.map((d) => (
<div
key={d.id}
draggable
onDragStart={(e) => handleDocDragStart(e, d.id)}
onClick={() => {
setSelectedDocId(d.id);
setSubView('editor');
setMobileShowDetail(true);
}}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
selectedDocId === d.id && subView === 'editor'
? 'bg-panel-elevated text-main font-semibold border-border/80'
: 'border-transparent text-main hover:bg-panel-elevated'
}`}
>
<span>{d.icon || '📝'}</span>
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
{d.sharedWith && d.sharedWith.length > 0 && (
<Users className="w-3 h-3 text-status-ok shrink-0" />
)}
</div>
))}
</div>
)}
</div>
{/* Bottom drop target for moving to root */}
{(draggingDocId || draggingFolderPath) && (
<div
@@ -0,0 +1,165 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { WorkspaceView } from '../WorkspaceView';
import { api } from '../../../lib/api';
import { Document } from '../../../lib/types';
vi.mock('../DocumentEditor', () => ({
DocumentEditor: () => <div data-testid="document-editor">DocumentEditor</div>,
}));
vi.mock('../../../lib/api', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../lib/api')>();
return {
...actual,
api: {
...actual.api,
getDocuments: vi.fn(),
getFolders: vi.fn(),
getSharedFolders: vi.fn(),
getWorkspaceTags: vi.fn(),
getTrash: vi.fn(),
},
onWebSocketMessage: vi.fn(() => () => {}),
triggerHaptic: vi.fn(),
};
});
const makeDoc = (overrides: Partial<Document> & { id: string; title: string }): Document => ({
id: overrides.id,
userId: 'u1',
title: overrides.title,
icon: '📝',
folderPath: '',
content: '',
blocks: [],
tags: [],
isPinned: false,
isArchived: false,
createdAt: '2026-10-29T10:00:00Z',
updatedAt: '2026-10-29T10:00:00Z',
...overrides,
});
describe('WorkspaceView sidebar explorer', () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.mocked(api.getFolders).mockResolvedValue([]);
vi.mocked(api.getSharedFolders).mockResolvedValue([]);
vi.mocked(api.getWorkspaceTags).mockResolvedValue([]);
vi.mocked(api.getTrash).mockResolvedValue([]);
});
it('renders empty state "Noch keine Notizen" when there are no notes and no folders', async () => {
vi.mocked(api.getDocuments).mockResolvedValue([]);
vi.mocked(api.getFolders).mockResolvedValue([]);
render(<WorkspaceView onStatsChange={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText('Noch keine Notizen')).toBeTruthy();
});
expect(screen.queryByText('Angeheftet')).toBeNull();
expect(screen.queryByText('Ordner')).toBeNull();
expect(screen.queryByText('Notizen (Hauptebene)')).toBeNull();
expect(screen.queryByText('Keine Notizen auf der Hauptebene.')).toBeNull();
});
it('renders pinned docs, folders, and root docs without headings', async () => {
const docs: Document[] = [
makeDoc({
id: 'doc_pin_root',
title: 'Wichtige Pinned Notiz',
icon: '⭐',
isPinned: true,
}),
makeDoc({
id: 'doc_root',
title: 'Normale Root Notiz',
icon: '📝',
}),
makeDoc({
id: 'doc_in_folder',
title: 'Projekt Plan',
icon: '📄',
folderPath: 'Projekte',
}),
];
vi.mocked(api.getDocuments).mockResolvedValue(docs);
vi.mocked(api.getFolders).mockResolvedValue(['Projekte']);
render(<WorkspaceView onStatsChange={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText('Wichtige Pinned Notiz')).toBeTruthy();
expect(screen.getByText('Normale Root Notiz')).toBeTruthy();
expect(screen.getByText('Projekte')).toBeTruthy();
});
// Ensure no old headings are rendered
expect(screen.queryByText('Angeheftet')).toBeNull();
expect(screen.queryByText('Ordner')).toBeNull();
expect(screen.queryByText('Notizen (Hauptebene)')).toBeNull();
expect(screen.queryByText('Keine Notizen auf der Hauptebene.')).toBeNull();
// The pinned root doc appears only once
const pinnedMatches = screen.getAllByText('Wichtige Pinned Notiz');
expect(pinnedMatches.length).toBe(1);
// Pinned container has separator when items follow
const pinnedItem = screen.getByText('Wichtige Pinned Notiz').closest('div[draggable="true"]');
const pinnedContainer = pinnedItem?.parentElement;
expect(pinnedContainer?.className).toContain('border-b border-border pb-1.5 mb-1.5');
});
it('does not render separator under pinned notes when nothing follows', async () => {
const docs: Document[] = [
makeDoc({
id: 'doc_pin_only',
title: 'Einzige Notiz Pinned',
icon: '⭐',
isPinned: true,
}),
];
vi.mocked(api.getDocuments).mockResolvedValue(docs);
vi.mocked(api.getFolders).mockResolvedValue([]);
render(<WorkspaceView onStatsChange={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText('Einzige Notiz Pinned')).toBeTruthy();
});
const pinnedItem = screen.getByText('Einzige Notiz Pinned').closest('div[draggable="true"]');
const pinnedContainer = pinnedItem?.parentElement;
expect(pinnedContainer?.className).not.toContain('border-b');
});
it('keeps pinned note in folder visible in its folder', async () => {
const docs: Document[] = [
makeDoc({
id: 'doc_pin_in_folder',
title: 'Pinned im Ordner',
icon: '📌',
folderPath: 'Team',
isPinned: true,
}),
];
vi.mocked(api.getDocuments).mockResolvedValue(docs);
vi.mocked(api.getFolders).mockResolvedValue(['Team']);
render(<WorkspaceView onStatsChange={vi.fn()} />);
await waitFor(() => {
// In pinned list
expect(screen.getByText('Pinned im Ordner')).toBeTruthy();
// Folder exists
expect(screen.getByText('Team')).toBeTruthy();
});
});
});