Commit graph

486 commits

Author SHA1 Message Date
mwiegand
be3a00a8f5
feat(css): state-cluster, inspection-strip, compact player grids
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 21:12:31 +02:00
mwiegand
e2b6f39828
feat(server-actions): remove inline job-log; link → job-log-modal trigger
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 21:10:02 +02:00
mwiegand
6656588b8f
refactor(live-state): hoist display_name into {% set %} to DRY card loops
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 21:08:46 +02:00
mwiegand
20fb564246
feat(live-state): compact 4-col current + 5-col recent chips + N Recent trigger
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 21:07:15 +02:00
mwiegand
9554661e5a
fix(live-state): cap recent_rows query at 50 to bound row count
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 21:05:13 +02:00
mwiegand
309354942a
feat(live-state): expose sliced recents + total count to template
Drop .limit(20) from the recent_rows query so the full history window is
available for the future recent-players modal; derive recent_players_overview
(first 10) and recent_players_total_count from the unbounded result and pass
both into _live_state.html alongside the existing recent_players key.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 21:02:57 +02:00
mwiegand
7963b69cb3
feat(templates): add _macros.html with config_field macro 2026-05-17 21:00:01 +02:00
mwiegand
5ca3db4a6e
docs(spec): server detail page redesign
Groups server state into a single top cluster (lifecycle + live state
+ config), demotes log/console/files to a tabbed inspection strip with
expand-to-modal, and routes the job log behind a modal so the page no
longer reflows during HTMX polls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:47:11 +02:00
mwiegand
b45adcd819
feat(console): add color legend under console input
Three labelled swatches mirror the dropdown's name colors so users
can decode the cvar/command/sourcemod color scheme without guesswork.
Plain-text caveat next to the sourcemod swatch notes that those
commands are plugin-dependent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:56:54 +02:00
mwiegand
44e82e3c42
feat(console): color-code sm_* (SourceMod) suggestions distinctly
sm_* commands depend on the SourceMod plugin being loaded on the
target server, which is not always the case. Render their names in
the third syntax-palette color (purple via --cm-number) so the user
can tell at a glance that these may not exist on the server they
are targeting. Vanilla cvars and commands keep their existing
pink/green colors. Theme-aware via the existing token swap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:44:27 +02:00
mwiegand
d21cd72f8d
test(files): cover server-detail hover-download
New test module (test_server_detail.py) — the server-detail page is
NOT a files-overlay (files_overlay=False in the template), it just
reuses _overlay_file_tree.html in read-only mode. Tests live
separately to make the semantic split visible.

The test navigates to /servers/<id>, hovers the server.cfg row to
defeat the CSS :hover gate on .files-row-actions
(opacity:0/pointer-events:none → 1/auto), clicks the ⬇ download
link, and asserts both the suggested filename and the byte content
of the downloaded file.

The :hover gate is load-bearing: without locator.hover() first,
pointer-events:none blocks the click. A regression that ships
actions always-visible would change the user-flow ergonomics and
needs to update this test deliberately.

Per docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md
(Tier 3).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:22:17 +02:00
mwiegand
b43bb9e0fa
test(files): add server-detail e2e fixture
Adds server_with_files fixture: seeds alice + a Blueprint + a Server
row pinned to port 27015, then pre-creates
LEFT4ME_ROOT/runtime/<server_id>/merged/ with a single seed file.
The /servers/<id> page lists files from that merged directory (the
kernel-overlayfs view of a running server), which never exists on
dev/test boxes — without the pre-mkdir + seed, the page renders
the empty-state branch instead of file rows.

Server.port is a global UNIQUE constraint on the model, but every
e2e test gets its own SQLite DB, so a fixed L4D2-default port is
fine.

Sets the stage for the Tier 3 hover-download test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:20:20 +02:00
mwiegand
e89dd25cdd
test(files): cover internal drag row to folder move
Drags server.cfg from the overlay root onto the cfg/ folder row,
asserts both that the file actually moved on disk AND that the tree
reflects the move after the debounced HTMX refresh of both parents.

Exercises the internal-drag path in uploads.js:332-366: the
custom-MIME setData/getData contract, the POST /files/move call,
and the dual scheduleRefresh(parentOf(src)) + scheduleRefresh(dst)
on success. Playwright's locator.drag_to() synthesizes setData
correctly — it relies on dataTransfer.getData(), NOT
webkitGetAsEntry which Playwright cannot fake.

