left4me/docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md
mwiegand 4337002bd0
docs(files): rewrite plan for files-overlay.js (3 phases, 12 commits)
Three-phase plan to consolidate files-overlay.js's mixed event-binding
patterns and complete the URL-addressable modals migration:

  Phase A (4 commits): split the 35 KB IIFE into 4 focused modules
  under static/js/files-overlay/ — core, editor, dialogs, uploads —
  with consistent document-level delegation. Behavior unchanged.

  Phase B (6 commits): migrate the two remaining inline-dialog flows
  (create-new-file, binary-replace) to URL-addressable modals via a
  new /files/new route and a binary-mode branch in the edit route +
  template. Delete the legacy <dialog id="files-editor-modal"> from
  overlay_detail.html. editor.js becomes single-purpose (~200 lines).

  Phase C (2 commits): extract shared path/editability helper for
  routes/files_routes.py; delete the now-unused /files/content JSON
  endpoint; consolidate save/replace rename duplication.

Each commit is independently verifiable + revertable. Natural pause
points at the end of each phase. Plan is the handoff artifact for
cross-session execution.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:49:02 +02:00

22 KiB

files-overlay.js Rewrite + Editor Migration + API Cleanup (handoff-ready)

Context

l4d2web/l4d2web/static/js/files-overlay.js has grown to ~35 KB / 1092 lines and hosts 9+ feature areas in one IIFE: helpers, the legacy inline editor dialog (text + binary + create-new modes), new-folder modal, conflict modal, delete-confirm modal, file-row action dispatch, drag-drop on the file tree, upload queue + progress panel, and document-level delegation for the URL-addressable editor's save/delete. Event-binding patterns are inconsistent — direct-binds, clone-and-rebind anti-patterns, bind-and-remove per-call, and document-level delegation all coexist for similar tasks.

Two flows still use the legacy inline <dialog id="files-editor-modal">: create-new-file (no URL to deep-link a file that doesn't exist yet) and binary-replace (the new URL-addressable template intentionally omitted binary-replace UI per Task 2's pilot scope cut). Migrating both flows to URL-addressable lets us delete the legacy dialog and simplifies the JS editor module dramatically.

Meanwhile routes/files_routes.py (640+ lines) duplicates path-resolution and editability checks between overlay_file_content (JSON) and overlay_file_edit_page (HTML), and the JSON /files/content endpoint becomes dead code once create-new + binary-replace migrate away from JS-populated inline dialogs.

Goal: rewrite the JS into focused modules with consistent delegation; migrate create-new + binary-replace flows to URL-addressable modals; delete the legacy dialog; clean up routes/files_routes.py to share helpers and drop dead endpoints. Behavior visible to the user is unchanged — same features, same UX. Each migration commit leaves the working tree in a known-good Chromium-verified state, so this plan can be paused, resumed, and handed off across sessions.

Approach: three phases, twelve commits

Three natural pause points for cross-session handoff. Each phase is independently completable and verifiable.

Phase Steps Scope Where the working tree lands
A 1-4 JS rewrite into 4 modules (core.js, editor.js, dialogs.js, uploads.js). editor.js is dual-purpose at this point (legacy inline dialog + URL-addressable modal). Old files-overlay.js is empty/stub; behavior unchanged; legacy dialog still exists and is still used by create-new + binary-replace.
B 5-10 URL-addressable migration of create-new + binary-replace. Adds GET /overlays/<id>/files/new?at=<folder>; binary-file detection in the edit route; binary-replace UI in overlay_file_editor.html; JS code-paths for both flows move to URL-addressable. Legacy dialog deleted from overlay_detail.html. editor.js becomes single-purpose. Legacy dialog gone; editor.js ~200 lines, URL-addressable-only; create-new + binary-replace are URL-addressable.
C 11-12 API cleanup. Extract shared path-resolution + editability helper used by edit/save/content endpoints. Delete GET /overlays/<id>/files/content JSON endpoint if confirmed unused. Consolidate save/replace duplication where reasonable. routes/files_routes.py ~450 lines; no dead routes; pytest still green.

Total estimate: ~12 commits, each independently revertable.

Decomposition (new JS modules)

