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>
This commit is contained in:
mwiegand 2026-05-17 16:03:01 +02:00
parent cb391ad456
commit 6b0231970c
No known key found for this signature in database
3 changed files with 122 additions and 6 deletions

View file

@ -272,6 +272,49 @@ def overlay_file_edit_page(overlay_id: int):
rel_path=sub_path, rel_path=sub_path,
content=content, content=content,
byte_count=len(content.encode("utf-8")), byte_count=len(content.encode("utf-8")),
is_new=False,
at_folder="",
)
@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,
at_folder=at,
) )

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>
@ -28,7 +28,7 @@
</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>
@ -37,11 +37,11 @@
</div> </div>
</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_new %}Create{% else %}Save{% endif %}</button>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -136,3 +136,76 @@ 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_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