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>
259 lines
11 KiB
Python
259 lines
11 KiB
Python
from datetime import UTC, datetime
|
||
|
||
from flask import render_template_string
|
||
|
||
from l4d2web.app import create_app
|
||
from l4d2web.auth import hash_password
|
||
from l4d2web.db import init_db, session_scope
|
||
from l4d2web.models import Overlay, User
|
||
|
||
|
||
def _make_app(tmp_path, monkeypatch, db_name: str):
|
||
db_url = f"sqlite:///{tmp_path/db_name}"
|
||
monkeypatch.setenv("DATABASE_URL", db_url)
|
||
return create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
|
||
|
||
|
||
def test_base_layout_is_modal_partial_when_hx_modal_header_set(tmp_path, monkeypatch):
|
||
app = _make_app(tmp_path, monkeypatch, "layout-modal.db")
|
||
with app.test_request_context("/", headers={"HX-Modal": "1"}):
|
||
assert render_template_string("{{ base_layout }}") == "_modal_partial.html"
|
||
|
||
|
||
def test_base_layout_is_base_html_for_normal_request(tmp_path, monkeypatch):
|
||
app = _make_app(tmp_path, monkeypatch, "layout-default.db")
|
||
with app.test_request_context("/"):
|
||
assert render_template_string("{{ base_layout }}") == "base.html"
|
||
|
||
|
||
def test_base_layout_does_not_react_to_plain_hx_request_header(tmp_path, monkeypatch):
|
||
# HTMX sets HX-Request on every request including the build-status poll;
|
||
# only HX-Modal should switch the layout.
|
||
app = _make_app(tmp_path, monkeypatch, "layout-hxreq.db")
|
||
with app.test_request_context("/", headers={"HX-Request": "true"}):
|
||
assert render_template_string("{{ base_layout }}") == "base.html"
|
||
|
||
|
||
def _auth_client_with_files_overlay(tmp_path, monkeypatch, db_name: str):
|
||
db_url = f"sqlite:///{tmp_path/db_name}"
|
||
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 session:
|
||
user = User(username="alice", password_digest=hash_password("secret"), admin=False)
|
||
session.add(user)
|
||
session.flush()
|
||
overlay = Overlay(name="cfgs", path="", type="files", user_id=user.id)
|
||
session.add(overlay)
|
||
session.flush()
|
||
overlay.path = str(overlay.id)
|
||
overlay_root = tmp_path / "overlays" / str(overlay.id)
|
||
overlay_root.mkdir(parents=True)
|
||
(overlay_root / "server.cfg").write_text("hostname \"left4me\"\nrcon_password \"hunter2\"\n", encoding="utf-8")
|
||
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()
|
||
return client, overlay_id
|
||
|
||
|
||
def test_edit_route_renders_full_page_without_modal_header(tmp_path, monkeypatch):
|
||
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "edit-full.db")
|
||
response = client.get(f"/overlays/{overlay_id}/files/edit?path=server.cfg")
|
||
text = response.get_data(as_text=True)
|
||
|
||
assert response.status_code == 200
|
||
assert "<!doctype html>" in text.lower() # full base.html rendered
|
||
assert 'href="/dashboard"' in text # nav present
|
||
assert 'class="files-editor-content"' in text
|
||
assert 'rcon_password' in text # content pre-filled
|
||
|
||
|
||
def test_edit_route_renders_fragment_with_modal_header(tmp_path, monkeypatch):
|
||
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "edit-fragment.db")
|
||
response = client.get(
|
||
f"/overlays/{overlay_id}/files/edit?path=server.cfg",
|
||
headers={"HX-Modal": "1"},
|
||
)
|
||
text = response.get_data(as_text=True)
|
||
|
||
assert response.status_code == 200
|
||
assert "<html" not in text # layoutless
|
||
assert 'class="primary-nav"' not in text
|
||
assert 'class="files-editor-content"' in text
|
||
assert "hostname" in text # content pre-filled
|
||
|
||
|
||
def test_edit_route_404s_for_missing_file(tmp_path, monkeypatch):
|
||
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "edit-404.db")
|
||
response = client.get(f"/overlays/{overlay_id}/files/edit?path=nonexistent.cfg")
|
||
assert response.status_code == 404
|
||
|
||
|
||
def test_edit_route_renders_binary_template_for_non_editable(tmp_path, monkeypatch):
|
||
"""Phase B Step 7: non-editable files no longer return 415; they render
|
||
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
|
||
overlay_root = tmp_path / "overlays" / str(overlay_id)
|
||
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")
|
||
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):
|
||
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "edit-400.db")
|
||
response = client.get(f"/overlays/{overlay_id}/files/edit?path=../../etc/passwd")
|
||
assert response.status_code == 400
|
||
|
||
|
||
def test_edit_route_404s_for_non_files_overlay(tmp_path, monkeypatch):
|
||
db_url = f"sqlite:///{tmp_path/'edit-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/edit?path=anything.cfg")
|
||
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
|