diff --git a/l4d2web/l4d2web/routes/files_routes.py b/l4d2web/l4d2web/routes/files_routes.py
index a53becc..07dffd2 100644
--- a/l4d2web/l4d2web/routes/files_routes.py
+++ b/l4d2web/l4d2web/routes/files_routes.py
@@ -31,6 +31,7 @@ Mutating endpoints (only `overlay.type == 'files'`, owner or admin):
from __future__ import annotations
import io
+import mimetypes
import os
import shutil
import tempfile
@@ -256,15 +257,18 @@ def overlay_file_edit_page(overlay_id: int):
if not target.exists() or not target.is_file():
return Response(status=404)
+
if not is_editable(target):
- return Response("not editable", status=415)
+ return _render_binary_editor(overlay, sub_path, target)
try:
content = target.read_text(encoding="utf-8")
except OSError:
return Response("read failed", status=500)
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(
"overlay_file_editor.html",
@@ -273,7 +277,28 @@ def overlay_file_edit_page(overlay_id: int):
content=content,
byte_count=len(content.encode("utf-8")),
is_new=False,
+ is_binary=False,
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="",
byte_count=0,
is_new=True,
+ is_binary=False,
at_folder=at,
+ mime_type="",
)
diff --git a/l4d2web/l4d2web/templates/overlay_file_editor.html b/l4d2web/l4d2web/templates/overlay_file_editor.html
index c2a0638..d7567e7 100644
--- a/l4d2web/l4d2web/templates/overlay_file_editor.html
+++ b/l4d2web/l4d2web/templates/overlay_file_editor.html
@@ -16,6 +16,7 @@
↻ Save will rename → .
+ {% if not is_binary %}
+ {% else %}
+
+
+ ⛌ Inline editing not available
+ · {{ byte_count }} bytes · {{ mime_type }}
+
+
+
+
↑ Drop a file here to replace ·
+ ·
+ single file only · keeps the filename
+
+
+ ↻ ·
+ ·
+ queued
+
+
+
+
+
+ {% endif %}
{% endblock %}
diff --git a/l4d2web/tests/test_url_addressable_modals.py b/l4d2web/tests/test_url_addressable_modals.py
index 5c38d95..336ba53 100644
--- a/l4d2web/tests/test_url_addressable_modals.py
+++ b/l4d2web/tests/test_url_addressable_modals.py
@@ -95,15 +95,49 @@ def test_edit_route_404s_for_missing_file(tmp_path, monkeypatch):
assert response.status_code == 404
-def test_edit_route_415s_for_non_editable_file(tmp_path, monkeypatch):
- client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "edit-415.db")
- # Forge a non-editable file by writing binary garbage.
+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")
- 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" 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):