From 2dda1f96a30eee2fda86be9a8a59ce5cb26dad7f Mon Sep 17 00:00:00 2001
From: soryu <soryu@soryu.co>
Date: Fri, 8 May 2026 12:17:07 +0100
Subject: feat(contracts): drag-to-reorder active contract rows in sidebar
 (#130)

HTML5 drag/drop on active contract rows. Dragging a row over
another in the same directive folder shows a green top-border drop
indicator; dropping calls reorderDirectiveContract(id, targetPosition)
and refreshes the folder. Shipped/archived rows aren't draggable
(historical, ordering is fixed).

Implementation:
- DocumentRow gains optional draggable + drag event props.
- DirectiveFolder owns the drag/over state and handleReorder
  callback; computes target position from the drop-target row's
  current position.
- Repository's reorder endpoint already exists from the backbone PR
  and handles sibling shift in a transaction.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 makima/frontend/src/routes/document-directives.tsx | 86 +++++++++++++++++++++-
 1 file changed, 85 insertions(+), 1 deletion(-)

(limited to 'makima/frontend/src/routes/document-directives.tsx')

diff --git a/makima/frontend/src/routes/document-directives.tsx b/makima/frontend/src/routes/document-directives.tsx
index 044c8af..b89e841 100644
--- a/makima/frontend/src/routes/document-directives.tsx
+++ b/makima/frontend/src/routes/document-directives.tsx
@@ -22,6 +22,7 @@ import {
   pauseDirectiveContract,
   completeDirectiveContract,
   unlockDirectiveContract,
+  reorderDirectiveContract,
   createDirectiveTask,
   startDirective,
   pauseDirective,
@@ -283,6 +284,30 @@ function DirectiveFolder({
       ? selection.documentId
       : null;
 
+  // Drag-to-reorder state — only active contracts (draft/queued/active) are
+  // reorderable. The drag id lives in the folder so the drop target can read
+  // it from props. `dragOverId` powers the visual indicator on hover.
+  const [dragId, setDragId] = useState<string | null>(null);
+  const [dragOverId, setDragOverId] = useState<string | null>(null);
+
+  const handleReorder = useCallback(
+    async (draggedId: string, targetDoc: Contract) => {
+      setDragId(null);
+      setDragOverId(null);
+      // No-op if dropping on self.
+      if (draggedId === targetDoc.id) return;
+      try {
+        await reorderDirectiveContract(draggedId, targetDoc.position);
+        await refresh();
+      } catch (e) {
+        // Fail open — sidebar will refresh on next refreshNonce bump.
+        // eslint-disable-next-line no-console
+        console.error("Reorder failed", e);
+      }
+    },
+    [refresh],
+  );
+
   return (
     <div className="select-none">
       {/* Directive folder header. Status is shown as a colored dot on the
@@ -383,6 +408,22 @@ function DirectiveFolder({
                     directive={directive}
                     selected={doc.id === selectedDocumentId}
                     onSelect={() => onSelectDocument(directive.id, doc)}
+                    draggable
+                    onDragStart={() => setDragId(doc.id)}
+                    onDragEnd={() => {
+                      setDragId(null);
+                      setDragOverId(null);
+                    }}
+                    onDragOver={() => {
+                      if (dragId && dragId !== doc.id) setDragOverId(doc.id);
+                    }}
+                    onDragLeave={() => {
+                      if (dragOverId === doc.id) setDragOverId(null);
+                    }}
+                    onDrop={() => {
+                      if (dragId) void handleReorder(dragId, doc);
+                    }}
+                    dragOver={dragOverId === doc.id}
                   />
                   <DocumentTasksFolder
                     documentId={doc.id}
@@ -460,6 +501,16 @@ interface DocumentRowProps {
   selected: boolean;
   onSelect: () => void;
   indent?: "normal" | "deep";
+  // ----- Drag-to-reorder props (optional — only wired by the active list) ---
+  /** Whether this row participates in HTML5 drag (active docs only). */
+  draggable?: boolean;
+  onDragStart?: () => void;
+  onDragEnd?: () => void;
+  onDragOver?: () => void;
+  onDragLeave?: () => void;
+  onDrop?: () => void;
+  /** True while a drag is hovering over this row — drives the drop indicator. */
+  dragOver?: boolean;
 }
 
 function DocumentRow({
@@ -468,17 +519,50 @@ function DocumentRow({
   selected,
   onSelect,
   indent = "normal",
+  draggable = false,
+  onDragStart,
+  onDragEnd,
+  onDragOver,
+  onDragLeave,
+  onDrop,
+  dragOver = false,
 }: DocumentRowProps) {
   const dot = DOC_STATUS_DOT[doc.status] ?? DOC_STATUS_DOT.draft;
   const padLeft = indent === "deep" ? "pl-[88px]" : "pl-14";
   const name = `${fileLabel(doc, directive)}.md`;
 
+  // Drop-indicator: a top border accent on the hovered target row.
+  const dropAccent = dragOver
+    ? "border-t-2 border-t-emerald-400"
+    : "border-t-2 border-t-transparent";
+
   return (
     <button
       type="button"
       onClick={onSelect}
       title={name}
-      className={`w-full text-left flex items-center gap-1.5 ${padLeft} pr-3 py-1 font-mono text-[11px] transition-colors ${
+      draggable={draggable}
+      onDragStart={(e) => {
+        if (!draggable) return;
+        // Required by Firefox to actually start the drag.
+        e.dataTransfer.effectAllowed = "move";
+        e.dataTransfer.setData("text/plain", doc.id);
+        onDragStart?.();
+      }}
+      onDragEnd={onDragEnd}
+      onDragOver={(e) => {
+        if (!draggable) return;
+        e.preventDefault();
+        e.dataTransfer.dropEffect = "move";
+        onDragOver?.();
+      }}
+      onDragLeave={onDragLeave}
+      onDrop={(e) => {
+        if (!draggable) return;
+        e.preventDefault();
+        onDrop?.();
+      }}
+      className={`w-full text-left flex items-center gap-1.5 ${padLeft} pr-3 py-1 font-mono text-[11px] transition-colors ${dropAccent} ${
         selected
           ? "bg-[rgba(117,170,252,0.12)] text-white border-l-2 border-[#75aafc]"
           : "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)] border-l-2 border-transparent"
-- 
cgit v1.2.3

