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:
parent
4d045e578d
commit
294b5b8489
3 changed files with 91 additions and 7 deletions
|
|
@ -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="",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue