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

`overlay_detail.html` (NOT base.html): add `<script src="files-overlay/core.js" defer>` BEFORE the existing `files-overlay.js` script tag (currently at `overlay_detail.html:285`, also `defer`). The script tag lives in `overlay_detail.html` because `files-overlay.js` activates only when `.files-manager` exists (line 23-24 of the current file) — loading from `base.html` would pull the JS onto every page unnecessarily. **All new module script tags use `defer`** to match the existing pattern (the modules query the DOM at load time and need the body parsed first).

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. Add `<script src="files-overlay/uploads.js" defer>` etc. to `overlay_detail.html` alongside the (now-empty) `files-overlay.js` script tag (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 `overlay_detail.html` (NOT base.html) to load only the 4 new modules (`core.js`, `editor.js`, `dialogs.js`, `uploads.js`), all with `defer`. Order doesn't strictly matter for execution (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/overlay_detail.html` | A, B | Script tag updates each phase end (currently loads files-overlay.js at line 285 with `defer`) |
| `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.

## Errata (pre-execution)

- **Script tag location:** the plan's first draft incorrectly referenced `base.html` for the script tag updates. The actual location is `overlay_detail.html:285` (with `defer`). `files-overlay.js` and its replacement modules are page-scoped to overlay detail — loading from `base.html` would pull the JS onto every page when the `.files-manager` element only exists on overlay detail. All script tag references in this plan now correctly say `overlay_detail.html`. This errata is itself an example of the `feedback_validate_before_implementing` memory: probe the live state before trusting a plan claim.

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