From 4e3a4d3763392a5b5ef721b31027581f436777e0 Mon Sep 17 00:00:00 2001 From: Addi90 Date: Sun, 23 Aug 2026 11:44:08 +0200 Subject: [PATCH 01/43] feat: support custom modifier pipeline order in config --- backend/engine/__init__.py | 2 ++ backend/engine/models.py | 6 ++++ backend/engine/pipeline.py | 56 +++++++++++++++++++++++---------- tests/test_engine.py | 64 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 111 insertions(+), 17 deletions(-) diff --git a/backend/engine/__init__.py b/backend/engine/__init__.py index ff1d216..eb50824 100644 --- a/backend/engine/__init__.py +++ b/backend/engine/__init__.py @@ -24,6 +24,7 @@ split_name, ) from .pipeline import ( + CANONICAL_ORDER, build_files, check_duplicates, compute, @@ -34,6 +35,7 @@ __all__ = [ "Config", + "CANONICAL_ORDER", "RenameFile", "AddConfig", "CaseConfig", diff --git a/backend/engine/models.py b/backend/engine/models.py index 682541b..5aa8998 100644 --- a/backend/engine/models.py +++ b/backend/engine/models.py @@ -174,6 +174,7 @@ class Config: remove: RemoveConfig = field(default_factory=RemoveConfig) counting: CountingConfig = field(default_factory=CountingConfig) date: DateConfig = field(default_factory=DateConfig) + pipeline_order: Optional[list[str]] = None # custom modifier order; None = canonical # -- (de)serialization for the JSON API ---------------------------------- # @classmethod @@ -210,6 +211,10 @@ def _build(dc_type, key): except ValueError: date_cfg.custom_date = None + order = data.get("pipeline_order") + if not isinstance(order, list) or any(not isinstance(o, str) for o in order): + order = None # null / non-list / non-string entries -> canonical order + return cls( add=_build(AddConfig, "add"), ifthen=_build(IfThenConfig, "ifthen"), @@ -218,6 +223,7 @@ def _build(dc_type, key): remove=_build(RemoveConfig, "remove"), counting=_build(CountingConfig, "counting"), date=date_cfg, + pipeline_order=order, ) def to_dict(self) -> dict: diff --git a/backend/engine/pipeline.py b/backend/engine/pipeline.py index 134e4dc..154bda8 100644 --- a/backend/engine/pipeline.py +++ b/backend/engine/pipeline.py @@ -5,10 +5,11 @@ with the same inputs yield identical results (the original re-ran the whole pipeline for preview, duplicate-check, and save). -Modifier application order is fixed and part of the contract: +Canonical modifier application order (the default): Replace -> Case -> If-Then -> Remove -> Add -> Counting -> Date -Only *active* (enabled) modifiers run. +``Config.pipeline_order`` may override it (e.g. the UI's drag-and-drop). Only +*active* (enabled) modifiers run. """ from __future__ import annotations @@ -18,6 +19,36 @@ from . import add, case, date, ifthen, number, remove, replace from .models import Config, RenameFile +#: Canonical modifier order (the locked-in default, used when no custom order is +#: given). Each id maps to the (module, config attribute) pair that runs it. +CANONICAL_ORDER = ("replace", "case", "ifthen", "remove", "add", "counting", "date") + +_MODIFIERS = { + "replace": (replace, "replace"), + "case": (case, "case"), + "ifthen": (ifthen, "ifthen"), + "remove": (remove, "remove"), + "add": (add, "add"), + "counting": (number, "counting"), + "date": (date, "date"), +} + + +def resolve_order(config: Config) -> list[str]: + """Resolve the effective pipeline order for a config. + + Falls back to :data:`CANONICAL_ORDER` when no custom order is set. Unknown ids + are dropped; ids missing from a custom order are appended in canonical order, + so a partial custom order never silently skips a modifier. + """ + order = list(config.pipeline_order or []) + seen = set(order) + for canonical in CANONICAL_ORDER: + if canonical not in seen: + order.append(canonical) + seen.add(canonical) + return [m for m in order if m in _MODIFIERS] + def compute(files: list[RenameFile], config: Config) -> list[RenameFile]: """Run the full modifier pipeline over ``files`` (mutates their ``new_base``).""" @@ -28,21 +59,12 @@ def compute(files: list[RenameFile], config: Config) -> list[RenameFile]: # 2. sort by row for deterministic, in-list-order numbering (sortList) files.sort(key=lambda f: f.row) - # 3. apply each active modifier in the fixed order - if config.replace.enabled: - replace.modify(files, config.replace) - if config.case.enabled: - case.modify(files, config.case) - if config.ifthen.enabled: - ifthen.modify(files, config.ifthen) - if config.remove.enabled: - remove.modify(files, config.remove) - if config.add.enabled: - add.modify(files, config.add) - if config.counting.enabled: - number.modify(files, config.counting) - if config.date.enabled: - date.modify(files, config.date) + # 3. apply each active modifier in order (canonical, or the config's custom order) + for name in resolve_order(config): + module, attr = _MODIFIERS[name] + cfg = getattr(config, attr) + if cfg.enabled: + module.modify(files, cfg) return files diff --git a/tests/test_engine.py b/tests/test_engine.py index 2ccb50f..57bf91e 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -541,6 +541,70 @@ def test_is_idempotent_across_calls(self): assert first == second == {"a": "1a", "b": "2b"} +# --------------------------------------------------------------------------- # +# custom pipeline order (Config.pipeline_order) +# --------------------------------------------------------------------------- # + +class TestCustomPipelineOrder: + def test_none_order_is_canonical(self): + f = one("abc") + cfg = Config( + counting=CountingConfig(enabled=True, position="suffix", start=1, padding=2), + remove=RemoveConfig(enabled=True, back=1), + ) + compute([f], cfg) # canonical: remove before counting -> "ab" + "01" + assert f.new_base == "ab01" + + def test_custom_order_changes_result(self): + # counting before remove: "abc" -> "abc01" -> drop last char -> "abc0" + f = one("abc") + cfg = Config( + counting=CountingConfig(enabled=True, position="suffix", start=1, padding=2), + remove=RemoveConfig(enabled=True, back=1), + pipeline_order=["counting", "remove"], + ) + compute([f], cfg) + assert f.new_base == "abc0" + + def test_partial_custom_order_appends_missing_canonical_ids(self): + # only "counting" given: it runs first, missing ids are appended in + # canonical order (so remove still runs, after counting) -> "abc0" + f = one("abc") + cfg = Config( + counting=CountingConfig(enabled=True, position="suffix", start=1, padding=2), + remove=RemoveConfig(enabled=True, back=1), + pipeline_order=["counting"], + ) + compute([f], cfg) + assert f.new_base == "abc0" + + def test_unknown_ids_are_dropped(self): + # "nope" is dropped; counting stays first of the remaining ids + f = one("abc") + cfg = Config( + counting=CountingConfig(enabled=True, position="suffix", start=1, padding=2), + remove=RemoveConfig(enabled=True, back=1), + pipeline_order=["nope", "counting"], + ) + compute([f], cfg) + assert f.new_base == "abc0" + + +class TestPipelineOrderSerialization: + def test_round_trip(self): + cfg = Config(pipeline_order=["counting", "remove"]) + assert Config.from_dict(cfg.to_dict()).pipeline_order == ["counting", "remove"] + + def test_null_order_becomes_none(self): + assert Config.from_dict({"pipeline_order": None}).pipeline_order is None + + def test_non_list_order_becomes_none(self): + assert Config.from_dict({"pipeline_order": "counting"}).pipeline_order is None + + def test_non_string_entries_become_none(self): + assert Config.from_dict({"pipeline_order": [1, "counting"]}).pipeline_order is None + + # --------------------------------------------------------------------------- # # preview + check_duplicates helpers # --------------------------------------------------------------------------- # From 5945fd19edc938d4d0d62e0d2c57f4f7794086ec Mon Sep 17 00:00:00 2001 From: Addi90 Date: Sun, 23 Aug 2026 12:35:56 +0200 Subject: [PATCH 02/43] feat: drag-and-drop modifier ordering in sidebar --- frontend/src/App.svelte | 38 ++++++---- frontend/src/components/ModifierCard.svelte | 77 +++++++++++++++++++++ frontend/src/lib/config.js | 4 ++ frontend/src/lib/i18n/de.js | 1 + frontend/src/lib/i18n/en.js | 1 + frontend/src/lib/state/store.svelte.js | 12 ++++ 6 files changed, 121 insertions(+), 12 deletions(-) create mode 100644 frontend/src/components/ModifierCard.svelte diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 451f05f..edd1023 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -11,9 +11,21 @@ import AddModifier from "./components/modifiers/AddModifier.svelte"; import CountingModifier from "./components/modifiers/CountingModifier.svelte"; import DateModifier from "./components/modifiers/DateModifier.svelte"; + import ModifierCard from "./components/ModifierCard.svelte"; import { state as appState, loadDir, openHome, goUp, refreshPreview } from "./lib/state/store.svelte.js"; import { language, setLanguage, t, languages } from "./lib/i18n/index.svelte.js"; + // id -> panel component; the sidebar renders them in `config.pipeline_order`. + const MODIFIERS = { + replace: ReplaceModifier, + case: CaseModifier, + ifthen: IfThenModifier, + remove: RemoveModifier, + add: AddModifier, + counting: CountingModifier, + date: DateModifier, + }; + // Draft path in the input; committed only on Open/Enter (so typing doesn't // re-trigger preview with a half-typed path). let pathInput = $state(""); @@ -93,20 +105,21 @@ {/snippet} - + @@ -157,7 +170,8 @@ flex-direction: column; min-height: 0; } - .modifiers-pane h2 { margin: 0 0 8px; font-size: 0.95rem; color: #374151; } + .modifiers-pane h2 { margin: 0 0 4px; font-size: 0.95rem; color: #374151; } + .modifiers-pane .hint { margin: 0 0 8px; font-size: 0.8rem; color: #6b7280; } .modifiers { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 12px; } /* Narrow screens (phones / small browser windows): fall back to the stacked diff --git a/frontend/src/components/ModifierCard.svelte b/frontend/src/components/ModifierCard.svelte new file mode 100644 index 0000000..c74026b --- /dev/null +++ b/frontend/src/components/ModifierCard.svelte @@ -0,0 +1,77 @@ + + +
+ ⠿ + +
+ + diff --git a/frontend/src/lib/config.js b/frontend/src/lib/config.js index b599e27..606d2f7 100644 --- a/frontend/src/lib/config.js +++ b/frontend/src/lib/config.js @@ -6,9 +6,13 @@ * silently ignores unknown keys, so a mismatched field name is a silent no-op. */ +/** The canonical modifier order (mirrors `CANONICAL_ORDER` in the engine). */ +export const PIPELINE_ORDER = ["replace", "case", "ifthen", "remove", "add", "counting", "date"]; + /** A fresh, all-disabled modifier config (mirrors `Config.to_dict()`). */ export function defaultConfig() { return { + pipeline_order: [...PIPELINE_ORDER], // drag-and-drop reorders this list (None/absent = canonical) add: { enabled: false, prefix: "", suffix: "", insert: "", insert_pos: 0 }, ifthen: { enabled: false, diff --git a/frontend/src/lib/i18n/de.js b/frontend/src/lib/i18n/de.js index 836123f..3f3c471 100644 --- a/frontend/src/lib/i18n/de.js +++ b/frontend/src/lib/i18n/de.js @@ -23,6 +23,7 @@ export const de = { // modifiers sidebar "modifiers.title": "Modifikatoren", + "modifiers.dragHint": "Karten per Drag & Drop umsortieren", // file list "fileList.selectAll": "Alle auswählen", diff --git a/frontend/src/lib/i18n/en.js b/frontend/src/lib/i18n/en.js index 65c6457..33b9770 100644 --- a/frontend/src/lib/i18n/en.js +++ b/frontend/src/lib/i18n/en.js @@ -22,6 +22,7 @@ export const en = { // modifiers sidebar "modifiers.title": "Modifiers", + "modifiers.dragHint": "Drag cards to change the pipeline order", // file list "fileList.selectAll": "Select all", diff --git a/frontend/src/lib/state/store.svelte.js b/frontend/src/lib/state/store.svelte.js index 2133b36..bfd1d1d 100644 --- a/frontend/src/lib/state/store.svelte.js +++ b/frontend/src/lib/state/store.svelte.js @@ -88,6 +88,18 @@ export function clearSelection() { state.selection = []; } +// --- modifier pipeline order ----------------------------------------------- # + +/** Move a modifier card to a new position in `config.pipeline_order` (drag & drop). */ +export function reorderModifier(from, to) { + if (from === to || from < 0 || to < 0) return; + const order = [...state.config.pipeline_order]; + if (from >= order.length) return; + const [moved] = order.splice(from, 1); + order.splice(Math.min(to, order.length), 0, moved); + state.config.pipeline_order = order; +} + // --- preview --------------------------------------------------------------- # /** The selected filenames in on-screen list order (the payload for check/rename/preview). */ From 70877449992cd4ed9f56e1eb6d49430f26829109 Mon Sep 17 00:00:00 2001 From: Addi90 Date: Sun, 23 Aug 2026 12:47:31 +0200 Subject: [PATCH 03/43] docs: document adjustable modifier order --- AGENTS.md | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f8bf11e..4fc7a1a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,12 +39,17 @@ When a preview or rename is computed, the engine (`backend/engine/pipeline.py`) 1. **Reset** every file's `new_base` to its original base name. 2. **Sort** files by list row (deterministic, in-list-order numbering). -3. Apply each **active** modifier in this fixed order: +3. Apply each **active** modifier in pipeline order: `Replace → Case → If-Then → Remove → Add → Counting → Date` -Each modifier transforms `new_base` in place, feeding the next. **This order is part -of the contract** and must be preserved (it's locked in by `tests/test_engine.py`). +The canonical order above is the **default** (`CANONICAL_ORDER` in +`backend/engine/pipeline.py`) and is locked in by `tests/test_engine.py`. It is +**user-adjustable**: `Config.pipeline_order` (an optional list of modifier ids, +`None` = canonical) lets the UI drag-and-drop the modifier cards into a custom +sequence. `resolve_order()` is defensive: unknown ids are dropped, missing ids +are appended in canonical order, so a partial/odd list never skips a modifier. +The order still applies to **all files uniformly** (there is no per-file order). Only the **base name** is ever modified. The extension (everything from the last `.` onward, dot included) is preserved and re-appended on rename. Files with no dot have @@ -114,6 +119,10 @@ The original had a few quirks. This rebuild makes explicit choices: faithful direct concatenation). When set, it goes between the date and the name text on each side that exists (`photo-2024-05-01`, `2024-05-01-photo`), never leaving a dangling separator at an edge or against an empty base. +- **Adjustable pipeline order — ADDED (not in the original).** The canonical order + (§2) stays the default and the test baseline; the UI additionally lets the user + drag the modifier cards into a custom sequence (`Config.pipeline_order`). The + order is per-session (no persistence — a fresh start always uses canonical). - **Invalid regex is a no-op** (does not crash the live preview) — a deliberate safety choice. - **Empty Replace search is a no-op** (a deliberate safety choice). `str.replace("", x)` would otherwise insert the replacement between every character and mangle names on disk. @@ -147,7 +156,7 @@ arenamer/ │ ├── components/ # FileList, DirectoryTree (+TreeNode), RenameButton, Dialog (done) │ └── components/modifiers/ # all seven panels: Replace, Case, IfThen, Remove, Add, Counting, Date └── tests/ - ├── test_engine.py # engine suite (61 tests) — modifiers, pipeline order, edge cases + ├── test_engine.py # engine suite (96 tests) — modifiers, pipeline order (incl. custom), edge cases └── test_api.py # API suite (10 tests) — list/dirs/preview/check/rename over HTTP ``` From f70e1327fe09758e7f66b0c23eea23095938aea7 Mon Sep 17 00:00:00 2001 From: Addi90 Date: Sun, 23 Aug 2026 13:08:18 +0200 Subject: [PATCH 04/43] fix: use children snippet instead of deprecated slot in ModifierCard --- frontend/src/components/ModifierCard.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/ModifierCard.svelte b/frontend/src/components/ModifierCard.svelte index c74026b..6860597 100644 --- a/frontend/src/components/ModifierCard.svelte +++ b/frontend/src/components/ModifierCard.svelte @@ -7,7 +7,7 @@ // `index` is the card's position in the store's `pipeline_order` (the drop // slot). The drop is deferred to the next frame so the browser finishes the // drag (dragend) before the {#each} re-renders the reordered cards. - let { id, index } = $props(); + let { id, index, children } = $props(); let cardEl; let dragging = $state(false); @@ -54,7 +54,7 @@
⠿ - + {@render children()}
+ \ No newline at end of file diff --git a/frontend/src/components/Dialog.svelte b/frontend/src/components/Dialog.svelte index 4dc326f..d4c4dc0 100644 --- a/frontend/src/components/Dialog.svelte +++ b/frontend/src/components/Dialog.svelte @@ -43,28 +43,44 @@ .backdrop { position: fixed; inset: 0; - background: rgba(15, 23, 42, 0.45); + background: var(--scrim); display: flex; align-items: center; justify-content: center; z-index: 100; } .box { - background: #fff; - border-radius: 10px; + background: var(--surface); + border: 1px solid var(--border-strong); + border-radius: var(--r-lg); padding: 20px 24px; min-width: 320px; max-width: 480px; - box-shadow: 0 20px 50px rgba(0, 0, 0, 0.25); - font-family: system-ui, -apple-system, "Segoe UI", sans-serif; + box-shadow: var(--shadow); } - h2 { margin: 0 0 8px; font-size: 1.1rem; color: #1f2430; display: flex; align-items: center; gap: 8px; } - h2.warning { color: #b45309; } - .icon { font-size: 1.2rem; } - .msg { margin: 0 0 18px; color: #4b5563; font-size: 0.9rem; white-space: pre-line; } + h2 { margin: 0 0 8px; font-size: 15px; font-weight: 650; color: var(--text); display: flex; align-items: center; gap: 8px; } + h2.warning { color: var(--danger); } + .icon { font-size: 15px; } + .msg { margin: 0 0 18px; color: var(--muted); font-size: 13px; white-space: pre-line; } .buttons { display: flex; justify-content: flex-end; gap: 8px; } - button { padding: 7px 16px; border: 1px solid #d1d5db; background: #fff; border-radius: 6px; cursor: pointer; font-size: 0.9rem; } - button:hover { background: #f3f4f6; } - button.primary { background: #2563eb; border-color: #2563eb; color: #fff; } - button.primary:hover { background: #1d4ed8; } - + button { + padding: 7px 16px; + border: 1px solid var(--border); + background: var(--surface-2); + color: var(--text); + border-radius: var(--r-md); + cursor: pointer; + font-size: 13px; + transition: background 0.12s ease, border-color 0.12s ease; + } + button:hover { background: var(--surface-3); border-color: var(--border-strong); } + button:active { transform: translateY(1px); } + button.primary { + background: var(--primary); + border-color: var(--primary); + color: var(--primary-contrast); + font-weight: 600; + box-shadow: var(--shadow-btn); + } + button.primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); } + \ No newline at end of file diff --git a/frontend/src/components/DirectoryTree.svelte b/frontend/src/components/DirectoryTree.svelte index 24eca0a..cdbfd23 100644 --- a/frontend/src/components/DirectoryTree.svelte +++ b/frontend/src/components/DirectoryTree.svelte @@ -149,13 +149,17 @@ diff --git a/frontend/src/components/FileList.svelte b/frontend/src/components/FileList.svelte index ba4ed17..f61d4d5 100644 --- a/frontend/src/components/FileList.svelte +++ b/frontend/src/components/FileList.svelte @@ -27,18 +27,20 @@
- - - - - + +
+ + +
+ + {@render actions?.()}
@@ -50,6 +52,7 @@ {t("fileList.name")} + {t("fileList.newName")} @@ -66,14 +69,15 @@ {#if file.type === "dir"}{t("fileList.typeDir")}{/if} {file.name} - + → + {prev ? prev.full_new_name : file.name}{duplicate ? " ⚠" : ""} {/each} {#if visible.length === 0} - {state.busy ? t("common.loading") : t("fileList.empty")} + {state.busy ? t("common.loading") : t("fileList.empty")} {/if} @@ -82,75 +86,128 @@
+ \ No newline at end of file diff --git a/frontend/src/components/ModifierCard.svelte b/frontend/src/components/ModifierCard.svelte index db8bbad..bd71f5a 100644 --- a/frontend/src/components/ModifierCard.svelte +++ b/frontend/src/components/ModifierCard.svelte @@ -1,7 +1,12 @@ -
- - ⠿ - {@render children()} + +
(e.key === "Enter" || e.key === " ") && (e.preventDefault(), toggleEnabled())}> + + {name} + + + e.stopPropagation()} title={t("modifiers.dragHint")} aria-label={t("modifiers.dragHint")}> + + +
+
+ {@render children()} +
+ \ No newline at end of file diff --git a/frontend/src/components/RenameButton.svelte b/frontend/src/components/RenameButton.svelte index cf66fba..b5481c1 100644 --- a/frontend/src/components/RenameButton.svelte +++ b/frontend/src/components/RenameButton.svelte @@ -87,13 +87,47 @@ + button.primary { + display: inline-flex; + align-items: center; + gap: 8px; + border: 1px solid transparent; + background: var(--primary); + color: var(--primary-contrast); + font-size: 13px; + font-weight: 600; + padding: 7px 14px; + border-radius: 999px; + cursor: pointer; + box-shadow: var(--shadow-btn); + transition: background 0.12s ease, transform 0.05s ease; + } + button.primary:hover:not(:disabled) { background: var(--primary-hover); } + button.primary:active:not(:disabled) { transform: translateY(1px); } + /* Selection count. Dark: near-white pill + ink (APCA 96) — accent on + accent-contrast is only 66; light theme overrides below (77.5). */ + .pill { + display: grid; + place-items: center; + min-width: 20px; + height: 20px; + padding: 0 6px; + border-radius: 999px; + background: var(--text); + color: var(--bg); + font-size: 11px; + font-weight: 600; + font-variant-numeric: tabular-nums; + } + [data-theme="light"] .pill { + background: var(--accent-contrast); + color: var(--accent); + } + \ No newline at end of file diff --git a/frontend/src/components/TreeNode.svelte b/frontend/src/components/TreeNode.svelte index 71190ec..858505e 100644 --- a/frontend/src/components/TreeNode.svelte +++ b/frontend/src/components/TreeNode.svelte @@ -68,7 +68,8 @@ {/if} diff --git a/frontend/src/components/modifiers/AddModifier.svelte b/frontend/src/components/modifiers/AddModifier.svelte index 22d9a8a..a6f7bff 100644 --- a/frontend/src/components/modifiers/AddModifier.svelte +++ b/frontend/src/components/modifiers/AddModifier.svelte @@ -2,51 +2,23 @@ import { state } from "../../lib/state/store.svelte.js"; import { t } from "../../lib/i18n/index.svelte.js"; - // All bindings target the shared store's `add` config directly, so edits are - // reactive and immediately picked up by the live-preview effect in App.svelte. + // Bindings target the shared store's `add` config (see ReplaceModifier); + // the card frame lives in ModifierCard. -
-
-
- -
- -
-
- - + \ No newline at end of file diff --git a/frontend/src/components/modifiers/CaseModifier.svelte b/frontend/src/components/modifiers/CaseModifier.svelte index c714d0b..5a9ca71 100644 --- a/frontend/src/components/modifiers/CaseModifier.svelte +++ b/frontend/src/components/modifiers/CaseModifier.svelte @@ -2,51 +2,25 @@ import { state } from "../../lib/state/store.svelte.js"; import { t } from "../../lib/i18n/index.svelte.js"; - // All bindings target the shared store's `case` config directly, so edits are - // reactive and immediately picked up by the live-preview effect in App.svelte. + // Bindings target the shared store's `case` config (see ReplaceModifier); + // the card frame lives in ModifierCard. -
-
-
- -
-
- -
- - diff --git a/frontend/src/components/modifiers/CountingModifier.svelte b/frontend/src/components/modifiers/CountingModifier.svelte index 7f68b28..f0b6dac 100644 --- a/frontend/src/components/modifiers/CountingModifier.svelte +++ b/frontend/src/components/modifiers/CountingModifier.svelte @@ -2,55 +2,27 @@ import { state } from "../../lib/state/store.svelte.js"; import { t } from "../../lib/i18n/index.svelte.js"; - // All bindings target the shared store's `counting` config directly, so edits are - // reactive and immediately picked up by the live-preview effect in App.svelte. + // Bindings target the shared store's `counting` config (see ReplaceModifier); + // the card frame lives in ModifierCard. -
-
-
- -
-
- - - -
- {#if state.config.counting.position === "insert"} - - {/if}
-
- - + {#if state.config.counting.position === "insert"} + + {/if} + \ No newline at end of file diff --git a/frontend/src/components/modifiers/DateModifier.svelte b/frontend/src/components/modifiers/DateModifier.svelte index a7d4257..7fcf995 100644 --- a/frontend/src/components/modifiers/DateModifier.svelte +++ b/frontend/src/components/modifiers/DateModifier.svelte @@ -2,77 +2,50 @@ import { state } from "../../lib/state/store.svelte.js"; import { t } from "../../lib/i18n/index.svelte.js"; - // All bindings target the shared store's `date` config directly, so edits are - // reactive and immediately picked up by the live-preview effect in App.svelte. + // Bindings target the shared store's `date` config (see ReplaceModifier); + // the card frame lives in ModifierCard. -
-
-
- -
-
- - - -
+
+ + {#if state.config.date.source === "custom"} + + {/if} +
+
+ + {#if state.config.date.position === "insert"} + -
-
- - {#if state.config.date.source === "custom"} - - {/if} -
-
- - {#if state.config.date.position === "insert"} - - {/if} -
+ {/if}
+ .sep input[type="text"] { flex: 0 1 90px; width: auto; } + \ No newline at end of file diff --git a/frontend/src/components/modifiers/IfThenModifier.svelte b/frontend/src/components/modifiers/IfThenModifier.svelte index 255232c..bc129ab 100644 --- a/frontend/src/components/modifiers/IfThenModifier.svelte +++ b/frontend/src/components/modifiers/IfThenModifier.svelte @@ -2,68 +2,37 @@ import { state } from "../../lib/state/store.svelte.js"; import { t } from "../../lib/i18n/index.svelte.js"; - // All bindings target the shared store's `ifthen` config directly, so edits are - // reactive and immediately picked up by the live-preview effect in App.svelte. + // Bindings target the shared store's `ifthen` config (see ReplaceModifier); + // the card frame lives in ModifierCard. -
-
- -
- -
-
- {t("ifthen.ifTag")} - - -
-
- - -
-
- {t("ifthen.thenTag")} - - - {#if state.config.ifthen.action === "insert"} - - {/if} -
+
+
+ {t("ifthen.ifTag")} + + +
+
+ + +
+
+ {t("ifthen.thenTag")} + + + {#if state.config.ifthen.action === "insert"} + + {/if}
- - diff --git a/frontend/src/components/modifiers/RemoveModifier.svelte b/frontend/src/components/modifiers/RemoveModifier.svelte index 51fa4b4..ae11bd7 100644 --- a/frontend/src/components/modifiers/RemoveModifier.svelte +++ b/frontend/src/components/modifiers/RemoveModifier.svelte @@ -2,55 +2,31 @@ import { state } from "../../lib/state/store.svelte.js"; import { t } from "../../lib/i18n/index.svelte.js"; - // All bindings target the shared store's `remove` config directly, so edits are - // reactive and immediately picked up by the live-preview effect in App.svelte. + // Bindings target the shared store's `remove` config (see ReplaceModifier); + // the card frame lives in ModifierCard. -
-
-
- -
-
- - -
-
- - - - -
+ +
+
+ + + +
+ .dimmed { opacity: 0.45; } + \ No newline at end of file diff --git a/frontend/src/components/modifiers/ReplaceModifier.svelte b/frontend/src/components/modifiers/ReplaceModifier.svelte index 928841b..a0934e9 100644 --- a/frontend/src/components/modifiers/ReplaceModifier.svelte +++ b/frontend/src/components/modifiers/ReplaceModifier.svelte @@ -4,44 +4,19 @@ // All bindings target the shared store's `replace` config directly, so edits are // reactive and immediately picked up by the live-preview effect in App.svelte. + // The card frame (number, name, enable toggle, reset, drag grip) lives in + // ModifierCard — this panel only holds the modifier's controls. -
-
- -
- -
- - -
- - -
+
+ + +
+ +
- - diff --git a/frontend/src/index.css b/frontend/src/index.css index a3a1105..4016606 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,15 +1,183 @@ -/* Global reset for the full-viewport app frame (App.svelte owns the layout). */ +/* ── Design tokens (single source of truth: DESIGN.md) ──────────────────── */ +:root { + --font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --mono: ui-monospace, "SF Mono", Menlo, monospace; + --r-lg: 14px; /* panes */ + --r-md: 10px; /* cards, dialogs */ + --r-sm: 8px; /* inputs, buttons */ +} +[data-theme="dark"] { + color-scheme: dark; + --bg: #0e0f12; + --surface: #16181d; + --surface-2: #1d2026; + --surface-3: #242830; + --border: #262b33; + --border-strong: #333945; + --text: #edeff2; /* APCA Lc ~97 */ + --muted: #d0d6df; /* secondary text; APCA ≥75 on every dark surface */ + --faint: #7c8694; /* metadata tier — APCA 36 (known ceiling, see DESIGN.md), WCAG 4.8:1 */ + /* Accent family = the logo gradient (#a855f7 → #6d28d9) */ + --accent: #a855f7; + --accent-deep: #8b36e8; + --accent-bright: #e9d5ff; /* logo hue, lightened: APCA ≥75 on --accent-soft fills */ + --accent-contrast: #0e0f12; + --accent-soft: rgba(168, 85, 247, 0.14); + --sel: rgba(168, 85, 247, 0.1); + /* Filled primary button: white text on accent-deep (APCA 81.9) — ink on + --accent is only 37.3, so the dark button uses the deeper stop. */ + --primary: #8b36e8; + --primary-hover: #6d28d9; + --primary-contrast: #ffffff; + --success: #70e4a1; + --danger: #ffc1b8; /* APCA ≥75 on dark surfaces */ + --row-hover: rgba(255, 255, 255, 0.035); + --shadow: 0 1px 0 rgba(255, 255, 255, 0.04) inset, 0 8px 28px rgba(0, 0, 0, 0.38); + --shadow-btn: 0 1px 2px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.06) inset; + --scrim: rgba(4, 5, 8, 0.6); +} +[data-theme="light"] { + color-scheme: light; + --bg: #f2f3f6; + --surface: #ffffff; + --surface-2: #f6f7f9; + --surface-3: #eceef2; + --border: #e4e7ee; + --border-strong: #d3d8e2; + --text: #181b21; + --muted: #545e6e; /* APCA ≥75 on --surface and --bg */ + --faint: #4e5767; /* APCA ≥75 on --surface-3 */ + --accent: #7c3aed; /* purple-700, sits between the two logo stops */ + --accent-deep: #6d28d9; + --accent-bright: #6523d6; /* APCA ≥75 on --accent-soft fills and white */ + --accent-contrast: #ffffff; + --accent-soft: rgba(124, 58, 237, 0.09); + --sel: rgba(124, 58, 237, 0.06); + --primary: #7c3aed; + --primary-hover: #6d28d9; + --primary-contrast: #ffffff; + --success: #157a49; + --danger: #b42c21; /* APCA ≥75 on white and tinted rows */ + --row-hover: rgba(20, 24, 40, 0.035); + --shadow: 0 1px 2px rgba(16, 24, 40, 0.05), 0 6px 20px rgba(16, 24, 40, 0.07); + --shadow-btn: 0 1px 2px rgba(16, 24, 40, 0.12); + --scrim: rgba(23, 26, 34, 0.4); +} + +/* ── Base ────────────────────────────────────────────────────────────────── */ +*, +*::before, +*::after { + box-sizing: border-box; +} html, body { margin: 0; - padding: 0; + height: 100%; +} +body { + background: var(--bg); + color: var(--text); + font-family: var(--font); + font-size: 14px; + -webkit-font-smoothing: antialiased; +} +button { + font: inherit; + color: inherit; + cursor: pointer; } -*, *::before, *::after { - box-sizing: border-box; +/* State contract — every interactive element (see DESIGN.md). */ +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +:disabled { + opacity: 0.5; + cursor: not-allowed; } -/* Subtle backdrop so the white panes read as distinct surfaces. */ -body { - background: #eef1f5; +/* ── Shared form controls (modifier panels, path bar, dialogs) ──────────── */ +input[type="text"], +input[type="number"], +input[type="date"], +select { + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--r-sm); + padding: 6px 9px; + font: inherit; + font-size: 12.5px; + min-width: 0; +} +input[type="text"]:focus, +input[type="number"]:focus, +input[type="date"]:focus, +select:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} +/* The soft ring above is their focus-visible treatment. */ +input[type="text"]:focus-visible, +input[type="number"]:focus-visible, +input[type="date"]:focus-visible, +select:focus-visible { + outline: none; +} +input::placeholder { + color: var(--faint); +} +input[type="checkbox"] { + accent-color: var(--accent); + margin: 0; +} +.mono { + font-family: var(--mono); +} + +/* Shared modifier-panel layout (each modifier's
). */ +.controls { + display: flex; + flex-direction: column; + gap: 8px; +} +.controls .row { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} +.controls label { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--muted); + min-width: 0; +} +.controls input[type="text"] { + flex: 1; + min-width: 40px; +} +.controls input[type="number"] { + width: 64px; + flex: none; +} +.controls select { + flex: none; +} +.controls .check { + cursor: pointer; +} +.controls .tag { + font-size: 10px; + font-weight: 600; + color: var(--faint); + text-transform: uppercase; + letter-spacing: 0.04em; +} +.controls .pos { + flex: 0 0 auto; } diff --git a/frontend/src/lib/i18n/de.js b/frontend/src/lib/i18n/de.js index a4a321f..8cd4ff2 100644 --- a/frontend/src/lib/i18n/de.js +++ b/frontend/src/lib/i18n/de.js @@ -5,14 +5,15 @@ export const de = { // header / path bar "app.title": "A-Renamer Tool", - "app.subtitle": "Dateien und Verzeichnisse auswählen, Modifikatoren konfigurieren, neue Namen in der Vorschau prüfen und dann umbenennen.", "app.home": "Home", "app.homeTitle": "Das Home-Verzeichnis öffnen", - "app.up": "Hoch", - "app.upTitle": "Das übergeordnete Verzeichnis öffnen", + "app.theme": "Theme umschalten", "app.open": "Öffnen", "app.errorDismiss": "Fehler schließen", + // breadcrumb path bar + "crumbs.aria": "Pfad", + // language switcher (option labels stay in their own language) "lang.label": "Sprache", @@ -26,6 +27,7 @@ export const de = { "modifiers.title": "Modifikatoren", "modifiers.dragHint": "Karten per Drag & Drop umsortieren", "modifiers.resetOrder": "Reihenfolge zurücksetzen", + "modifiers.reset": "Zurücksetzen", // file list "fileList.selectAll": "Alle auswählen", diff --git a/frontend/src/lib/i18n/en.js b/frontend/src/lib/i18n/en.js index bdac349..2c0b005 100644 --- a/frontend/src/lib/i18n/en.js +++ b/frontend/src/lib/i18n/en.js @@ -4,14 +4,15 @@ export const en = { // header / path bar "app.title": "A-Renamer Tool", - "app.subtitle": "Select files and directories, configure modifiers, preview the new names, then rename.", "app.home": "Home", "app.homeTitle": "Open the home directory", - "app.up": "Up", - "app.upTitle": "Open the parent directory", + "app.theme": "Toggle theme", "app.open": "Open", "app.errorDismiss": "Dismiss error", + // breadcrumb path bar + "crumbs.aria": "Path", + // language switcher (option labels stay in their own language) "lang.label": "Language", @@ -25,6 +26,7 @@ export const en = { "modifiers.title": "Modifiers", "modifiers.dragHint": "Drag cards to change the pipeline order", "modifiers.resetOrder": "Reset order", + "modifiers.reset": "Reset", // file list "fileList.selectAll": "Select all", From b459dcf870380e6525f0865817d834a630051806 Mon Sep 17 00:00:00 2001 From: test Date: Sun, 30 Aug 2026 00:44:53 +0200 Subject: [PATCH 36/43] fix: stray ellipsis next to file-list checkboxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 34px check cell had 12px side padding (10px content box), so the 18px checkbox overflowed it. Chrome's text-overflow: ellipsis on the cell then paints a "…" right next to the control (and clips it). Zero the horizontal padding on .col-check so nothing overflows. --- frontend/src/components/FileList.svelte | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/FileList.svelte b/frontend/src/components/FileList.svelte index f61d4d5..72b0b16 100644 --- a/frontend/src/components/FileList.svelte +++ b/frontend/src/components/FileList.svelte @@ -182,7 +182,10 @@ tr.duplicate td { background: color-mix(in srgb, var(--danger) 4%, transparent); color: var(--danger); } tr.duplicate:hover td { background: color-mix(in srgb, var(--danger) 12%, transparent); } - .col-check { width: 34px; text-align: center; } + /* No side padding: an 18px checkbox must not overflow the 34px cell — + overflowing inline content triggers a stray "…" from the cell's + text-overflow: ellipsis. */ + .col-check { width: 34px; text-align: center; padding-left: 0; padding-right: 0; } .col-arrow { width: 20px; text-align: center; color: var(--faint); } .badge { display: inline-block; margin-right: 6px; padding: 0 6px; From 649af8bd6110827f3a2946f1a17208a2c0b65969 Mon Sep 17 00:00:00 2001 From: test Date: Sat, 29 Aug 2026 00:23:09 +0200 Subject: [PATCH 37/43] test: tighten frontend tests to their actual contracts Remove dead code and hedged assertions that let implementations drift: - drop ghost "state.path" writes (no such property; guards key off currentPath) - drop the 250ms waits (the debounce lives in App.svelte's $effect, not in the store functions the tests call directly; a 0ms flush suffices) - assert the /preview payload strictly (names as strings, sanitized config) - assert duplicateNames as a plain array (the store assigns res.names) - exercise interpolation with a real key (rename.confirmMsg) instead of a fake key that only works via the unknown-key fallback --- .../src/components/components.smoke.test.js | 6 ++-- frontend/src/lib/i18n/languages.test.js | 6 ++-- frontend/src/lib/state/store.svelte.test.js | 30 +++++++------------ 3 files changed, 17 insertions(+), 25 deletions(-) diff --git a/frontend/src/components/components.smoke.test.js b/frontend/src/components/components.smoke.test.js index b32eda6..393f86a 100644 --- a/frontend/src/components/components.smoke.test.js +++ b/frontend/src/components/components.smoke.test.js @@ -47,7 +47,7 @@ function resetStore() { state.dialog = { open: false, title: "", message: "", variant: "info", buttons: [], dismissId: null }; state.error = ""; state.renaming = false; - state.path = "/tmp/somewhere"; + state.currentPath = "/tmp/somewhere"; } beforeEach(() => { @@ -151,7 +151,7 @@ describe("RenameButton", () => { render(RenameButton); await fireEvent.click(screen.getByRole("button")); - await new Promise((r) => setTimeout(r, 250)); // let the async check flow settle + await new Promise((r) => setTimeout(r, 0)); // flush the microtask queue (all api mocks are pre-resolved) expect(api.check).toHaveBeenCalled(); // No duplicates from the mocked check -> a confirmation dialog should be open. @@ -164,7 +164,7 @@ describe("RenameButton", () => { render(RenameButton); await fireEvent.click(screen.getByRole("button")); - await new Promise((r) => setTimeout(r, 250)); + await new Promise((r) => setTimeout(r, 0)); // flush the microtask queue (all api mocks are pre-resolved) expect(state.dialog.open).toBe(true); expect(api.rename).not.toHaveBeenCalled(); diff --git a/frontend/src/lib/i18n/languages.test.js b/frontend/src/lib/i18n/languages.test.js index da9b0a1..1883fbc 100644 --- a/frontend/src/lib/i18n/languages.test.js +++ b/frontend/src/lib/i18n/languages.test.js @@ -77,9 +77,9 @@ describe("language detection and t()", () => { it("interpolates {var} placeholders", async () => { const i18n = await loadI18n(); - // Unknown keys fall back to the raw key, so any {var} in the key itself - // still gets substituted — a stable way to exercise the interpolation loop. - expect(i18n.t("rename.{n} files", { n: 5 })).toBe("rename.5 files"); + // A real key with a placeholder ("Rename {n} Item(s)?) — interpolation runs + // on the current language's string. + expect(i18n.t("rename.confirmMsg", { n: 5 })).toBe("Rename 5 Item(s)?"); }); it("falls back to the raw key for unknown keys", async () => { diff --git a/frontend/src/lib/state/store.svelte.test.js b/frontend/src/lib/state/store.svelte.test.js index 22e891c..cfd371d 100644 --- a/frontend/src/lib/state/store.svelte.test.js +++ b/frontend/src/lib/state/store.svelte.test.js @@ -12,6 +12,7 @@ vi.mock("../api.js", () => ({ })); import * as api from "../api.js"; +import { sanitizeConfig } from "../config.js"; // `state` is a module-level $state singleton — every test gets a fresh module // (and therefore fresh state) via resetModules + dynamic import. @@ -148,47 +149,39 @@ describe("api-backed flows (mocked api)", () => { expect(state.error).toContain("no such directory"); }); - it("refreshPreview calls /preview with selected files and stores the result", async () => { + it("refreshPreview calls /preview with the selected names (list order) and stores the result", async () => { const { state, toggleSelect, refreshPreview } = await freshStore(); state.files = [...FILES]; - state.path = "/tmp/somewhere"; // some guards key off the current path state.currentPath = "/tmp/somewhere"; toggleSelect("a.txt"); const canned = { "a.txt": { name: "a.txt", new_base: "A", ext: ".txt" } }; - api.preview.mockResolvedValue(canned); + api.preview.mockResolvedValue({ previews: canned }); await refreshPreview(); - // refreshPreview may be internally debounced — give a pending timer room to fire. - await new Promise((r) => setTimeout(r, 250)); - expect(api.preview).toHaveBeenCalled(); + expect(api.preview).toHaveBeenCalledTimes(1); const payload = api.preview.mock.calls[0][0]; - expect(payload).toHaveProperty("config"); - // The /preview contract sends file names; accept name objects too. - const sent = payload.files.map((f) => (typeof f === "string" ? f : f?.name)); - expect(sent).toEqual(["a.txt"]); - // How the store stores the response (previews key/shape) is UI-internal; - // the contract tested here is the /preview request the store builds. + // The /preview contract: names as plain strings in list order, plus the + // sanitized config (Svelte may have left number fields as null). + expect(payload.files).toEqual(["a.txt"]); + expect(payload.config).toEqual(sanitizeConfig(state.config)); + expect(state.previews).toEqual(canned); }); it("checkDuplicates stores the clobbering names", async () => { const { state, toggleSelect, checkDuplicates } = await freshStore(); state.files = [...FILES]; - state.path = "/tmp/somewhere"; state.currentPath = "/tmp/somewhere"; toggleSelect("a.txt"); api.check.mockResolvedValue({ names: ["a.txt"] }); await checkDuplicates(); - await new Promise((r) => setTimeout(r, 250)); - expect(api.check).toHaveBeenCalled(); - // duplicateNames may be an array or a Set — normalize before comparing. - expect([...(state.duplicateNames ?? [])]).toEqual(["a.txt"]); + expect(api.check).toHaveBeenCalledTimes(1); + expect(state.duplicateNames).toEqual(["a.txt"]); }); it("performRename calls /rename and clears the renaming flag", async () => { const { state, toggleSelect, performRename } = await freshStore(); state.files = [...FILES]; - state.path = "/tmp/somewhere"; state.currentPath = "/tmp/somewhere"; toggleSelect("a.txt"); api.rename.mockResolvedValue({ renamed: 1, errors: [] }); @@ -257,7 +250,6 @@ describe("view toggles (files / directories)", () => { api.preview.mockResolvedValue({ previews: {} }); await refreshPreview(); - await new Promise((r) => setTimeout(r, 250)); const payload = api.preview.mock.calls[0][0]; expect(payload.files).toEqual(["a.txt", "Photos"]); // list order expect(payload.dirs).toEqual(["Photos"]); // only the directory names From 403e97c3935694153e870ae9c8f04add8053adba Mon Sep 17 00:00:00 2001 From: test Date: Sat, 29 Aug 2026 11:10:54 +0200 Subject: [PATCH 38/43] test: add missing frontend coverage (api client + modifier panels) Adds the two test files identified in the suite review and expands the store/component smoke tests: - lib/api.test.js: response parsing + error mapping (10 tests), including the new {path, dirs} payload contract - modifiers smoke: all seven panels (toggle class, bindings, conditional inputs, grey-out) as a second component layer (21 tests) - store: goUp bounds, refreshPreview guards, checkDuplicates, performRename happy + 409 paths, dialog resolution semantics (13 -> 27 tests) - components smoke: RenameButton dialog paths mount the Dialog host alongside; ModifierCard gets a proper (raw) snippet as children and real DataTransfer drag events Frontend suite: 48 -> 98 tests, all passing. Svelte 5 flushes DOM updates on a later tick, so DOM assertions follow an explicit settle. --- .../src/components/components.smoke.test.js | 233 +++++++++++++++- .../modifiers/modifiers.smoke.test.js | 261 ++++++++++++++++++ frontend/src/lib/api.test.js | 107 +++++++ frontend/src/lib/state/store.svelte.test.js | 76 +++++ 4 files changed, 666 insertions(+), 11 deletions(-) create mode 100644 frontend/src/components/modifiers/modifiers.smoke.test.js create mode 100644 frontend/src/lib/api.test.js diff --git a/frontend/src/components/components.smoke.test.js b/frontend/src/components/components.smoke.test.js index 393f86a..8c84ee9 100644 --- a/frontend/src/components/components.smoke.test.js +++ b/frontend/src/components/components.smoke.test.js @@ -3,8 +3,9 @@ // tests, so this file opts into it (installed as a dev dependency). // @vitest-environment jsdom -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { render, screen, fireEvent, cleanup } from "@testing-library/svelte"; +import { createRawSnippet } from "svelte"; // Components read the module-level store and i18n directly, so tests drive // behavior by seeding store state and (for RenameButton) mocking lib/api.js. @@ -18,11 +19,12 @@ vi.mock("../lib/api.js", () => ({ })); import * as api from "../lib/api.js"; -import { state } from "../lib/state/store.svelte.js"; +import { state, showDialog } from "../lib/state/store.svelte.js"; import { setLanguage } from "../lib/i18n/index.svelte.js"; import FileList from "./FileList.svelte"; import Dialog from "./Dialog.svelte"; import RenameButton from "./RenameButton.svelte"; +import ModifierCard from "./ModifierCard.svelte"; const FILES = [ { name: "a.txt", size: 1, mtime: 0 }, @@ -55,6 +57,7 @@ beforeEach(() => { setLanguage("en"); resetStore(); api.listFiles.mockReset(); + api.listFiles.mockResolvedValue({ files: FILES }); api.homeDir.mockReset(); api.preview.mockReset(); api.preview.mockResolvedValue({}); @@ -140,33 +143,241 @@ describe("Dialog", () => { }); }); +// All mocked api calls are pre-resolved, so a single 0ms macrotask wait flushes the +// whole microtask chain (click -> store actions -> dialogs) started by the click. +const settle = () => new Promise((r) => setTimeout(r, 0)); + describe("RenameButton", () => { - it("renders the rename control", () => { + it("renders the rename control with the selection count", () => { + state.selection = ["a.txt"]; render(RenameButton); - expect(screen.getByRole("button")).toBeTruthy(); + const btn = screen.getByRole("button", { name: /Rename/ }); + // the count sits in a pill span, not in the button label + expect(btn.querySelector(".pill")?.textContent).toBe("1"); + }); + + it("is disabled without a selection", () => { + state.selection = []; + const { getByRole } = render(RenameButton); + expect(getByRole("button").disabled).toBe(true); }); it("runs /check and opens the confirmation dialog on click", async () => { state.selection = ["a.txt"]; render(RenameButton); - + render(Dialog); // dialog host: RenameButton drives the store, Dialog renders it await fireEvent.click(screen.getByRole("button")); - await new Promise((r) => setTimeout(r, 0)); // flush the microtask queue (all api mocks are pre-resolved) + await settle(); - expect(api.check).toHaveBeenCalled(); - // No duplicates from the mocked check -> a confirmation dialog should be open. + expect(api.check).toHaveBeenCalledTimes(1); + // No duplicates from the mocked check -> the confirmation dialog is open. expect(state.dialog.open).toBe(true); + expect(screen.getByText("Rename 1 Item(s)?")).toBeTruthy(); }); it("shows the blocking duplicate warning instead of confirming", async () => { state.selection = ["a.txt"]; - api.check.mockResolvedValue({ names: ["a.txt"] }); + api.check.mockResolvedValue({ duplicates: 1, names: ["a.txt"] }); render(RenameButton); + render(Dialog); // dialog host: RenameButton drives the store, Dialog renders it + await fireEvent.click(screen.getByRole("button")); + await settle(); + // The warning (not the confirmation) is shown, the clobbering names are + // stored for row highlighting, and no rename happens. + expect(screen.getByText("Found existing entries for 1 new name(s)!")).toBeTruthy(); + expect(state.duplicateNames).toEqual(["a.txt"]); + expect(api.rename).not.toHaveBeenCalled(); + }); + + it("aborts without renaming when Abort is chosen in the confirmation", async () => { + state.selection = ["a.txt"]; + render(RenameButton); + render(Dialog); // dialog host: RenameButton drives the store, Dialog renders it await fireEvent.click(screen.getByRole("button")); - await new Promise((r) => setTimeout(r, 0)); // flush the microtask queue (all api mocks are pre-resolved) + await settle(); + await fireEvent.click(screen.getByText("Abort")); + await settle(); - expect(state.dialog.open).toBe(true); + expect(state.dialog.open).toBe(false); expect(api.rename).not.toHaveBeenCalled(); }); + + // The core action: check → confirm → Ok → /rename → success → re-list + tree bump. + it("renames on Ok: success dialog, re-list, tree bump", async () => { + state.selection = ["a.txt"]; + api.check.mockResolvedValue({ duplicates: 0, names: [] }); + api.rename.mockResolvedValue({ renamed: 1, errors: [] }); + render(RenameButton); + render(Dialog); // dialog host: RenameButton drives the store, Dialog renders it + await fireEvent.click(screen.getByRole("button")); + await settle(); + await fireEvent.click(screen.getByText("Ok")); // confirm + await settle(); + + expect(api.rename).toHaveBeenCalledTimes(1); + expect(screen.getByText("Successfully renamed 1 Item(s)!")).toBeTruthy(); + + await fireEvent.click(screen.getByText("Ok")); // close the success dialog + await settle(); + + expect(api.listFiles).toHaveBeenCalledTimes(1); // re-list the directory + expect(state.treeVersion).toBe(1); // let the tree refresh labels + expect(state.dialog.open).toBe(false); + }); + + it("notes individual failures in the success dialog", async () => { + state.selection = ["a.txt", "b.log"]; + api.check.mockResolvedValue({ duplicates: 0, names: [] }); + api.rename.mockResolvedValue({ renamed: 1, errors: ["boom"] }); + render(RenameButton); + render(Dialog); // dialog host: RenameButton drives the store, Dialog renders it + await fireEvent.click(screen.getByRole("button")); + await settle(); + await fireEvent.click(screen.getByText("Ok")); + await settle(); + expect(screen.getByText(/1 item\(s\) could not be renamed/)).toBeTruthy(); + }); + + it("shows the duplicate warning again when /rename fails with a 409", async () => { + state.selection = ["a.txt", "b.log"]; + api.check.mockResolvedValue({ duplicates: 0, names: [] }); + api.rename.mockRejectedValue( + new Error(JSON.stringify({ duplicates: 2, names: ["a.txt", "b.log"] })) + ); + render(RenameButton); + render(Dialog); // dialog host: RenameButton drives the store, Dialog renders it + await fireEvent.click(screen.getByRole("button")); + await settle(); + await fireEvent.click(screen.getByText("Ok")); // confirm + await settle(); + + // The 409 safety net: warning with the clobbering names, no re-list, + // no tree bump (nothing was renamed). + expect(state.dialog.open).toBe(true); + expect(state.duplicateNames).toEqual(["a.txt", "b.log"]); + expect(screen.getByText("Found existing entries for 2 new name(s)!")).toBeTruthy(); + expect(api.listFiles).not.toHaveBeenCalled(); + expect(state.treeVersion).toBe(0); + }); +}); + +describe("Dialog promise wiring (showDialog)", () => { + it("resolves the pending promise with the clicked button id", async () => { + const p = showDialog({ + title: "T", + message: "M", + buttons: [{ id: "ok", label: "Ok" }, { id: "abort", label: "Abort" }], + dismissId: "abort", + }); + render(Dialog); + await fireEvent.click(screen.getByText("Abort")); + await expect(p).resolves.toBe("abort"); + expect(state.dialog.open).toBe(false); + }); + + it("resolves with the dismissId on Escape", async () => { + const p = showDialog({ + title: "T", + message: "M", + buttons: [{ id: "ok", label: "Ok" }], + dismissId: "abort", + }); + render(Dialog); + await settle(); // the $effect registers the keydown listener asynchronously + await fireEvent.keyDown(window, { key: "Escape" }); + await expect(p).resolves.toBe("abort"); + }); +}); + +describe("ModifierCard (drag & drop)", () => { + // The drop handler defers the reorder to the next frame so the browser + // finishes the drag first — run it synchronously in the test. + const origRaf = globalThis.requestAnimationFrame; + beforeEach(() => { + state.config.pipeline_order = ["replace", "case", "ifthen"]; + globalThis.requestAnimationFrame = (fn) => (fn(), 0); + }); + afterEach(() => { + globalThis.requestAnimationFrame = origRaf; + }); + + /** A drag event with a mock dataTransfer (jsdom's is a poor fit). */ + function dragEvent(type, data, props = {}) { + const ev = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperty(ev, "dataTransfer", { + value: { + setData: vi.fn(), + getData: vi.fn().mockReturnValue(data), + dropEffect: null, + effectAllowed: null, + }, + }); + Object.assign(ev, props); + return ev; + } + + // Slot content as a raw snippet: a plain `() => "panel"` renders nothing, because + // Svelte 5 snippets are side-effecting anchor renderers, not value-returning + // functions — createRawSnippet is the programmatic equivalent of