Module Phase A end Phase B end Responsibility
static/js/files-overlay/core.js ~120 lines ~120 lines Helpers (postJson, postForm, scheduleRefresh, parentOf, joinPath). Manager-element detection, CSRF read. File-row click delegation dispatch.
static/js/files-overlay/editor.js ~350 lines (dual-purpose) ~200 lines (URL-addressable only) Save / delete / rename-on-save / 409 conflict handling. Filename rename hint. Ctrl+S. After Phase B, only handles URL-addressable modal content via document-level delegation gated on #modal-content.
static/js/files-overlay/dialogs.js ~180 lines ~180 lines New-folder modal, delete-confirm modal, conflict modal (askConflict returning a promise). All document-level delegated, no clone-and-rebind.
static/js/files-overlay/uploads.js ~280 lines ~280 lines Upload queue (concurrency 3), progress panel, per-row cancel via data-upload-id. Drag-drop on treeRoot kept direct-bound (5 coordinated events, persistent target).

Total after rewrite: ~780 lines across 4 files (vs 1092 in one).

Direct-bind escape hatches (places we keep direct binding deliberately):

  • editor.js: input / keydown on .files-editor-filename and .files-editor-content (high-frequency, persistent input elements in the swapped-in modal content — re-bound on each htmx:afterSwap).
  • uploads.js: dragstart / dragend / dragover / dragleave / drop on treeRoot (5 coordinated events sharing per-target highlight state; document delegation would obscure the coordination logic).

Migration sequence

Phase A — JS rewrite (steps 1-4)

Step 1: scaffold files-overlay/core.js

Create the new directory. Move helpers (lines ~40-170 of the current file: postJson, postForm, scheduleRefresh, parentOf, joinPath, byte-count utility, manager-element detection, CSRF read). Add the file-row click delegation that currently lives at line 1054 — same selector match, but instead of switch-casing into local handlers, dispatch through a registry window.__filesOverlay.handleAction(action, path, actionEl) that feature modules register handlers into. Old files-overlay.js still has its own handlers for now — core.js dispatch is unused until subsequent steps wire features into it.

base.html: add <script src="files-overlay/core.js"> BEFORE files-overlay.js (so the registry exists when the old file runs).

Verification: existing functionality still works (click any file row → opens editor, +new-file → opens editor, +new-folder → opens new-folder modal, zip download works).

Step 2: migrate editor handlers to files-overlay/editor.js

Move the editor section (lines 262-591: editorEls object, openEditorTextNew, openEditorForFile, save/delete handlers + sub-element handlers). At the start of editor.js's IIFE, query the legacy dialog once: const editorDialog = document.getElementById("files-editor-modal"). If present, register handlers; if absent (i.e., later when the legacy dialog is deleted in Phase B), skip the legacy branch.

For save/delete: convert from direct-bound editorEls.saveBtn.addEventListener to document-level delegation scoped via event.target.closest("#files-editor-modal"). The URL-addressable modal save/delete delegation already at lines 600-664 moves over too.

For replace-zone drag (lines 449-458): convert to delegation gated on event.target.closest(".files-editor-replace-zone") inside #files-editor-modal.

Keep direct-bound: input on filename, input and keydown on content textarea (these target persistent inputs inside the persistent legacy dialog).

Register the file-row action handlers (new-file, edit) into the core.js dispatch registry from step 1.

Delete the migrated handlers from the old files-overlay.js. The old file shrinks by ~330 lines.

Verification: open editor on text file → edit + save works; rename + save works; rename + 409 conflict alerts and modal stays open; delete works; binary file → opens in binary mode with replace UI; URL-addressable editor flow (file-row click on editable file) still works including rename and 409.

Step 3: migrate dialogs to files-overlay/dialogs.js

Move new-folder modal (lines 666-722), delete-confirm modal (lines 222-258), conflict modal (askConflict, lines 174-211).

Eliminate the clone-and-rebind pattern: register single document-level delegated handlers, scoped to each dialog by id. Per-dialog state (target folder for new-folder, current path for delete-confirm, resolve-callback for conflict) lives in module-scope variables set when the dialog opens and cleared when it closes.

Open the dialogs via window.modals.openInline(idOrEl) instead of dialog.showModal() directly, completing the inline-modal convention from commit c51089d.

Register the file-row action handlers (new-folder, delete) into core.js dispatch registry.

Delete migrated handlers from old files-overlay.js. Shrinks another ~150 lines.

