This commit is contained in:
1 parent
08dce738e5
commit
84f48caaa1
2 files changed
+316
No files matched your search
@@ -0,0 +1,291 @@
|
||||
# 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"
|
||||
```
|
||||
Reference in new issue
Block a user