# File drag-and-drop Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Allow dragging a file in the file manager onto a folder (or the root) to move it there, preserving the file ID so note references keep working. **Architecture:** Frontend-only change on top of the existing `PUT /api/files/{id}/move` endpoint: the file row becomes a drag source that stores the file ID in the DataTransfer, and the folder rows in the tree become drop targets that call the move API and refresh the tree. A characterization test in Go pins the backend contract the drag depends on (move preserves ID and note association). **Tech Stack:** Vanilla JS (no bundler, no npm deps), Go 1.24 + net/http, CSS custom properties. **Spec:** docs/superpowers/specs/2026-11-07-file-drag-and-drop-design.md ## Global Constraints - No new runtime dependencies: no package.json, no npm install, no CDN assets. - The file ID must never change on a move — note references point at `/api/files/static/{id}`. - The existing OS-file dropzone on `#files-list` must keep working: internal drags must not trigger uploads. - Picker mode (`filesState.picker`) disables dragging entirely. - All user-facing strings in Portuguese, matching the surrounding UI copy. - Every DOM node is created with `document.createElement` + `textContent`; never `innerHTML` with user data. ## Review Focus - Dropping a file onto the folder it already lives in: must be a no-op, not an API call that looks like a change. - Dragging an OS file from the desktop over the tree: must not move any stored file; the tree must ignore drags without an internal file ID. - Dropping on the root row while the file is already in the root: no-op, no error toast. - Moving a file that is attached to a note: the note reference must keep resolving after the move (same ID, same URL). - A failed move (API error): the tree must not show the file in the destination, and the user must see an error. --- ### Task 1: Pin the move contract in the API test **Files:** - Modify: `internal/api/files_test.go` (the move block, around line 180) **Interfaces:** - Consumes: existing `PUT /api/files/{id}/move` endpoint. - Produces: a permanent test asserting that a move keeps the same `id` and the same `note`, and that moving to a nonexistent folder is rejected with 400. - [ ] **Step 1: Write the failing test** Add to the existing move block, after the successful move assertion: ```go if mvd["id"] != mdID { t.Errorf("move deveria preservar o id do arquivo: %+v", mvd) } // mover para pasta inexistente → 400 badFolder := auth(t, srv, "PUT", "/api/files/"+mdID+"/move", `{"folder":"naoexiste1"}`) if badFolder.StatusCode != http.StatusBadRequest { t.Errorf("pasta fantasma no move: %s", badFolder.Status) } ``` - [ ] **Step 2: Run the test to verify it passes and pins real behavior** Run: `go test ./internal/api/ -run TestFiles -count=1 -v` Expected: PASS. This is a characterization test for behavior the drag-and-drop depends on; if the id assertion fails, the implementation is wrong, not the test. - [ ] **Step 3: Commit** ```bash git add internal/api/files_test.go git commit -m "test(files): pin move contract for drag-and-drop" ``` --- ### Task 2: Make the file row a drag source **Files:** - Modify: `web/js/app.js` in `fileRow(f)` (around line 2231) **Interfaces:** - Produces: module-level constant `DND_MIME = 'application/x-notas-file'`, a module-level variable `dragFileId`, and a function `dndIsInternalDrag(e)` returning true when the DataTransfer carries `DND_MIME`. - Consumes: existing `filesState.picker`, `api()` helper. - [ ] **Step 1: Add the drag source to the row** In `fileRow(f)`, after `row.tabIndex = 0;` add: ```js if (!filesState.picker) { row.draggable = true; row.addEventListener('dragstart', (e) => { dragFileId = f.id; if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData(DND_MIME, f.id); e.dataTransfer.setData('text/plain', f.name); } row.classList.add('dragging'); }); row.addEventListener('dragend', () => { dragFileId = ''; row.classList.remove('dragging'); clearDropHighlights(); }); } ``` - [ ] **Step 2: Add the shared helpers near the file manager state** Next to `const filesState = {...}` add: ```js const DND_MIME = 'application/x-notas-file'; let dragFileId = ''; function dndIsInternalDrag(e) { const dt = e.dataTransfer; if (!dt) return false; return dt.types.includes(DND_MIME) || !!dragFileId; } function clearDropHighlights() { for (const el of document.querySelectorAll('.drop-hl')) el.classList.remove('drop-hl'); } ``` - [ ] **Step 3: Verify the file parses** Run: `node --check web/js/app.js` Expected: no output (exit 0). --- ### Task 3: Make folder rows drop targets **Files:** - Modify: `web/js/app.js` in `folderRow(node, depth)` (around line 2147), in `renderFilesTree()` for the "Raiz" special row (around line 2210), and next to the existing drag & drop wiring (around line 2577). **Interfaces:** - Consumes: `DND_MIME`, `dragFileId`, `dndIsInternalDrag`, `clearDropHighlights`, `filesState`, `api()`, `loadFiles()`. - Produces: function `moveFileToFolder(fileId, folderId, folderLabel)` performing the API call, refreshing the tree, expanding the destination and reporting success or failure. - [ ] **Step 1: Write the move function** Add next to the existing drag & drop wiring: ```js async function moveFileToFolder(fileId, folderId, folderLabel) { try { await api('PUT', '/api/files/' + fileId + '/move', { folder: folderId || '' }); if (folderId) { expandAncestors(folderId); if (filesState.folder === '__all__') filesState.folder = folderId; } else { filesState.folder = ''; } await loadFiles(); toast('Movido para ' + folderLabel + '.'); } catch (err) { toast('Mover: ' + err.message); await loadFiles().catch(() => {}); } } ``` - [ ] **Step 2: Make a folder row a drop target** In `folderRow(node, depth)`, before `row.addEventListener('click', ...)` add: ```js row.addEventListener('dragover', (e) => { if (!dndIsInternalDrag(e)) return; e.preventDefault(); e.stopPropagation(); if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; row.classList.add('drop-hl'); }); row.addEventListener('dragleave', (e) => { if (!dndIsInternalDrag(e)) return; e.stopPropagation(); row.classList.remove('drop-hl'); }); row.addEventListener('drop', (e) => { if (!dndIsInternalDrag(e)) return; e.preventDefault(); e.stopPropagation(); row.classList.remove('drop-hl'); const id = (e.dataTransfer && e.dataTransfer.getData(DND_MIME)) || dragFileId; dragFileId = ''; if (!id) return; const f = filesState.files.get(id); if (f && f.folder === node.id) return; // já está nesta pasta moveFileToFolder(id, node.id, node.name); }); ``` - [ ] **Step 3: Make the "Raiz" row a drop target** In `renderFilesTree()`, in the `special(value, label)` helper, add the same three listeners with `folderId = ''` and label `'a raiz'`, dropping into the root. - [ ] **Step 4: Add `expandAncestors`** Next to `createFolder(parent)` add: ```js function expandAncestors(folderId) { const chain = []; (function walk(nodes, trail) { for (const n of nodes) { const next = trail.concat([n.id]); if (n.id === folderId) { chain.push(...next); return true; } if (walk(n.children || [], next)) return true; } return false; })(filesState.tree, []); for (const id of chain) filesState.expanded.add(id); localStorage.setItem('notas_files_expanded', JSON.stringify([...filesState.expanded])); } ``` - [ ] **Step 5: Keep the OS-file dropzone and the tree from interfering** The existing `#files-list` dropzone handler must ignore internal drags. Change its drop listener to: ```js ['dragleave', 'drop'].forEach((ev) => F.list.addEventListener(ev, (e) => { e.preventDefault(); F.list.classList.remove('drop-hl'); if (ev === 'drop' && e.dataTransfer && e.dataTransfer.files.length && !dndIsInternalDrag(e)) { handleFiles(e.dataTransfer.files, 'manager'); } })); ``` - [ ] **Step 6: Verify the file parses** Run: `node --check web/js/app.js` Expected: no output (exit 0). --- ### Task 4: Style the drag states **Files:** - Modify: `web/css/app.css` (next to the `.dropzone.drop-hl` rule, around line 1851) **Interfaces:** - Consumes: classes `.dragging` (file row) and `.drop-hl` (folder row). - [ ] **Step 1: Add the rules** ```css .fitem.dragging { opacity: .5; border-style: dashed; } .ftree-row.drop-hl { background: color-mix(in oklab, var(--panel), var(--accent) 24%); box-shadow: inset 3px 0 0 var(--accent); } ``` - [ ] **Step 2: Verify the CSS parses as part of the app** Run: `node --check web/js/app.js && go test ./... -count=1` Expected: all Go packages pass. --- ### Task 5: Verify the feature in a running browser **Files:** - No repository files. Use a throwaway script outside the repo and delete it afterwards. **Interfaces:** - Consumes: the running app (server started with `NOTES_TOKEN` and a temporary data dir) and the file manager UI. - Produces: evidence that a dragged file lands in the target folder and that a note reference still resolves. - [ ] **Step 1: Start the app on a temporary data dir** Run the built binary on `127.0.0.1:` with `NOTES_TOKEN` set and a fresh data dir. - [ ] **Step 2: Create fixtures** Create a folder and upload one file via the API so the tree has both a destination folder and a file in the root. - [ ] **Step 3: Drive the browser** Log in, open the file manager, dispatch `dragstart` on the file row and `dragover` + `drop` on the target folder row with a shared `DataTransfer`, then assert the file appears under the folder and no longer in the root. - [ ] **Step 4: Check the note reference survives** Fetch the moved file's static URL and assert it still returns the file bytes with HTTP 200. - [ ] **Step 5: Clean up** Stop the server, close the browser tab, delete the temporary script and data dir. - [ ] **Step 6: Commit** ```bash git add web/js/app.js web/css/app.css git commit -m "feat(files): move files between folders by drag and drop" ```