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>
This commit is contained in:
mwiegand 2026-05-17 16:11:17 +02:00
parent 4d045e578d
commit 294b5b8489
No known key found for this signature in database
3 changed files with 91 additions and 7 deletions

View file

@ -31,6 +31,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
@ -256,15 +257,18 @@ def overlay_file_edit_page(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 _render_binary_editor(overlay, sub_path, target)
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:
return Response("not editable", status=415) # is_editable's 8-KiB sniff said yes but the tail was binary.
# Fall back to the binary template path.
return _render_binary_editor(overlay, sub_path, target)
return render_template( return render_template(
"overlay_file_editor.html", "overlay_file_editor.html",
@ -273,7 +277,28 @@ def overlay_file_edit_page(overlay_id: int):
content=content, content=content,
byte_count=len(content.encode("utf-8")), byte_count=len(content.encode("utf-8")),
is_new=False, is_new=False,
is_binary=False,
at_folder="", at_folder="",
mime_type="",
)
def _render_binary_editor(overlay, sub_path, target):
"""Render overlay_file_editor.html in binary-replace mode. Used by
overlay_file_edit_page when the target file isn't a UTF-8 text file.
The template hides the CM6 textarea + language dropdown and shows a
Replace zone + Download link; the save button reads 'Replace'."""
mime, _enc = mimetypes.guess_type(target.name)
return render_template(
"overlay_file_editor.html",
overlay=overlay,
rel_path=sub_path,
content="",
byte_count=target.stat().st_size,
is_new=False,
is_binary=True,
at_folder="",
mime_type=mime or "application/octet-stream",
) )
@ -314,7 +339,9 @@ def overlay_file_new_page(overlay_id: int):
content="", content="",
byte_count=0, byte_count=0,
is_new=True, is_new=True,
is_binary=False,
at_folder=at, at_folder=at,
mime_type="",
) )

View file

@ -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>
@ -35,13 +36,35 @@
<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">
{% if not is_new %}<button type="button" class="danger-outline files-editor-delete">Delete</button>{% endif %} {% 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>
{% if not is_new %}<a class="button-secondary files-editor-download" href="/overlays/{{ overlay.id }}/files/download?path={{ rel_path|urlencode }}">⬇ Download</a>{% endif %} {% 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">{% if is_new %}Create{% else %}Save{% endif %}</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

@ -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):