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"
|
||||
```
|
||||
@@ -0,0 +1,25 @@
|
||||
# File drag-and-drop design
|
||||
|
||||
## Intent
|
||||
Allow users to move a file between folders in the file manager by dragging it onto a folder, including the root destination, without breaking references from notes.
|
||||
|
||||
## Goals and non-goals
|
||||
- Dragging a file onto a folder moves it there; dropping onto the root returns it to the root.
|
||||
- Preserve the file ID, blob, and note association. The existing note reference remains valid.
|
||||
- Reuse `PUT /api/files/{id}/move` with the target folder ID; do not duplicate or delete stored bytes.
|
||||
- Keep current list filters, preview, and selection/picker behavior intact.
|
||||
- Context menus and a file-information window are outside this change.
|
||||
|
||||
## Approaches
|
||||
1. **Move the existing file record (selected):** update its folder via the existing API. This preserves its ID and note references and avoids physical copying.
|
||||
2. Duplicate the blob under a new ID, rewrite note references, then delete the source. This adds failure and consistency risks without improving the requested user-visible result.
|
||||
|
||||
## Interaction and constraints
|
||||
- Use native browser drag-and-drop on file rows and folder-tree destinations; mark valid targets while dragging and clear that state on drop/cancel.
|
||||
- Disable drag-and-drop in picker mode.
|
||||
- Dropping onto the current folder is a no-op.
|
||||
- After a successful move, reload the file tree/list and retain the destination folder as the current view where applicable; on API failure, leave the UI data intact and show an error.
|
||||
- No backend schema or API changes are needed; the file ID and note association are unchanged.
|
||||
|
||||
## Acceptance
|
||||
A file can be dragged from a displayed list into a folder (or root), disappears from its previous location and appears in the destination, and remains accessible from any note that referenced it before the move. Errors do not make the UI claim that a move succeeded.
|
||||
Reference in new issue
Block a user