feat(workspace): turn a branch into its own note, merge notes
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m56s

- Extract: button on the bullet (desktop) or /extract (/notiz). The bullet
  becomes the title (hashtags stay at the link), its children the content
  of a new note in the same folder with the source's shares (a foreign
  source owner gets write access); the branch becomes [[title]] through
  the operation log, so open editors follow.
- Merge: drop a note onto another in the sidebar or use "Mit anderer
  Notiz zusammenführen…" in the note header. The own source is appended
  as an h2 section with its bullets below and goes to the trash; the toast
  offers a complete undo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-30 00:25:46 +01:00
1 parent 4c26eb2960
commit 4d1b37c956
15 files changed
+934 -9

No files matched your search

@@ -27,6 +27,7 @@ import {
import {
Document,
DocumentBlock,
MergeResult,
DocShare,
SharedFolder,
TagDetails,
@@ -47,6 +48,7 @@ import { sendDocOps } from '../../lib/docSync/oneoff';
import { useMobileActions } from '../../context/MobileActionContext';
import { DocumentEditor, DocSaveStatus } from './DocumentEditor';
import { TrashView } from './TrashView';
import { MergeDialog } from './MergeDialog';
import { CountBadge } from '../../components/CountBadge';
import { extractHashtags } from '../../lib/hashtags';
@@ -87,6 +89,9 @@ interface FolderTreeItemProps {
handleShareFolderClick: (folderPath: string, existingSf?: SharedFolder) => void;
handleDeleteFolder: (folderPath: string) => void;
onSelectDoc: (id: string) => void;
// Dropping a note onto another one offers to merge them
docDropProps: (targetId: string) => React.HTMLAttributes<HTMLDivElement>;
mergeHoverId: string | null;
}
const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
@@ -106,6 +111,8 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
handleShareFolderClick,
handleDeleteFolder,
onSelectDoc,
docDropProps,
mergeHoverId,
}) => {
const hasContent = node.subfolders.length > 0 || node.docs.length > 0;
const isExpanded = Boolean(hasContent && expandedFolders[node.fullPath]);
@@ -234,6 +241,8 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
handleShareFolderClick={handleShareFolderClick}
handleDeleteFolder={handleDeleteFolder}
onSelectDoc={onSelectDoc}
docDropProps={docDropProps}
mergeHoverId={mergeHoverId}
/>
))}
@@ -243,10 +252,13 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
key={d.id}
draggable
onDragStart={(e) => handleDocDragStart(e, d.id)}
{...docDropProps(d.id)}
onClick={() => onSelectDoc(d.id)}
style={{ paddingLeft: `${(depth + 1) * 12 + 16}px` }}
className={`w-full flex items-center gap-2 pr-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
selectedDocId === d.id && subView === 'editor'
mergeHoverId === d.id
? 'border-accent bg-accent/15 ring-2 ring-accent/30 text-main'
: selectedDocId === d.id && subView === 'editor'
? 'bg-panel-elevated text-main font-semibold border-border/80'
: 'border-transparent text-main hover:bg-panel-elevated'
}`}
@@ -378,6 +390,20 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
// Drag and Drop state
const [draggingDocId, setDraggingDocId] = useState<string | null>(null);
// Merging notes: dialog (picker/confirmation) and a toast with undo afterwards
const [mergeDialog, setMergeDialog] = useState<{ sourceId: string; targetId?: string } | null>(null);
const [mergeHoverId, setMergeHoverId] = useState<string | null>(null);
const [toast, setToast] = useState<{ text: string; actions: { label: string; run: () => void }[] } | null>(null);
const toastTimerRef = useRef<number | null>(null);
const showToast = useCallback((text: string, actions: { label: string; run: () => void }[] = []) => {
setToast({ text, actions });
if (toastTimerRef.current !== null) window.clearTimeout(toastTimerRef.current);
toastTimerRef.current = window.setTimeout(() => setToast(null), actions.length ? 10000 : 4000);
}, []);
useEffect(() => () => {
if (toastTimerRef.current !== null) window.clearTimeout(toastTimerRef.current);
}, []);
const [draggingFolderPath, setDraggingFolderPath] = useState<string | null>(null);
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
@@ -674,6 +700,66 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
setDraggingDocId(docId);
};
const requestMerge = (sourceId: string, targetId?: string) => {
const source = docs.find((d) => d.id === sourceId);
if (!source?.isOwner) {
showToast('Nur eigene Notizen lassen sich in eine andere einfügen');
return;
}
setMergeDialog({ sourceId, targetId });
};
// Dropping a dragged note onto another note (instead of a folder) offers to merge them
const docDropProps = (targetId: string): React.HTMLAttributes<HTMLDivElement> => ({
onDragOver: (e) => {
if (!draggingDocId || draggingDocId === targetId) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'move';
if (mergeHoverId !== targetId) setMergeHoverId(targetId);
},
onDragLeave: () => setMergeHoverId((cur) => (cur === targetId ? null : cur)),
onDrop: (e) => {
const sourceId = e.dataTransfer.getData('application/mindsift-doc-id') || draggingDocId;
if (!sourceId || sourceId === targetId) return;
e.preventDefault();
e.stopPropagation();
setDraggingDocId(null);
setMergeHoverId(null);
setDragOverFolder(null);
requestMerge(sourceId, targetId);
},
});
const handleMerged = async (result: MergeResult) => {
setMergeDialog(null);
triggerHaptic(20);
setDocs((prev) => prev.filter((d) => d.id !== result.sourceId));
setSelectedDocId(result.targetId);
setTargetBlockId(null);
setSubView('editor');
setMobileShowDetail(true);
setEditorNonce((n) => n + 1);
reloadTrash();
showToast(`„${result.sourceTitle || 'Notiz'}“ wurde angehängt`, [
{
label: 'Rückgängig',
run: async () => {
try {
await api.undoMerge(result);
await loadWorkspaceData();
setSelectedDocId(result.sourceId);
setEditorNonce((n) => n + 1);
reloadTrash();
showToast('Zusammenführen rückgängig gemacht');
} catch (err: any) {
showToast(err?.message || 'Rückgängig fehlgeschlagen');
}
},
},
]);
};
const handleFolderDragStart = (e: React.DragEvent, path: string) => {
e.stopPropagation();
e.dataTransfer.setData('application/mindsift-folder-path', path);
@@ -1348,13 +1434,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
key={`pin-${d.id}`}
draggable
onDragStart={(e) => handleDocDragStart(e, d.id)}
{...docDropProps(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'
mergeHoverId === d.id
? 'border-accent bg-accent/15 ring-2 ring-accent/30 text-main'
: selectedDocId === d.id && subView === 'editor'
? 'bg-panel-elevated text-main font-semibold border-border/80'
: 'border-transparent text-main hover:bg-panel-elevated'
}`}
@@ -1398,6 +1487,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
setSubView('editor');
setMobileShowDetail(true);
}}
docDropProps={docDropProps}
mergeHoverId={mergeHoverId}
/>
))}
@@ -1406,13 +1497,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
key={d.id}
draggable
onDragStart={(e) => handleDocDragStart(e, d.id)}
{...docDropProps(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'
mergeHoverId === d.id
? 'border-accent bg-accent/15 ring-2 ring-accent/30 text-main'
: selectedDocId === d.id && subView === 'editor'
? 'bg-panel-elevated text-main font-semibold border-border/80'
: 'border-transparent text-main hover:bg-panel-elevated'
}`}
@@ -1527,6 +1621,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
onSelectTag={handleSelectTag}
onWikiLinkClick={handleWikiLinkClick}
onOpenDocBlock={handleJumpToDocBlock}
onRequestMerge={(d) => setMergeDialog({ sourceId: d.id })}
onBackMobile={() => setMobileShowDetail(false)}
onOpenTask={onOpenTask}
onSaveStatusChange={(status, err) => {
@@ -2251,6 +2346,38 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
</FormSheet>
)}
{mergeDialog && (
<MergeDialog
docs={docs}
sourceId={mergeDialog.sourceId}
targetId={mergeDialog.targetId}
onClose={() => setMergeDialog(null)}
onMerged={(r) => void handleMerged(r)}
/>
)}
{toast && (
<div
role="status"
className="fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] sm:bottom-6 z-50 max-w-[calc(100vw-1.5rem)] px-3 py-2 rounded-card text-xs text-main bg-panel-elevated border border-border shadow-panel flex items-center gap-3"
>
<span>{toast.text}</span>
{toast.actions.map((a) => (
<button
key={a.label}
type="button"
onClick={() => {
setToast(null);
a.run();
}}
className="font-semibold text-accent hover:underline min-h-[44px] sm:min-h-0 shrink-0"
>
{a.label}
</button>
))}
</div>
)}
{/* Unified Sharing Modal (Strictly Read-Only for Docs and Folders) */}
{(shareModalDoc || shareModalFolder) && (
<UnifiedShareModal