Verification: new-folder open + create works; new-folder Enter-in-input creates; delete-confirm open + confirm deletes; upload-conflict prompt (overwrite + keep-both branches both work).

Step 4: migrate uploads + drag-drop to files-overlay/uploads.js

Move upload queue (lines ~750-900) and drag-drop on treeRoot (lines 913-1020).

Keep drag-drop direct-bound to treeRoot (deliberate — coordinated state across 5 events).

Convert upload-row cancel buttons (currently direct-bound at row creation, line 753) to document-level delegation: store data-upload-id="<id>" on each row and look up the upload at click time.

Register file-row action handlers (zip) into core.js dispatch registry. (zip is just a navigation — could live in core.js directly; pick whichever reads cleaner.)

Delete remaining migrated handlers from old files-overlay.js. After this step the old file is empty or near-empty. Replace with <script src="files-overlay/uploads.js"> etc. in base.html (or delete the old <script> tag entirely if the file is empty). Initially leave the old file in place to avoid stretching this step further.

Verification: drop a single file onto tree → uploads + appears; drop a folder onto tree → multiple uploads with progress; cancel in-flight upload → stops, row shows cancelled; click Clear → done rows removed; drag a file row to another folder → moves.

End of Phase A: working tree has 4 focused modules + a (near-)empty old file. All current behavior preserved.

Phase B — URL-addressable migration of create-new + binary-replace (steps 5-10)

Step 5: extend the edit route to support new-file mode (server + template + tests)

Add GET /overlays/<id>/files/new?at=<folder> to routes/files_routes.py. Returns the editor template (overlay_file_editor.html) with:

  • rel_path = "" (empty filename input — user types name)
  • content = "" (empty editor)
  • byte_count = 0
  • A new context flag is_new = True
  • The save button label "Create" instead of "Save"
  • The delete button hidden
  • The target folder rendered as a data-at-folder="<folder>" attribute on the textarea so JS save can compose path = at_folder + "/" + filename on submit.

Extend overlay_file_editor.html to render conditionally based on is_new. The existing template has filename, content, save button — just add a {% if is_new %}Create{% else %}Save{% endif %} and {% if not is_new %}<button class="files-editor-delete">{% endif %}.

Add pytest tests in tests/test_url_addressable_modals.py:

  • test_new_route_renders_with_empty_content
  • test_new_route_renders_with_target_folder_attribute
  • test_new_route_renders_create_button_not_save
  • test_new_route_400s_for_invalid_at_path (path traversal)
  • test_new_route_404s_for_non_files_overlay

Verification: pytest green; curl the new route, see the editor markup with empty content + "Create" button.

Step 6: migrate create-new-file JS flow to URL-addressable

In editor.js, the openEditorTextNew(folder) path currently populates the legacy inline dialog with an empty filename + content. Change it to call window.modals.openRouted("/overlays/<id>/files/new?at=" + encodeURIComponent(folder)).