Pitfalls handled inline: scoped the source/target locators to
li.file-tree-row-{file,dir} because action buttons inside the row
duplicate data-target-path + data-row-kind and would trip strict
mode. Used to_have_count instead of to_be_visible because cfg's
children div is collapsed (hidden) by default — the moved row is
in the DOM but not visually rendered until cfg is expanded.

Per docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md
(Tier 2 case C).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:18:57 +02:00
mwiegand
a6be29c6d2
test(files): cover Escape closes editor with no stale state
Opens server.cfg, drives the CM6 controller to a dirty buffer,
presses Escape to close without saving, then reopens the same file
and asserts the editor shows the original disk content — not the
discarded buffer.

Pins two invariants: native <dialog>'s cancel→close path stays
intact (no JS shortcut around Escape), and the reopened editor
fetches a FRESH fragment via htmx.ajax with CM6 re-mounting on
the new textarea. A regression that cached buffer state to "feel
snappy" would fail this test loudly.

Per docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md
(Tier 2 case B).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:17:21 +02:00
mwiegand
b222fdc918
test(files): cover share-URL deep link reopens editor
Navigates directly to /overlays/<id>?modal=<urlencoded edit path>
and asserts the routed editor auto-opens on the right file.

This is the central guarantee of the URL-addressable modals spec —
copy the URL, share it, and the recipient lands on the same modal
state. A regression in modals.js's DOMContentLoaded bootstrap (the
URLSearchParams.get("modal") → fetchAndShowRouted chain) would
silently dead-link every shared URL; this test fails loudly instead.

Per docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md
(Tier 2, highest-value case).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:16:51 +02:00
mwiegand
2fcf9c3778
docs(console): note single-form assumption near activeBinding
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:10:01 +02:00
mwiegand
97a4e51f8a
refactor(console): module-scope listeners + form-level event delegation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 19:05:27 +02:00
mwiegand
25016b0ff6
refactor(css): consolidate monospace stack into --font-mono token
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 19:00:11 +02:00
mwiegand
81ba4ac83a
test(files): cover filename-rename on save
Opens server.cfg, changes the filename input to server-renamed.cfg,
clicks Save, and asserts: the routed modal closes, the old name is
gone from disk, the new name carries the original content, and the
tree row swaps over. Pins the rename-on-save branch in
routedSaveClicked (the non-is_new path that diffs originalLeaf vs
editedFilename and emits `new_path`).

Deliberately omits __filesEditor.setContent — rename should preserve
the textarea-seeded content. A regression that wrote an empty body
fails on the content-equality assertion.

Tier 1 complete: 7 tests + fixture extension. Full suite runs in
<10s on warm Chromium.

Per docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:46:40 +02:00
mwiegand
3ea57b2bdb
test(files): cover new-folder + delete cycle
Creates a folder via the inline new-folder dialog's Enter-keydown
submit path, then deletes it via the inline delete-confirm dialog.
Each step asserts disk + tree state. The Enter path is the
direct-bound listener (not delegated), so it's the most likely to
break under future refactors — pinning it here surfaces such
regressions immediately.

Row-action buttons (`✕`) live inside `<li draggable="true">` — the
draggable ancestor confuses Playwright's hit-test even though real
browsers click through fine. The click uses force=True to skip the
hit-test (documented inline).

Per docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:44:58 +02:00
mwiegand
6b0fbb75bf
test(files): cover binary replace via browse
Opens icon.png, attaches new bytes via Playwright's file chooser
(intercepting the click → hidden-input.click() → OS picker chain),
clicks Replace, and asserts the new bytes land on disk under the
unchanged filename. Covers the multipart /files/replace endpoint and
the click → setRoutedReplacement → save-enabled UI wiring.

Per docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:42:13 +02:00
mwiegand
c1ea5eb11e
test(files): cover binary editor UI
Opens icon.png and asserts the routed editor renders the binary
branch of overlay_file_editor.html: replace-zone present, save
button labelled "Replace" and disabled on open, download link
pointing back at /files/download.

The same /files/edit route serves both text and binary modes — the
server picks the template branch from is_editable() + a magic-byte
check. Without this test, a regression that flipped a binary file
into the text branch would mean rendering raw PNG bytes inside an
editable textarea (and a misleadingly working save button).

Also asserts no textarea[data-rel-path] is in the DOM, so a future
regression that left both branches enabled fails loudly.

