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

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-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:

	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.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:

    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.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:

  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-hl rule, 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_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
git add web/js/app.js web/css/app.css
git commit -m "feat(files): move files between folders by drag and drop"