In the URL-addressable save delegation, detect is_new mode (look for data-at-folder on the textarea or value === "" on the filename input). When new: compose path = at_folder + "/" + filename.trim() from the form, send {path, content} to /files/save (existing endpoint handles creation when the file doesn't exist).

The core.js dispatch for op === "new-file" (currently calls into the old/legacy flow) is updated to call the new URL-addressable open.

Verification: click + on a folder → URL gains ?modal=/overlays/<id>/files/new?at=foo; editor opens with empty content + Create button + target folder shown; type filename + content + Create → file appears in tree at the right folder; rename test (type a path-like value into the filename input — should create nested as expected or 422 if invalid).

Step 7: add binary-file support to the edit route + template (server + template + tests)

Change overlay_file_edit_page in routes/files_routes.py: when is_editable(target) is False but the file IS readable (size check, no UnicodeDecodeError), instead of returning 415, return the editor template with:

  • is_binary = True
  • byte_count = target.stat().st_size
  • No content (or content = "")
  • A download_url and mime_type (best-effort guess)

Extend overlay_file_editor.html to render the binary-replace UI when is_binary:

  • Hide the CM6 textarea + language dropdown
  • Show: file info (name, size), Download button, Replace zone (drag-drop drop zone with browse fallback)
  • The Save button is replaced by "Replace" (only enabled when a file is queued)
  • The Delete button stays visible

Add pytest tests:

  • test_edit_route_renders_binary_template_for_non_editable
  • test_edit_route_still_404s_for_missing_file
  • test_edit_route_still_400s_for_path_traversal
  • test_binary_template_has_replace_zone

Verification: navigate to /overlays/<id>/files/edit?path=image.png (a real binary file) → page renders with replace UI; navigate via URL-addressable modal → modal opens with same UI.

Step 8: migrate binary-replace JS flow to URL-addressable

In editor.js, add document-level delegation for the binary-replace zone inside #modal-content:

  • dragover, dragleave, drop on .files-editor-replace-zone (delegated via event.target.closest(...))
  • click on .files-editor-replace-browse and the file input's change event for click-to-browse
  • click on .files-editor-replace-clear to clear the queued file
  • click on .files-editor-save when in binary mode → POST /files/replace (multipart) with the queued file

The legacy openEditorForFile(path, false) branch in files-overlay.js (currently called for binary files at line 1083) is replaced by window.modals.openRouted("/overlays/<id>/files/edit?path=" + encodeURIComponent(path)) — same as for editable files. The server figures out which template branch to render.

Verification: click on a binary file in the tree → URL-addressable modal opens with replace UI; drag a new binary onto the replace zone → queued; click Replace → POST 200; file size updates; file still binary.

Step 9: delete the legacy <dialog id="files-editor-modal"> from overlay_detail.html

By this point the legacy dialog has no callers. Delete the block from overlay_detail.html (originally lines 165-228, may have shifted).

In editor.js, delete all code paths that handle the legacy dialog: the editorDialog ref + the document-delegated handlers scoped to #files-editor-modal + the input/keydown direct-binds that were only needed for the legacy persistent inputs. The editor.js module shrinks to ~200 lines, single-purpose (URL-addressable modal only).

Add a pytest assertion (in test_url_addressable_modals.py) that id="files-editor-modal" does NOT appear in the rendered overlay detail page.

Verification: overlay detail page renders without the legacy dialog (DOM inspector); URL-addressable editor still works for text + binary + create-new flows; all existing pytest tests still pass.

Step 10: delete files-overlay.js stub + update base.html

If files-overlay.js is empty or just an IIFE shell, delete it. Update base.html to load only the 4 new modules (core.js, editor.js, dialogs.js, uploads.js). Order doesn't strictly matter (each is an independent IIFE), but core.js first makes the registry-of-handlers pattern explicit.

Verification: full re-run of the URL-addressable-modals spec's verification matrix (10 checks); 4 new modules' features all work (editor text + binary + create-new; new-folder; conflict; delete-confirm; drag-drop; uploads cancel + clear).

End of Phase B: legacy dialog gone, editor.js single-purpose, all editor flows URL-addressable.

Phase C — API cleanup (steps 11-12)

Step 11: extract shared path-resolution + editability helper

routes/files_routes.py has duplication between overlay_file_content (lines 203-234, JSON output) and overlay_file_edit_page (lines 237-275, HTML output, added in Task 3). Both:

  • Read request.args.get("path", "")
  • Call _load_files_overlay(overlay_id, user)
  • Call safe_resolve_for_listing(overlay.path, sub_path)
  • Check target.exists() and target.is_file()
  • Call is_editable(target)
  • Try target.read_text(encoding="utf-8") with OSError + UnicodeDecodeError fallback

Extract _load_file_for_editing(overlay_id, sub_path, user) -> (overlay, target_path, content_or_None, is_binary, byte_count) | Response:

  • Returns a tuple on success, a Response on any failure case (404, 415, 400, 403)
  • Both routes call this helper and translate the tuple into their respective output shapes

Same path: is_editable checks become part of the helper.

Add pytest tests for the helper directly if reasonable, plus confirm existing route tests still pass.

Verification: existing pytest tests stay green (no behavior change); both routes shorter and obviously parallel.

Step 12: delete GET /overlays/<id>/files/content if unused; consolidate save/replace

Audit: search the codebase for callers of /files/content (JSON endpoint). After Phase B, the legacy openEditorForFile() is gone, which was its only caller. If grep confirms no other callers, delete the endpoint + its tests.

overlay_file_save and overlay_file_replace share the rename branch (lines 276-285 of save, lines 322-335 of replace). Extract _apply_optional_rename(overlay, path, new_path) -> (write_target, echo_path) | Response. Both endpoints call it.

Verification: pytest stays green; grep confirms no remaining references to /files/content; both save/replace routes shorter.

End of Phase C: routes/files_routes.py ~450 lines (vs 640), no dead endpoints, shared helpers.

Critical files

Path Phases Action
l4d2web/l4d2web/static/js/files-overlay/core.js A New file
l4d2web/l4d2web/static/js/files-overlay/editor.js A → B New file, then shrinks in Phase B
l4d2web/l4d2web/static/js/files-overlay/dialogs.js A New file
l4d2web/l4d2web/static/js/files-overlay/uploads.js A New file
l4d2web/l4d2web/static/js/files-overlay.js A → B Shrinks each step; deleted in step 10
l4d2web/l4d2web/templates/base.html A, B Script tag updates each phase end
l4d2web/l4d2web/templates/overlay_file_editor.html B Add new-file + binary-replace branches
l4d2web/l4d2web/templates/overlay_detail.html B Delete legacy <dialog id="files-editor-modal"> (step 9)
l4d2web/l4d2web/routes/files_routes.py B, C Add /files/new route (step 5); extend /files/edit for binary (step 7); extract helpers (step 11); delete /files/content (step 12)
l4d2web/tests/test_url_addressable_modals.py B Add tests for new + binary modes (steps 5, 7); add legacy-dialog-gone assertion (step 9)

Existing functions and utilities to reuse

  • window.modals.openInline(idOrEl) / closeInline() / openRouted(path) / closeRouted() — at static/js/modals.js. New code uses these instead of dialog.showModal() directly.
  • window.__editor.initEditors(root) — at static/js/editor.js. CM6 re-init on swapped-in textareas. The new-file flow's empty textarea also needs CM6 mount; this just works because the htmx:afterSwap listener already covers it.
  • window.__filesEditor.getValue() — set by editor.js when CM6 mounts in the modal content. Used by editor.js's save delegation.
  • _load_files_overlay, safe_resolve_for_listing, is_editable, _validate_save_content, _stream_upload_into — all in routes/files_routes.py. Reused by new routes and the extracted helper.
  • Existing Flask routes /files/{save,delete,replace,upload,move,mkdir,download,download_zip,edit} — consumed unchanged by the new JS modules. /files/content deleted in Phase C.

Verification

Each step has its own check (above). Phase-end checks:

Phase A end (after step 4): all current features still work. 573 backend tests pass. Chromium pass on: file-row click → editor; +new-file → editor (in legacy dialog); +new-folder → new-folder modal; click binary file → editor in binary mode; drag file row → moves; drop file onto tree → uploads.

Phase B end (after step 10): legacy dialog gone; create-new + binary-replace are URL-addressable. Full re-run of the URL-addressable-modals spec verification matrix (docs/superpowers/specs/2026-05-17-url-addressable-modals-design.md, ## Verification, 10 checks) passes. New checks added: create-new URL deep-link works, binary-replace URL deep-link works.

Phase C end (after step 12): routes/files_routes.py shorter; 573 backend tests stay green; grep -rn "/files/content" returns nothing (or only confirms the deletion).

Handoff state

After each commit, the working tree is in a known-good Chromium-verified state. A future session resumes by:

  1. Reading this plan file at docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md.
  2. Reading git log --oneline to see which steps have shipped.
  3. Picking up at the next un-committed step.
  4. Per feedback_textarea_editor_v2_run memory: direct-to-master on left4me, skip subagent middleman for verbatim-from-plan tasks, document plan deviations in commit messages, Chromium verification works in default sandbox via ./scripts/dev-server.py.

Natural pause points: end of Phase A, end of Phase B, end of Phase C. Each phase delivers value independently — Phase A alone is a useful cleanup if the user doesn't want to do B and C.

Out of scope

  • Adding a JS test framework (no JS tests in the project today; verification stays Chromium-driven).
  • Migrating the other inline modals (rename, delete-overlay, new-folder-overlay, etc.) — unrelated to this file.
  • Restructuring the /files/upload chunking or progress-event behavior.
  • Refactoring _overlay_file_node.html, _overlay_file_tree.html, or other template partials that this JS doesn't own.