Files
notas-online/docs/superpowers/plans/2026-11-07-file-drag-and-drop.md
T
2026-11-07 19:13:44 -03:00

292 lines
10 KiB
Markdown

# 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:<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**
```bash
git add web/js/app.js web/css/app.css
git commit -m "feat(files): move files between folders by drag and drop"
```