Compare commits
No commits in common. "5f82950d7c2b709a6cee9cef697b8f98aa9ab6a4" and "c51089df1b734bec9ce2870e1ef147a5a659eb3b" have entirely different histories.
5f82950d7c
...
c51089df1b
12 changed files with 1286 additions and 1761 deletions
|
|
@ -1,271 +0,0 @@
|
||||||
# files-overlay.js Rewrite + Editor Migration + API Cleanup (handoff-ready)
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
`l4d2web/l4d2web/static/js/files-overlay.js` has grown to ~35 KB / 1092 lines and hosts 9+ feature areas in one IIFE: helpers, the legacy inline editor dialog (text + binary + create-new modes), new-folder modal, conflict modal, delete-confirm modal, file-row action dispatch, drag-drop on the file tree, upload queue + progress panel, and document-level delegation for the URL-addressable editor's save/delete. Event-binding patterns are inconsistent — direct-binds, clone-and-rebind anti-patterns, bind-and-remove per-call, and document-level delegation all coexist for similar tasks.
|
|
||||||
|
|
||||||
Two flows still use the legacy inline `<dialog id="files-editor-modal">`: **create-new-file** (no URL to deep-link a file that doesn't exist yet) and **binary-replace** (the new URL-addressable template intentionally omitted binary-replace UI per Task 2's pilot scope cut). Migrating both flows to URL-addressable lets us delete the legacy dialog and simplifies the JS editor module dramatically.
|
|
||||||
|
|
||||||
Meanwhile `routes/files_routes.py` (640+ lines) duplicates path-resolution and editability checks between `overlay_file_content` (JSON) and `overlay_file_edit_page` (HTML), and the JSON `/files/content` endpoint becomes dead code once create-new + binary-replace migrate away from JS-populated inline dialogs.
|
|
||||||
|
|
||||||
**Goal:** rewrite the JS into focused modules with consistent delegation; migrate create-new + binary-replace flows to URL-addressable modals; delete the legacy dialog; clean up `routes/files_routes.py` to share helpers and drop dead endpoints. Behavior visible to the user is unchanged — same features, same UX. Each migration commit leaves the working tree in a known-good Chromium-verified state, so this plan can be paused, resumed, and handed off across sessions.
|
|
||||||
|
|
||||||
## Approach: three phases, twelve commits
|
|
||||||
|
|
||||||
Three natural pause points for cross-session handoff. Each phase is independently completable and verifiable.
|
|
||||||
|
|
||||||
| Phase | Steps | Scope | Where the working tree lands |
|
|
||||||
|-------|-------|-------|------------------------------|
|
|
||||||
| **A** | 1-4 | JS rewrite into 4 modules (`core.js`, `editor.js`, `dialogs.js`, `uploads.js`). `editor.js` is dual-purpose at this point (legacy inline dialog + URL-addressable modal). | Old `files-overlay.js` is empty/stub; behavior unchanged; legacy dialog still exists and is still used by create-new + binary-replace. |
|
|
||||||
| **B** | 5-10 | URL-addressable migration of create-new + binary-replace. Adds `GET /overlays/<id>/files/new?at=<folder>`; binary-file detection in the edit route; binary-replace UI in `overlay_file_editor.html`; JS code-paths for both flows move to URL-addressable. Legacy dialog deleted from `overlay_detail.html`. `editor.js` becomes single-purpose. | Legacy dialog gone; `editor.js` ~200 lines, URL-addressable-only; create-new + binary-replace are URL-addressable. |
|
|
||||||
| **C** | 11-12 | API cleanup. Extract shared path-resolution + editability helper used by edit/save/content endpoints. Delete `GET /overlays/<id>/files/content` JSON endpoint if confirmed unused. Consolidate save/replace duplication where reasonable. | `routes/files_routes.py` ~450 lines; no dead routes; pytest still green. |
|
|
||||||
|
|
||||||
Total estimate: ~12 commits, each independently revertable.
|
|
||||||
|
|
||||||
## Decomposition (new JS modules)
|
|
||||||
|
|
||||||
| Module | Phase A end | Phase B end | Responsibility |
|
|
||||||
|--------|-------------|-------------|----------------|
|
|
||||||
| `static/js/files-overlay/core.js` | ~120 lines | ~120 lines | Helpers (`postJson`, `postForm`, `scheduleRefresh`, `parentOf`, `joinPath`). Manager-element detection, CSRF read. File-row click delegation dispatch. |
|
|
||||||
| `static/js/files-overlay/editor.js` | ~350 lines (dual-purpose) | ~200 lines (URL-addressable only) | Save / delete / rename-on-save / 409 conflict handling. Filename rename hint. Ctrl+S. After Phase B, only handles URL-addressable modal content via document-level delegation gated on `#modal-content`. |
|
|
||||||
| `static/js/files-overlay/dialogs.js` | ~180 lines | ~180 lines | New-folder modal, delete-confirm modal, conflict modal (`askConflict` returning a promise). All document-level delegated, no clone-and-rebind. |
|
|
||||||
| `static/js/files-overlay/uploads.js` | ~280 lines | ~280 lines | Upload queue (concurrency 3), progress panel, per-row cancel via `data-upload-id`. Drag-drop on `treeRoot` kept direct-bound (5 coordinated events, persistent target). |
|
|
||||||
|
|
||||||
Total after rewrite: ~780 lines across 4 files (vs 1092 in one).
|
|
||||||
|
|
||||||
Direct-bind escape hatches (places we keep direct binding deliberately):
|
|
||||||
- `editor.js`: `input` / `keydown` on `.files-editor-filename` and `.files-editor-content` (high-frequency, persistent input elements in the swapped-in modal content — re-bound on each `htmx:afterSwap`).
|
|
||||||
- `uploads.js`: `dragstart` / `dragend` / `dragover` / `dragleave` / `drop` on `treeRoot` (5 coordinated events sharing per-target highlight state; document delegation would obscure the coordination logic).
|
|
||||||
|
|
||||||
## Migration sequence
|
|
||||||
|
|
||||||
### Phase A — JS rewrite (steps 1-4)
|
|
||||||
|
|
||||||
**Step 1: scaffold `files-overlay/core.js`**
|
|
||||||
|
|
||||||
Create the new directory. Move helpers (lines ~40-170 of the current file: `postJson`, `postForm`, `scheduleRefresh`, `parentOf`, `joinPath`, byte-count utility, manager-element detection, CSRF read). Add the file-row click delegation that currently lives at line 1054 — same selector match, but instead of switch-casing into local handlers, dispatch through a registry `window.__filesOverlay.handleAction(action, path, actionEl)` that feature modules register handlers into. Old `files-overlay.js` still has its own handlers for now — `core.js` dispatch is unused until subsequent steps wire features into it.
|
|
||||||
|
|
||||||
`overlay_detail.html` (NOT base.html): add `<script src="files-overlay/core.js" defer>` BEFORE the existing `files-overlay.js` script tag (currently at `overlay_detail.html:285`, also `defer`). The script tag lives in `overlay_detail.html` because `files-overlay.js` activates only when `.files-manager` exists (line 23-24 of the current file) — loading from `base.html` would pull the JS onto every page unnecessarily. **All new module script tags use `defer`** to match the existing pattern (the modules query the DOM at load time and need the body parsed first).
|
|
||||||
|
|
||||||
Verification: existing functionality still works (click any file row → opens editor, +new-file → opens editor, +new-folder → opens new-folder modal, zip download works).
|
|
||||||
|
|
||||||
**Step 2: migrate editor handlers to `files-overlay/editor.js`**
|
|
||||||
|
|
||||||
Move the editor section (lines 262-591: `editorEls` object, `openEditorTextNew`, `openEditorForFile`, save/delete handlers + sub-element handlers). At the start of `editor.js`'s IIFE, query the legacy dialog once: `const editorDialog = document.getElementById("files-editor-modal")`. If present, register handlers; if absent (i.e., later when the legacy dialog is deleted in Phase B), skip the legacy branch.
|
|
||||||
|
|
||||||
For save/delete: convert from direct-bound `editorEls.saveBtn.addEventListener` to document-level delegation scoped via `event.target.closest("#files-editor-modal")`. The URL-addressable modal save/delete delegation already at lines 600-664 moves over too.
|
|
||||||
|
|
||||||
For replace-zone drag (lines 449-458): convert to delegation gated on `event.target.closest(".files-editor-replace-zone")` inside `#files-editor-modal`.
|
|
||||||
|
|
||||||
Keep direct-bound: `input` on filename, `input` and `keydown` on content textarea (these target persistent inputs inside the persistent legacy dialog).
|
|
||||||
|
|
||||||
Register the file-row action handlers (`new-file`, `edit`) into the `core.js` dispatch registry from step 1.
|
|
||||||
|
|
||||||
Delete the migrated handlers from the old `files-overlay.js`. The old file shrinks by ~330 lines.
|
|
||||||
|
|
||||||
Verification: open editor on text file → edit + save works; rename + save works; rename + 409 conflict alerts and modal stays open; delete works; binary file → opens in binary mode with replace UI; URL-addressable editor flow (file-row click on editable file) still works including rename and 409.
|
|
||||||
|
|
||||||
**Step 3: migrate dialogs to `files-overlay/dialogs.js`**
|
|
||||||
|
|
||||||
Move new-folder modal (lines 666-722), delete-confirm modal (lines 222-258), conflict modal (`askConflict`, lines 174-211).
|
|
||||||
|
|
||||||
Eliminate the clone-and-rebind pattern: register single document-level delegated handlers, scoped to each dialog by id. Per-dialog state (target folder for new-folder, current path for delete-confirm, resolve-callback for conflict) lives in module-scope variables set when the dialog opens and cleared when it closes.
|
|
||||||
|
|
||||||
Open the dialogs via `window.modals.openInline(idOrEl)` instead of `dialog.showModal()` directly, completing the inline-modal convention from commit `c51089d`.
|
|
||||||
|
|
||||||
Register the file-row action handlers (`new-folder`, `delete`) into `core.js` dispatch registry.
|
|
||||||
|
|
||||||
Delete migrated handlers from old `files-overlay.js`. Shrinks another ~150 lines.
|
|
||||||
|
|
||||||
Verification: new-folder open + create works; new-folder Enter-in-input creates; delete-confirm open + confirm deletes; upload-conflict prompt (overwrite + keep-both branches both work).
|
|
||||||
|
|
||||||
**Step 4: migrate uploads + drag-drop to `files-overlay/uploads.js`**
|
|
||||||
|
|
||||||
Move upload queue (lines ~750-900) and drag-drop on `treeRoot` (lines 913-1020).
|
|
||||||
|
|
||||||
Keep drag-drop direct-bound to `treeRoot` (deliberate — coordinated state across 5 events).
|
|
||||||
|
|
||||||
Convert upload-row cancel buttons (currently direct-bound at row creation, line 753) to document-level delegation: store `data-upload-id="<id>"` on each row and look up the upload at click time.
|
|
||||||
|
|
||||||
Register file-row action handlers (`zip`) into `core.js` dispatch registry. (`zip` is just a navigation — could live in `core.js` directly; pick whichever reads cleaner.)
|
|
||||||
|
|
||||||
Delete remaining migrated handlers from old `files-overlay.js`. After this step the old file is empty or near-empty. Add `<script src="files-overlay/uploads.js" defer>` etc. to `overlay_detail.html` alongside the (now-empty) `files-overlay.js` script tag (or delete the old `<script>` tag entirely if the file is empty). Initially leave the old file in place to avoid stretching this step further.
|
|
||||||
|
|
||||||
Verification: drop a single file onto tree → uploads + appears; drop a folder onto tree → multiple uploads with progress; cancel in-flight upload → stops, row shows cancelled; click Clear → done rows removed; drag a file row to another folder → moves.
|
|
||||||
|
|
||||||
End of Phase A: working tree has 4 focused modules + a (near-)empty old file. All current behavior preserved.
|
|
||||||
|
|
||||||
### Phase B — URL-addressable migration of create-new + binary-replace (steps 5-10)
|
|
||||||
|
|
||||||
**Step 5: extend the edit route to support new-file mode (server + template + tests)**
|
|
||||||
|
|
||||||
Add `GET /overlays/<id>/files/new?at=<folder>` to `routes/files_routes.py`. Returns the editor template (`overlay_file_editor.html`) with:
|
|
||||||
|
|
||||||
- `rel_path = ""` (empty filename input — user types name)
|
|
||||||
- `content = ""` (empty editor)
|
|
||||||
- `byte_count = 0`
|
|
||||||
- A new context flag `is_new = True`
|
|
||||||
- The save button label "Create" instead of "Save"
|
|
||||||
- The delete button hidden
|
|
||||||
- The target folder rendered as a `data-at-folder="<folder>"` attribute on the textarea so JS save can compose `path = at_folder + "/" + filename` on submit.
|
|
||||||
|
|
||||||
Extend `overlay_file_editor.html` to render conditionally based on `is_new`. The existing template has filename, content, save button — just add a `{% if is_new %}Create{% else %}Save{% endif %}` and `{% if not is_new %}<button class="files-editor-delete">{% endif %}`.
|
|
||||||
|
|
||||||
Add pytest tests in `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` (path traversal)
|
|
||||||
- `test_new_route_404s_for_non_files_overlay`
|
|
||||||
|
|
||||||
Verification: pytest green; curl the new route, see the editor markup with empty content + "Create" button.
|
|
||||||
|
|
||||||
**Step 6: migrate create-new-file JS flow to URL-addressable**
|
|
||||||
|
|
||||||
In `editor.js`, the `openEditorTextNew(folder)` path currently populates the legacy inline dialog with an empty filename + content. Change it to call `window.modals.openRouted("/overlays/<id>/files/new?at=" + encodeURIComponent(folder))`.
|
|
||||||
|
|
||||||
In the URL-addressable save delegation, detect `is_new` mode (look for `data-at-folder` on the textarea or `value === ""` on the filename input). When new: compose `path = at_folder + "/" + filename.trim()` from the form, send `{path, content}` to `/files/save` (existing endpoint handles creation when the file doesn't exist).
|
|
||||||
|
|
||||||
The `core.js` dispatch for `op === "new-file"` (currently calls into the old/legacy flow) is updated to call the new URL-addressable open.
|
|
||||||
|
|
||||||
Verification: click + on a folder → URL gains `?modal=/overlays/<id>/files/new?at=foo`; editor opens with empty content + Create button + target folder shown; type filename + content + Create → file appears in tree at the right folder; rename test (type a path-like value into the filename input — should create nested as expected or 422 if invalid).
|
|
||||||
|
|
||||||
**Step 7: add binary-file support to the edit route + template (server + template + tests)**
|
|
||||||
|
|
||||||
Change `overlay_file_edit_page` in `routes/files_routes.py`: when `is_editable(target)` is False but the file IS readable (size check, no UnicodeDecodeError), instead of returning 415, return the editor template with:
|
|
||||||
|
|
||||||
- `is_binary = True`
|
|
||||||
- `byte_count = target.stat().st_size`
|
|
||||||
- No `content` (or `content = ""`)
|
|
||||||
- A `download_url` and `mime_type` (best-effort guess)
|
|
||||||
|
|
||||||
Extend `overlay_file_editor.html` to render the binary-replace UI when `is_binary`:
|
|
||||||
- Hide the CM6 textarea + language dropdown
|
|
||||||
- Show: file info (name, size), Download button, Replace zone (drag-drop drop zone with browse fallback)
|
|
||||||
- The Save button is replaced by "Replace" (only enabled when a file is queued)
|
|
||||||
- The Delete button stays visible
|
|
||||||
|
|
||||||
Add pytest tests:
|
|
||||||
- `test_edit_route_renders_binary_template_for_non_editable`
|
|
||||||
- `test_edit_route_still_404s_for_missing_file`
|
|
||||||
- `test_edit_route_still_400s_for_path_traversal`
|
|
||||||
- `test_binary_template_has_replace_zone`
|
|
||||||
|
|
||||||
Verification: navigate to `/overlays/<id>/files/edit?path=image.png` (a real binary file) → page renders with replace UI; navigate via URL-addressable modal → modal opens with same UI.
|
|
||||||
|
|
||||||
**Step 8: migrate binary-replace JS flow to URL-addressable**
|
|
||||||
|
|
||||||
In `editor.js`, add document-level delegation for the binary-replace zone inside `#modal-content`:
|
|
||||||
- `dragover`, `dragleave`, `drop` on `.files-editor-replace-zone` (delegated via `event.target.closest(...)`)
|
|
||||||
- `click` on `.files-editor-replace-browse` and the file input's `change` event for click-to-browse
|
|
||||||
- `click` on `.files-editor-replace-clear` to clear the queued file
|
|
||||||
- `click` on `.files-editor-save` when in binary mode → POST `/files/replace` (multipart) with the queued file
|
|
||||||
|
|
||||||
The legacy `openEditorForFile(path, false)` branch in `files-overlay.js` (currently called for binary files at line 1083) is replaced by `window.modals.openRouted("/overlays/<id>/files/edit?path=" + encodeURIComponent(path))` — same as for editable files. The server figures out which template branch to render.
|
|
||||||
|
|
||||||
Verification: click on a binary file in the tree → URL-addressable modal opens with replace UI; drag a new binary onto the replace zone → queued; click Replace → POST 200; file size updates; file still binary.
|
|
||||||
|
|
||||||
**Step 9: delete the legacy `<dialog id="files-editor-modal">` from `overlay_detail.html`**
|
|
||||||
|
|
||||||
By this point the legacy dialog has no callers. Delete the block from `overlay_detail.html` (originally lines 165-228, may have shifted).
|
|
||||||
|
|
||||||
In `editor.js`, delete all code paths that handle the legacy dialog: the `editorDialog` ref + the document-delegated handlers scoped to `#files-editor-modal` + the `input`/`keydown` direct-binds that were only needed for the legacy persistent inputs. The `editor.js` module shrinks to ~200 lines, single-purpose (URL-addressable modal only).
|
|
||||||
|
|
||||||
Add a pytest assertion (in `test_url_addressable_modals.py`) that `id="files-editor-modal"` does NOT appear in the rendered overlay detail page.
|
|
||||||
|
|
||||||
Verification: overlay detail page renders without the legacy dialog (DOM inspector); URL-addressable editor still works for text + binary + create-new flows; all existing pytest tests still pass.
|
|
||||||
|
|
||||||
**Step 10: delete `files-overlay.js` stub + update base.html**
|
|
||||||
|
|
||||||
If `files-overlay.js` is empty or just an IIFE shell, delete it. Update `overlay_detail.html` (NOT base.html) to load only the 4 new modules (`core.js`, `editor.js`, `dialogs.js`, `uploads.js`), all with `defer`. Order doesn't strictly matter for execution (each is an independent IIFE), but `core.js` first makes the registry-of-handlers pattern explicit.
|
|
||||||
|
|
||||||
Verification: full re-run of the URL-addressable-modals spec's verification matrix (10 checks); 4 new modules' features all work (editor text + binary + create-new; new-folder; conflict; delete-confirm; drag-drop; uploads cancel + clear).
|
|
||||||
|
|
||||||
End of Phase B: legacy dialog gone, `editor.js` single-purpose, all editor flows URL-addressable.
|
|
||||||
|
|
||||||
### Phase C — API cleanup (steps 11-12)
|
|
||||||
|
|
||||||
**Step 11: extract shared path-resolution + editability helper**
|
|
||||||
|
|
||||||
`routes/files_routes.py` has duplication between `overlay_file_content` (lines 203-234, JSON output) and `overlay_file_edit_page` (lines 237-275, HTML output, added in Task 3). Both:
|
|
||||||
- Read `request.args.get("path", "")`
|
|
||||||
- Call `_load_files_overlay(overlay_id, user)`
|
|
||||||
- Call `safe_resolve_for_listing(overlay.path, sub_path)`
|
|
||||||
- Check `target.exists() and target.is_file()`
|
|
||||||
- Call `is_editable(target)`
|
|
||||||
- Try `target.read_text(encoding="utf-8")` with OSError + UnicodeDecodeError fallback
|
|
||||||
|
|
||||||
Extract `_load_file_for_editing(overlay_id, sub_path, user) -> (overlay, target_path, content_or_None, is_binary, byte_count) | Response`:
|
|
||||||
- Returns a tuple on success, a Response on any failure case (404, 415, 400, 403)
|
|
||||||
- Both routes call this helper and translate the tuple into their respective output shapes
|
|
||||||
|
|
||||||
Same path: `is_editable` checks become part of the helper.
|
|
||||||
|
|
||||||
Add pytest tests for the helper directly if reasonable, plus confirm existing route tests still pass.
|
|
||||||
|
|
||||||
Verification: existing pytest tests stay green (no behavior change); both routes shorter and obviously parallel.
|
|
||||||
|
|
||||||
**Step 12: delete `GET /overlays/<id>/files/content` if unused; consolidate save/replace**
|
|
||||||
|
|
||||||
Audit: search the codebase for callers of `/files/content` (JSON endpoint). After Phase B, the legacy `openEditorForFile()` is gone, which was its only caller. If grep confirms no other callers, delete the endpoint + its tests.
|
|
||||||
|
|
||||||
`overlay_file_save` and `overlay_file_replace` share the rename branch (lines 276-285 of save, lines 322-335 of replace). Extract `_apply_optional_rename(overlay, path, new_path) -> (write_target, echo_path) | Response`. Both endpoints call it.
|
|
||||||
|
|
||||||
Verification: pytest stays green; grep confirms no remaining references to `/files/content`; both save/replace routes shorter.
|
|
||||||
|
|
||||||
End of Phase C: `routes/files_routes.py` ~450 lines (vs 640), no dead endpoints, shared helpers.
|
|
||||||
|
|
||||||
## Critical files
|
|
||||||
|
|
||||||
| Path | Phases | Action |
|
|
||||||
|------|--------|--------|
|
|
||||||
| `l4d2web/l4d2web/static/js/files-overlay/core.js` | A | New file |
|
|
||||||
| `l4d2web/l4d2web/static/js/files-overlay/editor.js` | A → B | New file, then shrinks in Phase B |
|
|
||||||
| `l4d2web/l4d2web/static/js/files-overlay/dialogs.js` | A | New file |
|
|
||||||
| `l4d2web/l4d2web/static/js/files-overlay/uploads.js` | A | New file |
|
|
||||||
| `l4d2web/l4d2web/static/js/files-overlay.js` | A → B | Shrinks each step; deleted in step 10 |
|
|
||||||
| `l4d2web/l4d2web/templates/overlay_detail.html` | A, B | Script tag updates each phase end (currently loads files-overlay.js at line 285 with `defer`) |
|
|
||||||
| `l4d2web/l4d2web/templates/overlay_file_editor.html` | B | Add new-file + binary-replace branches |
|
|
||||||
| `l4d2web/l4d2web/templates/overlay_detail.html` | B | Delete legacy `<dialog id="files-editor-modal">` (step 9) |
|
|
||||||
| `l4d2web/l4d2web/routes/files_routes.py` | B, C | Add `/files/new` route (step 5); extend `/files/edit` for binary (step 7); extract helpers (step 11); delete `/files/content` (step 12) |
|
|
||||||
| `l4d2web/tests/test_url_addressable_modals.py` | B | Add tests for new + binary modes (steps 5, 7); add legacy-dialog-gone assertion (step 9) |
|
|
||||||
|
|
||||||
## Existing functions and utilities to reuse
|
|
||||||
|
|
||||||
- `window.modals.openInline(idOrEl)` / `closeInline()` / `openRouted(path)` / `closeRouted()` — at `static/js/modals.js`. New code uses these instead of `dialog.showModal()` directly.
|
|
||||||
- `window.__editor.initEditors(root)` — at `static/js/editor.js`. CM6 re-init on swapped-in textareas. The new-file flow's empty textarea also needs CM6 mount; this just works because the `htmx:afterSwap` listener already covers it.
|
|
||||||
- `window.__filesEditor.getValue()` — set by `editor.js` when CM6 mounts in the modal content. Used by `editor.js`'s save delegation.
|
|
||||||
- `_load_files_overlay`, `safe_resolve_for_listing`, `is_editable`, `_validate_save_content`, `_stream_upload_into` — all in `routes/files_routes.py`. Reused by new routes and the extracted helper.
|
|
||||||
- Existing Flask routes `/files/{save,delete,replace,upload,move,mkdir,download,download_zip,edit}` — consumed unchanged by the new JS modules. `/files/content` deleted in Phase C.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
Each step has its own check (above). Phase-end checks:
|
|
||||||
|
|
||||||
**Phase A end** (after step 4): all current features still work. 573 backend tests pass. Chromium pass on: file-row click → editor; +new-file → editor (in legacy dialog); +new-folder → new-folder modal; click binary file → editor in binary mode; drag file row → moves; drop file onto tree → uploads.
|
|
||||||
|
|
||||||
**Phase B end** (after step 10): legacy dialog gone; create-new + binary-replace are URL-addressable. Full re-run of the URL-addressable-modals spec verification matrix (`docs/superpowers/specs/2026-05-17-url-addressable-modals-design.md`, ## Verification, 10 checks) passes. New checks added: create-new URL deep-link works, binary-replace URL deep-link works.
|
|
||||||
|
|
||||||
**Phase C end** (after step 12): `routes/files_routes.py` shorter; 573 backend tests stay green; `grep -rn "/files/content"` returns nothing (or only confirms the deletion).
|
|
||||||
|
|
||||||
## Handoff state
|
|
||||||
|
|
||||||
After **each** commit, the working tree is in a known-good Chromium-verified state. A future session resumes by:
|
|
||||||
|
|
||||||
1. Reading this plan file at `docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md`.
|
|
||||||
2. Reading `git log --oneline` to see which steps have shipped.
|
|
||||||
3. Picking up at the next un-committed step.
|
|
||||||
4. Per `feedback_textarea_editor_v2_run` memory: direct-to-master on left4me, skip subagent middleman for verbatim-from-plan tasks, document plan deviations in commit messages, Chromium verification works in default sandbox via `./scripts/dev-server.py`.
|
|
||||||
|
|
||||||
Natural pause points: end of Phase A, end of Phase B, end of Phase C. Each phase delivers value independently — Phase A alone is a useful cleanup if the user doesn't want to do B and C.
|
|
||||||
|
|
||||||
## Errata (pre-execution)
|
|
||||||
|
|
||||||
- **Script tag location:** the plan's first draft incorrectly referenced `base.html` for the script tag updates. The actual location is `overlay_detail.html:285` (with `defer`). `files-overlay.js` and its replacement modules are page-scoped to overlay detail — loading from `base.html` would pull the JS onto every page when the `.files-manager` element only exists on overlay detail. All script tag references in this plan now correctly say `overlay_detail.html`. This errata is itself an example of the `feedback_validate_before_implementing` memory: probe the live state before trusting a plan claim.
|
|
||||||
|
|
||||||
## Out of scope
|
|
||||||
|
|
||||||
- Adding a JS test framework (no JS tests in the project today; verification stays Chromium-driven).
|
|
||||||
- Migrating the other inline modals (rename, delete-overlay, new-folder-overlay, etc.) — unrelated to this file.
|
|
||||||
- Restructuring the `/files/upload` chunking or progress-event behavior.
|
|
||||||
- Refactoring `_overlay_file_node.html`, `_overlay_file_tree.html`, or other template partials that this JS doesn't own.
|
|
||||||
|
|
@ -10,6 +10,8 @@ Read-only endpoints (any overlay):
|
||||||
is refused.
|
is refused.
|
||||||
|
|
||||||
Mutating endpoints (only `overlay.type == 'files'`, owner or admin):
|
Mutating endpoints (only `overlay.type == 'files'`, owner or admin):
|
||||||
|
- `GET /overlays/<id>/files/content?path=` — JSON `{path, content}` for
|
||||||
|
an editable text file, 415 if not editable.
|
||||||
- `POST /overlays/<id>/files/save` — JSON `{path, content, new_path?}`,
|
- `POST /overlays/<id>/files/save` — JSON `{path, content, new_path?}`,
|
||||||
text-mode write with optional atomic rename.
|
text-mode write with optional atomic rename.
|
||||||
- `POST /overlays/<id>/files/replace` — multipart `path`, `file`,
|
- `POST /overlays/<id>/files/replace` — multipart `path`, `file`,
|
||||||
|
|
@ -29,7 +31,6 @@ 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
|
||||||
|
|
@ -199,22 +200,14 @@ def server_files_fragment(server_id: int):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _load_file_for_editing(overlay_id: int, sub_path: str, user):
|
@bp.get("/overlays/<int:overlay_id>/files/content")
|
||||||
"""Shared prelude for overlay_file_content + overlay_file_edit_page:
|
@require_login
|
||||||
load the files-overlay, resolve sub_path safely, confirm it's a
|
def overlay_file_content(overlay_id: int):
|
||||||
regular file, attempt a UTF-8 read.
|
"""Return `{path, content}` for an editable text file."""
|
||||||
|
user = current_user()
|
||||||
|
assert user is not None
|
||||||
|
sub_path = request.args.get("path", "")
|
||||||
|
|
||||||
Returns either:
|
|
||||||
* a 5-tuple (overlay, target, content, is_binary, byte_count) on
|
|
||||||
success — content is the UTF-8 text in text mode, None in
|
|
||||||
binary mode; byte_count is encoded text length or st_size
|
|
||||||
* a Flask Response on any failure: 400 (path traversal), 404
|
|
||||||
(missing or non-file), 403 (system overlay, non-admin), 500
|
|
||||||
(OS read error)
|
|
||||||
|
|
||||||
is_binary is True both when is_editable(target) said no up front
|
|
||||||
(8-KiB sniff) and when the tail of the file failed UTF-8 decode
|
|
||||||
even though the sniff passed."""
|
|
||||||
result = _load_files_overlay(overlay_id, user)
|
result = _load_files_overlay(overlay_id, user)
|
||||||
if isinstance(result, Response):
|
if isinstance(result, Response):
|
||||||
return result
|
return result
|
||||||
|
|
@ -227,18 +220,18 @@ def _load_file_for_editing(overlay_id: int, sub_path: str, user):
|
||||||
|
|
||||||
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 overlay, target, None, True, target.stat().st_size
|
return Response("not editable", status=415)
|
||||||
|
|
||||||
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 overlay, target, None, True, target.stat().st_size
|
# is_editable sniffed only the first 8 KiB; the tail can still fail.
|
||||||
|
return Response("not editable", status=415)
|
||||||
|
|
||||||
return overlay, target, content, False, len(content.encode("utf-8"))
|
return jsonify({"path": sub_path, "content": content})
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/overlays/<int:overlay_id>/files/edit")
|
@bp.get("/overlays/<int:overlay_id>/files/edit")
|
||||||
|
|
@ -246,84 +239,39 @@ def _load_file_for_editing(overlay_id: int, sub_path: str, user):
|
||||||
def overlay_file_edit_page(overlay_id: int):
|
def overlay_file_edit_page(overlay_id: int):
|
||||||
"""Server-rendered editor page. Renders full-page by default or as a
|
"""Server-rendered editor page. Renders full-page by default or as a
|
||||||
layoutless modal fragment when the HX-Modal header is set (see the
|
layoutless modal fragment when the HX-Modal header is set (see the
|
||||||
inject_base_layout context processor in app.py). Non-editable files
|
inject_base_layout context processor in app.py)."""
|
||||||
render the binary-replace branch instead of returning 415."""
|
|
||||||
user = current_user()
|
user = current_user()
|
||||||
assert user is not None
|
assert user is not None
|
||||||
sub_path = request.args.get("path", "")
|
sub_path = request.args.get("path", "")
|
||||||
|
|
||||||
result = _load_file_for_editing(overlay_id, sub_path, user)
|
|
||||||
if isinstance(result, Response):
|
|
||||||
return result
|
|
||||||
overlay, target, content, is_binary, byte_count = result
|
|
||||||
|
|
||||||
if is_binary:
|
|
||||||
mime, _enc = mimetypes.guess_type(target.name)
|
|
||||||
return render_template(
|
|
||||||
"overlay_file_editor.html",
|
|
||||||
overlay=overlay,
|
|
||||||
rel_path=sub_path,
|
|
||||||
content="",
|
|
||||||
byte_count=byte_count,
|
|
||||||
is_new=False,
|
|
||||||
is_binary=True,
|
|
||||||
at_folder="",
|
|
||||||
mime_type=mime or "application/octet-stream",
|
|
||||||
)
|
|
||||||
|
|
||||||
return render_template(
|
|
||||||
"overlay_file_editor.html",
|
|
||||||
overlay=overlay,
|
|
||||||
rel_path=sub_path,
|
|
||||||
content=content,
|
|
||||||
byte_count=byte_count,
|
|
||||||
is_new=False,
|
|
||||||
is_binary=False,
|
|
||||||
at_folder="",
|
|
||||||
mime_type="",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@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)
|
result = _load_files_overlay(overlay_id, user)
|
||||||
if isinstance(result, Response):
|
if isinstance(result, Response):
|
||||||
return result
|
return result
|
||||||
overlay = result
|
overlay = result
|
||||||
|
|
||||||
if at:
|
|
||||||
try:
|
try:
|
||||||
target = safe_resolve_for_listing(overlay.path, at)
|
target = safe_resolve_for_listing(overlay.path, sub_path)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return Response("invalid path", status=400)
|
return Response("invalid path", status=400)
|
||||||
if not target.exists() or not target.is_dir():
|
|
||||||
|
if not target.exists() or not target.is_file():
|
||||||
return Response(status=404)
|
return Response(status=404)
|
||||||
|
if not is_editable(target):
|
||||||
|
return Response("not editable", status=415)
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = target.read_text(encoding="utf-8")
|
||||||
|
except OSError:
|
||||||
|
return Response("read failed", status=500)
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return Response("not editable", status=415)
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"overlay_file_editor.html",
|
"overlay_file_editor.html",
|
||||||
overlay=overlay,
|
overlay=overlay,
|
||||||
rel_path="",
|
rel_path=sub_path,
|
||||||
content="",
|
content=content,
|
||||||
byte_count=0,
|
byte_count=len(content.encode("utf-8")),
|
||||||
is_new=True,
|
|
||||||
is_binary=False,
|
|
||||||
at_folder=at,
|
|
||||||
mime_type="",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -335,40 +283,6 @@ def _validate_save_content(content: str) -> Response | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _apply_optional_rename(overlay, path: str, new_path: str | None):
|
|
||||||
"""Resolve the write target for save / replace, atomically renaming
|
|
||||||
when `new_path` differs from `path`. Returns:
|
|
||||||
* (write_target, echo_path) on success — write_target is the
|
|
||||||
resolved Path the caller should write to; echo_path is the
|
|
||||||
canonical relative path to echo back in the response
|
|
||||||
* a Flask Response on failure: 422 (path validation) or 409
|
|
||||||
(rename destination already exists)
|
|
||||||
|
|
||||||
The atomic rename happens INSIDE this helper. Save's "destination
|
|
||||||
is not a file" 409 (creation branch) stays in the caller, since
|
|
||||||
/replace doesn't enforce that check."""
|
|
||||||
if new_path is not None and new_path != path:
|
|
||||||
try:
|
|
||||||
src_path, dst_path = safe_resolve_for_move(overlay.path, path, new_path)
|
|
||||||
except ValueError as exc:
|
|
||||||
return Response(str(exc), status=422)
|
|
||||||
if dst_path.exists():
|
|
||||||
return Response("destination already exists", status=409)
|
|
||||||
dst_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
os.rename(src_path, dst_path)
|
|
||||||
return dst_path, new_path
|
|
||||||
try:
|
|
||||||
write_target = safe_resolve_for_write(overlay.path, path)
|
|
||||||
except ValueError as exc:
|
|
||||||
return Response(str(exc), status=422)
|
|
||||||
write_target.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
return write_target, path
|
|
||||||
|
|
||||||
|
|
||||||
def _is_existing_file(path) -> bool:
|
|
||||||
return path.is_file() and not path.is_symlink()
|
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/overlays/<int:overlay_id>/files/save")
|
@bp.post("/overlays/<int:overlay_id>/files/save")
|
||||||
@require_login
|
@require_login
|
||||||
def overlay_file_save(overlay_id: int):
|
def overlay_file_save(overlay_id: int):
|
||||||
|
|
@ -394,15 +308,26 @@ def overlay_file_save(overlay_id: int):
|
||||||
return result
|
return result
|
||||||
overlay = result
|
overlay = result
|
||||||
|
|
||||||
rename_result = _apply_optional_rename(overlay, path, new_path)
|
try:
|
||||||
if isinstance(rename_result, Response):
|
write_target = safe_resolve_for_write(overlay.path, new_path or path)
|
||||||
return rename_result
|
except ValueError as exc:
|
||||||
write_target, echo_path = rename_result
|
return Response(str(exc), status=422)
|
||||||
|
|
||||||
# Creation branch (no rename): must not collide with an existing
|
# Rename branch: source must exist, dst must not collide.
|
||||||
# non-file (e.g. a directory at the same path).
|
if new_path is not None and new_path != path:
|
||||||
if (new_path is None or new_path == path) \
|
try:
|
||||||
and write_target.exists() and not _is_existing_file(write_target):
|
src_path, dst_path = safe_resolve_for_move(overlay.path, path, new_path)
|
||||||
|
except ValueError as exc:
|
||||||
|
return Response(str(exc), status=422)
|
||||||
|
if dst_path.exists():
|
||||||
|
return Response("destination already exists", status=409)
|
||||||
|
dst_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
os.rename(src_path, dst_path)
|
||||||
|
write_target = dst_path
|
||||||
|
else:
|
||||||
|
write_target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Creation branch: must not collide with an existing path.
|
||||||
|
if write_target.exists() and not _is_existing_file(write_target):
|
||||||
return Response("destination is not a file", status=409)
|
return Response("destination is not a file", status=409)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -410,7 +335,11 @@ def overlay_file_save(overlay_id: int):
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
return Response(f"write failed: {exc}", status=500)
|
return Response(f"write failed: {exc}", status=500)
|
||||||
|
|
||||||
return jsonify({"path": echo_path})
|
return jsonify({"path": new_path or path})
|
||||||
|
|
||||||
|
|
||||||
|
def _is_existing_file(path) -> bool:
|
||||||
|
return path.is_file() and not path.is_symlink()
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/overlays/<int:overlay_id>/files/replace")
|
@bp.post("/overlays/<int:overlay_id>/files/replace")
|
||||||
|
|
@ -432,10 +361,24 @@ def overlay_file_replace(overlay_id: int):
|
||||||
return result
|
return result
|
||||||
overlay = result
|
overlay = result
|
||||||
|
|
||||||
rename_result = _apply_optional_rename(overlay, path, new_path)
|
if new_path and new_path != path:
|
||||||
if isinstance(rename_result, Response):
|
try:
|
||||||
return rename_result
|
src_path, dst_path = safe_resolve_for_move(overlay.path, path, new_path)
|
||||||
write_target, echo_path = rename_result
|
except ValueError as exc:
|
||||||
|
return Response(str(exc), status=422)
|
||||||
|
if dst_path.exists():
|
||||||
|
return Response("destination already exists", status=409)
|
||||||
|
dst_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
os.rename(src_path, dst_path)
|
||||||
|
write_target = dst_path
|
||||||
|
echo_path = new_path
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
write_target = safe_resolve_for_write(overlay.path, path)
|
||||||
|
except ValueError as exc:
|
||||||
|
return Response(str(exc), status=422)
|
||||||
|
write_target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
echo_path = path
|
||||||
|
|
||||||
return _stream_upload_into(upload, write_target, echo_path)
|
return _stream_upload_into(upload, write_target, echo_path)
|
||||||
|
|
||||||
|
|
|
||||||
1091
l4d2web/l4d2web/static/js/files-overlay.js
Normal file
1091
l4d2web/l4d2web/static/js/files-overlay.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,247 +0,0 @@
|
||||||
// files-overlay/core.js — Phase A scaffold (step 1/12).
|
|
||||||
//
|
|
||||||
// First module of the multi-file rewrite of files-overlay.js. Provides:
|
|
||||||
// * Manager-element detection (.files-manager guard, same as legacy).
|
|
||||||
// * Helpers (joinPath, parentOf, basename, escapeHtml, humanSize,
|
|
||||||
// fetchJson, postJson, postForm, refreshFolder, findRowByPath,
|
|
||||||
// cssEscape, scheduleRefresh) — duplicated from the legacy file
|
|
||||||
// for the duration of Phase A. They de-duplicate in steps 2–4 as
|
|
||||||
// features migrate out and the legacy file shrinks.
|
|
||||||
// * window.__filesOverlay registry: feature modules call
|
|
||||||
// registerHandler(op, fn); a document-level click listener here
|
|
||||||
// dispatches matching actions via handleAction.
|
|
||||||
//
|
|
||||||
// Until Steps 2–4 populate the registry, the click listener here is a
|
|
||||||
// no-op — the legacy files-overlay.js still owns dispatch via its own
|
|
||||||
// switch-case at the same selector. Both listeners fire on every click
|
|
||||||
// during the transition; the new one matches and dispatches into an
|
|
||||||
// empty registry, the old one runs the actual handler. Per-op
|
|
||||||
// migration removes each case from the legacy switch as the
|
|
||||||
// corresponding feature module registers its handler.
|
|
||||||
|
|
||||||
(function () {
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
const manager = document.querySelector(".files-manager");
|
|
||||||
if (!manager) return;
|
|
||||||
|
|
||||||
const overlayId = manager.dataset.overlayId;
|
|
||||||
const baseUrl = manager.dataset.baseUrl; // /overlays/<id>
|
|
||||||
const treeRoot = manager.querySelector(".files-tree-root");
|
|
||||||
const csrfToken =
|
|
||||||
document.querySelector("meta[name='csrf-token']")?.getAttribute("content") || "";
|
|
||||||
|
|
||||||
// ---------- helpers ----------
|
|
||||||
|
|
||||||
function joinPath(folder, leaf) {
|
|
||||||
folder = (folder || "").replace(/^\/+|\/+$/g, "");
|
|
||||||
leaf = (leaf || "").replace(/^\/+|\/+$/g, "");
|
|
||||||
if (folder && leaf) return folder + "/" + leaf;
|
|
||||||
return folder || leaf;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parentOf(rel) {
|
|
||||||
const i = (rel || "").lastIndexOf("/");
|
|
||||||
return i < 0 ? "" : rel.slice(0, i);
|
|
||||||
}
|
|
||||||
|
|
||||||
function basename(rel) {
|
|
||||||
const i = (rel || "").lastIndexOf("/");
|
|
||||||
return i < 0 ? rel : rel.slice(i + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(s) {
|
|
||||||
return String(s).replace(/[&<>"']/g, (c) => ({
|
|
||||||
"&": "&",
|
|
||||||
"<": "<",
|
|
||||||
">": ">",
|
|
||||||
'"': """,
|
|
||||||
"'": "'",
|
|
||||||
})[c]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function humanSize(bytes) {
|
|
||||||
if (bytes === undefined || bytes === null) return "";
|
|
||||||
if (bytes < 1024) return bytes + " B";
|
|
||||||
const units = ["KB", "MB", "GB", "TB"];
|
|
||||||
let v = bytes / 1024;
|
|
||||||
let u = "KB";
|
|
||||||
for (const next of units) {
|
|
||||||
u = next;
|
|
||||||
if (v < 1024) break;
|
|
||||||
v /= 1024;
|
|
||||||
}
|
|
||||||
return v.toFixed(1) + " " + u;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchJson(url, options) {
|
|
||||||
options = options || {};
|
|
||||||
options.headers = Object.assign({ Accept: "application/json" }, options.headers || {});
|
|
||||||
if (options.method && options.method !== "GET") {
|
|
||||||
options.headers["X-CSRF-Token"] = csrfToken;
|
|
||||||
}
|
|
||||||
options.credentials = "same-origin";
|
|
||||||
const response = await fetch(url, options);
|
|
||||||
let body = null;
|
|
||||||
let rawText = "";
|
|
||||||
try {
|
|
||||||
rawText = await response.text();
|
|
||||||
body = rawText ? JSON.parse(rawText) : null;
|
|
||||||
} catch (_e) {
|
|
||||||
body = null;
|
|
||||||
}
|
|
||||||
return { ok: response.ok, status: response.status, body, rawText };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function postJson(url, payload) {
|
|
||||||
return fetchJson(url, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function postForm(url, formData) {
|
|
||||||
return fetchJson(url, { method: "POST", body: formData });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- tree refresh ----------
|
|
||||||
|
|
||||||
// Re-fetch a folder's listing partial and swap it into the tree.
|
|
||||||
// `path === ""` refreshes the overlay root container.
|
|
||||||
async function refreshFolder(path) {
|
|
||||||
if (!treeRoot) return;
|
|
||||||
const url = `${baseUrl}/files?path=${encodeURIComponent(path || "")}`;
|
|
||||||
let html;
|
|
||||||
try {
|
|
||||||
const r = await fetch(url, {
|
|
||||||
headers: { Accept: "text/html" },
|
|
||||||
credentials: "same-origin",
|
|
||||||
});
|
|
||||||
if (!r.ok) {
|
|
||||||
if (r.status === 404) {
|
|
||||||
// Folder no longer exists — refresh its parent instead.
|
|
||||||
if (path) await refreshFolder(parentOf(path));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
html = await r.text();
|
|
||||||
} catch (_e) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!path) {
|
|
||||||
// Overlay root — swap into the synthetic root row's children div.
|
|
||||||
const target = manager.querySelector(".files-root-children");
|
|
||||||
if (!target) return;
|
|
||||||
const empty = target.querySelector(".files-empty");
|
|
||||||
if (empty) empty.remove();
|
|
||||||
const existingUl = target.querySelector(":scope > ul.file-tree");
|
|
||||||
if (existingUl) existingUl.remove();
|
|
||||||
target.insertAdjacentHTML("beforeend", html);
|
|
||||||
const newUl = target.querySelector(":scope > ul.file-tree");
|
|
||||||
if (newUl && newUl.children.length === 0) {
|
|
||||||
newUl.remove();
|
|
||||||
const p = document.createElement("p");
|
|
||||||
p.className = "muted files-empty";
|
|
||||||
p.textContent = 'Empty — drop files here, or click "+ new file" on this row.';
|
|
||||||
target.appendChild(p);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sub-folder: find the matching folder row and swap its children div.
|
|
||||||
const row = findRowByPath(path, "dir");
|
|
||||||
if (!row) return;
|
|
||||||
const childrenDiv = row.querySelector(":scope > .file-tree-children");
|
|
||||||
const toggleBtn = row.querySelector(":scope > .file-tree-toggle");
|
|
||||||
if (!childrenDiv || !toggleBtn) return;
|
|
||||||
childrenDiv.innerHTML = html;
|
|
||||||
childrenDiv.hidden = false;
|
|
||||||
toggleBtn.setAttribute("aria-expanded", "true");
|
|
||||||
toggleBtn.dataset.loaded = "1";
|
|
||||||
}
|
|
||||||
|
|
||||||
function findRowByPath(path, kind) {
|
|
||||||
const sel = kind
|
|
||||||
? `[data-target-path="${cssEscape(path)}"][data-row-kind="${kind}"]`
|
|
||||||
: `[data-target-path="${cssEscape(path)}"]`;
|
|
||||||
return treeRoot ? treeRoot.querySelector(sel) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cssEscape(s) {
|
|
||||||
if (window.CSS && window.CSS.escape) return window.CSS.escape(s);
|
|
||||||
return String(s).replace(/["\\]/g, "\\$&");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debounce per-folder refreshes so a flurry of finishes coalesces.
|
|
||||||
const pendingRefresh = new Map();
|
|
||||||
function scheduleRefresh(path) {
|
|
||||||
const key = path || "";
|
|
||||||
if (pendingRefresh.has(key)) return;
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
pendingRefresh.delete(key);
|
|
||||||
refreshFolder(key);
|
|
||||||
}, 50);
|
|
||||||
pendingRefresh.set(key, timer);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- action-dispatch registry ----------
|
|
||||||
|
|
||||||
const handlers = new Map();
|
|
||||||
|
|
||||||
function registerHandler(op, fn) {
|
|
||||||
handlers.set(op, fn);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAction(op, path, actionEl) {
|
|
||||||
const fn = handlers.get(op);
|
|
||||||
if (typeof fn === "function") fn(path, actionEl);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- action click delegation ----------
|
|
||||||
//
|
|
||||||
// Document-level delegation mirroring the legacy switch-case in
|
|
||||||
// files-overlay.js. While the registry is empty (Step 1 state), this
|
|
||||||
// is a no-op — the legacy file still owns dispatch. Steps 2–4
|
|
||||||
// migrate one op at a time: feature module registers its handler
|
|
||||||
// here, then the matching case is deleted from the legacy file.
|
|
||||||
|
|
||||||
document.addEventListener("click", (event) => {
|
|
||||||
const action = event.target?.closest?.(
|
|
||||||
".files-row-action[data-action], .file-tree-name-button[data-action]"
|
|
||||||
);
|
|
||||||
if (!action) return;
|
|
||||||
if (!manager.contains(action)) return;
|
|
||||||
const op = action.dataset.action;
|
|
||||||
const path = action.dataset.targetPath || "";
|
|
||||||
handleAction(op, path, action);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------- public ----------
|
|
||||||
|
|
||||||
window.__filesOverlay = {
|
|
||||||
manager,
|
|
||||||
overlayId,
|
|
||||||
baseUrl,
|
|
||||||
treeRoot,
|
|
||||||
csrfToken,
|
|
||||||
helpers: {
|
|
||||||
joinPath,
|
|
||||||
parentOf,
|
|
||||||
basename,
|
|
||||||
escapeHtml,
|
|
||||||
humanSize,
|
|
||||||
fetchJson,
|
|
||||||
postJson,
|
|
||||||
postForm,
|
|
||||||
refreshFolder,
|
|
||||||
findRowByPath,
|
|
||||||
cssEscape,
|
|
||||||
scheduleRefresh,
|
|
||||||
},
|
|
||||||
registerHandler,
|
|
||||||
handleAction,
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
|
|
@ -1,212 +0,0 @@
|
||||||
// files-overlay/dialogs.js — Phase A, Step 3.
|
|
||||||
//
|
|
||||||
// Owns the three inline dialogs that surround the file manager:
|
|
||||||
// * #files-new-folder-modal — "+ folder" / mkdir prompt
|
|
||||||
// * #files-delete-modal — delete-confirm for files and folders
|
|
||||||
// * #files-conflict-modal — overwrite / keep-both / cancel choice
|
|
||||||
// shown when save / upload / move hits 409
|
|
||||||
//
|
|
||||||
// Dispatch:
|
|
||||||
// * "new-folder" registered into __filesOverlay → openNewFolder(path)
|
|
||||||
// * "delete" registered into __filesOverlay → openDelete(path,
|
|
||||||
// kind, name) — kind + name read from actionEl
|
|
||||||
// dataset.
|
|
||||||
//
|
|
||||||
// askConflict(path) → Promise<"overwrite" | "keep-both" | "cancel">
|
|
||||||
// is exposed on __filesOverlay so legacy upload / drag-drop callers in
|
|
||||||
// files-overlay.js (lines runUpload at 452 and the drop handler at
|
|
||||||
// 569) can still reach it after the function moved out of their file.
|
|
||||||
// editor.js's save 409-conflict path also goes through this same
|
|
||||||
// exposure.
|
|
||||||
//
|
|
||||||
// All three dialogs use a single delegated click listener per dialog,
|
|
||||||
// reading per-dialog state from module-scope. The clone-and-rebind
|
|
||||||
// pattern from the legacy file (replaceWith(cloneNode(true)) to drop
|
|
||||||
// stale listeners) is gone — the listener is attached once and reads
|
|
||||||
// freshly-set state each time the dialog opens.
|
|
||||||
//
|
|
||||||
// Dialogs open through window.modals.openInline / closeInline (the
|
|
||||||
// consolidated modal API at static/js/modals.js) rather than calling
|
|
||||||
// dialog.showModal() / close() directly, completing the inline-modal
|
|
||||||
// convention from commit c51089d.
|
|
||||||
|
|
||||||
(function () {
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
const fo = window.__filesOverlay;
|
|
||||||
if (!fo) return;
|
|
||||||
|
|
||||||
const { baseUrl, csrfToken } = fo;
|
|
||||||
const { joinPath, parentOf, basename, postJson, postForm, scheduleRefresh } = fo.helpers;
|
|
||||||
|
|
||||||
const newFolderDialog = document.getElementById("files-new-folder-modal");
|
|
||||||
const deleteDialog = document.getElementById("files-delete-modal");
|
|
||||||
const conflictDialog = document.getElementById("files-conflict-modal");
|
|
||||||
|
|
||||||
function openModal(dialog) {
|
|
||||||
if (window.modals && typeof window.modals.openInline === "function") {
|
|
||||||
window.modals.openInline(dialog);
|
|
||||||
} else {
|
|
||||||
dialog.showModal();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function closeModal(dialog) {
|
|
||||||
if (window.modals && typeof window.modals.closeInline === "function") {
|
|
||||||
window.modals.closeInline(dialog);
|
|
||||||
} else {
|
|
||||||
dialog.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- conflict modal ----------------------------------------------
|
|
||||||
|
|
||||||
// While a conflict dialog is open, conflictState.resolve is the
|
|
||||||
// pending Promise resolver. The delegated click handler calls it and
|
|
||||||
// nulls it so subsequent clicks don't double-resolve.
|
|
||||||
let conflictState = null;
|
|
||||||
|
|
||||||
function askConflict(path) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
if (!conflictDialog) {
|
|
||||||
resolve("cancel");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
conflictDialog.querySelector(".files-conflict-path").textContent = path;
|
|
||||||
conflictState = { resolve };
|
|
||||||
openModal(conflictDialog);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
fo.askConflict = askConflict;
|
|
||||||
|
|
||||||
if (conflictDialog) {
|
|
||||||
conflictDialog.addEventListener("click", (event) => {
|
|
||||||
const action = event.target?.dataset?.filesConflictAction;
|
|
||||||
if (!action) return;
|
|
||||||
const state = conflictState;
|
|
||||||
conflictState = null;
|
|
||||||
closeModal(conflictDialog);
|
|
||||||
if (state && typeof state.resolve === "function") {
|
|
||||||
state.resolve(action);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// If the user dismisses the dialog any other way (Esc, backdrop,
|
|
||||||
// programmatic close), resolve as "cancel" so callers don't hang.
|
|
||||||
conflictDialog.addEventListener("close", () => {
|
|
||||||
const state = conflictState;
|
|
||||||
conflictState = null;
|
|
||||||
if (state && typeof state.resolve === "function") {
|
|
||||||
state.resolve("cancel");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- delete-confirm modal ----------------------------------------
|
|
||||||
|
|
||||||
let deleteState = null;
|
|
||||||
|
|
||||||
function openDelete(targetPath, kind, name) {
|
|
||||||
if (!deleteDialog) return;
|
|
||||||
deleteDialog.querySelector(".files-delete-name").textContent = name;
|
|
||||||
const errEl = deleteDialog.querySelector(".files-delete-error");
|
|
||||||
errEl.hidden = true;
|
|
||||||
errEl.textContent = "";
|
|
||||||
deleteState = { path: targetPath, kind, errEl };
|
|
||||||
openModal(deleteDialog);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deleteDialog) {
|
|
||||||
deleteDialog.addEventListener("click", async (event) => {
|
|
||||||
const btn = event.target?.closest?.(".files-delete-confirm");
|
|
||||||
if (!btn) return;
|
|
||||||
if (!deleteState) return;
|
|
||||||
const { path, errEl } = deleteState;
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append("path", path);
|
|
||||||
fd.append("csrf_token", csrfToken);
|
|
||||||
const r = await postForm(`${baseUrl}/files/delete`, fd);
|
|
||||||
if (r.ok) {
|
|
||||||
deleteState = null;
|
|
||||||
closeModal(deleteDialog);
|
|
||||||
scheduleRefresh(parentOf(path));
|
|
||||||
} else {
|
|
||||||
errEl.hidden = false;
|
|
||||||
errEl.textContent =
|
|
||||||
(r.body && r.body.error) || `Delete failed (HTTP ${r.status}).`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
deleteDialog.addEventListener("close", () => {
|
|
||||||
deleteState = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- new-folder modal --------------------------------------------
|
|
||||||
|
|
||||||
let newFolderState = null;
|
|
||||||
|
|
||||||
function openNewFolder(targetFolder) {
|
|
||||||
if (!newFolderDialog) return;
|
|
||||||
const folder = targetFolder || "";
|
|
||||||
newFolderDialog.querySelector(".files-new-folder-target").textContent =
|
|
||||||
folder ? folder + "/" : "/";
|
|
||||||
const input = newFolderDialog.querySelector(".files-new-folder-name");
|
|
||||||
const errEl = newFolderDialog.querySelector(".files-new-folder-error");
|
|
||||||
input.value = "";
|
|
||||||
errEl.hidden = true;
|
|
||||||
errEl.textContent = "";
|
|
||||||
newFolderState = { folder, input, errEl };
|
|
||||||
openModal(newFolderDialog);
|
|
||||||
setTimeout(() => input.focus(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function newFolderSubmit() {
|
|
||||||
if (!newFolderState) return;
|
|
||||||
const { folder, input, errEl } = newFolderState;
|
|
||||||
const name = input.value.trim().replace(/^\/+|\/+$/g, "");
|
|
||||||
if (!name) return;
|
|
||||||
const fullPath = joinPath(folder, name);
|
|
||||||
const r = await postJson(`${baseUrl}/files/mkdir`, { path: fullPath });
|
|
||||||
if (r.ok) {
|
|
||||||
newFolderState = null;
|
|
||||||
closeModal(newFolderDialog);
|
|
||||||
scheduleRefresh(folder);
|
|
||||||
} else {
|
|
||||||
errEl.hidden = false;
|
|
||||||
errEl.textContent =
|
|
||||||
(r.body && r.body.error) ||
|
|
||||||
(r.status === 409
|
|
||||||
? "A file or folder with that name already exists."
|
|
||||||
: `Failed (HTTP ${r.status}).`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newFolderDialog) {
|
|
||||||
newFolderDialog.addEventListener("click", (event) => {
|
|
||||||
if (event.target?.closest?.(".files-new-folder-create")) {
|
|
||||||
newFolderSubmit();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Enter inside the name input submits. Direct-bound to the
|
|
||||||
// persistent input — high-frequency keydown, no benefit from
|
|
||||||
// delegation.
|
|
||||||
newFolderDialog
|
|
||||||
.querySelector(".files-new-folder-name")
|
|
||||||
.addEventListener("keydown", (event) => {
|
|
||||||
if (event.key !== "Enter") return;
|
|
||||||
event.preventDefault();
|
|
||||||
newFolderSubmit();
|
|
||||||
});
|
|
||||||
newFolderDialog.addEventListener("close", () => {
|
|
||||||
newFolderState = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- register action-registry handlers ----------
|
|
||||||
|
|
||||||
fo.registerHandler("new-folder", (path) => openNewFolder(path));
|
|
||||||
|
|
||||||
fo.registerHandler("delete", (path, actionEl) => {
|
|
||||||
const kind = actionEl?.dataset?.rowKind;
|
|
||||||
const name = actionEl?.dataset?.rowName || basename(path);
|
|
||||||
openDelete(path, kind, name);
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
@ -1,309 +0,0 @@
|
||||||
// files-overlay/editor.js — URL-addressable editor only (post-Step 9).
|
|
||||||
//
|
|
||||||
// After Phase B Step 9 deleted the legacy <dialog id="files-editor-modal">,
|
|
||||||
// this module is single-purpose: it drives save / delete / replace /
|
|
||||||
// new-file flows through the URL-addressable modal swapped into
|
|
||||||
// #modal-content. The server template overlay_file_editor.html picks
|
|
||||||
// one of three modes based on the route + the target file:
|
|
||||||
// * is_new (GET /overlays/<id>/files/new?at=<folder>)
|
|
||||||
// * text edit (GET /overlays/<id>/files/edit?path=<rel>, editable)
|
|
||||||
// * binary-replace (GET /overlays/<id>/files/edit?path=<rel>, !editable)
|
|
||||||
//
|
|
||||||
// Dispatch (registered into __filesOverlay):
|
|
||||||
// * "new-file" → opens the new-file route via window.modals.openRouted
|
|
||||||
// * "edit" → opens the edit route via window.modals.openRouted;
|
|
||||||
// the server picks the template branch
|
|
||||||
//
|
|
||||||
// Click delegation on .files-editor-save branches on which panel is in
|
|
||||||
// #modal-content — text textarea → routedSaveClicked, binary panel →
|
|
||||||
// routedReplaceClicked. Delete and replace-zone clicks have a single
|
|
||||||
// path each.
|
|
||||||
//
|
|
||||||
// All listeners are document-level delegated. #modal-content is
|
|
||||||
// swapped on every modal open, so direct binding wouldn't survive a
|
|
||||||
// re-open. Module-scope routedReplacement holds the queued File for
|
|
||||||
// binary-replace mode; cleared on modal-container close so reopening
|
|
||||||
// starts fresh.
|
|
||||||
|
|
||||||
(function () {
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
const fo = window.__filesOverlay;
|
|
||||||
if (!fo) return;
|
|
||||||
|
|
||||||
const { overlayId, baseUrl, csrfToken } = fo;
|
|
||||||
const {
|
|
||||||
parentOf, humanSize,
|
|
||||||
postJson, postForm, scheduleRefresh,
|
|
||||||
} = fo.helpers;
|
|
||||||
|
|
||||||
// ---------- routed binary-replace state ----------
|
|
||||||
|
|
||||||
let routedReplacement = null;
|
|
||||||
|
|
||||||
function inRoutedEditor(el) {
|
|
||||||
const mc = document.getElementById("modal-content");
|
|
||||||
return !!mc && mc.contains(el);
|
|
||||||
}
|
|
||||||
function isRoutedBinaryMode(modalContent) {
|
|
||||||
return !!modalContent?.querySelector(".files-editor-binary");
|
|
||||||
}
|
|
||||||
|
|
||||||
function setRoutedReplacement(modalContent, file) {
|
|
||||||
routedReplacement = file;
|
|
||||||
const idle = modalContent.querySelector(".files-editor-replace-idle");
|
|
||||||
const queued = modalContent.querySelector(".files-editor-replace-queued");
|
|
||||||
if (file) {
|
|
||||||
if (idle) idle.hidden = true;
|
|
||||||
if (queued) queued.hidden = false;
|
|
||||||
const name = modalContent.querySelector(".files-editor-replace-name");
|
|
||||||
const size = modalContent.querySelector(".files-editor-replace-size");
|
|
||||||
if (name) name.textContent = file.name;
|
|
||||||
if (size) size.textContent = humanSize(file.size);
|
|
||||||
} else {
|
|
||||||
if (idle) idle.hidden = false;
|
|
||||||
if (queued) queued.hidden = true;
|
|
||||||
}
|
|
||||||
updateRoutedBinarySaveEnabled(modalContent);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enables Save (labeled "Replace" in binary mode) when either a file
|
|
||||||
// is queued OR the filename input has been edited. Rename-only is a
|
|
||||||
// valid Replace and routes to /files/move below.
|
|
||||||
function updateRoutedBinarySaveEnabled(modalContent) {
|
|
||||||
const panel = modalContent.querySelector(".files-editor-binary[data-rel-path]");
|
|
||||||
const saveBtn = modalContent.querySelector(".files-editor-save");
|
|
||||||
if (!panel || !saveBtn) return;
|
|
||||||
const relPath = panel.dataset.relPath || "";
|
|
||||||
const originalLeaf = relPath.split("/").pop() || relPath;
|
|
||||||
const filenameInput = modalContent.querySelector("[data-editor-filename]");
|
|
||||||
const filenameChanged =
|
|
||||||
filenameInput && filenameInput.value.trim() &&
|
|
||||||
filenameInput.value.trim() !== originalLeaf;
|
|
||||||
saveBtn.disabled = !routedReplacement && !filenameChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- delegated handlers (#modal-content only) ----------
|
|
||||||
|
|
||||||
document.addEventListener("dragover", (event) => {
|
|
||||||
const zone = event.target?.closest?.(".files-editor-replace-zone");
|
|
||||||
if (!zone || !inRoutedEditor(zone)) return;
|
|
||||||
if (Array.from(event.dataTransfer.types).includes("Files")) {
|
|
||||||
event.preventDefault();
|
|
||||||
zone.classList.add("is-drop-target");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
document.addEventListener("dragleave", (event) => {
|
|
||||||
const zone = event.target?.closest?.(".files-editor-replace-zone");
|
|
||||||
if (!zone || !inRoutedEditor(zone)) return;
|
|
||||||
zone.classList.remove("is-drop-target");
|
|
||||||
});
|
|
||||||
document.addEventListener("drop", (event) => {
|
|
||||||
const zone = event.target?.closest?.(".files-editor-replace-zone");
|
|
||||||
if (!zone || !inRoutedEditor(zone)) return;
|
|
||||||
if (!Array.from(event.dataTransfer.types).includes("Files")) return;
|
|
||||||
event.preventDefault();
|
|
||||||
zone.classList.remove("is-drop-target");
|
|
||||||
const f = event.dataTransfer.files && event.dataTransfer.files[0];
|
|
||||||
if (f) setRoutedReplacement(document.getElementById("modal-content"), f);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener("change", (event) => {
|
|
||||||
const input = event.target?.closest?.(".files-editor-replace-input");
|
|
||||||
if (!input || !inRoutedEditor(input)) return;
|
|
||||||
const f = input.files && input.files[0];
|
|
||||||
if (f) setRoutedReplacement(document.getElementById("modal-content"), f);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Filename input: re-evaluate Save enablement in routed binary mode
|
|
||||||
// so rename-only Replace becomes reachable without queueing a file.
|
|
||||||
document.addEventListener("input", (event) => {
|
|
||||||
const input = event.target?.closest?.("[data-editor-filename]");
|
|
||||||
if (!input || !inRoutedEditor(input)) return;
|
|
||||||
const mc = document.getElementById("modal-content");
|
|
||||||
if (mc && isRoutedBinaryMode(mc)) updateRoutedBinarySaveEnabled(mc);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener("click", async (event) => {
|
|
||||||
const target = event.target;
|
|
||||||
if (!target) return;
|
|
||||||
|
|
||||||
const clearBtn = target.closest?.(".files-editor-replace-clear");
|
|
||||||
if (clearBtn && inRoutedEditor(clearBtn)) {
|
|
||||||
setRoutedReplacement(document.getElementById("modal-content"), null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const browseBtn = target.closest?.(".files-editor-replace-browse");
|
|
||||||
if (browseBtn && inRoutedEditor(browseBtn)) {
|
|
||||||
const mc = document.getElementById("modal-content");
|
|
||||||
const fileInput = mc?.querySelector(".files-editor-replace-input");
|
|
||||||
fileInput?.click();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveBtn = target.closest?.(".files-editor-save");
|
|
||||||
if (saveBtn && inRoutedEditor(saveBtn)) {
|
|
||||||
const mc = document.getElementById("modal-content");
|
|
||||||
if (isRoutedBinaryMode(mc)) {
|
|
||||||
await routedReplaceClicked();
|
|
||||||
} else {
|
|
||||||
await routedSaveClicked();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const delBtn = target.closest?.(".files-editor-delete");
|
|
||||||
if (delBtn && inRoutedEditor(delBtn)) {
|
|
||||||
await routedDeleteClicked();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear queued replacement when the modal closes (Esc, backdrop,
|
|
||||||
// dismiss button, programmatic closeRouted). Otherwise reopening
|
|
||||||
// would inherit a stale queued File from the prior session.
|
|
||||||
document.getElementById("modal-container")?.addEventListener("close", () => {
|
|
||||||
routedReplacement = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------- save / replace / delete ----------
|
|
||||||
|
|
||||||
async function routedSaveClicked() {
|
|
||||||
const modalContent = document.getElementById("modal-content");
|
|
||||||
if (!modalContent) return;
|
|
||||||
const ta = modalContent.querySelector("textarea[data-rel-path]");
|
|
||||||
if (!ta) return;
|
|
||||||
const relPath = ta.dataset.relPath;
|
|
||||||
const filenameInput = modalContent.querySelector("[data-editor-filename]");
|
|
||||||
const editedFilename = filenameInput ? filenameInput.value.trim() : "";
|
|
||||||
const content = (window.__filesEditor && window.__filesEditor.getValue)
|
|
||||||
? window.__filesEditor.getValue()
|
|
||||||
: ta.value;
|
|
||||||
|
|
||||||
// is_new mode: relPath is empty; compose path from data-at-folder
|
|
||||||
// + filename. The /save endpoint creates the file when missing.
|
|
||||||
if (!relPath) {
|
|
||||||
if (!editedFilename) return;
|
|
||||||
const atFolder = ta.dataset.atFolder || "";
|
|
||||||
const fullPath = atFolder
|
|
||||||
? `${atFolder.replace(/\/+$/, "")}/${editedFilename}`
|
|
||||||
: editedFilename;
|
|
||||||
const r = await postJson(`${baseUrl}/files/save`, { path: fullPath, content });
|
|
||||||
if (r.ok) {
|
|
||||||
if (typeof window.modals?.closeRouted === "function") window.modals.closeRouted();
|
|
||||||
scheduleRefresh(parentOf(fullPath));
|
|
||||||
} else if (r.status === 409) {
|
|
||||||
alert(r.rawText || `A file at ${fullPath} already exists. Pick a different name.`);
|
|
||||||
} else {
|
|
||||||
alert((r.body && r.body.error) || r.rawText || `Create failed (HTTP ${r.status}).`);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Edit mode: rename-on-save (sibling rename only — parent stays).
|
|
||||||
const originalLeaf = relPath.split("/").pop() || relPath;
|
|
||||||
const parent = relPath.includes("/") ? relPath.slice(0, relPath.lastIndexOf("/")) : "";
|
|
||||||
let newPath = null;
|
|
||||||
if (editedFilename && editedFilename !== originalLeaf) {
|
|
||||||
newPath = parent ? `${parent}/${editedFilename}` : editedFilename;
|
|
||||||
}
|
|
||||||
const payload = { path: relPath, content };
|
|
||||||
if (newPath) payload.new_path = newPath;
|
|
||||||
const r = await postJson(`${baseUrl}/files/save`, payload);
|
|
||||||
if (r.ok) {
|
|
||||||
if (typeof window.modals?.closeRouted === "function") window.modals.closeRouted();
|
|
||||||
scheduleRefresh(parentOf(newPath || relPath));
|
|
||||||
} else if (r.status === 409) {
|
|
||||||
alert(r.rawText || `Conflict: destination already exists.`);
|
|
||||||
} else {
|
|
||||||
alert((r.body && r.body.error) || r.rawText || `Save failed (HTTP ${r.status}).`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function routedReplaceClicked() {
|
|
||||||
const modalContent = document.getElementById("modal-content");
|
|
||||||
if (!modalContent) return;
|
|
||||||
const panel = modalContent.querySelector(".files-editor-binary[data-rel-path]");
|
|
||||||
if (!panel) return;
|
|
||||||
const relPath = panel.dataset.relPath;
|
|
||||||
if (!relPath) return;
|
|
||||||
|
|
||||||
const filenameInput = modalContent.querySelector("[data-editor-filename]");
|
|
||||||
const editedFilename = filenameInput ? filenameInput.value.trim() : "";
|
|
||||||
const originalLeaf = relPath.split("/").pop() || relPath;
|
|
||||||
const parent = relPath.includes("/") ? relPath.slice(0, relPath.lastIndexOf("/")) : "";
|
|
||||||
const renaming = !!editedFilename && editedFilename !== originalLeaf;
|
|
||||||
const newPath = renaming
|
|
||||||
? (parent ? `${parent}/${editedFilename}` : editedFilename)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (routedReplacement) {
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append("path", relPath);
|
|
||||||
fd.append("csrf_token", csrfToken);
|
|
||||||
if (newPath) fd.append("new_path", newPath);
|
|
||||||
fd.append("file", routedReplacement);
|
|
||||||
const r = await postForm(`${baseUrl}/files/replace`, fd);
|
|
||||||
if (r.ok) {
|
|
||||||
routedReplacement = null;
|
|
||||||
if (typeof window.modals?.closeRouted === "function") window.modals.closeRouted();
|
|
||||||
scheduleRefresh(parentOf(newPath || relPath));
|
|
||||||
} else {
|
|
||||||
alert((r.body && r.body.error) || `Replace failed (HTTP ${r.status}).`);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (renaming) {
|
|
||||||
const r = await postJson(`${baseUrl}/files/move`, { src: relPath, dst: newPath });
|
|
||||||
if (r.ok) {
|
|
||||||
if (typeof window.modals?.closeRouted === "function") window.modals.closeRouted();
|
|
||||||
scheduleRefresh(parentOf(newPath));
|
|
||||||
} else {
|
|
||||||
alert((r.body && r.body.error) || `Rename failed (HTTP ${r.status}).`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function routedDeleteClicked() {
|
|
||||||
const modalContent = document.getElementById("modal-content");
|
|
||||||
if (!modalContent) return;
|
|
||||||
// rel-path lives on the text-mode textarea OR on the binary panel.
|
|
||||||
const ta = modalContent.querySelector("textarea[data-rel-path]");
|
|
||||||
const panel = modalContent.querySelector(".files-editor-binary[data-rel-path]");
|
|
||||||
const relPath = ta?.dataset?.relPath || panel?.dataset?.relPath;
|
|
||||||
if (!relPath) return;
|
|
||||||
if (!confirm(`Delete ${relPath}?`)) return;
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append("path", relPath);
|
|
||||||
fd.append("csrf_token", csrfToken);
|
|
||||||
const r = await postForm(`${baseUrl}/files/delete`, fd);
|
|
||||||
if (r.ok) {
|
|
||||||
if (typeof window.modals?.closeRouted === "function") window.modals.closeRouted();
|
|
||||||
scheduleRefresh(parentOf(relPath));
|
|
||||||
} else {
|
|
||||||
alert((r.body && r.body.error) || `Delete failed (HTTP ${r.status}).`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- register action-registry handlers ----------
|
|
||||||
|
|
||||||
fo.registerHandler("new-file", (path) => {
|
|
||||||
const url = `/overlays/${overlayId}/files/new?at=${encodeURIComponent(path)}`;
|
|
||||||
if (typeof window.modals?.openRouted === "function") {
|
|
||||||
window.modals.openRouted(url);
|
|
||||||
} else {
|
|
||||||
window.location.href = url;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
fo.registerHandler("edit", (path, _actionEl) => {
|
|
||||||
// Both text and binary files use the same edit route — the server
|
|
||||||
// picks the template branch based on is_editable(target).
|
|
||||||
const url = `/overlays/${overlayId}/files/edit?path=${encodeURIComponent(path)}`;
|
|
||||||
if (typeof window.modals?.openRouted === "function") {
|
|
||||||
window.modals.openRouted(url);
|
|
||||||
} else {
|
|
||||||
window.location.href = url;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
@ -1,423 +0,0 @@
|
||||||
// files-overlay/uploads.js — Phase A, Step 4.
|
|
||||||
//
|
|
||||||
// Owns the upload queue (concurrency 3 with XHR-based progress events)
|
|
||||||
// and the drag-drop UX on the file tree. Both OS-file drops (external)
|
|
||||||
// and row-to-folder moves (internal) flow through the same drop
|
|
||||||
// handler on treeRoot.
|
|
||||||
//
|
|
||||||
// Drag-drop is deliberately direct-bound to treeRoot (5 events:
|
|
||||||
// dragstart, dragend, dragover, dragleave, drop) per the plan escape
|
|
||||||
// hatch. They share coordinated highlight state across events, and
|
|
||||||
// delegation would obscure that coordination. treeRoot is persistent
|
|
||||||
// — never swapped out — so direct binding is safe.
|
|
||||||
//
|
|
||||||
// Upload-row cancel buttons converted from direct-bound per row to a
|
|
||||||
// single document-level delegated listener that finds the upload via
|
|
||||||
// data-upload-id on the row. uploads Map keyed by that id.
|
|
||||||
//
|
|
||||||
// Dispatch:
|
|
||||||
// * "zip" registered into __filesOverlay → folder-zip download
|
|
||||||
// (pure URL navigation). This is the last remaining action — with
|
|
||||||
// Step 4 the legacy click-delegation switch goes away.
|
|
||||||
//
|
|
||||||
// withCollisionSuffix moves here from files-overlay.js (its biggest
|
|
||||||
// caller is the upload-conflict path). Exposed on __filesOverlay for
|
|
||||||
// editor.js's save-409 "keep both" branch.
|
|
||||||
|
|
||||||
(function () {
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
const fo = window.__filesOverlay;
|
|
||||||
if (!fo) return;
|
|
||||||
|
|
||||||
const { baseUrl, csrfToken, manager, treeRoot } = fo;
|
|
||||||
const { joinPath, parentOf, basename, postJson, scheduleRefresh } = fo.helpers;
|
|
||||||
|
|
||||||
const uploadsPanel = document.querySelector(".files-uploads");
|
|
||||||
const uploadsList = uploadsPanel?.querySelector(".files-uploads-list");
|
|
||||||
const uploadsClearBtn = uploadsPanel?.querySelector(".files-uploads-clear");
|
|
||||||
|
|
||||||
// Attach a path-collision suffix: foo.txt → foo (1).txt
|
|
||||||
function withCollisionSuffix(path) {
|
|
||||||
const dot = path.lastIndexOf(".");
|
|
||||||
const slash = path.lastIndexOf("/");
|
|
||||||
if (dot > slash + 0 && dot > -1) {
|
|
||||||
return path.slice(0, dot) + " (1)" + path.slice(dot);
|
|
||||||
}
|
|
||||||
return path + " (1)";
|
|
||||||
}
|
|
||||||
fo.withCollisionSuffix = withCollisionSuffix;
|
|
||||||
|
|
||||||
// ---------- upload queue + progress panel ----------
|
|
||||||
|
|
||||||
const uploadQueue = [];
|
|
||||||
let uploadActive = 0;
|
|
||||||
const UPLOAD_CONCURRENCY = 3;
|
|
||||||
|
|
||||||
// Map<uploadId, item> — used by the delegated cancel-button click
|
|
||||||
// handler to find the item from the row's data-upload-id attribute.
|
|
||||||
const uploads = new Map();
|
|
||||||
let nextUploadId = 0;
|
|
||||||
|
|
||||||
function showPanel() {
|
|
||||||
if (uploadsPanel) uploadsPanel.hidden = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function maybeHidePanel() {
|
|
||||||
if (!uploadsList) return;
|
|
||||||
const anyActive = uploadsList.querySelector("[data-state='active'], [data-state='queued']");
|
|
||||||
if (!anyActive && uploadsList.children.length === 0) {
|
|
||||||
uploadsPanel.hidden = true;
|
|
||||||
if (uploadsClearBtn) uploadsClearBtn.hidden = true;
|
|
||||||
}
|
|
||||||
const anyDone = uploadsList.querySelector("[data-state='done'], [data-state='error']");
|
|
||||||
if (uploadsClearBtn) uploadsClearBtn.hidden = !anyDone;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildUploadRow(item) {
|
|
||||||
const li = document.createElement("li");
|
|
||||||
li.className = "files-uploads-row";
|
|
||||||
li.dataset.state = "queued";
|
|
||||||
li.dataset.uploadId = item.id;
|
|
||||||
li.innerHTML = `
|
|
||||||
<div class="files-uploads-row-meta">
|
|
||||||
<span class="files-uploads-row-name"></span>
|
|
||||||
<span class="files-uploads-row-target muted"></span>
|
|
||||||
</div>
|
|
||||||
<div class="files-uploads-row-progress"><div class="files-uploads-bar"></div></div>
|
|
||||||
<div class="files-uploads-row-status">
|
|
||||||
<span class="files-uploads-row-state">queued</span>
|
|
||||||
<button type="button" class="link-button files-uploads-row-cancel" aria-label="Cancel">✕</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
li.querySelector(".files-uploads-row-name").textContent = item.relative;
|
|
||||||
li.querySelector(".files-uploads-row-target").textContent = `→ ${item.targetFolder || "(root)"}`;
|
|
||||||
return li;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setRowState(item, state, percent) {
|
|
||||||
if (!item.row) return;
|
|
||||||
item.row.dataset.state = state;
|
|
||||||
const stateEl = item.row.querySelector(".files-uploads-row-state");
|
|
||||||
const cancelBtn = item.row.querySelector(".files-uploads-row-cancel");
|
|
||||||
const bar = item.row.querySelector(".files-uploads-bar");
|
|
||||||
if (state === "queued") {
|
|
||||||
stateEl.textContent = "queued";
|
|
||||||
bar.style.width = "0%";
|
|
||||||
} else if (state === "active") {
|
|
||||||
stateEl.textContent = `${Math.round(percent || 0)}%`;
|
|
||||||
bar.style.width = `${percent || 0}%`;
|
|
||||||
} else if (state === "done") {
|
|
||||||
stateEl.textContent = "done";
|
|
||||||
bar.style.width = "100%";
|
|
||||||
bar.classList.add("is-done");
|
|
||||||
cancelBtn.textContent = "✓";
|
|
||||||
cancelBtn.disabled = true;
|
|
||||||
} else if (state === "cancelled") {
|
|
||||||
stateEl.textContent = "cancelled";
|
|
||||||
bar.classList.add("is-cancelled");
|
|
||||||
cancelBtn.disabled = true;
|
|
||||||
} else if (state === "error") {
|
|
||||||
stateEl.textContent = item.errorText || "error";
|
|
||||||
bar.classList.add("is-error");
|
|
||||||
cancelBtn.textContent = "✕";
|
|
||||||
} else if (state === "conflict") {
|
|
||||||
stateEl.textContent = "conflict — overwrite / keep both";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelUpload(item) {
|
|
||||||
if (item.xhr && item.row.dataset.state === "active") {
|
|
||||||
item.cancelled = true;
|
|
||||||
item.xhr.abort();
|
|
||||||
}
|
|
||||||
if (item.row.dataset.state === "queued") {
|
|
||||||
const idx = uploadQueue.indexOf(item);
|
|
||||||
if (idx >= 0) uploadQueue.splice(idx, 1);
|
|
||||||
setRowState(item, "cancelled");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delegated click handler for upload-row cancel buttons. Replaces
|
|
||||||
// the per-row direct-bound listener that the legacy code attached
|
|
||||||
// inside buildUploadRow. Reads the row's data-upload-id and looks up
|
|
||||||
// the upload in the Map — works for rows added at any later time.
|
|
||||||
document.addEventListener("click", (event) => {
|
|
||||||
const btn = event.target?.closest?.(".files-uploads-row-cancel");
|
|
||||||
if (!btn) return;
|
|
||||||
const row = btn.closest(".files-uploads-row");
|
|
||||||
if (!row || !uploadsList || !uploadsList.contains(row)) return;
|
|
||||||
const id = row.dataset.uploadId;
|
|
||||||
if (!id) return;
|
|
||||||
const item = uploads.get(id);
|
|
||||||
if (item) cancelUpload(item);
|
|
||||||
});
|
|
||||||
|
|
||||||
uploadsClearBtn?.addEventListener("click", () => {
|
|
||||||
uploadsList.querySelectorAll("[data-state='done'], [data-state='error'], [data-state='cancelled']").forEach((row) => {
|
|
||||||
const id = row.dataset.uploadId;
|
|
||||||
if (id) uploads.delete(id);
|
|
||||||
row.remove();
|
|
||||||
});
|
|
||||||
maybeHidePanel();
|
|
||||||
});
|
|
||||||
|
|
||||||
function enqueueUpload(file, targetFolder, relativePath) {
|
|
||||||
const id = String(nextUploadId++);
|
|
||||||
const item = {
|
|
||||||
id,
|
|
||||||
file,
|
|
||||||
targetFolder,
|
|
||||||
relative: relativePath || file.name,
|
|
||||||
cancelled: false,
|
|
||||||
xhr: null,
|
|
||||||
errorText: null,
|
|
||||||
};
|
|
||||||
item.row = buildUploadRow(item);
|
|
||||||
uploads.set(id, item);
|
|
||||||
uploadsList.prepend(item.row);
|
|
||||||
uploadQueue.push(item);
|
|
||||||
showPanel();
|
|
||||||
pump();
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pump() {
|
|
||||||
while (uploadActive < UPLOAD_CONCURRENCY && uploadQueue.length) {
|
|
||||||
const item = uploadQueue.shift();
|
|
||||||
runUpload(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function runUpload(item, overwriteFlag) {
|
|
||||||
uploadActive += 1;
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append("target_path", item.targetFolder || "");
|
|
||||||
fd.append("relative_path", item.relative);
|
|
||||||
fd.append("csrf_token", csrfToken);
|
|
||||||
if (overwriteFlag) fd.append("overwrite", "1");
|
|
||||||
fd.append("file", item.file, item.file.name);
|
|
||||||
|
|
||||||
const xhr = new XMLHttpRequest();
|
|
||||||
item.xhr = xhr;
|
|
||||||
xhr.open("POST", `${baseUrl}/files/upload`);
|
|
||||||
xhr.setRequestHeader("X-CSRF-Token", csrfToken);
|
|
||||||
xhr.upload.onprogress = (event) => {
|
|
||||||
if (event.lengthComputable) {
|
|
||||||
const pct = (event.loaded / event.total) * 100;
|
|
||||||
setRowState(item, "active", pct);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
setRowState(item, "active", 0);
|
|
||||||
xhr.onload = () => {
|
|
||||||
uploadActive -= 1;
|
|
||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
|
||||||
setRowState(item, "done");
|
|
||||||
scheduleRefresh(joinPath(item.targetFolder || "", parentOf(item.relative)));
|
|
||||||
} else if (xhr.status === 409 && !overwriteFlag) {
|
|
||||||
setRowState(item, "conflict");
|
|
||||||
const collidingPath = joinPath(item.targetFolder || "", item.relative);
|
|
||||||
fo.askConflict(collidingPath).then((action) => {
|
|
||||||
if (action === "overwrite") {
|
|
||||||
runUpload(item, true);
|
|
||||||
} else if (action === "keep-both") {
|
|
||||||
item.relative = withCollisionSuffix(item.relative);
|
|
||||||
item.row.querySelector(".files-uploads-row-name").textContent = item.relative;
|
|
||||||
runUpload(item, false);
|
|
||||||
} else {
|
|
||||||
setRowState(item, "cancelled");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
item.errorText = `HTTP ${xhr.status}`;
|
|
||||||
try {
|
|
||||||
const text = xhr.responseText;
|
|
||||||
if (text) item.errorText = `HTTP ${xhr.status}: ${text.slice(0, 80)}`;
|
|
||||||
} catch (_e) {}
|
|
||||||
setRowState(item, "error");
|
|
||||||
}
|
|
||||||
maybeHidePanel();
|
|
||||||
pump();
|
|
||||||
};
|
|
||||||
xhr.onerror = () => {
|
|
||||||
uploadActive -= 1;
|
|
||||||
item.errorText = item.cancelled ? "cancelled" : "network error";
|
|
||||||
setRowState(item, item.cancelled ? "cancelled" : "error");
|
|
||||||
maybeHidePanel();
|
|
||||||
pump();
|
|
||||||
};
|
|
||||||
xhr.onabort = () => {
|
|
||||||
uploadActive -= 1;
|
|
||||||
setRowState(item, "cancelled");
|
|
||||||
maybeHidePanel();
|
|
||||||
pump();
|
|
||||||
};
|
|
||||||
xhr.send(fd);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- drag-drop on tree rows ----------
|
|
||||||
|
|
||||||
function rowFromEvent(event) {
|
|
||||||
return event.target.closest("[data-row-kind]");
|
|
||||||
}
|
|
||||||
|
|
||||||
function isInternalDrag(event) {
|
|
||||||
return Array.from(event.dataTransfer.types).includes("application/x-files-overlay");
|
|
||||||
}
|
|
||||||
|
|
||||||
function isExternalDrag(event) {
|
|
||||||
const types = Array.from(event.dataTransfer.types);
|
|
||||||
return types.includes("Files");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expose containers (rows AND the empty-state placeholder + tree-root)
|
|
||||||
// as drop targets — both for OS files and internal moves.
|
|
||||||
if (treeRoot) {
|
|
||||||
treeRoot.addEventListener("dragstart", (event) => {
|
|
||||||
const row = rowFromEvent(event);
|
|
||||||
if (!row) return;
|
|
||||||
const path = row.dataset.targetPath;
|
|
||||||
if (!path) return; // overlay root row isn't draggable
|
|
||||||
event.dataTransfer.setData("application/x-files-overlay", path);
|
|
||||||
event.dataTransfer.setData("text/plain", path);
|
|
||||||
event.dataTransfer.effectAllowed = "move";
|
|
||||||
row.classList.add("is-drag-source");
|
|
||||||
});
|
|
||||||
treeRoot.addEventListener("dragend", () => {
|
|
||||||
treeRoot.querySelectorAll(".is-drag-source").forEach((el) => el.classList.remove("is-drag-source"));
|
|
||||||
treeRoot.querySelectorAll(".is-drop-target").forEach((el) => el.classList.remove("is-drop-target"));
|
|
||||||
});
|
|
||||||
|
|
||||||
treeRoot.addEventListener("dragover", (event) => {
|
|
||||||
// Only react to file or row drags.
|
|
||||||
if (!isExternalDrag(event) && !isInternalDrag(event)) return;
|
|
||||||
const row = rowFromEvent(event);
|
|
||||||
// Find the closest folder row, or the tree-root container itself for
|
|
||||||
// root-level drops.
|
|
||||||
const target = row && row.dataset.rowKind === "dir" ? row : null;
|
|
||||||
const fallbackRoot = !row || row.dataset.rowKind !== "dir" ? treeRoot : null;
|
|
||||||
const dropEl = target || fallbackRoot;
|
|
||||||
if (!dropEl) return;
|
|
||||||
event.preventDefault();
|
|
||||||
event.dataTransfer.dropEffect = isInternalDrag(event) ? "move" : "copy";
|
|
||||||
// Highlight the dropEl exclusively.
|
|
||||||
treeRoot.querySelectorAll(".is-drop-target").forEach((el) => el.classList.remove("is-drop-target"));
|
|
||||||
dropEl.classList.add("is-drop-target");
|
|
||||||
});
|
|
||||||
treeRoot.addEventListener("dragleave", (event) => {
|
|
||||||
// Leaving the whole tree clears highlights.
|
|
||||||
if (!treeRoot.contains(event.relatedTarget)) {
|
|
||||||
treeRoot.querySelectorAll(".is-drop-target").forEach((el) => el.classList.remove("is-drop-target"));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
treeRoot.addEventListener("drop", async (event) => {
|
|
||||||
const internal = isInternalDrag(event);
|
|
||||||
const external = isExternalDrag(event);
|
|
||||||
if (!internal && !external) return;
|
|
||||||
event.preventDefault();
|
|
||||||
const row = rowFromEvent(event);
|
|
||||||
const dropFolder =
|
|
||||||
row && row.dataset.rowKind === "dir" ? row.dataset.targetPath || "" : "";
|
|
||||||
|
|
||||||
treeRoot.querySelectorAll(".is-drop-target").forEach((el) => el.classList.remove("is-drop-target"));
|
|
||||||
|
|
||||||
if (internal) {
|
|
||||||
const src = event.dataTransfer.getData("application/x-files-overlay");
|
|
||||||
if (!src) return;
|
|
||||||
const dst = joinPath(dropFolder, basename(src));
|
|
||||||
if (src === dst) return;
|
|
||||||
// Cycle guard: refuse moving a folder into itself or descendant.
|
|
||||||
if (dropFolder === src || dropFolder.startsWith(src + "/")) return;
|
|
||||||
const r = await postJson(`${baseUrl}/files/move`, { src, dst });
|
|
||||||
if (r.ok) {
|
|
||||||
scheduleRefresh(parentOf(src));
|
|
||||||
if (parentOf(src) !== dropFolder) scheduleRefresh(dropFolder);
|
|
||||||
} else if (r.status === 409) {
|
|
||||||
const action = await fo.askConflict(dst);
|
|
||||||
if (action === "overwrite") {
|
|
||||||
const r2 = await postJson(`${baseUrl}/files/move`, { src, dst, overwrite: true });
|
|
||||||
if (r2.ok) {
|
|
||||||
scheduleRefresh(parentOf(src));
|
|
||||||
scheduleRefresh(dropFolder);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
alert((r.body && r.body.error) || `Move failed (HTTP ${r.status}).`);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// External drop — collect entries via webkitGetAsEntry where it
|
|
||||||
// returns an Entry (real OS drag with folder support), and fall back
|
|
||||||
// to getAsFile() for any item whose entry is null (synthetic events,
|
|
||||||
// browsers without the API, or items that have no folder structure).
|
|
||||||
const items = event.dataTransfer.items;
|
|
||||||
const files = [];
|
|
||||||
const tasks = [];
|
|
||||||
if (items && items.length) {
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
|
||||||
const item = items[i];
|
|
||||||
if (typeof item.webkitGetAsEntry === "function") {
|
|
||||||
const entry = item.webkitGetAsEntry();
|
|
||||||
if (entry) {
|
|
||||||
tasks.push(walkEntry(entry, "", files));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fallback: treat as a flat file.
|
|
||||||
if (typeof item.getAsFile === "function") {
|
|
||||||
const f = item.getAsFile();
|
|
||||||
if (f) files.push({ file: f, rel: f.name });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (event.dataTransfer.files) {
|
|
||||||
for (let i = 0; i < event.dataTransfer.files.length; i++) {
|
|
||||||
files.push({ file: event.dataTransfer.files[i], rel: event.dataTransfer.files[i].name });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await Promise.all(tasks);
|
|
||||||
for (const f of files) {
|
|
||||||
enqueueUpload(f.file, dropFolder, f.rel);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function walkEntry(entry, prefix, out) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
if (entry.isFile) {
|
|
||||||
entry.file((f) => {
|
|
||||||
out.push({ file: f, rel: prefix + entry.name });
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
} else if (entry.isDirectory) {
|
|
||||||
const reader = entry.createReader();
|
|
||||||
const children = [];
|
|
||||||
const readBatch = () => {
|
|
||||||
reader.readEntries((batch) => {
|
|
||||||
if (!batch.length) {
|
|
||||||
Promise.all(
|
|
||||||
children.map((c) => walkEntry(c, prefix + entry.name + "/", out))
|
|
||||||
).then(() => resolve());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const c of batch) children.push(c);
|
|
||||||
readBatch();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
readBatch();
|
|
||||||
} else {
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- register action-registry handlers ----------
|
|
||||||
|
|
||||||
// The last remaining file-row action — pure URL navigation. Could
|
|
||||||
// equally live in core.js; placed here since uploads.js is the
|
|
||||||
// closest thematic home (folder-level action like + new file etc.).
|
|
||||||
fo.registerHandler("zip", (path) => {
|
|
||||||
window.location.href = `${baseUrl}/files/download_zip?path=${encodeURIComponent(path)}`;
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
@ -18,9 +18,7 @@
|
||||||
<div class="file-tree-children" hidden></div>
|
<div class="file-tree-children" hidden></div>
|
||||||
</li>
|
</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% set show_download = download_supported and not entry.broken %}
|
<li class="file-tree-row file-tree-row-file{% if files_overlay %} files-row{% endif %}"
|
||||||
{% set has_actions = (files_overlay or show_download) and not entry.broken %}
|
|
||||||
<li class="file-tree-row file-tree-row-file{% if has_actions %} files-row{% endif %}"
|
|
||||||
{% if files_overlay %}draggable="true" data-target-path="{{ entry.rel }}" data-row-kind="file" data-editable="{{ '1' if entry.editable else '0' }}"{% endif %}>
|
{% if files_overlay %}draggable="true" data-target-path="{{ entry.rel }}" data-row-kind="file" data-editable="{{ '1' if entry.editable else '0' }}"{% endif %}>
|
||||||
{% if entry.broken %}
|
{% if entry.broken %}
|
||||||
<span>{{ entry.name }}</span>
|
<span>{{ entry.name }}</span>
|
||||||
|
|
@ -28,20 +26,18 @@
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if files_overlay %}
|
{% if files_overlay %}
|
||||||
<button type="button" class="file-tree-name-button" data-action="edit" data-target-path="{{ entry.rel }}" data-editable="{{ '1' if entry.editable else '0' }}" title="Open in editor">{{ entry.name }}</button>
|
<button type="button" class="file-tree-name-button" data-action="edit" data-target-path="{{ entry.rel }}" data-editable="{{ '1' if entry.editable else '0' }}" title="Open in editor">{{ entry.name }}</button>
|
||||||
|
{% elif download_supported %}
|
||||||
|
<a href="{{ files_base_url }}/files/download?path={{ entry.rel|urlencode }}">{{ entry.name }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span>{{ entry.name }}</span>
|
<span>{{ entry.name }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if entry.is_symlink %}<span class="file-tree-badge">link</span>{% endif %}
|
{% if entry.is_symlink %}<span class="file-tree-badge">link</span>{% endif %}
|
||||||
<span class="muted">{{ entry.size_human }}</span>
|
<span class="muted">{{ entry.size_human }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if has_actions %}
|
{% if files_overlay and not entry.broken %}
|
||||||
<span class="files-row-actions" aria-label="File actions">
|
<span class="files-row-actions" aria-label="File actions">
|
||||||
{% if show_download %}
|
|
||||||
<a class="files-row-action" href="{{ files_base_url }}/files/download?path={{ entry.rel|urlencode }}" title="Download">⬇</a>
|
<a class="files-row-action" href="{{ files_base_url }}/files/download?path={{ entry.rel|urlencode }}" title="Download">⬇</a>
|
||||||
{% endif %}
|
|
||||||
{% if files_overlay %}
|
|
||||||
<button type="button" class="files-row-action files-row-danger" data-action="delete" data-target-path="{{ entry.rel }}" data-row-kind="file" data-row-name="{{ entry.name }}" title="Delete">✕</button>
|
<button type="button" class="files-row-action files-row-danger" data-action="delete" data-target-path="{{ entry.rel }}" data-row-kind="file" data-row-name="{{ entry.name }}" title="Delete">✕</button>
|
||||||
{% endif %}
|
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</li>
|
</li>
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,68 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if files_can_edit %}
|
{% if files_can_edit %}
|
||||||
|
<dialog id="files-editor-modal" class="modal modal-wide" aria-labelledby="files-editor-title">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="files-editor-title" class="files-editor-path"><span class="files-editor-title-text">…</span></h2>
|
||||||
|
<button type="button" class="modal-close" data-inline-modal-close aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<label class="files-editor-field">
|
||||||
|
<span class="files-field-label">Filename</span>
|
||||||
|
<input type="text" class="files-editor-filename" data-editor-filename autocomplete="off" spellcheck="false">
|
||||||
|
</label>
|
||||||
|
<p class="files-editor-rename-hint" hidden>↻ Save will rename <code class="files-rename-from"></code> → <code class="files-rename-to"></code>.</p>
|
||||||
|
|
||||||
|
<div class="files-editor-text">
|
||||||
|
<label class="files-editor-field files-editor-language-field">
|
||||||
|
<span class="files-field-label">Language</span>
|
||||||
|
<select data-editor-language-select aria-label="Editor language">
|
||||||
|
<option value="auto">auto (from filename)</option>
|
||||||
|
<option value="srccfg">srccfg (.cfg)</option>
|
||||||
|
<option value="bash">bash (.sh)</option>
|
||||||
|
<option value="plain">plain</option>
|
||||||
|
</select>
|
||||||
|
</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"></textarea></div>
|
||||||
|
</label>
|
||||||
|
<div class="files-editor-meta muted">
|
||||||
|
<span class="files-editor-byte-count">UTF-8 · 0 bytes</span>
|
||||||
|
<span>Ctrl+S to save</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="files-editor-binary" hidden>
|
||||||
|
<div class="files-editor-binary-note">
|
||||||
|
<strong>⛌ Inline editing not available</strong>
|
||||||
|
· <span class="files-editor-binary-size">—</span> · binary content
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer files-editor-footer">
|
||||||
|
<button type="button" class="danger-outline files-editor-delete">Delete</button>
|
||||||
|
<span class="files-editor-footer-spacer"></span>
|
||||||
|
<a class="button-secondary files-editor-download" href="#" hidden>⬇ Download</a>
|
||||||
|
<button type="button" class="button-secondary" data-inline-modal-close>Cancel</button>
|
||||||
|
<button type="button" class="files-editor-save">Save</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<dialog id="files-new-folder-modal" class="modal" aria-labelledby="files-new-folder-title">
|
<dialog id="files-new-folder-modal" class="modal" aria-labelledby="files-new-folder-title">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 id="files-new-folder-title">New folder in <code class="files-new-folder-target">…</code></h2>
|
<h2 id="files-new-folder-title">New folder in <code class="files-new-folder-target">…</code></h2>
|
||||||
|
|
@ -220,9 +282,6 @@
|
||||||
<ul class="files-uploads-list"></ul>
|
<ul class="files-uploads-list"></ul>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<script src="{{ url_for('static', filename='js/files-overlay/core.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='js/files-overlay.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', filename='js/files-overlay/editor.js') }}" defer></script>
|
|
||||||
<script src="{{ url_for('static', filename='js/files-overlay/dialogs.js') }}" defer></script>
|
|
||||||
<script src="{{ url_for('static', filename='js/files-overlay/uploads.js') }}" defer></script>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
{% extends base_layout %}
|
{% extends base_layout %}
|
||||||
{% block title %}{% if is_new %}New file{% else %}Edit {{ rel_path }}{% endif %} · {{ overlay.name }}{% endblock %}
|
{% block title %}Edit {{ rel_path }} · {{ 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">{% if is_new %}{{ at_folder ~ '/' if at_folder else '' }}…new file{% else %}{{ rel_path }}{% endif %}</span>
|
<span class="files-editor-title-text">{{ rel_path }}</span>
|
||||||
</h2>
|
</h2>
|
||||||
<button type="button" class="modal-close" data-routed-modal-dismiss aria-label="Close">×</button>
|
<button type="button" class="modal-close" data-routed-modal-dismiss aria-label="Close">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -16,7 +16,6 @@
|
||||||
</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>
|
||||||
|
|
@ -29,42 +28,20 @@
|
||||||
</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 }}" data-at-folder="{{ at_folder }}">{{ 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 }}">{{ 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>
|
||||||
<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 %}
|
<button type="button" class="danger-outline files-editor-delete">Delete</button>
|
||||||
<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 %}
|
<a class="button-secondary files-editor-download" href="/overlays/{{ overlay.id }}/files/download?path={{ rel_path|urlencode }}">⬇ Download</a>
|
||||||
<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_binary %} disabled{% endif %}>{% if is_new %}Create{% elif is_binary %}Replace{% else %}Save{% endif %}</button>
|
<button type="button" class="files-editor-save">Save</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -582,6 +582,47 @@ def _csrf_headers():
|
||||||
return {"X-CSRF-Token": "test-token"}
|
return {"X-CSRF-Token": "test-token"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---- /content -------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_content_returns_text(app, left4me_root: Path) -> None:
|
||||||
|
user_id = _make_user()
|
||||||
|
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
||||||
|
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
||||||
|
(overlay_dir / "motd.txt").write_text("welcome")
|
||||||
|
|
||||||
|
client = _client_for(app, user_id)
|
||||||
|
r = client.get(f"/overlays/{overlay_id}/files/content?path=motd.txt")
|
||||||
|
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.get_json() == {"path": "motd.txt", "content": "welcome"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_content_returns_415_for_binary(app, left4me_root: Path) -> None:
|
||||||
|
user_id = _make_user()
|
||||||
|
overlay_id = _make_files_overlay(left4me_root, user_id=user_id, name="files")
|
||||||
|
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
||||||
|
(overlay_dir / "image.bin").write_bytes(b"\x00\x01\x02")
|
||||||
|
|
||||||
|
client = _client_for(app, user_id)
|
||||||
|
r = client.get(f"/overlays/{overlay_id}/files/content?path=image.bin")
|
||||||
|
|
||||||
|
assert r.status_code == 415
|
||||||
|
|
||||||
|
|
||||||
|
def test_content_404_for_non_files_overlay(app, left4me_root: Path) -> None:
|
||||||
|
"""`content` is gated to type=='files' to keep the new editor scoped."""
|
||||||
|
user_id = _make_user()
|
||||||
|
overlay_id = _make_overlay(left4me_root, user_id=user_id, name="ws") # type='script' helper
|
||||||
|
overlay_dir = left4me_root / "overlays" / str(overlay_id)
|
||||||
|
(overlay_dir / "motd.txt").write_text("hi")
|
||||||
|
|
||||||
|
client = _client_for(app, user_id)
|
||||||
|
r = client.get(f"/overlays/{overlay_id}/files/content?path=motd.txt")
|
||||||
|
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
# ---- /save ---------------------------------------------------------------
|
# ---- /save ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1023,6 +1064,7 @@ def test_mutating_endpoints_404_for_workshop_overlay(app, left4me_root: Path) ->
|
||||||
("post", f"/overlays/{overlay_id}/files/delete",
|
("post", f"/overlays/{overlay_id}/files/delete",
|
||||||
{"data": {"path": "x", "csrf_token": "test-token"}}),
|
{"data": {"path": "x", "csrf_token": "test-token"}}),
|
||||||
("get", f"/overlays/{overlay_id}/files/download_zip?path=", {}),
|
("get", f"/overlays/{overlay_id}/files/download_zip?path=", {}),
|
||||||
|
("get", f"/overlays/{overlay_id}/files/content?path=x.txt", {}),
|
||||||
]
|
]
|
||||||
for method, url, kwargs in paths:
|
for method, url, kwargs in paths:
|
||||||
kwargs = dict(kwargs)
|
kwargs = dict(kwargs)
|
||||||
|
|
|
||||||
|
|
@ -95,49 +95,15 @@ 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_renders_binary_template_for_non_editable(tmp_path, monkeypatch):
|
def test_edit_route_415s_for_non_editable_file(tmp_path, monkeypatch):
|
||||||
"""Phase B Step 7: non-editable files no longer return 415; they render
|
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "edit-415.db")
|
||||||
the editor template with is_binary=True so the modal shows the
|
# Forge a non-editable file by writing binary garbage.
|
||||||
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")
|
||||||
text = response.get_data(as_text=True)
|
assert response.status_code == 415
|
||||||
|
|
||||||
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):
|
||||||
|
|
@ -170,90 +136,3 @@ 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_overlay_detail_no_longer_renders_legacy_editor_dialog(tmp_path, monkeypatch):
|
|
||||||
"""Phase B Step 9: the inline <dialog id="files-editor-modal"> is gone.
|
|
||||||
All editor flows route through the URL-addressable modal instead."""
|
|
||||||
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "no-legacy-dialog.db")
|
|
||||||
response = client.get(f"/overlays/{overlay_id}")
|
|
||||||
text = response.get_data(as_text=True)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert 'id="files-editor-modal"' not in text
|
|
||||||
# The other inline dialogs (new-folder, conflict, delete-confirm) are
|
|
||||||
# still inline — only the editor dialog moved out.
|
|
||||||
assert 'id="files-new-folder-modal"' in text
|
|
||||||
|
|
||||||
|
|
||||||
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