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:
parent
cb391ad456
commit
6b0231970c
3 changed files with 122 additions and 6 deletions
|
|
@ -272,6 +272,49 @@ def overlay_file_edit_page(overlay_id: int):
|
|||
rel_path=sub_path,
|
||||
content=content,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
{% 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 content %}
|
||||
<div id="files-editor-fragment" aria-labelledby="files-editor-title">
|
||||
<div class="modal-header">
|
||||
<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>
|
||||
<button type="button" class="modal-close" data-routed-modal-dismiss aria-label="Close">×</button>
|
||||
</div>
|
||||
|
|
@ -28,7 +28,7 @@
|
|||
</label>
|
||||
<label class="files-editor-field">
|
||||
<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>
|
||||
<div class="files-editor-meta muted">
|
||||
<span class="files-editor-byte-count">UTF-8 · {{ byte_count }} bytes</span>
|
||||
|
|
@ -37,11 +37,11 @@
|
|||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<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="files-editor-save">Save</button>
|
||||
<button type="button" class="files-editor-save">{% if is_new %}Create{% else %}Save{% endif %}</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue