Compare commits

...

15 commits

Author SHA1 Message Date
mwiegand
5f82950d7c
feat(files): delete /files/content endpoint + extract _apply_optional_rename
Step 12/12 of docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md.
End of Phase C — end of the rewrite plan.

Two cleanups in one commit:

1. Delete GET /overlays/<id>/files/content.

The legacy openEditorForFile in files-overlay.js was its only caller,
and Step 9 deleted that code path. grep confirms no remaining live
callers (the matches in .claude/worktrees/* are other in-flight
branches; matches in docs/ are plan/spec text describing the route's
history). Removed:

  * The @bp.get route function (was already a thin wrapper around
    _load_file_for_editing from Step 11)
  * The endpoint's mention in the module docstring
  * test_content_returns_text
  * test_content_returns_415_for_binary
  * test_content_404_for_non_files_overlay
  * The /files/content entry in the batched "non-files-overlay 404s
    everywhere" test

The _load_file_for_editing helper from Step 11 becomes single-caller
(only the edit route uses it now). Kept because the function name
gives the prelude a useful named concept and inlining would add ~17
lines of low-density logic into overlay_file_edit_page.

2. Extract _apply_optional_rename.

overlay_file_save and overlay_file_replace had near-identical rename
branches: safe_resolve_for_move → 422-on-traversal, 409-if-dst-exists,
mkdir-parents, os.rename → echo_path = new_path. Extracted into
_apply_optional_rename(overlay, path, new_path) → (write_target,
echo_path) | Response.

The helper handles both cases:
  * Rename: atomic rename, returns (dst, new_path)
  * No rename: safe_resolve_for_write, mkdir parents, returns
    (write_target, path)

Save's "destination is not a file" 409 (creation branch) stays inline
in overlay_file_save — it's save-specific behavior that doesn't apply
to /replace (which assumes a file exists or creates one).

Subtle behavior change in /save: the prior code called
safe_resolve_for_write(new_path or path) upfront and then potentially
overrode write_target via safe_resolve_for_move. The new code only
calls one validator per branch. Confirmed equivalent: per
overlay_files.py:36-58 (safe_resolve_for_write) vs. lines 76-106
(safe_resolve_for_move), the dst-side checks are identical (root
escape, symlink refuse, parent-is-dir) and safe_resolve_for_move adds
strictly more (src must exist, cycle check for directory moves). pytest
covers the save/replace paths and stays green.

pytest: 580 → 577 passed, 1 skipped, 3 deselected. The -3 is the 3
deleted /files/content tests.

files_routes.py: ended at 735 lines. The plan estimated ~450 — the
delta is the new /files/new route (~43 lines, Step 5), the binary
template branch (Step 7), the _load_file_for_editing helper (Step 11),
and module-header expansions. The structural goal (no dead routes,
shared helpers across paths) is met.

End-of-plan summary:
  * 1091-line files-overlay.js → 4 focused modules totaling 1191 lines
    (core.js 247, editor.js 309, dialogs.js 212, uploads.js 423)
  * Editor flows (text edit, binary replace, create new) all run
    through URL-addressable modals (?modal= deep-linkable)
  * Legacy <dialog id="files-editor-modal"> deleted
  * /files/content deleted (dead endpoint)
  * Shared helpers _load_file_for_editing + _apply_optional_rename
  * pytest stayed green at every step
  * Chromium-verified every step

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:29:55 +02:00
mwiegand
3facc323b6
refactor(files): extract _load_file_for_editing helper
Step 11/12 of docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md.

overlay_file_content and overlay_file_edit_page used to repeat the
same prelude: load the files-overlay, resolve sub_path safely, confirm
it's a regular file, attempt a UTF-8 read with the is_editable sniff
+ UnicodeDecodeError-on-tail fallback. The bodies have diverged in
two places (the binary branch differs — content endpoint returns 415,
edit page renders the binary template) but the prelude is identical.

Extract _load_file_for_editing(overlay_id, sub_path, user). Returns
either a Flask Response (400/404/403/500) or a 5-tuple
(overlay, target, content_or_None, is_binary, byte_count). Both
routes call it and translate the tuple into their output shape:

  * overlay_file_content: 415 when is_binary, else jsonify text
  * overlay_file_edit_page: render binary template when is_binary,
    else render text template

The previous _render_binary_editor helper folds back into the route
since it was only called from there and benefits from inlining now
that the prelude is shared.

is_binary in the tuple is True both when is_editable() said no up
front (8-KiB sniff) AND when the tail-of-file read raised
UnicodeDecodeError — collapsing what was a duplicated try/except
branch in each route.

No behavior change. pytest still 580 passed, 1 skipped, 3 deselected.

Step 12 (next) audits /files/content callers and likely deletes the
endpoint since Phase B removed its only caller (legacy
openEditorForFile via files-overlay.js); the shared helper would
then collapse to single-caller status but remains useful for
grouping the prelude into a named concept.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:24:32 +02:00
mwiegand
ddf03c6fb8
feat(files): delete files-overlay.js stub + its script tag
Step 10/12 of docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md.
End of Phase B.

The 19-line tombstone file at static/js/files-overlay.js had no code
since Step 4; it was kept around as a stable resolution target for the
<script> tag while the modules de-duplicated. After Step 9 made the
modules complete and stand-alone, the stub is just a wasted HTTP
request and a misleading filename in the manifest. Deleted, plus the
matching <script defer> tag in overlay_detail.html.

Final loader shape — exactly 4 script tags, all defer, all in
files-overlay/:
  1. core.js   — helpers, manager guard, action registry
  2. editor.js — URL-addressable editor (text + binary + new-file)
  3. dialogs.js — new-folder + delete-confirm + conflict
  4. uploads.js — upload queue + drag-drop + zip action

Verified live on /overlays/2 in Chromium:
  * Exactly 4 files-overlay script tags load (no more files-overlay.js)
  * window.__filesOverlay registry has its 10 keys; askConflict +
    withCollisionSuffix + handleAction + registerHandler all functions
  * File tree renders (3 file rows + 1 folder row, as before)
  * No legacy #files-editor-modal in DOM
  * No console errors
  * pytest still 580 passed, 1 skipped, 3 deselected

Phase B end-state vs. Phase A end-state:
  * editor.js: 550 → 309 lines (Step 9 gutted legacy)
  * files-overlay.js: 19 → 0 (deleted in this step)
  * 5 new pytest tests for the /files/new + binary template
  * Legacy <dialog id="files-editor-modal"> gone from
    overlay_detail.html
  * Editor flows (text edit, binary replace, create new) all run
    through the URL-addressable modal (?modal= deep-linkable)

Phase C (steps 11–12) is server-only: extract a shared path-resolution
helper between overlay_file_content + overlay_file_edit_page, then
delete /files/content if grep confirms no remaining callers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:22:12 +02:00
mwiegand
10f93b863b
feat(files): delete legacy editor dialog + gut editor.js legacy paths
Step 9/12 of docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md.

The inline <dialog id="files-editor-modal"> block in overlay_detail.html
is gone. All editor flows (text edit, binary replace, create new) now
exclusively use the URL-addressable modal swapped into #modal-content.

editor.js is now single-purpose (URL-addressable only). Removed:
  * editorDialog reference and the editorEls DOM-ref struct
  * The legacy editor state object
  * CM6 bridge wrappers + UI helpers (getEditorValue/setEditorValue/
    setEditorTitle/updateByteCount/updateRenameHint/updateSaveEnabled/
    setQueuedReplacement) — they only ever drove the legacy dialog
  * withCollisionSuffix (uploads.js still has its copy for the
    upload-conflict path; editor.js no longer needs it since the
    URL-addressable conflict path is "alert + keep modal open" rather
    than overwrite/keep-both)
  * openEditorTextNew and openEditorForFile — both functions were
    already unreachable from a user action after Steps 6/8
  * inLegacyEditor predicate
  * Direct-bound listeners on editorEls.filename / contentBox /
    editorDialog (input, keydown for Ctrl+S, close)
  * Legacy branches in every delegated handler (dragover, dragleave,
    drop, change, click)
  * legacySaveClicked and legacyDeleteClicked

What stays:
  * Routed state (routedReplacement, isRoutedBinaryMode,
    setRoutedReplacement, updateRoutedBinarySaveEnabled)
  * Delegated dragover/dragleave/drop/change/click handlers — now
    single-path each, no legacy/routed branching
  * Filename input delegated listener for routed binary mode (so
    rename-only Replace stays reachable)
  * modal-container close listener that clears routedReplacement
  * routedSaveClicked (text edit + is_new), routedReplaceClicked
    (binary, with rename-or-replace fork), routedDeleteClicked
  * "new-file" and "edit" registered handlers (the "edit" handler is
    no longer split editable/binary — the server picks the template
    branch)

routedDeleteClicked gained one capability that was missing in Step 8:
it now reads rel-path from either the textarea (text mode) OR the
.files-editor-binary panel, so deletion works for binary files in the
URL-addressable modal too (previously routed-mode binary delete fell
through to legacy, which is now gone).

Test added: test_overlay_detail_no_longer_renders_legacy_editor_dialog
asserts the legacy dialog markup is absent from /overlays/<id> while
the other inline dialogs are still present (Step 3 didn't move them).

Numbers:
  editor.js: 660 → 309 lines (-351). Plan estimated ~200; actual is
  ~50% larger due to module-header comments + the rename-or-replace
  fork in routedReplaceClicked. Pure-routing-only and single-mode
  per click, which was the structural goal.

  Total across files-overlay/: 1432 → 1191 lines.

pytest: 579 → 580 passed, 1 skipped, 3 deselected.

Verified live on /overlays/2 in Chromium:
  * id="files-editor-modal" not in DOM; new-folder/delete/conflict
    dialogs still present
  * "+ new file" → routed modal, Create button
  * Click other.cfg (editable) → routed modal, Save button, content
    pre-filled
  * Click test.png (binary) → routed modal, Replace button, initially
    disabled
  * No console errors

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:20:27 +02:00
mwiegand
e75280f780
feat(files): migrate binary-replace JS flow to URL-addressable modal
Step 8/12 of docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md.

The "edit" action handler in editor.js now ALWAYS opens the URL-
addressable modal — both editable text files and binary files. The
server's /files/edit route picks the template branch (text editor vs.
binary-replace panel) based on is_editable(target); data-editable on
the action element becomes informational only.

Binary-replace handlers extended in editor.js to cover the routed
modal (the panel that .files-editor-binary now lives inside):

  * routedReplacement (module-scope nullable) — the queued File for
    routed binary mode. Cleared on modal-container close so reopening
    starts fresh.
  * setRoutedReplacement(mc, file) — updates idle/queued markup,
    populates name/size labels, calls updateRoutedBinarySaveEnabled.
  * isRoutedBinaryMode(mc) — true when #modal-content holds a binary
    panel; used by the click delegation to route Save → replace.
  * updateRoutedBinarySaveEnabled(mc) — enables Save when a file is
    queued OR the filename input has been edited. Mirrors the legacy
    binary-mode updateSaveEnabled logic.

The existing dragover / dragleave / drop / change / click delegated
listeners gained routed-mode branches alongside the legacy branches,
gated by inLegacyEditor / inRoutedEditor (mutually exclusive — the
selectors only match inside one editor at a time).

routedReplaceClicked added, mirroring legacy binary save:
  * Queued file present → POST /files/replace (multipart, with optional
    new_path for rename-on-replace)
  * Queued file absent but filename edited → POST /files/move (rename
    only)
  * Neither → no-op (Save was disabled, shouldn't be reachable)

Filename-input delegated listener re-evaluates Save enablement when
the user types in routed binary mode (so rename-only is reachable
without queueing a file).

The legacy openEditorForFile function in editor.js is now unreachable
from a user action (the "edit" handler no longer calls it). It stays
in this file until Step 9 deletes the legacy dialog block wholesale.

Verified live on /overlays/2 in Chromium:
  * Click test.png (binary, data-editable="0") → URL becomes
    ?modal=%2Foverlays%2F2%2Ffiles%2Fedit%3Fpath%3Dtest.png
  * Routed modal opens with binary panel; Save labeled "Replace" and
    disabled
  * Synthetic drop event with a File → Save enables, idle label hides,
    queued label shows "new.png · 18 B"
  * Clear button → idle restored, Save disables
  * Type new filename without queueing → Save enables (rename-only)
  * Revert filename → Save disables
  * No console errors
  * pytest still 579 passed, 1 skipped, 3 deselected (no Python changes
    in Step 8)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:15:57 +02:00
mwiegand
294b5b8489
feat(files): add binary-file support to edit route + template
Step 7/12 of docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md.

overlay_file_edit_page now renders the binary-replace UI instead of
returning 415 for non-editable files. Two cases bridge to the same
binary template: the up-front is_editable() check and the late-binding
UnicodeDecodeError on read (8-KiB sniff said yes but the tail had
non-UTF-8 bytes). Both paths route through a small _render_binary_editor
helper that fills in:
  * is_binary=True
  * content="" (no inline editor)
  * byte_count=target.stat().st_size
  * mime_type from mimetypes.guess_type, falling back to
    application/octet-stream

Template (overlay_file_editor.html) gains an is_binary branch in the
modal body: hides .files-editor-text (CM6 textarea + language
dropdown), renders .files-editor-binary instead with file-info note,
replace-zone (drop area + browse button + hidden file input), and
the per-state idle/queued labels. The Save button reads "Replace"
in binary mode and starts disabled — Step 8 enables it via JS when
a replacement file is queued.

Delete and Download stay visible in binary mode (binary files can be
deleted and downloaded the same way text files can).

The /files/new route gets is_binary=False + mime_type="" passed
explicitly so the template's binary branch never fires there.

Server-side only — no JS changes in Step 7. Step 8 wires up the
binary-replace handlers (replace-zone drag, browse/clear clicks,
Replace button → POST /files/replace).

Tests:
  * Removed test_edit_route_415s_for_non_editable_file — the route
    no longer returns 415 for non-editable files; the binary template
    is the new contract.
  * Added test_edit_route_renders_binary_template_for_non_editable
    (asserts 200 + is_binary markup + Save button label + disabled
    state + .files-editor-text absent + byte count rendered).
  * Added test_binary_template_has_replace_zone (asserts the replace-
    zone markup: drop zone, idle/queued labels, browse button, hidden
    file input, and that Download stays visible).

pytest: 578 → 579 passed, 1 skipped, 3 deselected. The pre-existing
"still 404 / still 400" cases the plan asks for are covered by the
existing test_edit_route_404s_for_missing_file and
test_edit_route_400s_for_path_traversal tests — left alone rather
than duplicated.

Verified live: GET /overlays/2/files/edit?path=test.png (an actual
binary file in the demo overlay) returns the binary template; DOM
inspection confirms .files-editor-binary present with data-rel-path,
"Replace" save button starts disabled, Download href points at the
file's download endpoint, MIME type and byte count match, .files-
editor-text is absent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:11:17 +02:00
mwiegand
4d045e578d
feat(files): migrate create-new-file JS flow to URL-addressable modal
Step 6/12 of docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md.

Two changes in editor.js:

1. The "new-file" registered handler now opens the URL-addressable
   modal at GET /overlays/<id>/files/new?at=<folder> via
   window.modals.openRouted, replacing the call to openEditorTextNew.
   The legacy openEditorTextNew function stays in this file for now —
   it's no longer reachable from a user action; Step 9 deletes it
   alongside the rest of the legacy dialog block.

2. routedSaveClicked gains an is_new branch. When the textarea's
   data-rel-path is empty, the save composes the new file's path from
   data-at-folder (set by the /files/new route) + the user-typed
   filename and POSTs {path, content} to /files/save. The /save
   endpoint creates the file when it doesn't exist; 409 means a file
   at that path already exists and the user picks a different name
   (alert + modal stays open so the form value is preserved).

The legacy slash-in-filename guard from openEditorTextNew's legacy
save path is deliberately not carried over — the plan permits typing
"sub/foo.txt" in the filename input to create a nested file via
/save, matching the route's path semantics.

Verified live on /overlays/2 in Chromium:
  * Click "+ new file" on overlay root → URL becomes
    ?modal=%2Foverlays%2F2%2Ffiles%2Fnew%3Fat%3D
  * Routed modal opens with empty data-rel-path, empty data-at-folder,
    empty filename input, save button labeled "Create", no Delete or
    Download buttons, title "…new file"
  * No console errors
  * pytest still 578 passed, 1 skipped, 3 deselected (no Python or
    test changes in Step 6)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:05:55 +02:00
mwiegand
6b0231970c
feat(files): add GET /overlays/<id>/files/new + extend editor template
Step 5/12 of docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md.

Adds a server-rendered editor page for creating a new file in a target
folder. Renders the same overlay_file_editor.html as the edit route,
but with is_new=True so the template:
  * Hides the Delete button
  * Hides the Download link (no file to download yet)
  * Changes the Save button label to "Create"
  * Emits data-at-folder on the textarea for the JS save handler to
    compose path = at_folder + "/" + filename
  * Updates the title block (browser tab + heading) to "New file" /
    "<at_folder>/…new file"

The existing edit route now passes is_new=False and at_folder=""
explicitly so both call sites are explicit about the contract.

Path validation: ?at may be empty (overlay root) or a relative folder
path. safe_resolve_for_listing rejects traversal attempts (400). A
missing or non-directory at returns 404. Reused the helper to keep
the safety story identical to the listing / edit routes.

Phase B Step 5 is server-side only — no JS changes here. Step 6
migrates openEditorTextNew in editor.js to use this route via
window.modals.openRouted().

Tests added (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
  * test_new_route_404s_for_non_files_overlay

pytest: 573 → 578 passed, 1 skipped, 3 deselected.

Verified live: curl /overlays/2/files/new returns markup with
data-at-folder="", "UTF-8 · 0 bytes" byte count, save button label
"Create", and no Delete / Download buttons.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:03:01 +02:00
mwiegand
cb391ad456
feat(files): migrate uploads + drag-drop to uploads.js; legacy file is a stub
Step 4/12 of docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md.

End of Phase A. files-overlay.js is now a 19-line tombstone comment;
all behavior lives in the 4 modules under files-overlay/. Step 10
deletes the stub and its <script> tag.

uploads.js owns:
  * The upload queue (concurrency 3, XHR-based progress, queued/active/
    done/cancelled/error/conflict states) and the progress panel
  * Drag-drop on treeRoot (5 events: dragstart, dragend, dragover,
    dragleave, drop). Internal drags (row → folder, via the custom
    application/x-files-overlay MIME) and external drags (OS files +
    folders via webkitGetAsEntry) flow through the same drop handler.
  * walkEntry for recursive folder walks during external drops
  * withCollisionSuffix (moves here from files-overlay.js — its biggest
    caller is the upload-conflict "keep both" path; exposed on
    __filesOverlay for editor.js's save-409 path too)
  * "zip" action handler (registered into __filesOverlay) — pure URL
    navigation; placed here as the closest thematic home

Pattern change: upload-row cancel buttons converted from direct-bound
per row (inside buildUploadRow, which captured `item` in a closure) to
a single document-level delegated click listener. Each row carries
data-upload-id; an uploads Map<uploadId, item> looks up the item at
click time. The Map entry is removed in the uploadsClearBtn handler
when a done/error/cancelled row is cleared, so the Map doesn't grow
unbounded.

Drag-drop stays direct-bound to treeRoot per the plan escape hatch —
the 5 events share coordinated highlight state (is-drag-source,
is-drop-target classes that are toggled across events), and treeRoot
is persistent (never swapped). Delegation would obscure the state
coordination logic without any real benefit.

Phase A end-state:
  * core.js (247 lines): helpers, manager guard, registry dispatch
  * editor.js (550 lines): editor flows (legacy + URL-addressable)
  * dialogs.js (212 lines): new-folder, delete-confirm, conflict
  * uploads.js (423 lines): upload queue + drag-drop + zip + collision
  * files-overlay.js (19 lines): tombstone comment, deleted in Step 10

Total: ~1432 lines across 4 modules + 19-line stub. The plan estimated
~780 lines across 4 modules; actual is ~1.8× larger, the difference
being module-header comments and the delegation-with-state scaffolding
(e.g., editor.js's dual-editor listener split, dialogs.js's per-dialog
state plus close-event resolvers).

Verified live on /overlays/2 in Chromium:
  * 5 script tags load in document order (core → editor → dialogs →
    uploads → legacy stub)
  * Registry has 10 keys; askConflict and withCollisionSuffix are both
    callable; withCollisionSuffix('foo.tar.gz') === 'foo.tar (1).gz'
    (legacy behavior preserved — lastIndexOf('.') splits before the
    final extension)
  * Uploads panel + list elements present in DOM, panel hidden by
    default
  * "zip" action button exists; registered handler would set
    window.location.href to /overlays/2/files/download_zip?path=...
  * No console errors
  * pytest still 573 passed, 1 skipped, 3 deselected

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:57:30 +02:00
mwiegand
307df9c23a
feat(files): migrate dialogs (new-folder, delete, conflict) to dialogs.js
Step 3/12 of docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md.

dialogs.js owns the three inline <dialog> modals that surround the
file manager:
  * #files-new-folder-modal — "+ folder" / mkdir prompt
  * #files-delete-modal — delete-confirm for files and folders
  * #files-conflict-modal — overwrite / keep-both / cancel choice

Pattern change: the legacy file used clone-and-rebind
(replaceWith(cloneNode(true)) + fresh addEventListener) to drop stale
state-bearing click listeners between dialog opens. dialogs.js replaces
that with a single delegated listener per dialog, reading per-dialog
state from module-scope nullable variables (conflictState,
deleteState, newFolderState). The state is set when the dialog opens
and cleared on the 'close' event so dismissals don't leave stale
references. Listener attaches once per page load.

Dialog opens go through window.modals.openInline()/closeInline()
instead of dialog.showModal()/close() directly, completing the inline-
modal convention from commit c51089d.

askConflict now resolves to "cancel" on any dismissal (Esc, backdrop,
programmatic close) thanks to the 'close' event handler — the legacy
version left the promise pending forever in those paths. Verified
live: closeInline() on an open conflict dialog resolves the pending
askConflict promise to "cancel".

Action-registry dispatch: dialogs.js registers "new-folder" and
"delete" handlers into __filesOverlay. Combined with editor.js's
registration of "new-file" and "edit" (Step 2), only "zip" remains in
the legacy click switch (pure URL navigation, no module dependency).

Cross-module exposure: askConflict moves from files-overlay.js to
dialogs.js; both set __filesOverlay.askConflict, but dialogs.js wins
by document order (it loads before legacy via the <script defer>
ordering in overlay_detail.html). The legacy upload + drag-drop call
sites switch from local askConflict() to window.__filesOverlay
.askConflict() — same shape, different lookup.

The orphaned newFolderDialog / conflictDialog / deleteDialog
declarations at the top of legacy are deleted; legacy no longer holds
references to those elements.

Numbers:
  files-overlay.js: 669 → 589 lines (-80)
  files-overlay/dialogs.js: 212 lines (new)
  Net: +132 lines. Growth is from the delegation/state-management
  scaffolding and module-header comments. The delete went lighter
  than the plan's ~150-line estimate because the new code is more
  carefully structured (less duplication across the 3 dialogs).

Verified live on /overlays/2 in Chromium:
  * 4 script tags load in order (core → editor → dialogs → legacy)
  * Registry has 10 keys; askConflict + withCollisionSuffix still set
  * "+ new folder" on overlay root → new-folder dialog opens with
    empty name input and "/" target label, closes cleanly
  * "✕" on a file row → delete-confirm dialog opens with the file's
    name displayed, closes cleanly
  * askConflict('a/b.txt') → conflict dialog opens with path shown
    - close via window.modals.closeInline() → resolves "cancel"
    - click [data-files-conflict-action="overwrite"] → resolves "overwrite"
    - click [data-files-conflict-action="keep-both"] → resolves "keep-both"
  * No console errors throughout
  * pytest still 573 passed, 1 skipped, 3 deselected

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:51:47 +02:00
mwiegand
f094eca074
feat(files): migrate editor handlers to files-overlay/editor.js
Step 2/12 of docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md.

editor.js is dual-purpose during Phase A: drives both the legacy
inline #files-editor-modal <dialog> (binary-replace + create-new flows)
and the URL-addressable modal swapped into #modal-content (editable
text files). Phase B migrates the legacy flows to URL-addressable too
and removes the legacy branches.

What moved:
  * Editor state object, editorEls DOM refs, CM6 bridge (getEditorValue,
    setEditorValue), UI helpers (setEditorTitle, updateByteCount,
    updateRenameHint, updateSaveEnabled, setQueuedReplacement)
  * openEditorTextNew (create-new file flow)
  * openEditorForFile (legacy binary + editable-as-fallback flow)
  * All save/delete/replace handlers — converted from direct-bound on
    editorEls.{saveBtn,deleteBtn} to a single document-level click
    listener that discriminates by ancestor (legacy editorDialog vs.
    URL-addressable #modal-content)
  * Replace-zone dragover/dragleave/drop — direct-bound on
    editorEls.replaceZone → document-level delegation gated on the zone
    being inside the legacy dialog
  * Replace-input change, replace-clear / replace-browse clicks — also
    delegated
  * The previously-separate URL-addressable save/delete delegation
    block (lines 593-664 of the legacy file) collapses into the same
    delegated listeners

What stays direct-bound (per plan escape hatch):
  * input on .files-editor-filename
  * input + keydown on .files-editor-content (Ctrl+S handling)
  * close on the persistent legacy <dialog>
These are high-frequency events on persistent inputs inside the
persistent legacy dialog; delegation would add per-keystroke
selector-matching overhead with no benefit.

Action dispatch: editor.js registers "new-file" and "edit" handlers
into __filesOverlay (set up by core.js). The legacy switch-case in
files-overlay.js's click delegation loses both cases — they're now
dispatched via the registry. The legacy switch still owns new-folder,
zip, and delete (those migrate in Step 3).

Cross-module exposure: askConflict and withCollisionSuffix stay in
files-overlay.js (the upload queue and drag-drop code at lines 857
and 974 still use them) and are exposed on __filesOverlay so editor.js
can call them. They migrate to dialogs.js (askConflict, Step 3) and
uploads.js (withCollisionSuffix, Step 4); the call sites in editor.js
don't change.

Numbers:
  files-overlay.js: 1091 → 669 lines (-422)
  files-overlay/editor.js: 550 lines (new)
  Net: +128 lines; the growth is from the dual-editor delegation
  scaffolding (separate handler functions for legacy vs. routed) and
  module-header comments. The legacy file is now a stub editor section
  comment plus the unmigrated dialogs/uploads/drag-drop blocks.

Verified live on /overlays/2 in Chromium:
  * 3 script tags load in document order (core → editor → legacy)
  * window.__filesOverlay registry now has 10 keys (added askConflict +
    withCollisionSuffix); withCollisionSuffix('foo.txt') = 'foo (1).txt'
  * No console errors on page load or after synthetic actions
  * E2E dispatch check: clicking a "+ new file" action button opens the
    legacy dialog with empty filename + Create save-button label
    (proves core → handleAction → editor.js handler → openEditorTextNew
    chain works)
  * E2E dispatch check: clicking the filename button on an editable
    file sets ?modal=%2Foverlays%2F2%2Ffiles%2Fedit%3Fpath%3D... in the
    URL (proves editor.js's "edit" handler correctly routes editable
    files through window.modals.openRouted)
  * pytest still 573 passed, 1 skipped, 3 deselected

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:42:58 +02:00
mwiegand
052ddcb4f0
feat(files): scaffold files-overlay/core.js with helpers + action registry
Step 1/12 of docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md.

Pure scaffolding — no behavior change. core.js is loaded with defer in
overlay_detail.html before the existing files-overlay.js script tag
(both with defer, so execution order follows document order). Both
files run the same .files-manager guard, both attach document-level
click listeners on the action selector. The legacy file's switch-case
still owns dispatch; core.js's listener dispatches into an empty
registry until Steps 2–4 populate handlers.

window.__filesOverlay exposes:
  * manager / overlayId / baseUrl / treeRoot / csrfToken (manager-
    element-derived state, computed once)
  * helpers.{joinPath, parentOf, basename, escapeHtml, humanSize,
    fetchJson, postJson, postForm, refreshFolder, findRowByPath,
    cssEscape, scheduleRefresh} (duplicated from legacy file for the
    duration of Phase A; de-duplicates as feature modules migrate out)
  * registerHandler(op, fn) / handleAction(op, path, actionEl) — the
    action-dispatch registry that Steps 2–4 populate

Per the canonical plan's errata commit (d76ee05), the script tag goes
in overlay_detail.html (not base.html as the original plan said) and
uses defer to match the existing pattern.

Verified live on /overlays/2 in Chromium: both <script> tags present
in DOM order; window.__filesOverlay shape matches expectation (8
top-level keys, 12 helpers); overlayId="2", baseUrl="/overlays/2",
treeRoot resolved; joinPath('foo/','/bar') === 'foo/bar' smoke test
passes; no console errors; existing 3-file/1-folder tree still
renders. pytest still 573 passed, 1 skipped, 3 deselected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:22:54 +02:00
mwiegand
4fa39642b0
refactor(files): collapse files_overlay binary mode into per-capability gates
Read-only file explorers (server detail page, read-only overlay) used
to omit the hover-action panel entirely and make the filename itself
the download link. Editable overlays did the opposite: hover-action
panel with download + delete, filename-click opens the editor.

Unify both surfaces around the editable pattern: always emit the
hover-action span when any action applies. Gate the download button
on download_supported only (now visible on all surfaces). Keep
delete + folder actions (+file, +folder, zip) gated on files_overlay,
since read-only surfaces never offer those. In read-only mode, the
filename becomes a plain <span> — the hover ⬇ is the single download
affordance, matching editable mode's filename-click ≠ hover-download
split.

Prefactor for the files-overlay.js rewrite (docs/superpowers/plans/
2026-05-17-files-overlay-rewrite.md). No JS changes; files-overlay.js
doesn't run on read-only surfaces (manager-element guard at line 23-24).

Verified: pytest stayed at 573/1/3 (URL substrings still appear in the
rendered HTML even though they moved out of the filename anchor into
the hover-action span). Direct Jinja render confirmed all four
branches: read-only downloadable file (new hover ⬇, plain <span>
filename), broken read-only symlink (no hover panel — correct, can't
download dangling links), read-only download_supported=False (no
hover panel, plain <span>), editable mode (byte-identical to before).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:19:00 +02:00
mwiegand
d76ee05956
docs(files): errata — script tag lives in overlay_detail.html, not base.html
files-overlay.js is loaded from overlay_detail.html:285 (with defer),
not base.html — the JS activates only when .files-manager exists,
which is only on overlay detail for files-type overlays. Loading from
base.html would pull it onto every page. The plan's first draft had
this wrong in four places (step 1, step 4, step 10, critical files
table). Following the plan verbatim would have moved the script tag
to the wrong template — exactly the failure mode that
feedback_validate_before_implementing memory warns about.

Added an Errata section at the bottom of the plan documenting this.
Also clarified that all new module script tags should use defer to
match the existing pattern (the modules query the DOM at load and
need the body parsed first).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:02:21 +02:00
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
12 changed files with 1761 additions and 1286 deletions

View file

@ -0,0 +1,271 @@
# 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.

View file

@ -10,8 +10,6 @@ Read-only endpoints (any overlay):
is refused. is refused.
Mutating endpoints (only `overlay.type == 'files'`, owner or admin): Mutating endpoints (only `overlay.type == 'files'`, owner or admin):
- `GET /overlays/<id>/files/content?path=` JSON `{path, content}` for
an editable text file, 415 if not editable.
- `POST /overlays/<id>/files/save` JSON `{path, content, new_path?}`, - `POST /overlays/<id>/files/save` JSON `{path, content, new_path?}`,
text-mode write with optional atomic rename. text-mode write with optional atomic rename.
- `POST /overlays/<id>/files/replace` multipart `path`, `file`, - `POST /overlays/<id>/files/replace` multipart `path`, `file`,
@ -31,6 +29,7 @@ Mutating endpoints (only `overlay.type == 'files'`, owner or admin):
from __future__ import annotations from __future__ import annotations
import io import io
import mimetypes
import os import os
import shutil import shutil
import tempfile import tempfile
@ -200,14 +199,22 @@ def server_files_fragment(server_id: int):
) )
@bp.get("/overlays/<int:overlay_id>/files/content") def _load_file_for_editing(overlay_id: int, sub_path: str, user):
@require_login """Shared prelude for overlay_file_content + overlay_file_edit_page:
def overlay_file_content(overlay_id: int): load the files-overlay, resolve sub_path safely, confirm it's a
"""Return `{path, content}` for an editable text file.""" regular file, attempt a UTF-8 read.
user = current_user()
assert user is not None
sub_path = request.args.get("path", "")
Returns either:
* a 5-tuple (overlay, target, content, is_binary, byte_count) on
success content is the UTF-8 text in text mode, None in
binary mode; byte_count is encoded text length or st_size
* a Flask Response on any failure: 400 (path traversal), 404
(missing or non-file), 403 (system overlay, non-admin), 500
(OS read error)
is_binary is True both when is_editable(target) said no up front
(8-KiB sniff) and when the tail of the file failed UTF-8 decode
even though the sniff passed."""
result = _load_files_overlay(overlay_id, user) result = _load_files_overlay(overlay_id, user)
if isinstance(result, Response): if isinstance(result, Response):
return result return result
@ -220,18 +227,18 @@ def overlay_file_content(overlay_id: int):
if not target.exists() or not target.is_file(): if not target.exists() or not target.is_file():
return Response(status=404) return Response(status=404)
if not is_editable(target): if not is_editable(target):
return Response("not editable", status=415) return overlay, target, None, True, target.stat().st_size
try: try:
content = target.read_text(encoding="utf-8") content = target.read_text(encoding="utf-8")
except OSError: except OSError:
return Response("read failed", status=500) return Response("read failed", status=500)
except UnicodeDecodeError: except UnicodeDecodeError:
# is_editable sniffed only the first 8 KiB; the tail can still fail. return overlay, target, None, True, target.stat().st_size
return Response("not editable", status=415)
return jsonify({"path": sub_path, "content": content}) return overlay, target, content, False, len(content.encode("utf-8"))
@bp.get("/overlays/<int:overlay_id>/files/edit") @bp.get("/overlays/<int:overlay_id>/files/edit")
@ -239,39 +246,84 @@ def overlay_file_content(overlay_id: int):
def overlay_file_edit_page(overlay_id: int): def overlay_file_edit_page(overlay_id: int):
"""Server-rendered editor page. Renders full-page by default or as a """Server-rendered editor page. Renders full-page by default or as a
layoutless modal fragment when the HX-Modal header is set (see the layoutless modal fragment when the HX-Modal header is set (see the
inject_base_layout context processor in app.py).""" inject_base_layout context processor in app.py). Non-editable files
render the binary-replace branch instead of returning 415."""
user = current_user() user = current_user()
assert user is not None assert user is not None
sub_path = request.args.get("path", "") sub_path = request.args.get("path", "")
result = _load_files_overlay(overlay_id, user) result = _load_file_for_editing(overlay_id, sub_path, user)
if isinstance(result, Response): if isinstance(result, Response):
return result return result
overlay = result overlay, target, content, is_binary, byte_count = result
try: if is_binary:
target = safe_resolve_for_listing(overlay.path, sub_path) mime, _enc = mimetypes.guess_type(target.name)
except ValueError: return render_template(
return Response("invalid path", status=400) "overlay_file_editor.html",
overlay=overlay,
if not target.exists() or not target.is_file(): rel_path=sub_path,
return Response(status=404) content="",
if not is_editable(target): byte_count=byte_count,
return Response("not editable", status=415) is_new=False,
is_binary=True,
try: at_folder="",
content = target.read_text(encoding="utf-8") mime_type=mime or "application/octet-stream",
except OSError: )
return Response("read failed", status=500)
except UnicodeDecodeError:
return Response("not editable", status=415)
return render_template( return render_template(
"overlay_file_editor.html", "overlay_file_editor.html",
overlay=overlay, overlay=overlay,
rel_path=sub_path, rel_path=sub_path,
content=content, content=content,
byte_count=len(content.encode("utf-8")), byte_count=byte_count,
is_new=False,
is_binary=False,
at_folder="",
mime_type="",
)
@bp.get("/overlays/<int:overlay_id>/files/new")
@require_login
def overlay_file_new_page(overlay_id: int):
"""Server-rendered editor page for creating a new file under `?at=<folder>`.
Renders the same overlay_file_editor.html template as the edit route,
with `is_new=True` so the save button label is "Create" (not "Save"),
the Delete and Download buttons are hidden, and the filename input
starts empty. The textarea carries `data-at-folder` so the JS save
handler can compose `<at_folder>/<filename>` on submit.
`?at` may be empty (= overlay root) or a relative folder path. Path-
traversal attempts return 400; a missing or non-directory `at`
returns 404."""
user = current_user()
assert user is not None
at = request.args.get("at", "")
result = _load_files_overlay(overlay_id, user)
if isinstance(result, Response):
return result
overlay = result
if at:
try:
target = safe_resolve_for_listing(overlay.path, at)
except ValueError:
return Response("invalid path", status=400)
if not target.exists() or not target.is_dir():
return Response(status=404)
return render_template(
"overlay_file_editor.html",
overlay=overlay,
rel_path="",
content="",
byte_count=0,
is_new=True,
is_binary=False,
at_folder=at,
mime_type="",
) )
@ -283,6 +335,40 @@ def _validate_save_content(content: str) -> Response | None:
return None return None
def _apply_optional_rename(overlay, path: str, new_path: str | None):
"""Resolve the write target for save / replace, atomically renaming
when `new_path` differs from `path`. Returns:
* (write_target, echo_path) on success write_target is the
resolved Path the caller should write to; echo_path is the
canonical relative path to echo back in the response
* a Flask Response on failure: 422 (path validation) or 409
(rename destination already exists)
The atomic rename happens INSIDE this helper. Save's "destination
is not a file" 409 (creation branch) stays in the caller, since
/replace doesn't enforce that check."""
if new_path is not None and new_path != path:
try:
src_path, dst_path = safe_resolve_for_move(overlay.path, path, new_path)
except ValueError as exc:
return Response(str(exc), status=422)
if dst_path.exists():
return Response("destination already exists", status=409)
dst_path.parent.mkdir(parents=True, exist_ok=True)
os.rename(src_path, dst_path)
return dst_path, new_path
try:
write_target = safe_resolve_for_write(overlay.path, path)
except ValueError as exc:
return Response(str(exc), status=422)
write_target.parent.mkdir(parents=True, exist_ok=True)
return write_target, path
def _is_existing_file(path) -> bool:
return path.is_file() and not path.is_symlink()
@bp.post("/overlays/<int:overlay_id>/files/save") @bp.post("/overlays/<int:overlay_id>/files/save")
@require_login @require_login
def overlay_file_save(overlay_id: int): def overlay_file_save(overlay_id: int):
@ -308,26 +394,15 @@ def overlay_file_save(overlay_id: int):
return result return result
overlay = result overlay = result
try: rename_result = _apply_optional_rename(overlay, path, new_path)
write_target = safe_resolve_for_write(overlay.path, new_path or path) if isinstance(rename_result, Response):
except ValueError as exc: return rename_result
return Response(str(exc), status=422) write_target, echo_path = rename_result
# Rename branch: source must exist, dst must not collide. # Creation branch (no rename): must not collide with an existing
if new_path is not None and new_path != path: # non-file (e.g. a directory at the same path).
try: if (new_path is None or new_path == path) \
src_path, dst_path = safe_resolve_for_move(overlay.path, path, new_path) and write_target.exists() and not _is_existing_file(write_target):
except ValueError as exc:
return Response(str(exc), status=422)
if dst_path.exists():
return Response("destination already exists", status=409)
dst_path.parent.mkdir(parents=True, exist_ok=True)
os.rename(src_path, dst_path)
write_target = dst_path
else:
write_target.parent.mkdir(parents=True, exist_ok=True)
# Creation branch: must not collide with an existing path.
if write_target.exists() and not _is_existing_file(write_target):
return Response("destination is not a file", status=409) return Response("destination is not a file", status=409)
try: try:
@ -335,11 +410,7 @@ def overlay_file_save(overlay_id: int):
except OSError as exc: except OSError as exc:
return Response(f"write failed: {exc}", status=500) return Response(f"write failed: {exc}", status=500)
return jsonify({"path": new_path or path}) return jsonify({"path": echo_path})
def _is_existing_file(path) -> bool:
return path.is_file() and not path.is_symlink()
@bp.post("/overlays/<int:overlay_id>/files/replace") @bp.post("/overlays/<int:overlay_id>/files/replace")
@ -361,24 +432,10 @@ def overlay_file_replace(overlay_id: int):
return result return result
overlay = result overlay = result
if new_path and new_path != path: rename_result = _apply_optional_rename(overlay, path, new_path)
try: if isinstance(rename_result, Response):
src_path, dst_path = safe_resolve_for_move(overlay.path, path, new_path) return rename_result
except ValueError as exc: write_target, echo_path = rename_result
return Response(str(exc), status=422)
if dst_path.exists():
return Response("destination already exists", status=409)
dst_path.parent.mkdir(parents=True, exist_ok=True)
os.rename(src_path, dst_path)
write_target = dst_path
echo_path = new_path
else:
try:
write_target = safe_resolve_for_write(overlay.path, path)
except ValueError as exc:
return Response(str(exc), status=422)
write_target.parent.mkdir(parents=True, exist_ok=True)
echo_path = path
return _stream_upload_into(upload, write_target, echo_path) return _stream_upload_into(upload, write_target, echo_path)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,247 @@
// files-overlay/core.js — Phase A scaffold (step 1/12).
//
// First module of the multi-file rewrite of files-overlay.js. Provides:
// * Manager-element detection (.files-manager guard, same as legacy).
// * Helpers (joinPath, parentOf, basename, escapeHtml, humanSize,
// fetchJson, postJson, postForm, refreshFolder, findRowByPath,
// cssEscape, scheduleRefresh) — duplicated from the legacy file
// for the duration of Phase A. They de-duplicate in steps 24 as
// features migrate out and the legacy file shrinks.
// * window.__filesOverlay registry: feature modules call
// registerHandler(op, fn); a document-level click listener here
// dispatches matching actions via handleAction.
//
// Until Steps 24 populate the registry, the click listener here is a
// no-op — the legacy files-overlay.js still owns dispatch via its own
// switch-case at the same selector. Both listeners fire on every click
// during the transition; the new one matches and dispatches into an
// empty registry, the old one runs the actual handler. Per-op
// migration removes each case from the legacy switch as the
// corresponding feature module registers its handler.
(function () {
"use strict";
const manager = document.querySelector(".files-manager");
if (!manager) return;
const overlayId = manager.dataset.overlayId;
const baseUrl = manager.dataset.baseUrl; // /overlays/<id>
const treeRoot = manager.querySelector(".files-tree-root");
const csrfToken =
document.querySelector("meta[name='csrf-token']")?.getAttribute("content") || "";
// ---------- helpers ----------
function joinPath(folder, leaf) {
folder = (folder || "").replace(/^\/+|\/+$/g, "");
leaf = (leaf || "").replace(/^\/+|\/+$/g, "");
if (folder && leaf) return folder + "/" + leaf;
return folder || leaf;
}
function parentOf(rel) {
const i = (rel || "").lastIndexOf("/");
return i < 0 ? "" : rel.slice(0, i);
}
function basename(rel) {
const i = (rel || "").lastIndexOf("/");
return i < 0 ? rel : rel.slice(i + 1);
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[c]);
}
function humanSize(bytes) {
if (bytes === undefined || bytes === null) return "";
if (bytes < 1024) return bytes + " B";
const units = ["KB", "MB", "GB", "TB"];
let v = bytes / 1024;
let u = "KB";
for (const next of units) {
u = next;
if (v < 1024) break;
v /= 1024;
}
return v.toFixed(1) + " " + u;
}
async function fetchJson(url, options) {
options = options || {};
options.headers = Object.assign({ Accept: "application/json" }, options.headers || {});
if (options.method && options.method !== "GET") {
options.headers["X-CSRF-Token"] = csrfToken;
}
options.credentials = "same-origin";
const response = await fetch(url, options);
let body = null;
let rawText = "";
try {
rawText = await response.text();
body = rawText ? JSON.parse(rawText) : null;
} catch (_e) {
body = null;
}
return { ok: response.ok, status: response.status, body, rawText };
}
async function postJson(url, payload) {
return fetchJson(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
async function postForm(url, formData) {
return fetchJson(url, { method: "POST", body: formData });
}
// ---------- tree refresh ----------
// Re-fetch a folder's listing partial and swap it into the tree.
// `path === ""` refreshes the overlay root container.
async function refreshFolder(path) {
if (!treeRoot) return;
const url = `${baseUrl}/files?path=${encodeURIComponent(path || "")}`;
let html;
try {
const r = await fetch(url, {
headers: { Accept: "text/html" },
credentials: "same-origin",
});
if (!r.ok) {
if (r.status === 404) {
// Folder no longer exists — refresh its parent instead.
if (path) await refreshFolder(parentOf(path));
return;
}
return;
}
html = await r.text();
} catch (_e) {
return;
}
if (!path) {
// Overlay root — swap into the synthetic root row's children div.
const target = manager.querySelector(".files-root-children");
if (!target) return;
const empty = target.querySelector(".files-empty");
if (empty) empty.remove();
const existingUl = target.querySelector(":scope > ul.file-tree");
if (existingUl) existingUl.remove();
target.insertAdjacentHTML("beforeend", html);
const newUl = target.querySelector(":scope > ul.file-tree");
if (newUl && newUl.children.length === 0) {
newUl.remove();
const p = document.createElement("p");
p.className = "muted files-empty";
p.textContent = 'Empty — drop files here, or click "+ new file" on this row.';
target.appendChild(p);
}
return;
}
// Sub-folder: find the matching folder row and swap its children div.
const row = findRowByPath(path, "dir");
if (!row) return;
const childrenDiv = row.querySelector(":scope > .file-tree-children");
const toggleBtn = row.querySelector(":scope > .file-tree-toggle");
if (!childrenDiv || !toggleBtn) return;
childrenDiv.innerHTML = html;
childrenDiv.hidden = false;
toggleBtn.setAttribute("aria-expanded", "true");
toggleBtn.dataset.loaded = "1";
}
function findRowByPath(path, kind) {
const sel = kind
? `[data-target-path="${cssEscape(path)}"][data-row-kind="${kind}"]`
: `[data-target-path="${cssEscape(path)}"]`;
return treeRoot ? treeRoot.querySelector(sel) : null;
}
function cssEscape(s) {
if (window.CSS && window.CSS.escape) return window.CSS.escape(s);
return String(s).replace(/["\\]/g, "\\$&");
}
// Debounce per-folder refreshes so a flurry of finishes coalesces.
const pendingRefresh = new Map();
function scheduleRefresh(path) {
const key = path || "";
if (pendingRefresh.has(key)) return;
const timer = setTimeout(() => {
pendingRefresh.delete(key);
refreshFolder(key);
}, 50);
pendingRefresh.set(key, timer);
}
// ---------- action-dispatch registry ----------
const handlers = new Map();
function registerHandler(op, fn) {
handlers.set(op, fn);
}
function handleAction(op, path, actionEl) {
const fn = handlers.get(op);
if (typeof fn === "function") fn(path, actionEl);
}
// ---------- action click delegation ----------
//
// Document-level delegation mirroring the legacy switch-case in
// files-overlay.js. While the registry is empty (Step 1 state), this
// is a no-op — the legacy file still owns dispatch. Steps 24
// migrate one op at a time: feature module registers its handler
// here, then the matching case is deleted from the legacy file.
document.addEventListener("click", (event) => {
const action = event.target?.closest?.(
".files-row-action[data-action], .file-tree-name-button[data-action]"
);
if (!action) return;
if (!manager.contains(action)) return;
const op = action.dataset.action;
const path = action.dataset.targetPath || "";
handleAction(op, path, action);
});
// ---------- public ----------
window.__filesOverlay = {
manager,
overlayId,
baseUrl,
treeRoot,
csrfToken,
helpers: {
joinPath,
parentOf,
basename,
escapeHtml,
humanSize,
fetchJson,
postJson,
postForm,
refreshFolder,
findRowByPath,
cssEscape,
scheduleRefresh,
},
registerHandler,
handleAction,
};
})();

View file

@ -0,0 +1,212 @@
// files-overlay/dialogs.js — Phase A, Step 3.
//
// Owns the three inline dialogs that surround the file manager:
// * #files-new-folder-modal — "+ folder" / mkdir prompt
// * #files-delete-modal — delete-confirm for files and folders
// * #files-conflict-modal — overwrite / keep-both / cancel choice
// shown when save / upload / move hits 409
//
// Dispatch:
// * "new-folder" registered into __filesOverlay → openNewFolder(path)
// * "delete" registered into __filesOverlay → openDelete(path,
// kind, name) — kind + name read from actionEl
// dataset.
//
// askConflict(path) → Promise<"overwrite" | "keep-both" | "cancel">
// is exposed on __filesOverlay so legacy upload / drag-drop callers in
// files-overlay.js (lines runUpload at 452 and the drop handler at
// 569) can still reach it after the function moved out of their file.
// editor.js's save 409-conflict path also goes through this same
// exposure.
//
// All three dialogs use a single delegated click listener per dialog,
// reading per-dialog state from module-scope. The clone-and-rebind
// pattern from the legacy file (replaceWith(cloneNode(true)) to drop
// stale listeners) is gone — the listener is attached once and reads
// freshly-set state each time the dialog opens.
//
// Dialogs open through window.modals.openInline / closeInline (the
// consolidated modal API at static/js/modals.js) rather than calling
// dialog.showModal() / close() directly, completing the inline-modal
// convention from commit c51089d.
(function () {
"use strict";
const fo = window.__filesOverlay;
if (!fo) return;
const { baseUrl, csrfToken } = fo;
const { joinPath, parentOf, basename, postJson, postForm, scheduleRefresh } = fo.helpers;
const newFolderDialog = document.getElementById("files-new-folder-modal");
const deleteDialog = document.getElementById("files-delete-modal");
const conflictDialog = document.getElementById("files-conflict-modal");
function openModal(dialog) {
if (window.modals && typeof window.modals.openInline === "function") {
window.modals.openInline(dialog);
} else {
dialog.showModal();
}
}
function closeModal(dialog) {
if (window.modals && typeof window.modals.closeInline === "function") {
window.modals.closeInline(dialog);
} else {
dialog.close();
}
}
// ---------- conflict modal ----------------------------------------------
// While a conflict dialog is open, conflictState.resolve is the
// pending Promise resolver. The delegated click handler calls it and
// nulls it so subsequent clicks don't double-resolve.
let conflictState = null;
function askConflict(path) {
return new Promise((resolve) => {
if (!conflictDialog) {
resolve("cancel");
return;
}
conflictDialog.querySelector(".files-conflict-path").textContent = path;
conflictState = { resolve };
openModal(conflictDialog);
});
}
fo.askConflict = askConflict;
if (conflictDialog) {
conflictDialog.addEventListener("click", (event) => {
const action = event.target?.dataset?.filesConflictAction;
if (!action) return;
const state = conflictState;
conflictState = null;
closeModal(conflictDialog);
if (state && typeof state.resolve === "function") {
state.resolve(action);
}
});
// If the user dismisses the dialog any other way (Esc, backdrop,
// programmatic close), resolve as "cancel" so callers don't hang.
conflictDialog.addEventListener("close", () => {
const state = conflictState;
conflictState = null;
if (state && typeof state.resolve === "function") {
state.resolve("cancel");
}
});
}
// ---------- delete-confirm modal ----------------------------------------
let deleteState = null;
function openDelete(targetPath, kind, name) {
if (!deleteDialog) return;
deleteDialog.querySelector(".files-delete-name").textContent = name;
const errEl = deleteDialog.querySelector(".files-delete-error");
errEl.hidden = true;
errEl.textContent = "";
deleteState = { path: targetPath, kind, errEl };
openModal(deleteDialog);
}
if (deleteDialog) {
deleteDialog.addEventListener("click", async (event) => {
const btn = event.target?.closest?.(".files-delete-confirm");
if (!btn) return;
if (!deleteState) return;
const { path, errEl } = deleteState;
const fd = new FormData();
fd.append("path", path);
fd.append("csrf_token", csrfToken);
const r = await postForm(`${baseUrl}/files/delete`, fd);
if (r.ok) {
deleteState = null;
closeModal(deleteDialog);
scheduleRefresh(parentOf(path));
} else {
errEl.hidden = false;
errEl.textContent =
(r.body && r.body.error) || `Delete failed (HTTP ${r.status}).`;
}
});
deleteDialog.addEventListener("close", () => {
deleteState = null;
});
}
// ---------- new-folder modal --------------------------------------------
let newFolderState = null;
function openNewFolder(targetFolder) {
if (!newFolderDialog) return;
const folder = targetFolder || "";
newFolderDialog.querySelector(".files-new-folder-target").textContent =
folder ? folder + "/" : "/";
const input = newFolderDialog.querySelector(".files-new-folder-name");
const errEl = newFolderDialog.querySelector(".files-new-folder-error");
input.value = "";
errEl.hidden = true;
errEl.textContent = "";
newFolderState = { folder, input, errEl };
openModal(newFolderDialog);
setTimeout(() => input.focus(), 0);
}
async function newFolderSubmit() {
if (!newFolderState) return;
const { folder, input, errEl } = newFolderState;
const name = input.value.trim().replace(/^\/+|\/+$/g, "");
if (!name) return;
const fullPath = joinPath(folder, name);
const r = await postJson(`${baseUrl}/files/mkdir`, { path: fullPath });
if (r.ok) {
newFolderState = null;
closeModal(newFolderDialog);
scheduleRefresh(folder);
} else {
errEl.hidden = false;
errEl.textContent =
(r.body && r.body.error) ||
(r.status === 409
? "A file or folder with that name already exists."
: `Failed (HTTP ${r.status}).`);
}
}
if (newFolderDialog) {
newFolderDialog.addEventListener("click", (event) => {
if (event.target?.closest?.(".files-new-folder-create")) {
newFolderSubmit();
}
});
// Enter inside the name input submits. Direct-bound to the
// persistent input — high-frequency keydown, no benefit from
// delegation.
newFolderDialog
.querySelector(".files-new-folder-name")
.addEventListener("keydown", (event) => {
if (event.key !== "Enter") return;
event.preventDefault();
newFolderSubmit();
});
newFolderDialog.addEventListener("close", () => {
newFolderState = null;
});
}
// ---------- register action-registry handlers ----------
fo.registerHandler("new-folder", (path) => openNewFolder(path));
fo.registerHandler("delete", (path, actionEl) => {
const kind = actionEl?.dataset?.rowKind;
const name = actionEl?.dataset?.rowName || basename(path);
openDelete(path, kind, name);
});
})();

View file

@ -0,0 +1,309 @@
// files-overlay/editor.js — URL-addressable editor only (post-Step 9).
//
// After Phase B Step 9 deleted the legacy <dialog id="files-editor-modal">,
// this module is single-purpose: it drives save / delete / replace /
// new-file flows through the URL-addressable modal swapped into
// #modal-content. The server template overlay_file_editor.html picks
// one of three modes based on the route + the target file:
// * is_new (GET /overlays/<id>/files/new?at=<folder>)
// * text edit (GET /overlays/<id>/files/edit?path=<rel>, editable)
// * binary-replace (GET /overlays/<id>/files/edit?path=<rel>, !editable)
//
// Dispatch (registered into __filesOverlay):
// * "new-file" → opens the new-file route via window.modals.openRouted
// * "edit" → opens the edit route via window.modals.openRouted;
// the server picks the template branch
//
// Click delegation on .files-editor-save branches on which panel is in
// #modal-content — text textarea → routedSaveClicked, binary panel →
// routedReplaceClicked. Delete and replace-zone clicks have a single
// path each.
//
// All listeners are document-level delegated. #modal-content is
// swapped on every modal open, so direct binding wouldn't survive a
// re-open. Module-scope routedReplacement holds the queued File for
// binary-replace mode; cleared on modal-container close so reopening
// starts fresh.
(function () {
"use strict";
const fo = window.__filesOverlay;
if (!fo) return;
const { overlayId, baseUrl, csrfToken } = fo;
const {
parentOf, humanSize,
postJson, postForm, scheduleRefresh,
} = fo.helpers;
// ---------- routed binary-replace state ----------
let routedReplacement = null;
function inRoutedEditor(el) {
const mc = document.getElementById("modal-content");
return !!mc && mc.contains(el);
}
function isRoutedBinaryMode(modalContent) {
return !!modalContent?.querySelector(".files-editor-binary");
}
function setRoutedReplacement(modalContent, file) {
routedReplacement = file;
const idle = modalContent.querySelector(".files-editor-replace-idle");
const queued = modalContent.querySelector(".files-editor-replace-queued");
if (file) {
if (idle) idle.hidden = true;
if (queued) queued.hidden = false;
const name = modalContent.querySelector(".files-editor-replace-name");
const size = modalContent.querySelector(".files-editor-replace-size");
if (name) name.textContent = file.name;
if (size) size.textContent = humanSize(file.size);
} else {
if (idle) idle.hidden = false;
if (queued) queued.hidden = true;
}
updateRoutedBinarySaveEnabled(modalContent);
}
// Enables Save (labeled "Replace" in binary mode) when either a file
// is queued OR the filename input has been edited. Rename-only is a
// valid Replace and routes to /files/move below.
function updateRoutedBinarySaveEnabled(modalContent) {
const panel = modalContent.querySelector(".files-editor-binary[data-rel-path]");
const saveBtn = modalContent.querySelector(".files-editor-save");
if (!panel || !saveBtn) return;
const relPath = panel.dataset.relPath || "";
const originalLeaf = relPath.split("/").pop() || relPath;
const filenameInput = modalContent.querySelector("[data-editor-filename]");
const filenameChanged =
filenameInput && filenameInput.value.trim() &&
filenameInput.value.trim() !== originalLeaf;
saveBtn.disabled = !routedReplacement && !filenameChanged;
}
// ---------- delegated handlers (#modal-content only) ----------
document.addEventListener("dragover", (event) => {
const zone = event.target?.closest?.(".files-editor-replace-zone");
if (!zone || !inRoutedEditor(zone)) return;
if (Array.from(event.dataTransfer.types).includes("Files")) {
event.preventDefault();
zone.classList.add("is-drop-target");
}
});
document.addEventListener("dragleave", (event) => {
const zone = event.target?.closest?.(".files-editor-replace-zone");
if (!zone || !inRoutedEditor(zone)) return;
zone.classList.remove("is-drop-target");
});
document.addEventListener("drop", (event) => {
const zone = event.target?.closest?.(".files-editor-replace-zone");
if (!zone || !inRoutedEditor(zone)) return;
if (!Array.from(event.dataTransfer.types).includes("Files")) return;
event.preventDefault();
zone.classList.remove("is-drop-target");
const f = event.dataTransfer.files && event.dataTransfer.files[0];
if (f) setRoutedReplacement(document.getElementById("modal-content"), f);
});
document.addEventListener("change", (event) => {
const input = event.target?.closest?.(".files-editor-replace-input");
if (!input || !inRoutedEditor(input)) return;
const f = input.files && input.files[0];
if (f) setRoutedReplacement(document.getElementById("modal-content"), f);
});
// Filename input: re-evaluate Save enablement in routed binary mode
// so rename-only Replace becomes reachable without queueing a file.
document.addEventListener("input", (event) => {
const input = event.target?.closest?.("[data-editor-filename]");
if (!input || !inRoutedEditor(input)) return;
const mc = document.getElementById("modal-content");
if (mc && isRoutedBinaryMode(mc)) updateRoutedBinarySaveEnabled(mc);
});
document.addEventListener("click", async (event) => {
const target = event.target;
if (!target) return;
const clearBtn = target.closest?.(".files-editor-replace-clear");
if (clearBtn && inRoutedEditor(clearBtn)) {
setRoutedReplacement(document.getElementById("modal-content"), null);
return;
}
const browseBtn = target.closest?.(".files-editor-replace-browse");
if (browseBtn && inRoutedEditor(browseBtn)) {
const mc = document.getElementById("modal-content");
const fileInput = mc?.querySelector(".files-editor-replace-input");
fileInput?.click();
return;
}
const saveBtn = target.closest?.(".files-editor-save");
if (saveBtn && inRoutedEditor(saveBtn)) {
const mc = document.getElementById("modal-content");
if (isRoutedBinaryMode(mc)) {
await routedReplaceClicked();
} else {
await routedSaveClicked();
}
return;
}
const delBtn = target.closest?.(".files-editor-delete");
if (delBtn && inRoutedEditor(delBtn)) {
await routedDeleteClicked();
return;
}
});
// Clear queued replacement when the modal closes (Esc, backdrop,
// dismiss button, programmatic closeRouted). Otherwise reopening
// would inherit a stale queued File from the prior session.
document.getElementById("modal-container")?.addEventListener("close", () => {
routedReplacement = null;
});
// ---------- save / replace / delete ----------
async function routedSaveClicked() {
const modalContent = document.getElementById("modal-content");
if (!modalContent) return;
const ta = modalContent.querySelector("textarea[data-rel-path]");
if (!ta) return;
const relPath = ta.dataset.relPath;
const filenameInput = modalContent.querySelector("[data-editor-filename]");
const editedFilename = filenameInput ? filenameInput.value.trim() : "";
const content = (window.__filesEditor && window.__filesEditor.getValue)
? window.__filesEditor.getValue()
: ta.value;
// is_new mode: relPath is empty; compose path from data-at-folder
// + filename. The /save endpoint creates the file when missing.
if (!relPath) {
if (!editedFilename) return;
const atFolder = ta.dataset.atFolder || "";
const fullPath = atFolder
? `${atFolder.replace(/\/+$/, "")}/${editedFilename}`
: editedFilename;
const r = await postJson(`${baseUrl}/files/save`, { path: fullPath, content });
if (r.ok) {
if (typeof window.modals?.closeRouted === "function") window.modals.closeRouted();
scheduleRefresh(parentOf(fullPath));
} else if (r.status === 409) {
alert(r.rawText || `A file at ${fullPath} already exists. Pick a different name.`);
} else {
alert((r.body && r.body.error) || r.rawText || `Create failed (HTTP ${r.status}).`);
}
return;
}
// Edit mode: rename-on-save (sibling rename only — parent stays).
const originalLeaf = relPath.split("/").pop() || relPath;
const parent = relPath.includes("/") ? relPath.slice(0, relPath.lastIndexOf("/")) : "";
let newPath = null;
if (editedFilename && editedFilename !== originalLeaf) {
newPath = parent ? `${parent}/${editedFilename}` : editedFilename;
}
const payload = { path: relPath, content };
if (newPath) payload.new_path = newPath;
const r = await postJson(`${baseUrl}/files/save`, payload);
if (r.ok) {
if (typeof window.modals?.closeRouted === "function") window.modals.closeRouted();
scheduleRefresh(parentOf(newPath || relPath));
} else if (r.status === 409) {
alert(r.rawText || `Conflict: destination already exists.`);
} else {
alert((r.body && r.body.error) || r.rawText || `Save failed (HTTP ${r.status}).`);
}
}
async function routedReplaceClicked() {
const modalContent = document.getElementById("modal-content");
if (!modalContent) return;
const panel = modalContent.querySelector(".files-editor-binary[data-rel-path]");
if (!panel) return;
const relPath = panel.dataset.relPath;
if (!relPath) return;
const filenameInput = modalContent.querySelector("[data-editor-filename]");
const editedFilename = filenameInput ? filenameInput.value.trim() : "";
const originalLeaf = relPath.split("/").pop() || relPath;
const parent = relPath.includes("/") ? relPath.slice(0, relPath.lastIndexOf("/")) : "";
const renaming = !!editedFilename && editedFilename !== originalLeaf;
const newPath = renaming
? (parent ? `${parent}/${editedFilename}` : editedFilename)
: null;
if (routedReplacement) {
const fd = new FormData();
fd.append("path", relPath);
fd.append("csrf_token", csrfToken);
if (newPath) fd.append("new_path", newPath);
fd.append("file", routedReplacement);
const r = await postForm(`${baseUrl}/files/replace`, fd);
if (r.ok) {
routedReplacement = null;
if (typeof window.modals?.closeRouted === "function") window.modals.closeRouted();
scheduleRefresh(parentOf(newPath || relPath));
} else {
alert((r.body && r.body.error) || `Replace failed (HTTP ${r.status}).`);
}
return;
}
if (renaming) {
const r = await postJson(`${baseUrl}/files/move`, { src: relPath, dst: newPath });
if (r.ok) {
if (typeof window.modals?.closeRouted === "function") window.modals.closeRouted();
scheduleRefresh(parentOf(newPath));
} else {
alert((r.body && r.body.error) || `Rename failed (HTTP ${r.status}).`);
}
}
}
async function routedDeleteClicked() {
const modalContent = document.getElementById("modal-content");
if (!modalContent) return;
// rel-path lives on the text-mode textarea OR on the binary panel.
const ta = modalContent.querySelector("textarea[data-rel-path]");
const panel = modalContent.querySelector(".files-editor-binary[data-rel-path]");
const relPath = ta?.dataset?.relPath || panel?.dataset?.relPath;
if (!relPath) return;
if (!confirm(`Delete ${relPath}?`)) return;
const fd = new FormData();
fd.append("path", relPath);
fd.append("csrf_token", csrfToken);
const r = await postForm(`${baseUrl}/files/delete`, fd);
if (r.ok) {
if (typeof window.modals?.closeRouted === "function") window.modals.closeRouted();
scheduleRefresh(parentOf(relPath));
} else {
alert((r.body && r.body.error) || `Delete failed (HTTP ${r.status}).`);
}
}
// ---------- register action-registry handlers ----------
fo.registerHandler("new-file", (path) => {
const url = `/overlays/${overlayId}/files/new?at=${encodeURIComponent(path)}`;
if (typeof window.modals?.openRouted === "function") {
window.modals.openRouted(url);
} else {
window.location.href = url;
}
});
fo.registerHandler("edit", (path, _actionEl) => {
// Both text and binary files use the same edit route — the server
// picks the template branch based on is_editable(target).
const url = `/overlays/${overlayId}/files/edit?path=${encodeURIComponent(path)}`;
if (typeof window.modals?.openRouted === "function") {
window.modals.openRouted(url);
} else {
window.location.href = url;
}
});
})();

View file

@ -0,0 +1,423 @@
// files-overlay/uploads.js — Phase A, Step 4.
//
// Owns the upload queue (concurrency 3 with XHR-based progress events)
// and the drag-drop UX on the file tree. Both OS-file drops (external)
// and row-to-folder moves (internal) flow through the same drop
// handler on treeRoot.
//
// Drag-drop is deliberately direct-bound to treeRoot (5 events:
// dragstart, dragend, dragover, dragleave, drop) per the plan escape
// hatch. They share coordinated highlight state across events, and
// delegation would obscure that coordination. treeRoot is persistent
// — never swapped out — so direct binding is safe.
//
// Upload-row cancel buttons converted from direct-bound per row to a
// single document-level delegated listener that finds the upload via
// data-upload-id on the row. uploads Map keyed by that id.
//
// Dispatch:
// * "zip" registered into __filesOverlay → folder-zip download
// (pure URL navigation). This is the last remaining action — with
// Step 4 the legacy click-delegation switch goes away.
//
// withCollisionSuffix moves here from files-overlay.js (its biggest
// caller is the upload-conflict path). Exposed on __filesOverlay for
// editor.js's save-409 "keep both" branch.
(function () {
"use strict";
const fo = window.__filesOverlay;
if (!fo) return;
const { baseUrl, csrfToken, manager, treeRoot } = fo;
const { joinPath, parentOf, basename, postJson, scheduleRefresh } = fo.helpers;
const uploadsPanel = document.querySelector(".files-uploads");
const uploadsList = uploadsPanel?.querySelector(".files-uploads-list");
const uploadsClearBtn = uploadsPanel?.querySelector(".files-uploads-clear");
// Attach a path-collision suffix: foo.txt → foo (1).txt
function withCollisionSuffix(path) {
const dot = path.lastIndexOf(".");
const slash = path.lastIndexOf("/");
if (dot > slash + 0 && dot > -1) {
return path.slice(0, dot) + " (1)" + path.slice(dot);
}
return path + " (1)";
}
fo.withCollisionSuffix = withCollisionSuffix;
// ---------- upload queue + progress panel ----------
const uploadQueue = [];
let uploadActive = 0;
const UPLOAD_CONCURRENCY = 3;
// Map<uploadId, item> — used by the delegated cancel-button click
// handler to find the item from the row's data-upload-id attribute.
const uploads = new Map();
let nextUploadId = 0;
function showPanel() {
if (uploadsPanel) uploadsPanel.hidden = false;
}
function maybeHidePanel() {
if (!uploadsList) return;
const anyActive = uploadsList.querySelector("[data-state='active'], [data-state='queued']");
if (!anyActive && uploadsList.children.length === 0) {
uploadsPanel.hidden = true;
if (uploadsClearBtn) uploadsClearBtn.hidden = true;
}
const anyDone = uploadsList.querySelector("[data-state='done'], [data-state='error']");
if (uploadsClearBtn) uploadsClearBtn.hidden = !anyDone;
}
function buildUploadRow(item) {
const li = document.createElement("li");
li.className = "files-uploads-row";
li.dataset.state = "queued";
li.dataset.uploadId = item.id;
li.innerHTML = `
<div class="files-uploads-row-meta">
<span class="files-uploads-row-name"></span>
<span class="files-uploads-row-target muted"></span>
</div>
<div class="files-uploads-row-progress"><div class="files-uploads-bar"></div></div>
<div class="files-uploads-row-status">
<span class="files-uploads-row-state">queued</span>
<button type="button" class="link-button files-uploads-row-cancel" aria-label="Cancel"></button>
</div>
`;
li.querySelector(".files-uploads-row-name").textContent = item.relative;
li.querySelector(".files-uploads-row-target").textContent = `${item.targetFolder || "(root)"}`;
return li;
}
function setRowState(item, state, percent) {
if (!item.row) return;
item.row.dataset.state = state;
const stateEl = item.row.querySelector(".files-uploads-row-state");
const cancelBtn = item.row.querySelector(".files-uploads-row-cancel");
const bar = item.row.querySelector(".files-uploads-bar");
if (state === "queued") {
stateEl.textContent = "queued";
bar.style.width = "0%";
} else if (state === "active") {
stateEl.textContent = `${Math.round(percent || 0)}%`;
bar.style.width = `${percent || 0}%`;
} else if (state === "done") {
stateEl.textContent = "done";
bar.style.width = "100%";
bar.classList.add("is-done");
cancelBtn.textContent = "✓";
cancelBtn.disabled = true;
} else if (state === "cancelled") {
stateEl.textContent = "cancelled";
bar.classList.add("is-cancelled");
cancelBtn.disabled = true;
} else if (state === "error") {
stateEl.textContent = item.errorText || "error";
bar.classList.add("is-error");
cancelBtn.textContent = "✕";
} else if (state === "conflict") {
stateEl.textContent = "conflict — overwrite / keep both";
}
}
function cancelUpload(item) {
if (item.xhr && item.row.dataset.state === "active") {
item.cancelled = true;
item.xhr.abort();
}
if (item.row.dataset.state === "queued") {
const idx = uploadQueue.indexOf(item);
if (idx >= 0) uploadQueue.splice(idx, 1);
setRowState(item, "cancelled");
}
}
// Delegated click handler for upload-row cancel buttons. Replaces
// the per-row direct-bound listener that the legacy code attached
// inside buildUploadRow. Reads the row's data-upload-id and looks up
// the upload in the Map — works for rows added at any later time.
document.addEventListener("click", (event) => {
const btn = event.target?.closest?.(".files-uploads-row-cancel");
if (!btn) return;
const row = btn.closest(".files-uploads-row");
if (!row || !uploadsList || !uploadsList.contains(row)) return;
const id = row.dataset.uploadId;
if (!id) return;
const item = uploads.get(id);
if (item) cancelUpload(item);
});
uploadsClearBtn?.addEventListener("click", () => {
uploadsList.querySelectorAll("[data-state='done'], [data-state='error'], [data-state='cancelled']").forEach((row) => {
const id = row.dataset.uploadId;
if (id) uploads.delete(id);
row.remove();
});
maybeHidePanel();
});
function enqueueUpload(file, targetFolder, relativePath) {
const id = String(nextUploadId++);
const item = {
id,
file,
targetFolder,
relative: relativePath || file.name,
cancelled: false,
xhr: null,
errorText: null,
};
item.row = buildUploadRow(item);
uploads.set(id, item);
uploadsList.prepend(item.row);
uploadQueue.push(item);
showPanel();
pump();
return item;
}
function pump() {
while (uploadActive < UPLOAD_CONCURRENCY && uploadQueue.length) {
const item = uploadQueue.shift();
runUpload(item);
}
}
function runUpload(item, overwriteFlag) {
uploadActive += 1;
const fd = new FormData();
fd.append("target_path", item.targetFolder || "");
fd.append("relative_path", item.relative);
fd.append("csrf_token", csrfToken);
if (overwriteFlag) fd.append("overwrite", "1");
fd.append("file", item.file, item.file.name);
const xhr = new XMLHttpRequest();
item.xhr = xhr;
xhr.open("POST", `${baseUrl}/files/upload`);
xhr.setRequestHeader("X-CSRF-Token", csrfToken);
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const pct = (event.loaded / event.total) * 100;
setRowState(item, "active", pct);
}
};
setRowState(item, "active", 0);
xhr.onload = () => {
uploadActive -= 1;
if (xhr.status >= 200 && xhr.status < 300) {
setRowState(item, "done");
scheduleRefresh(joinPath(item.targetFolder || "", parentOf(item.relative)));
} else if (xhr.status === 409 && !overwriteFlag) {
setRowState(item, "conflict");
const collidingPath = joinPath(item.targetFolder || "", item.relative);
fo.askConflict(collidingPath).then((action) => {
if (action === "overwrite") {
runUpload(item, true);
} else if (action === "keep-both") {
item.relative = withCollisionSuffix(item.relative);
item.row.querySelector(".files-uploads-row-name").textContent = item.relative;
runUpload(item, false);
} else {
setRowState(item, "cancelled");
}
});
} else {
item.errorText = `HTTP ${xhr.status}`;
try {
const text = xhr.responseText;
if (text) item.errorText = `HTTP ${xhr.status}: ${text.slice(0, 80)}`;
} catch (_e) {}
setRowState(item, "error");
}
maybeHidePanel();
pump();
};
xhr.onerror = () => {
uploadActive -= 1;
item.errorText = item.cancelled ? "cancelled" : "network error";
setRowState(item, item.cancelled ? "cancelled" : "error");
maybeHidePanel();
pump();
};
xhr.onabort = () => {
uploadActive -= 1;
setRowState(item, "cancelled");
maybeHidePanel();
pump();
};
xhr.send(fd);
}
// ---------- drag-drop on tree rows ----------
function rowFromEvent(event) {
return event.target.closest("[data-row-kind]");
}
function isInternalDrag(event) {
return Array.from(event.dataTransfer.types).includes("application/x-files-overlay");
}
function isExternalDrag(event) {
const types = Array.from(event.dataTransfer.types);
return types.includes("Files");
}
// Expose containers (rows AND the empty-state placeholder + tree-root)
// as drop targets — both for OS files and internal moves.
if (treeRoot) {
treeRoot.addEventListener("dragstart", (event) => {
const row = rowFromEvent(event);
if (!row) return;
const path = row.dataset.targetPath;
if (!path) return; // overlay root row isn't draggable
event.dataTransfer.setData("application/x-files-overlay", path);
event.dataTransfer.setData("text/plain", path);
event.dataTransfer.effectAllowed = "move";
row.classList.add("is-drag-source");
});
treeRoot.addEventListener("dragend", () => {
treeRoot.querySelectorAll(".is-drag-source").forEach((el) => el.classList.remove("is-drag-source"));
treeRoot.querySelectorAll(".is-drop-target").forEach((el) => el.classList.remove("is-drop-target"));
});
treeRoot.addEventListener("dragover", (event) => {
// Only react to file or row drags.
if (!isExternalDrag(event) && !isInternalDrag(event)) return;
const row = rowFromEvent(event);
// Find the closest folder row, or the tree-root container itself for
// root-level drops.
const target = row && row.dataset.rowKind === "dir" ? row : null;
const fallbackRoot = !row || row.dataset.rowKind !== "dir" ? treeRoot : null;
const dropEl = target || fallbackRoot;
if (!dropEl) return;
event.preventDefault();
event.dataTransfer.dropEffect = isInternalDrag(event) ? "move" : "copy";
// Highlight the dropEl exclusively.
treeRoot.querySelectorAll(".is-drop-target").forEach((el) => el.classList.remove("is-drop-target"));
dropEl.classList.add("is-drop-target");
});
treeRoot.addEventListener("dragleave", (event) => {
// Leaving the whole tree clears highlights.
if (!treeRoot.contains(event.relatedTarget)) {
treeRoot.querySelectorAll(".is-drop-target").forEach((el) => el.classList.remove("is-drop-target"));
}
});
treeRoot.addEventListener("drop", async (event) => {
const internal = isInternalDrag(event);
const external = isExternalDrag(event);
if (!internal && !external) return;
event.preventDefault();
const row = rowFromEvent(event);
const dropFolder =
row && row.dataset.rowKind === "dir" ? row.dataset.targetPath || "" : "";
treeRoot.querySelectorAll(".is-drop-target").forEach((el) => el.classList.remove("is-drop-target"));
if (internal) {
const src = event.dataTransfer.getData("application/x-files-overlay");
if (!src) return;
const dst = joinPath(dropFolder, basename(src));
if (src === dst) return;
// Cycle guard: refuse moving a folder into itself or descendant.
if (dropFolder === src || dropFolder.startsWith(src + "/")) return;
const r = await postJson(`${baseUrl}/files/move`, { src, dst });
if (r.ok) {
scheduleRefresh(parentOf(src));
if (parentOf(src) !== dropFolder) scheduleRefresh(dropFolder);
} else if (r.status === 409) {
const action = await fo.askConflict(dst);
if (action === "overwrite") {
const r2 = await postJson(`${baseUrl}/files/move`, { src, dst, overwrite: true });
if (r2.ok) {
scheduleRefresh(parentOf(src));
scheduleRefresh(dropFolder);
}
}
} else {
alert((r.body && r.body.error) || `Move failed (HTTP ${r.status}).`);
}
return;
}
// External drop — collect entries via webkitGetAsEntry where it
// returns an Entry (real OS drag with folder support), and fall back
// to getAsFile() for any item whose entry is null (synthetic events,
// browsers without the API, or items that have no folder structure).
const items = event.dataTransfer.items;
const files = [];
const tasks = [];
if (items && items.length) {
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (typeof item.webkitGetAsEntry === "function") {
const entry = item.webkitGetAsEntry();
if (entry) {
tasks.push(walkEntry(entry, "", files));
continue;
}
}
// Fallback: treat as a flat file.
if (typeof item.getAsFile === "function") {
const f = item.getAsFile();
if (f) files.push({ file: f, rel: f.name });
}
}
} else if (event.dataTransfer.files) {
for (let i = 0; i < event.dataTransfer.files.length; i++) {
files.push({ file: event.dataTransfer.files[i], rel: event.dataTransfer.files[i].name });
}
}
await Promise.all(tasks);
for (const f of files) {
enqueueUpload(f.file, dropFolder, f.rel);
}
});
}
function walkEntry(entry, prefix, out) {
return new Promise((resolve) => {
if (entry.isFile) {
entry.file((f) => {
out.push({ file: f, rel: prefix + entry.name });
resolve();
});
} else if (entry.isDirectory) {
const reader = entry.createReader();
const children = [];
const readBatch = () => {
reader.readEntries((batch) => {
if (!batch.length) {
Promise.all(
children.map((c) => walkEntry(c, prefix + entry.name + "/", out))
).then(() => resolve());
return;
}
for (const c of batch) children.push(c);
readBatch();
});
};
readBatch();
} else {
resolve();
}
});
}
// ---------- register action-registry handlers ----------
// The last remaining file-row action — pure URL navigation. Could
// equally live in core.js; placed here since uploads.js is the
// closest thematic home (folder-level action like + new file etc.).
fo.registerHandler("zip", (path) => {
window.location.href = `${baseUrl}/files/download_zip?path=${encodeURIComponent(path)}`;
});
})();

View file

@ -18,7 +18,9 @@
<div class="file-tree-children" hidden></div> <div class="file-tree-children" hidden></div>
</li> </li>
{% else %} {% else %}
<li class="file-tree-row file-tree-row-file{% if files_overlay %} files-row{% endif %}" {% set show_download = download_supported and not entry.broken %}
{% set has_actions = (files_overlay or show_download) and not entry.broken %}
<li class="file-tree-row file-tree-row-file{% if has_actions %} files-row{% endif %}"
{% if files_overlay %}draggable="true" data-target-path="{{ entry.rel }}" data-row-kind="file" data-editable="{{ '1' if entry.editable else '0' }}"{% endif %}> {% if files_overlay %}draggable="true" data-target-path="{{ entry.rel }}" data-row-kind="file" data-editable="{{ '1' if entry.editable else '0' }}"{% endif %}>
{% if entry.broken %} {% if entry.broken %}
<span>{{ entry.name }}</span> <span>{{ entry.name }}</span>
@ -26,18 +28,20 @@
{% else %} {% else %}
{% if files_overlay %} {% if files_overlay %}
<button type="button" class="file-tree-name-button" data-action="edit" data-target-path="{{ entry.rel }}" data-editable="{{ '1' if entry.editable else '0' }}" title="Open in editor">{{ entry.name }}</button> <button type="button" class="file-tree-name-button" data-action="edit" data-target-path="{{ entry.rel }}" data-editable="{{ '1' if entry.editable else '0' }}" title="Open in editor">{{ entry.name }}</button>
{% elif download_supported %}
<a href="{{ files_base_url }}/files/download?path={{ entry.rel|urlencode }}">{{ entry.name }}</a>
{% else %} {% else %}
<span>{{ entry.name }}</span> <span>{{ entry.name }}</span>
{% endif %} {% endif %}
{% if entry.is_symlink %}<span class="file-tree-badge">link</span>{% endif %} {% if entry.is_symlink %}<span class="file-tree-badge">link</span>{% endif %}
<span class="muted">{{ entry.size_human }}</span> <span class="muted">{{ entry.size_human }}</span>
{% endif %} {% endif %}
{% if files_overlay and not entry.broken %} {% if has_actions %}
<span class="files-row-actions" aria-label="File actions"> <span class="files-row-actions" aria-label="File actions">
{% if show_download %}
<a class="files-row-action" href="{{ files_base_url }}/files/download?path={{ entry.rel|urlencode }}" title="Download"></a> <a class="files-row-action" href="{{ files_base_url }}/files/download?path={{ entry.rel|urlencode }}" title="Download"></a>
{% endif %}
{% if files_overlay %}
<button type="button" class="files-row-action files-row-danger" data-action="delete" data-target-path="{{ entry.rel }}" data-row-kind="file" data-row-name="{{ entry.name }}" title="Delete"></button> <button type="button" class="files-row-action files-row-danger" data-action="delete" data-target-path="{{ entry.rel }}" data-row-kind="file" data-row-name="{{ entry.name }}" title="Delete"></button>
{% endif %}
</span> </span>
{% endif %} {% endif %}
</li> </li>

View file

@ -162,68 +162,6 @@
{% endif %} {% endif %}
{% if files_can_edit %} {% if files_can_edit %}
<dialog id="files-editor-modal" class="modal modal-wide" aria-labelledby="files-editor-title">
<div class="modal-header">
<h2 id="files-editor-title" class="files-editor-path"><span class="files-editor-title-text"></span></h2>
<button type="button" class="modal-close" data-inline-modal-close aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<label class="files-editor-field">
<span class="files-field-label">Filename</span>
<input type="text" class="files-editor-filename" data-editor-filename autocomplete="off" spellcheck="false">
</label>
<p class="files-editor-rename-hint" hidden>↻ Save will rename <code class="files-rename-from"></code><code class="files-rename-to"></code>.</p>
<div class="files-editor-text">
<label class="files-editor-field files-editor-language-field">
<span class="files-field-label">Language</span>
<select data-editor-language-select aria-label="Editor language">
<option value="auto">auto (from filename)</option>
<option value="srccfg">srccfg (.cfg)</option>
<option value="bash">bash (.sh)</option>
<option value="plain">plain</option>
</select>
</label>
<label class="files-editor-field">
<span class="files-field-label">Content</span>
<div class="editor-mount" style="--editor-rows: 14"><textarea class="files-editor-content" rows="14" spellcheck="false" data-editor-language="auto"></textarea></div>
</label>
<div class="files-editor-meta muted">
<span class="files-editor-byte-count">UTF-8 · 0 bytes</span>
<span>Ctrl+S to save</span>
</div>
</div>
<div class="files-editor-binary" hidden>
<div class="files-editor-binary-note">
<strong>⛌ Inline editing not available</strong>
· <span class="files-editor-binary-size"></span> · binary content
</div>
<label class="files-field-label files-editor-binary-replace-label">Replace file</label>
<div class="files-editor-replace-zone">
<p class="files-editor-replace-idle">↑ Drop a file here to replace ·
<button type="button" class="link-button files-editor-replace-browse">browse</button> ·
single file only · keeps the filename
</p>
<p class="files-editor-replace-queued" hidden>
<strong class="files-editor-replace-name"></strong> ·
<span class="files-editor-replace-size"></span> ·
<span class="muted">queued</span>
<button type="button" class="link-button files-editor-replace-clear" aria-label="Clear queued replacement"></button>
</p>
<input type="file" class="files-editor-replace-input" hidden>
</div>
</div>
</div>
<div class="modal-footer files-editor-footer">
<button type="button" class="danger-outline files-editor-delete">Delete</button>
<span class="files-editor-footer-spacer"></span>
<a class="button-secondary files-editor-download" href="#" hidden>⬇ Download</a>
<button type="button" class="button-secondary" data-inline-modal-close>Cancel</button>
<button type="button" class="files-editor-save">Save</button>
</div>
</dialog>
<dialog id="files-new-folder-modal" class="modal" aria-labelledby="files-new-folder-title"> <dialog id="files-new-folder-modal" class="modal" aria-labelledby="files-new-folder-title">
<div class="modal-header"> <div class="modal-header">
<h2 id="files-new-folder-title">New folder in <code class="files-new-folder-target"></code></h2> <h2 id="files-new-folder-title">New folder in <code class="files-new-folder-target"></code></h2>
@ -282,6 +220,9 @@
<ul class="files-uploads-list"></ul> <ul class="files-uploads-list"></ul>
</aside> </aside>
<script src="{{ url_for('static', filename='js/files-overlay.js') }}" defer></script> <script src="{{ url_for('static', filename='js/files-overlay/core.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/files-overlay/editor.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/files-overlay/dialogs.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/files-overlay/uploads.js') }}" defer></script>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View file

@ -1,11 +1,11 @@
{% extends base_layout %} {% extends base_layout %}
{% block title %}Edit {{ rel_path }} · {{ overlay.name }}{% endblock %} {% block title %}{% if is_new %}New file{% else %}Edit {{ rel_path }}{% endif %} · {{ overlay.name }}{% endblock %}
{% block extra_head %}{% include "_editor_assets.html" %}{% endblock %} {% block extra_head %}{% include "_editor_assets.html" %}{% endblock %}
{% block content %} {% block content %}
<div id="files-editor-fragment" aria-labelledby="files-editor-title"> <div id="files-editor-fragment" aria-labelledby="files-editor-title">
<div class="modal-header"> <div class="modal-header">
<h2 id="files-editor-title" class="files-editor-path"> <h2 id="files-editor-title" class="files-editor-path">
<span class="files-editor-title-text">{{ rel_path }}</span> <span class="files-editor-title-text">{% if is_new %}{{ at_folder ~ '/' if at_folder else '' }}…new file{% else %}{{ rel_path }}{% endif %}</span>
</h2> </h2>
<button type="button" class="modal-close" data-routed-modal-dismiss aria-label="Close">&times;</button> <button type="button" class="modal-close" data-routed-modal-dismiss aria-label="Close">&times;</button>
</div> </div>
@ -16,6 +16,7 @@
</label> </label>
<p class="files-editor-rename-hint" hidden>↻ Save will rename <code class="files-rename-from"></code><code class="files-rename-to"></code>.</p> <p class="files-editor-rename-hint" hidden>↻ Save will rename <code class="files-rename-from"></code><code class="files-rename-to"></code>.</p>
{% if not is_binary %}
<div class="files-editor-text"> <div class="files-editor-text">
<label class="files-editor-field files-editor-language-field"> <label class="files-editor-field files-editor-language-field">
<span class="files-field-label">Language</span> <span class="files-field-label">Language</span>
@ -28,20 +29,42 @@
</label> </label>
<label class="files-editor-field"> <label class="files-editor-field">
<span class="files-field-label">Content</span> <span class="files-field-label">Content</span>
<div class="editor-mount" style="--editor-rows: 14"><textarea class="files-editor-content" rows="14" spellcheck="false" data-editor-language="auto" data-overlay-id="{{ overlay.id }}" data-rel-path="{{ rel_path }}">{{ content }}</textarea></div> <div class="editor-mount" style="--editor-rows: 14"><textarea class="files-editor-content" rows="14" spellcheck="false" data-editor-language="auto" data-overlay-id="{{ overlay.id }}" data-rel-path="{{ rel_path }}" data-at-folder="{{ at_folder }}">{{ content }}</textarea></div>
</label> </label>
<div class="files-editor-meta muted"> <div class="files-editor-meta muted">
<span class="files-editor-byte-count">UTF-8 · {{ byte_count }} bytes</span> <span class="files-editor-byte-count">UTF-8 · {{ byte_count }} bytes</span>
<span>Ctrl+S to save</span> <span>Ctrl+S to save</span>
</div> </div>
</div> </div>
{% else %}
<div class="files-editor-binary" data-rel-path="{{ rel_path }}">
<div class="files-editor-binary-note">
<strong>⛌ Inline editing not available</strong>
· <span class="files-editor-binary-size">{{ byte_count }} bytes</span> · {{ mime_type }}
</div>
<label class="files-field-label files-editor-binary-replace-label">Replace file</label>
<div class="files-editor-replace-zone">
<p class="files-editor-replace-idle">↑ Drop a file here to replace ·
<button type="button" class="link-button files-editor-replace-browse">browse</button> ·
single file only · keeps the filename
</p>
<p class="files-editor-replace-queued" hidden>
<strong class="files-editor-replace-name"></strong> ·
<span class="files-editor-replace-size"></span> ·
<span class="muted">queued</span>
<button type="button" class="link-button files-editor-replace-clear" aria-label="Clear queued replacement"></button>
</p>
<input type="file" class="files-editor-replace-input" hidden>
</div>
</div>
{% endif %}
</div> </div>
<div class="modal-footer files-editor-footer"> <div class="modal-footer files-editor-footer">
<button type="button" class="danger-outline files-editor-delete">Delete</button> {% if not is_new %}<button type="button" class="danger-outline files-editor-delete">Delete</button>{% endif %}
<span class="files-editor-footer-spacer"></span> <span class="files-editor-footer-spacer"></span>
<a class="button-secondary files-editor-download" href="/overlays/{{ overlay.id }}/files/download?path={{ rel_path|urlencode }}">⬇ Download</a> {% if not is_new %}<a class="button-secondary files-editor-download" href="/overlays/{{ overlay.id }}/files/download?path={{ rel_path|urlencode }}">⬇ Download</a>{% endif %}
<button type="button" class="button-secondary" data-routed-modal-dismiss>Cancel</button> <button type="button" class="button-secondary" data-routed-modal-dismiss>Cancel</button>
<button type="button" class="files-editor-save">Save</button> <button type="button" class="files-editor-save"{% if is_binary %} disabled{% endif %}>{% if is_new %}Create{% elif is_binary %}Replace{% else %}Save{% endif %}</button>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -582,47 +582,6 @@ def _csrf_headers():
return {"X-CSRF-Token": "test-token"} return {"X-CSRF-Token": "test-token"}
# ---- /content -------------------------------------------------------------
def test_content_returns_text(app, left4me_root: Path) -> None:
user_id = _make_user()
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
overlay_dir = left4me_root / "overlays" / str(overlay_id)
(overlay_dir / "motd.txt").write_text("welcome")
client = _client_for(app, user_id)
r = client.get(f"/overlays/{overlay_id}/files/content?path=motd.txt")
assert r.status_code == 200
assert r.get_json() == {"path": "motd.txt", "content": "welcome"}
def test_content_returns_415_for_binary(app, left4me_root: Path) -> None:
user_id = _make_user()
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
overlay_dir = left4me_root / "overlays" / str(overlay_id)
(overlay_dir / "image.bin").write_bytes(b"\x00\x01\x02")
client = _client_for(app, user_id)
r = client.get(f"/overlays/{overlay_id}/files/content?path=image.bin")
assert r.status_code == 415
def test_content_404_for_non_files_overlay(app, left4me_root: Path) -> None:
"""`content` is gated to type=='files' to keep the new editor scoped."""
user_id = _make_user()
overlay_id = _make_overlay(left4me_root, user_id=user_id, name="ws") # type='script' helper
overlay_dir = left4me_root / "overlays" / str(overlay_id)
(overlay_dir / "motd.txt").write_text("hi")
client = _client_for(app, user_id)
r = client.get(f"/overlays/{overlay_id}/files/content?path=motd.txt")
assert r.status_code == 404
# ---- /save --------------------------------------------------------------- # ---- /save ---------------------------------------------------------------
@ -1064,7 +1023,6 @@ def test_mutating_endpoints_404_for_workshop_overlay(app, left4me_root: Path) ->
("post", f"/overlays/{overlay_id}/files/delete", ("post", f"/overlays/{overlay_id}/files/delete",
{"data": {"path": "x", "csrf_token": "test-token"}}), {"data": {"path": "x", "csrf_token": "test-token"}}),
("get", f"/overlays/{overlay_id}/files/download_zip?path=", {}), ("get", f"/overlays/{overlay_id}/files/download_zip?path=", {}),
("get", f"/overlays/{overlay_id}/files/content?path=x.txt", {}),
] ]
for method, url, kwargs in paths: for method, url, kwargs in paths:
kwargs = dict(kwargs) kwargs = dict(kwargs)

View file

@ -95,15 +95,49 @@ def test_edit_route_404s_for_missing_file(tmp_path, monkeypatch):
assert response.status_code == 404 assert response.status_code == 404
def test_edit_route_415s_for_non_editable_file(tmp_path, monkeypatch): def test_edit_route_renders_binary_template_for_non_editable(tmp_path, monkeypatch):
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "edit-415.db") """Phase B Step 7: non-editable files no longer return 415; they render
# Forge a non-editable file by writing binary garbage. the editor template with is_binary=True so the modal shows the
replace-zone UI instead of an error."""
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "edit-binary.db")
from pathlib import Path from pathlib import Path
overlay_root = tmp_path / "overlays" / str(overlay_id) overlay_root = tmp_path / "overlays" / str(overlay_id)
Path(overlay_root).joinpath("blob.bin").write_bytes(b"\x00\x01\x02\x03" * 1024) Path(overlay_root).joinpath("blob.bin").write_bytes(b"\x00\x01\x02\x03" * 1024)
response = client.get(f"/overlays/{overlay_id}/files/edit?path=blob.bin") response = client.get(f"/overlays/{overlay_id}/files/edit?path=blob.bin")
assert response.status_code == 415 text = response.get_data(as_text=True)
assert response.status_code == 200
assert 'class="files-editor-binary"' in text
# Save button is labeled "Replace" and starts disabled (no file queued).
assert ">Replace</button>" in text
assert 'class="files-editor-save" disabled' in text
# Byte count + MIME type rendered.
assert "4096 bytes" in text # 4 bytes × 1024
# The CM6 text editor isn't rendered in binary mode.
assert 'class="files-editor-text"' not in text
def test_binary_template_has_replace_zone(tmp_path, monkeypatch):
"""Spot-check the binary panel's replace-zone markup: drop zone,
idle and queued labels, browse button, and a hidden file input."""
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "edit-binary-zone.db")
from pathlib import Path
overlay_root = tmp_path / "overlays" / str(overlay_id)
Path(overlay_root).joinpath("blob.bin").write_bytes(b"\xff" * 100)
response = client.get(f"/overlays/{overlay_id}/files/edit?path=blob.bin")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert 'class="files-editor-replace-zone"' in text
assert 'class="files-editor-replace-idle"' in text
assert 'class="files-editor-replace-queued"' in text
assert 'class="link-button files-editor-replace-browse"' in text
assert 'class="files-editor-replace-input" hidden' in text
# Download link stays visible in binary mode (it's how the user
# gets the existing file out).
assert "files-editor-download" in text
def test_edit_route_400s_for_path_traversal(tmp_path, monkeypatch): def test_edit_route_400s_for_path_traversal(tmp_path, monkeypatch):
@ -136,3 +170,90 @@ def test_edit_route_404s_for_non_files_overlay(tmp_path, monkeypatch):
response = client.get(f"/overlays/{overlay_id}/files/edit?path=anything.cfg") response = client.get(f"/overlays/{overlay_id}/files/edit?path=anything.cfg")
assert response.status_code == 404 assert response.status_code == 404
# ---------------------------------------------------------------- /files/new
def test_overlay_detail_no_longer_renders_legacy_editor_dialog(tmp_path, monkeypatch):
"""Phase B Step 9: the inline <dialog id="files-editor-modal"> is gone.
All editor flows route through the URL-addressable modal instead."""
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "no-legacy-dialog.db")
response = client.get(f"/overlays/{overlay_id}")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert 'id="files-editor-modal"' not in text
# The other inline dialogs (new-folder, conflict, delete-confirm) are
# still inline — only the editor dialog moved out.
assert 'id="files-new-folder-modal"' in text
def test_new_route_renders_with_empty_content(tmp_path, monkeypatch):
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "new-empty.db")
response = client.get(f"/overlays/{overlay_id}/files/new")
text = response.get_data(as_text=True)
assert response.status_code == 200
# Empty content + empty filename + zero byte count.
assert 'class="files-editor-content"' in text
assert 'value=""' in text # filename input
assert "UTF-8 · 0 bytes" in text
def test_new_route_renders_with_target_folder_attribute(tmp_path, monkeypatch):
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "new-atfolder.db")
# Create a subfolder to target.
(tmp_path / "overlays" / str(overlay_id) / "cfg").mkdir()
response = client.get(f"/overlays/{overlay_id}/files/new?at=cfg")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert 'data-at-folder="cfg"' in text
# Title text reflects the target folder.
assert "cfg/…new file" in text
def test_new_route_renders_create_button_not_save(tmp_path, monkeypatch):
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "new-button.db")
response = client.get(f"/overlays/{overlay_id}/files/new")
text = response.get_data(as_text=True)
assert response.status_code == 200
# Save button shows "Create" instead of "Save"; Delete + Download
# buttons are absent on a new file.
assert 'class="files-editor-save">Create</button>' in text
assert 'class="danger-outline files-editor-delete"' not in text
assert 'class="button-secondary files-editor-download"' not in text
def test_new_route_400s_for_invalid_at_path(tmp_path, monkeypatch):
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "new-400.db")
response = client.get(f"/overlays/{overlay_id}/files/new?at=../../etc")
assert response.status_code == 400
def test_new_route_404s_for_non_files_overlay(tmp_path, monkeypatch):
db_url = f"sqlite:///{tmp_path/'new-script-overlay.db'}"
monkeypatch.setenv("DATABASE_URL", db_url)
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
init_db()
with session_scope() as s:
user = User(username="alice", password_digest=hash_password("secret"), admin=False)
s.add(user)
s.flush()
overlay = Overlay(name="scripted", path="", type="script", user_id=user.id)
s.add(overlay)
s.flush()
overlay.path = str(overlay.id)
(tmp_path / "overlays" / str(overlay.id)).mkdir(parents=True)
user_id = user.id
overlay_id = overlay.id
client = app.test_client()
with client.session_transaction() as sess:
sess["user_id"] = user_id
sess["pw_changed_at"] = datetime.now(UTC).isoformat()
response = client.get(f"/overlays/{overlay_id}/files/new")
assert response.status_code == 404