10 KiB
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-listmust 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; neverinnerHTMLwith 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}/moveendpoint. -
Produces: a permanent test asserting that a move keeps the same
idand the samenote, 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:
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
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.jsinfileRow(f)(around line 2231)
Interfaces:
-
Produces: module-level constant
DND_MIME = 'application/x-notas-file', a module-level variabledragFileId, and a functiondndIsInternalDrag(e)returning true when the DataTransfer carriesDND_MIME. -
Consumes: existing
filesState.picker,api()helper. -
Step 1: Add the drag source to the row
In fileRow(f), after row.tabIndex = 0; add:
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:
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.jsinfolderRow(node, depth)(around line 2147), inrenderFilesTree()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:
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:
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:
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:
['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-hlrule, around line 1851)
Interfaces:
-
Consumes: classes
.dragging(file row) and.drop-hl(folder row). -
Step 1: Add the rules
.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_TOKENand 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:<port> 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
git add web/js/app.js web/css/app.css
git commit -m "feat(files): move files between folders by drag and drop"