Per docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:41:37 +02:00
mwiegand
aad8356613
test(files): cover 409 askConflict keep-both path
Tries to create `cfg`, which collides with the seeded directory of the
same name. /files/save returns 409 "destination is not a file";
routedSaveClicked routes that through fo.askConflict, which opens the
inline #files-conflict-modal on top of the still-open routed editor.
Clicking keep-both triggers a second POST with the suffixed path
(`cfg (1)`), the routed modal closes, and the new row materialises in
the tree.

This is the F4 path from 8dc14f0 ("wire askConflict into the routed
new-file 409 path"). Before that commit, the routed code branch fell
through to a generic alert(). With this test in place, a missing
call site fails loudly instead of silently.

Pins three invariants:
  * The conflict dialog is INLINE, not routed — it appears without a
    URL change (the decision tree in AGENTS.md "Modals: inline vs
    routed" hinges on this).
  * .files-conflict-path echoes the original colliding path, not the
    computed suffix — the suffix is internal, the user sees the
    collision.
  * withCollisionSuffix("cfg") → "cfg (1)" (no dot after the last
    slash → trailing-suffix branch in uploads.js).

Per docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:40:56 +02:00
mwiegand
d92f71f691
test(files): cover routed new-file flow
Clicks `+ new file` at the overlay root, fills the routed editor's
filename + CM6 content, and clicks Create. Asserts the modal closes,
the file lands on disk, AND the new row appears in the live tree
after the debounced HTMX refresh — the last assertion catches the
class of bug where /files/save persists but
scheduleRefresh(parentOf(fullPath)) never lands a fresh listing.

The new-file modal reuses overlay_file_editor.html with is_new=True;
this test exercises the branch in routedSaveClicked that composes
fullPath from filename + data-at-folder, distinct from the
rename-on-save path the edit-mode test covers.

Per docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:39:51 +02:00
mwiegand
3cafdba2cc
test(files): cover text-file edit round trip
Opens server.cfg through the file-row name button, drives the CM6
controller via window.__filesEditor.setContent, clicks save, and
asserts both that the routed modal closes and that the new bytes
landed on disk under overlay_root. Guards against four classes of
regression at once: the /files/edit fragment delivering the wrong
data-rel-path, editor.js's htmx:afterSwap re-init failing to wire
__filesEditor, routedSaveClicked stopping short of closeRouted(),
and the /files/save endpoint failing to persist.

Adds a `_wait_for_routed_editor` helper centered on `.cm-content`
inside `#files-editor-fragment` — the textarea itself is
display:none after CM6 mounts, so to_be_visible on the textarea
would always fail; the cm-content surface is the real "editor is
ready" signal.

Per docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:39:03 +02:00
mwiegand
19357124f4
docs(editor): document vocab argument shape on rankVocab
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 18:38:01 +02:00
mwiegand
2060af44f2
fix(console): guard against missing window.__rankVocab
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 18:37:42 +02:00
mwiegand
911bbf9103
test(files): add files-overlay e2e fixture
Extracts the shared boot/serve plumbing out of live_server into
_boot_app + _serve helpers, then adds files_overlay_server: a
function-scoped fixture that monkey-patches LEFT4ME_ROOT to tmp_path
BEFORE create_app(), seeds a files-type Overlay owned by alice, and
populates the overlay root with one editable text file, one binary
file, and one nested folder. Sets up the surface area the Tier-1
files-overlay e2e tests need without duplicating the live_server
boilerplate.

Also exposes a top-level `login(page, base_url, ...)` helper so
future test modules can share it instead of re-pasting the form-POST
flow.

Per docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:35:43 +02:00
mwiegand
2d5a72b317
fix(editor): rebuild script and docs cover both bundles
build-editor.sh was calling npx esbuild directly for editor-entry.js
only, leaving vocab-rank.bundle.js stale when devs used the documented
rebuild path. Switch to npm run build (the single source of truth in
package.json) so both bundles are always rebuilt together. Add
vocab-rank.bundle.js to the sha256 manifest and update the vendor README
to describe both build artifacts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 18:21:47 +02:00
mwiegand
2173685de6
feat(console): wire up autocomplete bundle + stylesheet in base.html
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 17:56:11 +02:00
mwiegand
7aa9b0b49c
fix(console): use existing CSS tokens for autocomplete dropdown
Replace phantom token refs (--border-strong, --fg-muted, --font-mono)
with the project's real tokens (--color-border, --color-muted) or a
plain font stack where no project-wide token exists.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 17:54:44 +02:00
mwiegand
5a85153c4f
feat(console): add autocomplete dropdown stylesheet 2026-05-17 17:52:02 +02:00
mwiegand
cdb6a87960
fix(console): apply review fixes for first-keystroke race and exact-match Tab
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 17:50:31 +02:00
mwiegand
40961eacdd
feat(console): add vanilla autocomplete dropdown module 2026-05-17 17:45:31 +02:00
mwiegand
d8dd2d23d2
feat(editor): build standalone vocab-rank bundle for console
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 17:41:24 +02:00
mwiegand
ca6a7aa74c
refactor(editor): use shared rankVocab in autocomplete
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 17:37:54 +02:00
mwiegand
2875993339
docs(console): add implementation plan for console autocomplete
Bite-sized, TDD-style plan for the spec at
docs/superpowers/specs/2026-05-17-console-command-autocomplete-design.md.
Seven tasks (rankVocab extraction → second esbuild target → vanilla
dropdown module → stylesheet → wire-up → smoke test). Will be executed
task-by-task via subagent-driven-development.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:33:56 +02:00
mwiegand
9ff93164d7
feat(editor): extract pure rankVocab module + tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 17:33:26 +02:00
mwiegand
02d96b593e
docs(console): add design for console command autocomplete
Spec for adding srccfg-vocab autocomplete to the runtime console input
on server-detail. Reuses the editor's ranking algorithm (extracted to a
shared module) but ships a small vanilla dropdown so the console stays
independent of CodeMirror. Tab/Esc drive the dropdown; ArrowUp/Down keep
recalling history; Enter always submits the typed text, never the
highlighted suggestion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:20:47 +02:00
mwiegand
86ac10a1d9
docs(files): handoff plan for files-overlay Playwright e2e tests
Companion to the just-shipped files-overlay rewrite. The rewrite
verified each step's behavior live in Chromium but left no automated
browser regression net. This handoff plan specifies what to add:

  * Fixture extension (conftest.py): a files_overlay_server fixture
    that seeds a files-type Overlay with one text file, one binary
    file, and a nested folder under tmp_path-rooted LEFT4ME_ROOT.
  * 11 test cases in three tiers — Tier 1 covers the critical paths
    (text edit save, create-new, 409→askConflict, binary replace,
    new-folder + delete, rename-on-save), Tier 2 rounds out drag /
    upload / deep-link, Tier 3 hits the server-detail download
    button.
  * Patterns to follow + pitfalls (the SESSION_COOKIE_SECURE=0 gotcha,
    the data-rel-path location split between text and binary modes,
    the htmx.ajax async wait, why os-drag-with-folders can't be
    synthesized).

Pinned references at the bottom point at the existing test_editor.py
pattern model and the relevant module-header comments. Estimated
half-day for the critical 7 cases, full day for the full 11.

Lives under docs/superpowers/plans/ per the project's planning-
artifact convention. Move to specs/ if it's executed and turned into
shipped tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:20:37 +02:00
mwiegand
e1723f751c
docs(agents): update modal-pattern reference + add files-overlay layout
Two updates to AGENTS.md after the files-overlay rewrite:

1. The "canonical pattern" reference at the bottom of the inline-vs-
   routed modals section pointed at files-overlay.js lines ~599-664.
   That file is gone. Updated the reference to point at the new
   single-purpose location: editor.js's document-level click listener
   and the routedSaveClicked / routedReplaceClicked / routedDeleteClicked
   functions.

2. Added a "Files overlay: module layout" subsection right after the
   modals one. Names the four modules under static/js/files-overlay/,
   what each owns, the window.__filesOverlay action-registry contract,
   and the recipe for adding a new file-row action. Future agents
   touching the file manager hit a one-paragraph orientation instead
   of re-deriving the layout from git log.

No behavior change. The "Modals: inline vs routed" decision tree
itself stays accurate post-rewrite: editor is routed; new-folder,
delete-confirm, conflict stay inline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:20:23 +02:00
mwiegand
8dc14f0cca
feat(files): wire askConflict into the routed new-file 409 path
The is_new branch of routedSaveClicked in editor.js used to alert on
409 and force the user to manually pick a different filename. Restore
the overwrite / keep-both / cancel prompt the legacy openEditorTextNew
flow had (via askConflict, lost when Step 9 deleted legacySaveClicked).

Flow on 409:
  * "overwrite" → re-POST /save with the same path. /save overwrites
    in place when the destination is a regular file.
  * "keep-both" → compose a suffixed path via fo.withCollisionSuffix
    (now multi-extension-aware after F3) and POST that.
  * "cancel" → leave the routed modal open with the user's typed
    content intact so they can edit the filename and retry.

Defensively gates the askConflict + withCollisionSuffix calls on
typeof === "function" so older bookmarks (or a dev environment with
one of the modules missing) fall back to an alert rather than a
TypeError. The 409 alert branch is preserved for that path.

Note on when /save actually 409s: regular-file collisions overwrite
silently (200). 409 fires only when the new path collides with a
directory (or a symlink, or a non-file fs entry) — same contract as
the legacy flow had.

Verified live on /overlays/2 in Chromium with a real round-trip:
  1. Click "+ new folder" → create tmp_409_probe
  2. Click "+ new file" → type "tmp_409_probe" → click Create
  3. /save returns 409 (destination is not a file) → askConflict
     opens with the colliding path displayed
  4. Routed modal stays open behind the conflict dialog (typed
     content preserved)
  5. Cancel on conflict → conflict closes, routed modal still open
  6. Cleanup: delete the tmp_409_probe folder via the action API
  * No console errors throughout
  * Demo overlay state unchanged after cleanup

pytest stays at 577 passed, 1 skipped, 3 deselected (no Python
changes in F4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:12:14 +02:00
mwiegand
8ccb2339ca
fix(files): handle double-extensions in withCollisionSuffix
The legacy implementation used lastIndexOf('.') which splits inside
compressed-tarball extensions: foo.tar.gz collided to foo.tar (1).gz,
which then isn't valid as a .gz to unpack and is also ugly.

Recognize the common double-extensions (.tar.gz, .tar.bz2, .tar.xz,
.tar.zst, .tar.lz, .tar.lzma) and treat the entire suffix as a single
logical extension when collision-renaming. So:

  foo.txt          → foo (1).txt              (unchanged)
  foo.tar.gz       → foo (1).tar.gz           (fixed)
  archive.tar.bz2  → archive (1).tar.bz2      (fixed)
  ARCHIVE.TAR.XZ   → ARCHIVE (1).TAR.XZ       (case-insensitive detect)
  subdir/foo.tar.gz → subdir/foo (1).tar.gz   (works with nested paths)
  backup.tar       → backup (1).tar           (single .tar, unchanged)
  config.local.json → config.local (1).json   (multi-dot non-tar, unchanged)
  README           → README (1)               (no extension, unchanged)
  .hidden          → ' (1).hidden'            (legacy quirk preserved)

Detection is lowercase against the basename only, so /path/with.dots/
in folder names doesn't trip the match.

The .hidden edge case (leading-dot basename, no extension) keeps its
legacy '" (1).hidden"' result — fixing it requires changing the dot >
slash predicate which would also shift other behaviors. Marked
separately if anyone wants to revisit.

The helper is exposed on window.__filesOverlay.withCollisionSuffix
and is called from two paths in uploads.js (the upload-conflict
'keep both' branch and the move-conflict 'keep both' branch via
askConflict). Both paths now produce a sensible filename when the
colliding path uses a double-extension.

No pytest tests added: the helper is JS-only and the project has no
JS test framework (per the plan's Out-of-Scope clause). Chromium
manual verification via window.__filesOverlay.withCollisionSuffix
called on 10 inputs confirms each case above.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:06:44 +02:00
mwiegand
1de61e8e4d
refactor(files): drop the always-True download_supported flag
download_supported was set to True at every call site (3 templates, 2
route render calls) and never to False. The {% set show_download =
download_supported and not entry.broken %} branch in
_overlay_file_node.html was therefore equivalent to {% set
show_download = not entry.broken %}, and {% set has_actions =
(files_overlay or show_download) and not entry.broken %} simplifies
further: when not broken, both clauses make has_actions True
regardless of files_overlay; when broken, both clauses make it False.
So has_actions = not entry.broken.

Collapsed:
  * Removed download_supported = True from overlay_detail.html (×2),
    server_detail.html, and the two render_template calls in
    files_routes.py
  * Removed the show_download intermediate and the inner {% if
    show_download %} guard in _overlay_file_node.html (the surrounding
    {% if has_actions %} already guarantees not entry.broken)
  * has_actions now directly equals not entry.broken

If a future requirement actually wants per-overlay download-disable,
re-introduce a flag at that point with a real callsite that sets it
False (and a test that exercises that path).

pytest: 577 passed, 1 skipped, 3 deselected — unchanged. None of the
existing tests gated on download_supported.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:05:17 +02:00
mwiegand
b6db596d0f
docs(files): drop the completed files-overlay rewrite plan
The plan at docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md
described work that's now shipped across commits 4fa3964..5f82950
(12 implementation steps + the Step 0 prefactor). The design intent
lives in the resulting code (4 modules under static/js/files-overlay/,
the URL-addressable editor template, the shared route helpers) and
the commit messages capture each step's reasoning.

Git history preserves the plan content if anyone needs the original
roadmap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:02:50 +02:00
mwiegand
5f82950d7c
feat(files): delete /files/content endpoint + extract _apply_optional_rename
Step 12/12 of docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md.
End of Phase C — end of the rewrite plan.

Two cleanups in one commit:

1. Delete GET /overlays/<id>/files/content.

The legacy openEditorForFile in files-overlay.js was its only caller,
and Step 9 deleted that code path. grep confirms no remaining live
callers (the matches in .claude/worktrees/* are other in-flight
branches; matches in docs/ are plan/spec text describing the route's
history). Removed:

  * The @bp.get route function (was already a thin wrapper around
    _load_file_for_editing from Step 11)
  * The endpoint's mention in the module docstring
  * test_content_returns_text
  * test_content_returns_415_for_binary
  * test_content_404_for_non_files_overlay
  * The /files/content entry in the batched "non-files-overlay 404s
    everywhere" test

The _load_file_for_editing helper from Step 11 becomes single-caller
(only the edit route uses it now). Kept because the function name
gives the prelude a useful named concept and inlining would add ~17
lines of low-density logic into overlay_file_edit_page.

2. Extract _apply_optional_rename.

overlay_file_save and overlay_file_replace had near-identical rename
branches: safe_resolve_for_move → 422-on-traversal, 409-if-dst-exists,
mkdir-parents, os.rename → echo_path = new_path. Extracted into
_apply_optional_rename(overlay, path, new_path) → (write_target,
echo_path) | Response.

The helper handles both cases:
  * Rename: atomic rename, returns (dst, new_path)
  * No rename: safe_resolve_for_write, mkdir parents, returns
    (write_target, path)

Save's "destination is not a file" 409 (creation branch) stays inline
in overlay_file_save — it's save-specific behavior that doesn't apply
to /replace (which assumes a file exists or creates one).

Subtle behavior change in /save: the prior code called
safe_resolve_for_write(new_path or path) upfront and then potentially
overrode write_target via safe_resolve_for_move. The new code only
calls one validator per branch. Confirmed equivalent: per
overlay_files.py:36-58 (safe_resolve_for_write) vs. lines 76-106
(safe_resolve_for_move), the dst-side checks are identical (root
escape, symlink refuse, parent-is-dir) and safe_resolve_for_move adds
strictly more (src must exist, cycle check for directory moves). pytest
covers the save/replace paths and stays green.

pytest: 580 → 577 passed, 1 skipped, 3 deselected. The -3 is the 3
deleted /files/content tests.

files_routes.py: ended at 735 lines. The plan estimated ~450 — the
delta is the new /files/new route (~43 lines, Step 5), the binary
template branch (Step 7), the _load_file_for_editing helper (Step 11),
and module-header expansions. The structural goal (no dead routes,
shared helpers across paths) is met.

End-of-plan summary:
  * 1091-line files-overlay.js → 4 focused modules totaling 1191 lines
    (core.js 247, editor.js 309, dialogs.js 212, uploads.js 423)
  * Editor flows (text edit, binary replace, create new) all run
    through URL-addressable modals (?modal= deep-linkable)
  * Legacy <dialog id="files-editor-modal"> deleted
  * /files/content deleted (dead endpoint)
  * Shared helpers _load_file_for_editing + _apply_optional_rename
  * pytest stayed green at every step
  * Chromium-verified every step

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:29:55 +02:00
mwiegand
3facc323b6
refactor(files): extract _load_file_for_editing helper
Step 11/12 of docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md.

overlay_file_content and overlay_file_edit_page used to repeat the
same prelude: load the files-overlay, resolve sub_path safely, confirm
it's a regular file, attempt a UTF-8 read with the is_editable sniff
+ UnicodeDecodeError-on-tail fallback. The bodies have diverged in
two places (the binary branch differs — content endpoint returns 415,
edit page renders the binary template) but the prelude is identical.

Extract _load_file_for_editing(overlay_id, sub_path, user). Returns
either a Flask Response (400/404/403/500) or a 5-tuple
(overlay, target, content_or_None, is_binary, byte_count). Both
routes call it and translate the tuple into their output shape:

  * overlay_file_content: 415 when is_binary, else jsonify text
  * overlay_file_edit_page: render binary template when is_binary,
    else render text template

The previous _render_binary_editor helper folds back into the route
since it was only called from there and benefits from inlining now
that the prelude is shared.

is_binary in the tuple is True both when is_editable() said no up
front (8-KiB sniff) AND when the tail-of-file read raised
UnicodeDecodeError — collapsing what was a duplicated try/except
branch in each route.

No behavior change. pytest still 580 passed, 1 skipped, 3 deselected.

Step 12 (next) audits /files/content callers and likely deletes the
endpoint since Phase B removed its only caller (legacy
openEditorForFile via files-overlay.js); the shared helper would
then collapse to single-caller status but remains useful for
grouping the prelude into a named concept.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:24:32 +02:00
mwiegand
ddf03c6fb8
feat(files): delete files-overlay.js stub + its script tag
Step 10/12 of docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md.
End of Phase B.

The 19-line tombstone file at static/js/files-overlay.js had no code
since Step 4; it was kept around as a stable resolution target for the
<script> tag while the modules de-duplicated. After Step 9 made the
modules complete and stand-alone, the stub is just a wasted HTTP
request and a misleading filename in the manifest. Deleted, plus the
matching <script defer> tag in overlay_detail.html.

Final loader shape — exactly 4 script tags, all defer, all in
files-overlay/:
  1. core.js   — helpers, manager guard, action registry
  2. editor.js — URL-addressable editor (text + binary + new-file)
  3. dialogs.js — new-folder + delete-confirm + conflict
  4. uploads.js — upload queue + drag-drop + zip action

Verified live on /overlays/2 in Chromium:
  * Exactly 4 files-overlay script tags load (no more files-overlay.js)
  * window.__filesOverlay registry has its 10 keys; askConflict +
    withCollisionSuffix + handleAction + registerHandler all functions
  * File tree renders (3 file rows + 1 folder row, as before)
  * No legacy #files-editor-modal in DOM
  * No console errors
  * pytest still 580 passed, 1 skipped, 3 deselected

Phase B end-state vs. Phase A end-state:
  * editor.js: 550 → 309 lines (Step 9 gutted legacy)
  * files-overlay.js: 19 → 0 (deleted in this step)
  * 5 new pytest tests for the /files/new + binary template
  * Legacy <dialog id="files-editor-modal"> gone from
    overlay_detail.html
  * Editor flows (text edit, binary replace, create new) all run
    through the URL-addressable modal (?modal= deep-linkable)

Phase C (steps 11–12) is server-only: extract a shared path-resolution
helper between overlay_file_content + overlay_file_edit_page, then
delete /files/content if grep confirms no remaining callers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:22:12 +02:00
mwiegand
10f93b863b
feat(files): delete legacy editor dialog + gut editor.js legacy paths
Step 9/12 of docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md.

The inline <dialog id="files-editor-modal"> block in overlay_detail.html
is gone. All editor flows (text edit, binary replace, create new) now
exclusively use the URL-addressable modal swapped into #modal-content.

editor.js is now single-purpose (URL-addressable only). Removed:
  * editorDialog reference and the editorEls DOM-ref struct
  * The legacy editor state object
  * CM6 bridge wrappers + UI helpers (getEditorValue/setEditorValue/
    setEditorTitle/updateByteCount/updateRenameHint/updateSaveEnabled/
    setQueuedReplacement) — they only ever drove the legacy dialog
  * withCollisionSuffix (uploads.js still has its copy for the
    upload-conflict path; editor.js no longer needs it since the
    URL-addressable conflict path is "alert + keep modal open" rather
    than overwrite/keep-both)
  * openEditorTextNew and openEditorForFile — both functions were
    already unreachable from a user action after Steps 6/8
  * inLegacyEditor predicate
  * Direct-bound listeners on editorEls.filename / contentBox /
    editorDialog (input, keydown for Ctrl+S, close)
  * Legacy branches in every delegated handler (dragover, dragleave,
    drop, change, click)
  * legacySaveClicked and legacyDeleteClicked

What stays:
  * Routed state (routedReplacement, isRoutedBinaryMode,
    setRoutedReplacement, updateRoutedBinarySaveEnabled)
  * Delegated dragover/dragleave/drop/change/click handlers — now
    single-path each, no legacy/routed branching
  * Filename input delegated listener for routed binary mode (so
    rename-only Replace stays reachable)
  * modal-container close listener that clears routedReplacement
  * routedSaveClicked (text edit + is_new), routedReplaceClicked
    (binary, with rename-or-replace fork), routedDeleteClicked
  * "new-file" and "edit" registered handlers (the "edit" handler is
    no longer split editable/binary — the server picks the template
    branch)

routedDeleteClicked gained one capability that was missing in Step 8:
it now reads rel-path from either the textarea (text mode) OR the
.files-editor-binary panel, so deletion works for binary files in the
URL-addressable modal too (previously routed-mode binary delete fell
through to legacy, which is now gone).

Test added: test_overlay_detail_no_longer_renders_legacy_editor_dialog
asserts the legacy dialog markup is absent from /overlays/<id> while
the other inline dialogs are still present (Step 3 didn't move them).

Numbers:
  editor.js: 660 → 309 lines (-351). Plan estimated ~200; actual is
  ~50% larger due to module-header comments + the rename-or-replace
  fork in routedReplaceClicked. Pure-routing-only and single-mode
  per click, which was the structural goal.

  Total across files-overlay/: 1432 → 1191 lines.

pytest: 579 → 580 passed, 1 skipped, 3 deselected.

Verified live on /overlays/2 in Chromium:
  * id="files-editor-modal" not in DOM; new-folder/delete/conflict
    dialogs still present
  * "+ new file" → routed modal, Create button
  * Click other.cfg (editable) → routed modal, Save button, content
    pre-filled
  * Click test.png (binary) → routed modal, Replace button, initially
    disabled
  * No console errors

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:20:27 +02:00
mwiegand
e75280f780
feat(files): migrate binary-replace JS flow to URL-addressable modal
Step 8/12 of docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md.

The "edit" action handler in editor.js now ALWAYS opens the URL-
addressable modal — both editable text files and binary files. The
server's /files/edit route picks the template branch (text editor vs.
binary-replace panel) based on is_editable(target); data-editable on
the action element becomes informational only.

Binary-replace handlers extended in editor.js to cover the routed
modal (the panel that .files-editor-binary now lives inside):

  * routedReplacement (module-scope nullable) — the queued File for
    routed binary mode. Cleared on modal-container close so reopening
    starts fresh.
  * setRoutedReplacement(mc, file) — updates idle/queued markup,
    populates name/size labels, calls updateRoutedBinarySaveEnabled.
  * isRoutedBinaryMode(mc) — true when #modal-content holds a binary
    panel; used by the click delegation to route Save → replace.
  * updateRoutedBinarySaveEnabled(mc) — enables Save when a file is
    queued OR the filename input has been edited. Mirrors the legacy
    binary-mode updateSaveEnabled logic.

The existing dragover / dragleave / drop / change / click delegated
listeners gained routed-mode branches alongside the legacy branches,
gated by inLegacyEditor / inRoutedEditor (mutually exclusive — the
selectors only match inside one editor at a time).

routedReplaceClicked added, mirroring legacy binary save:
  * Queued file present → POST /files/replace (multipart, with optional
    new_path for rename-on-replace)
  * Queued file absent but filename edited → POST /files/move (rename
    only)
  * Neither → no-op (Save was disabled, shouldn't be reachable)

Filename-input delegated listener re-evaluates Save enablement when
the user types in routed binary mode (so rename-only is reachable
without queueing a file).

The legacy openEditorForFile function in editor.js is now unreachable
from a user action (the "edit" handler no longer calls it). It stays
in this file until Step 9 deletes the legacy dialog block wholesale.

Verified live on /overlays/2 in Chromium:
  * Click test.png (binary, data-editable="0") → URL becomes
    ?modal=%2Foverlays%2F2%2Ffiles%2Fedit%3Fpath%3Dtest.png
  * Routed modal opens with binary panel; Save labeled "Replace" and
    disabled
  * Synthetic drop event with a File → Save enables, idle label hides,
    queued label shows "new.png · 18 B"
  * Clear button → idle restored, Save disables
  * Type new filename without queueing → Save enables (rename-only)
  * Revert filename → Save disables
  * No console errors
  * pytest still 579 passed, 1 skipped, 3 deselected (no Python changes
    in Step 8)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:15:57 +02:00