diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 43f69a5..7532eb0 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -99,4 +99,4 @@ jobs:
run: |
set -euo pipefail
- git push origin master --tags
\ No newline at end of file
+ git push origin master --tags
diff --git a/.gitignore b/.gitignore
index 710640e..5156498 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,3 +21,5 @@ build/smoke/
.DS_Store
.idea/
.vscode/
+outputs/
+graphify-out/
diff --git a/AGENTS.md b/AGENTS.md
index 03cbaa6..55748d8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,445 +1,253 @@
# AGENTS.md — A-Renamer Tool (Python + Svelte rebuild)
-Authoritative reference for working on **this** repository: a modern, web-based
-rebuild of the original Qt/C++ "A-Renamer Tool". It documents (1) what the program
-does, (2) every feature/ability it must expose, and (3) how this codebase is
-structured and built. Use it as the source of truth when adding features.
+Authoritative reference for this repo: a web-based rebuild of the original Qt/C++
+"A-Renamer Tool" (sibling repo `../ARenamerTool` — the *behavioral* reference; when
+intended behavior is unclear, check there, but the intentional fixes below win).
-> The original Qt implementation lives in the sibling repo `../ARenamerTool` and is
-> the behavioral reference. When in doubt about *intended* behavior, check there —
-> but this rebuild intentionally fixes a few of its quirks (noted inline below).
+Desktop GUI for **bulk-renaming files *and* directories**: pick a directory, multi-select
+entries (Files/Directories show-hide toggles; default: files only), configure a pipeline of
+text modifiers, get a live per-entry preview, then rename with duplicate + confirmation
+safeguards. UI is internationalized (German + English, runtime switcher).
----
-
-## 1. What this program is
-
-A desktop GUI for **bulk-renaming files and directories**. The user:
-
-1. Selects a directory (folder browser or built-in directory tree).
-2. Selects one or more entries in it — files and/or directories (multi-select,
- with Files/Directories show-hide toggles; default view shows files only).
-3. Configures zero or more **modifiers** (text operations) that transform each name.
-4. Sees a **live preview** of every selected entry's new name as they tweak modifiers.
-5. Clicks **Rename** to apply all transformations on disk (with duplicate + confirmation safeguards).
-
-Core value: batch rename with a composable set of text operations, instant per-entry
-preview, and safe renaming — for files *and* folders. The UI is internationalized
-(German + English) with a runtime language switcher.
-
-### Delivery model (this rebuild)
-- **Backend:** Python — FastAPI serves a small JSON API; the rename engine is pure,
- framework-agnostic Python (unit-tested). `pywebview` wraps it in a native desktop window.
-- **Frontend:** Svelte (SPA) built by Vite, served by the backend. Talks to the API over `/api/*`.
-- One command (`python run.py`) starts the server and opens the desktop window.
-
----
-
-## 2. The rename pipeline (critical contract)
-
-When a preview or rename is computed, the engine (`backend/engine/pipeline.py`) does:
-
-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 pipeline order:
-
- `Replace → Case → If-Then → Remove → Add → Counting → Date`
-
-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
-an empty extension. A *leading* dot is not an extension: dot-files like `.bashrc`
-are extension-less (whole name is the base) — the ``os.path.splitext`` convention;
-`.hidden.tar` still splits at its last real dot (base `.hidden`).
-
-**Directories are extension-less**: a `RenameFile` flagged `is_dir=True` keeps
-`ext = ""` no matter what (a directory named `backup.tar` renames to
-`x_backup.tar` — the dot is part of the name, not an extension). All seven modifiers
-operate on the base name and therefore work unchanged for directories; the counting
-modifier's number simply appends to the full name (`Photos` + suffix `02` → `Photos02`).
-Numbering spans a mixed selection in on-screen list order (one combined sequence).
-
----
-
-## 3. Features / abilities (the full checklist)
-
-Every item below must work in the rebuilt UI. All of it is implemented and
-tested (see §7 for status).
-
-### Navigation & selection
-- Browse/select a directory (native folder dialog and/or a directory tree).
-- Directory tree navigation that re-roots the file list (and clears selection).
-- Entry list (files and/or directories) with **multi-selection**; directory rows
- carry a small type badge.
-- **Files / Directories show-hide toggles** (default: files shown, directories
- hidden — the historical view). Hidden entries are never rendered, never
- selectable, and are pruned from the selection, previews and duplicate
- highlights the moment their type is toggled off.
-- "Select all" (selects the *visible* entries) and "Clear selection" actions.
-- After a successful rename, the directory tree refreshes the labels of renamed
- directories (store `treeVersion` bump).
-- Current-path display.
-
-### Live preview
-- Per-entry "new name" preview column, updated instantly on any control change.
-- Preview reflects the full modifier pipeline in the correct order (§2).
-
-### Modifiers (all seven, each independently toggleable; disabled controls greyed out)
-- **Add / Insert** — prefix, suffix, and insert-at-position. (Insert applies first, then the name is wrapped `prefix + name + suffix`.)
-- **If-Then** — condition (CONTAINS / CONTAINS-NOT, plain or regex, case option) evaluated against the file's *original* base name → consequence (add as PREFIX / INSERT-at-pos / SUFFIX).
-- **Replace** — search (plain or regex, case option) → replacement; replaces all occurrences.
-- **Case** — letter case of the base name: UPPERCASE / lowercase / Title Case / Sentence case, plus word cases (camelCase, PascalCase, snake_case, kebab-case, CONSTANT_CASE, train case) that split the name on delimiters and camelCase boundaries (digits never split; acronyms split naively, one letter per word).
-- **Remove** — first-n chars, last-n chars, and a character range (start–end) with an "until end" option. Ranges that run past a shorter name clamp to the actual end (no out-of-bounds).
-- **Counting / Number** — start number, zero-padding (e.g. `001`), placed as prefix / suffix / insert-at-pos. Numbers follow on-screen list order, not alphabetical.
-- **Date** — format (DD-MM-YYYY / YYYY-MM-DD / MM-DD-YYYY), date separator, optional name separator (between the date and the rest of the name; empty = direct concatenation), source (created / last-modified / today / custom + date picker), placed as prefix / suffix / insert-at-pos.
-
-### Safety & workflow (the Rename button)
-Duplicate detection is **cross-type**: it compares against whatever exists on disk at
-the target path, so dir→existing-file, dir→existing-dir and file→existing-dir are all
-catched. (Copy was generalized: the original's "File(s)" dialogs now say "Item(s)" / de "Element(e)".)
-1. **Duplicate check** — re-run the pipeline; if any resulting name already exists on disk (skipping unchanged entries), show a blocking **Warning** ("Found existing entries for N new name(s)!") with only an Abort button, and stop.
-2. **Confirmation** — "Rename N Item(s)?" with Ok / Abort (N = selected count).
-3. On Ok → perform the renames; only rename entries whose new name differs from the current one.
-4. **Success** — "Successfully renamed N Item(s)!"
-
-### UI/UX
-- At-a-glance active-modifier indicator per modifier group (✓ / ✗).
-- Disabled controls greyed out when their parent modifier is off.
-- Internationalized UI (German + English) with a runtime language switcher and system-locale auto-detection.
+**Stack:** FastAPI JSON API + pure-Python rename engine (unit-tested), Svelte 5 SPA (Vite)
+served by the backend, `pywebview` desktop window. One command: `python run.py`
+(desktop window, or `:8000` web fallback).
---
-## 4. Behavior decisions vs the original (quirks)
-
-The original had a few quirks. This rebuild makes explicit choices:
-
-- **Replace double-application — FIXED.** The original ran an extra unconditional
- plain/case-insensitive `replace()` after the regex branch, double-applying. This port
- applies the replacement exactly once per the selected mode (`backend/engine/replace.py`).
-- **If-Then condition uses the original base name — PRESERVED.** The condition is tested
- against the immutable `base`, while the consequence applies to the evolving `new_base`.
-- **Numbering follows list order — PRESERVED.**
-- **Case modifier — ADDED (not in the original).** Placed right after Replace in the
- pipeline: case is a text transformation like Replace, so text *inserted* later
- (Add, numbers, dates, If-Then consequences) keeps its own casing. Title Case uses
- `str.title`, whose apostrophe quirk is documented and preserved (`it's` → `It'S`).
-- **Preview shows the full name** (`base + extension`) in the preview column — FIXED (the
- original showed the base name only). More useful and unambiguous; a display choice only.
-- **No separator between a name and an appended number — PRESERVED (faithful).** The
- original appends/prepends the value directly, so `name` + number-suffix `01` yields
- `name01`. If a nicer result is wanted, the user combines with **Add** (e.g. an `-` suffix).
-- **Optional name separator for Date — ADDED.** The original concatenated the date directly
- (`name2024-05-01`); this rebuild adds an *optional* `name_separator` (default empty = the
- 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.
-- **Directory renaming — ADDED (not in the original).** The original listed files only.
- This rebuild lists *entries*: `/api/list` returns files **and** subdirectories, each
- with a `type: "file"|"dir"` field (dirs report `size: 0`, real `mtime`), and the
- preview/check/rename endpoints accept an optional `dirs[]` (the selected names that
- are directories) so the engine can flag them extension-less (`RenameFile.is_dir`).
- Omitting `dirs` ⇒ everything is a file, so old clients keep working. The **default
- view still shows files only** (toggles in §3), so the historical behavior is
- preserved out of the box.
-- **View toggles are view state, not modifier config — ADDED.** `showFiles`/`showDirs`
- live in the frontend store (with `treeVersion`), deliberately **not** in
- `defaultConfig()`/`sanitizeConfig()`/the backend recipe: they are presentation,
- not part of the rename pipeline, and are per-session (no persistence).
-- **Dot-files are extension-less — FIXED.** The original (and this port's first cut)
- treated the leading dot of `.bashrc` as the extension delimiter, leaving an *empty*
- base — and since every modifier operates on the base, dot-files could never be
- renamed at all. Now a dot at index 0 is not an extension (matches
- `os.path.splitext`): `.bashrc` is fully renameable (Replace `.bashrc`→`.zshrc`,
- Case, Add, Remove, Counting all work), while `.hidden.tar` still splits at its last
- real dot (base `.hidden`, ext `.tar`).
-
----
-
-## 5. Repository layout
+## 1. Rename pipeline (the core contract)
+
+`backend/engine/pipeline.py: compute(files, config)`:
+
+1. Reset each file's `new_base` to its original base name.
+2. Sort by list row (numbering follows **on-screen list order**, not alphabetical).
+3. Apply **active** modifiers in canonical order `Replace → Case → If-Then → Remove → Add
+ → Counting → Date` (`CANONICAL_ORDER`, locked by tests). User-adjustable via
+ `Config.pipeline_order` (drag-and-drop cards; `None` = canonical; per-session, not
+ persisted). `resolve_order()` is defensive: unknown ids dropped, missing appended
+ canonically — a partial list never skips a modifier.
+
+Rules:
+- Only the **base name** is modified; the extension (from the last `.`) is preserved.
+ A *leading* dot is not an extension (`.bashrc` → whole name is the base;
+ `.hidden.tar` → base `.hidden`) — the `os.path.splitext` convention.
+- **Directories** (`RenameFile.is_dir=True`) are always extension-less (`backup.tar` dir →
+ `x_backup.tar`); all seven modifiers work unchanged on them. Numbering spans a mixed
+ selection in list order (one combined sequence).
+- Invalid regex = no-op (never crashes the live preview). Empty Replace search = no-op
+ (`str.replace("", x)` would mangle names on disk).
+- The engine is **pure stdlib** (dataclasses, `re`, `os`, `datetime`) — no web deps;
+ HTTP concerns live in `backend/api/`.
+
+## 2. The seven modifiers
+
+Each is independently toggleable (✓/✗ indicator); when off its panel **collapses** (hidden) and
+its `
` root keeps every control in a real disabled state (for assistive
+tech) — and its card is drag-reorderable.
+
+| Modifier | What it does | Notes |
+|---|---|---|
+| **Replace** | search → replace, all occurrences; plain or regex, case option | fixed: applied exactly once (original double-applied) |
+| **Case** | UPPER / lower / Title / Sentence + camel, Pascal, snake, kebab, CONSTANT, train | added (not in original); splits on delimiters + camel boundaries; `str.title` apostrophe quirk (`it's` → `It'S`) preserved |
+| **If-Then** | condition (CONTAINS / CONTAINS-NOT, plain or regex, case) evaluated on the **original** `base` → consequence as PREFIX / INSERT-at-pos / SUFFIX | consequence applies to the evolving `new_base` (preserved quirk) |
+| **Remove** | first-n chars, last-n chars, range start–end (+ "until end") | ranges clamping past a shorter name never go out of bounds |
+| **Add** | prefix, suffix, insert-at-position | insert applies first, then `prefix + name + suffix` |
+| **Counting** | start number, zero-padding (`001`), as prefix / suffix / insert | no separator (faithful: `name01`); combine with **Add** for a dash |
+| **Date** | format DD-MM-YYYY / YYYY-MM-DD / MM-DD-YYYY, date separator, optional **name separator** (default empty = direct concat, faithful), source created/mtime/today/custom (+picker), prefix/suffix/insert | never leaves a dangling separator at an edge or against an empty base |
+
+Sub-option inputs follow one rule: mode-only inputs render conditionally (`{#if}`),
+persistent-value inputs (Remove's range) stay visible and use real `disabled` attributes.
+
+## 3. Rename workflow (safety)
+
+`/check` → if any resulting name would clobber an existing on-disk entry, a blocking
+**Warning** "Found existing entries for N new name(s)!" (Abort only) and stop. Duplicate
+detection is **cross-type** (compares against whatever exists at the target path:
+file→dir, dir→file, dir→dir). Else **Confirmation** "Rename N Item(s)?" (Ok/Abort;
+dialogs say "Item(s)" / de "Element(e)", not "File(s)") → on Ok rename only entries
+whose new name differs → **"Successfully renamed N Item(s)!"**
+
+## 4. Behavior decisions vs the original (keep unless explicitly told otherwise)
+
+- **Fixed:** Replace double-application; preview shows the full name (base + extension);
+ dot-files are fully renameable; dialogs say "Item(s)"; disabled modifier panels are a real
+ `` and, in the modern UI, collapsed (was a CSS-only grey-out — keyboard
+ users could still Tab in and edit).
+- **Preserved:** If-Then condition on the original base; list-order numbering; no
+ separator around numbers; `str.title` apostrophe quirk.
+- **Added (beyond the original):** Case modifier; directory renaming (typed `/api/list` +
+ optional `dirs[]` payload); Files/Directories **view toggles** (view state, *not* modifier
+ config — deliberately not in `defaultConfig()`/`Config`); date name separator; custom
+ pipeline order.
+
+## 5. Repo layout
```
-arenamer/
-├── run.py # one-command launcher (desktop window, or web fallback)
-├── do # release tooling: bump/tag/changelog/build/test (pure stdlib)
-├── requirements.txt # runtime deps: fastapi, uvicorn, pywebview
-├── requirements-dev.txt # + pytest
-├── pyproject.toml # app name/version (read by the PyInstaller spec and CI version-sync)
-├── requirements-build.txt # + pyinstaller, cairosvg, pillow (best-effort icon regen)
-├── build/ # packaging: build.py (cross-platform orchestrator), arenamer.spec,
-│ # _bundle.py, _smoke.py + smoke.spec (headless frozen-bundle test);
-│ # build/arenamer/ and build/smoke/ are gitignored build output
-├── backend/
-│ ├── main.py # FastAPI app: static mount, /api/health, pywebview bootstrap
-│ ├── api/ # API routes + Pydantic schemas (list/dirs/preview/check/rename)
-│ ├── engine/ # PURE rename engine (no web deps) — the correctness core
-│ │ ├── models.py # RenameFile + Config dataclasses (+ JSON (de)serialization)
-│ │ ├── pipeline.py # compute/preview/find_duplicates/check_duplicates/perform_rename/build_files
-│ │ ├── add.py remove.py replace.py case.py number.py ifthen.py date.py
-│ └── static/ # built SPA (gitignored; `npm run build` output)
-├── frontend/ # Svelte SPA (Vite)
-│ ├── package.json vite.config.js vitest.config.js index.html
-│ └── src/
-│ ├── main.js # mounts the Svelte app
-│ ├── App.svelte # root: path bar + tree | file list layout + modifier panels + live-preview effect
-│ ├── lib/api.js # fetch client for /api/*
-│ ├── lib/config.js # defaultConfig() + sanitizeConfig() (plain JS, no runes)
-│ ├── lib/i18n/ # en.js + de.js (string tables) and index.svelte.js (language $state, t())
-│ ├── lib/state/ # store.svelte.js — central $state (files, selection, view toggles, config, previews, dialog)
-│ ├── components/ # FileList, DirectoryTree (+TreeNode), RenameButton, ModifierCard, Dialog
-│ └── components/modifiers/ # all seven panels: Replace, Case, IfThen, Remove, Add, Counting, Date
-├── .github/workflows/ # GitHub Actions: ci.yml (dev), release.yml (bump+tag), build.yml (3-OS releases)
-└── tests/
- └── backend/
- ├── test_engine.py # engine suite (127 tests) — modifiers, pipeline order (incl. custom), directories, dot-files, edge cases
- └── test_api.py # API suite (17 tests) — list/dirs/preview/check/rename over HTTP (incl. typed entries + dirs)
+run.py # one-command launcher (desktop window / :8000 web)
+do # release tooling, pure stdlib: bump / tag / changelog / build / test
+pyproject.toml # single source of version truth (PyInstaller spec + CI version-sync);
+ # also pip-installable (pip install -e . -> `arenamer`)
+DESIGN.md # design system: tokens, two themes (dark default), elevation, state contract
+plan.md # historical planning doc (directory renaming; shipped in 0.3.0 — don't re-execute)
+requirements*.txt # runtime (fastapi, uvicorn, pywebview) / -dev (+pytest) / -build (+pyinstaller, cairosvg, pillow)
+changelog.md # per-tag sections written by `do bump`
+backend/
+├── main.py # FastAPI app, static mount, /api/health, pywebview bootstrap; _base_dir() honors sys._MEIPASS when frozen
+├── api/ # routes.py (all /api/*) + schemas.py
+└── engine/ # PURE engine: models.py (Config, RenameFile + 7 modifier configs, to/from_dict),
+ # pipeline.py, add remove replace case number ifthen date
+frontend/src/
+├── index.css # design tokens (two themes, dark default) + global state contract, per DESIGN.md
+├── App.svelte # 3-pane layout (tree | file list | modifier sidebar), header (language + theme
+│ # toggle), breadcrumb path bar (+ path input / Open), dismissible error banner,
+│ # dialog host, debounced preview $effect; page never scrolls (each pane does),
+│ # stacked below ~980px
+├── lib/state/store.svelte.js # THE single $state store + all actions (see §6)
+├── lib/api.js # fetch client for /api/*
+├── lib/config.js # defaultConfig() + sanitizeConfig()
+├── lib/i18n/ # en.js (source of truth), de.js (identical key set), index.svelte.js (language $state, t())
+└── components/ # FileList, DirectoryTree(+TreeNode), ModifierCard, RenameButton, Dialog
+ └── modifiers/ # seven panels: Replace, Case, IfThen, Remove, Add, Counting, Date
+build/ # build.py (cross-platform orchestrator: frontend → icons → PyInstaller → archive),
+ # arenamer.spec, _bundle.py, _smoke.py + smoke.spec, icons/, make_icons.py
+tests/backend/ # test_engine.py (127 tests), test_api.py (18 tests)
+.github/workflows/ # ci.yml, release.yml, build.yml
```
-### Engine public API (`backend/engine/__init__.py`)
-- `Config` / `RenameFile` and the seven modifier config dataclasses — from `models.py`.
- `RenameFile` has `is_dir: bool = False`: directories are extension-less entries.
-- `compute(files, config)` — run the full pipeline (mutates each file's `new_base`).
-- `build_files(path, names, dirs=None)` — build `RenameFile` objects (row = list position);
- the `dirs` names are flagged `is_dir=True`.
-- `preview(files, config)` — per-entry new-name info keyed by original name (incl. `type`).
-- `find_duplicates(files, config)` / `check_duplicates(...)` — names/count that would clobber an existing entry (cross-type).
-- `perform_rename(files, config)` — rename on disk; returns `{renamed, errors}`.
-
-The engine is **pure stdlib** (dataclasses, `re`, `os`, `datetime`) — no web deps — so it
-is trivially unit-testable and reusable.
-
-### API surface (`backend/api/routes.py`, all under `/api`)
-- `GET /list?path=` — the directory's entries: files **and** subdirectories, each with
- `type: "file"|"dir"` (dirs report `size: 0`, real `mtime`), sorted. The response field
- is still named `files`; filtering by type is client-side (the view toggles, §3).
-- `GET /dirs?path=` — immediate subdirectories (for lazy tree navigation).
-- `GET /home` — the user's home directory (default starting point for the UI).
-- `POST /preview` `{path, files[], dirs[], config}` → per-entry new-name preview (incl. `type`).
-- `POST /check` `{path, files[], dirs[], config}` → duplicate names that would clobber an existing entry.
-- `POST /rename` `{path, files[], dirs[], config}` → renames on disk; **409** if any would clobber.
-
-`files` carries the selected names in on-screen list order; `dirs` (optional) lists which
-of them are directories — omitted ⇒ everything is a file (backward compatible).
-`config` is a plain JSON object matching `Config.to_dict()` (see §3); the backend converts
-it via `Config.from_dict`, so partial configs from the UI are fine. Unknown keys are ignored
-and `null` values fall back to each field's default, so a cleared UI input never 500s. The
-rename workflow is: UI calls `/check` (blocking warning if duplicates) → its own confirm
-dialog → `/rename`.
-
-### Frontend architecture (`frontend/src`)
-- **`lib/state/store.svelte.js`** — the single source of truth. A module-level Svelte 5
- `$state` object (files, selection, **showFiles/showDirs view toggles**, **treeVersion**,
- config, previews, duplicateNames, dialog) plus action
- functions (`loadDir`, `openHome`, `goUp`, `toggleSelect`, `selectAll`, `clearSelection`,
- `setShowFiles`, `setShowDirs`, `reorderModifier`, `resetModifierOrder`,
- `bumpTreeVersion`,
- `refreshPreview`, `showDialog`, `checkDuplicates`, `performRename`). `selectAll` selects
- the *visible* entries only; hiding a type prunes it from selection/previews/duplicates.
- Preview/check/rename payloads include the `dirs` field. It uses the
- `.svelte.js` extension because `$state` is a rune (only compiled in `.svelte*` files).
-- **`lib/api.js`** — thin `fetch` client for the `/api/*` endpoints.
-- **`lib/config.js`** — `defaultConfig()` (the all-disabled recipe; its shape MUST mirror
- backend `Config.to_dict()`) and `sanitizeConfig()` (coerces every numeric field to an int
- before each API call — Svelte binds a cleared ` ` to `null`, which
- would otherwise crash the engine and 500 the live preview).
-- **`lib/i18n/`** — runtime internationalization (German + English). `en.js` is the source
- of truth; `de.js` has the identical key set (wording taken from the original Qt app's
- `languages/ARenamerTool_de_DE.ts`, modernized to standard German capitalization).
- `index.svelte.js` holds the language as a `$state` property, so every `t("key")` call in
- a template is reactive — switching re-renders all strings. Startup detection mirrors the
- original's `QLocale::system()`: a saved user choice (localStorage) wins, otherwise
- `navigator.language` (`de*` → German). All UI strings go through `t()`; backend error
- messages stay English (technical, not user-facing copy).
- - **`components/FileList.svelte`** — Files/Directories view-toggle checkboxes, a
- Select-all/Clear toolbar, and the Name + New Name preview table of *visible* entries
- (multi-select; row/checkbox click toggles; header checkbox selects all; dir rows
- carry a type badge), with red highlighting of rows that would clobber an existing
- entry. The table fills the center column and scrolls
- internally (sticky header); `table-layout: fixed` + ellipsis keeps long names from stretching
- the view. **`components/modifiers/`** — all seven panels (Replace, Case, If-Then, Remove, Add,
- Counting/Number, Date), each a self-contained section with an enable toggle + ✓/✗ indicator
- and controls greyed out when disabled; panels are rendered in `App.svelte` in pipeline order
- (§2), stacked vertically in the right-hand sidebar.
-- **`components/DirectoryTree.svelte`** (+ recursive `TreeNode.svelte`) — lazy directory tree
- rooted at home (`/api/dirs` per expansion); clicking a node re-roots the file list. It
- watches `treeVersion` and re-fetches the children of every loaded node, so renamed
- directories get fresh labels.
-- **`components/ModifierCard.svelte`** — wrapper around each modifier panel: the grip
- handle drag-and-drops the card into a custom `pipeline_order` (`reorderModifier`); the
- hover marks the insertion gap with a line so the drop position is unambiguous (the drop
- is deferred a frame so the `{#each}` can re-render). Cards are `role="listitem"` inside
- the `role="list"` modifier sidebar (`App.svelte`).
-- **`components/RenameButton.svelte`** — drives the rename workflow: `/api/check` (blocking
- duplicate warning) → confirm dialog → `/api/rename` → success dialog, then re-lists the
- dir and bumps `treeVersion` (tree label refresh).
-- **`components/Dialog.svelte`** — reusable modal (warning / confirm / info variants; Esc or
- backdrop click dismisses). Rendered once in `App.svelte`; driven by the store's `dialog` state.
-- **`App.svelte`** — composes the header (title + language switcher), the path bar
- (Home / Up / Open) and a full-viewport three-pane layout: directory tree | file list
- | modifier sidebar. The page itself never scrolls — each pane scrolls internally
- (native-app feel); below ~980px it falls back to a stacked, page-scrolling layout.
- A debounced `$effect` re-runs `/api/preview` whenever config, selection or directory
- changes (live preview).
-
----
+Engine public API (`backend/engine/__init__.py`): `Config`, `RenameFile` (incl. `is_dir`),
+the seven `*Config` dataclasses, `CANONICAL_ORDER`, `compute`, `build_files(path, names,
+dirs=None)`, `preview`, `find_duplicates` / `check_duplicates`, `perform_rename`.
+
+### API surface (`/api/*`, `backend/api/routes.py`)
+
+- `GET /list?path=` — the directory's entries: files **and** subdirectories, each
+ `type: "file"|"dir"` (dirs report `size: 0`, real mtime), sorted. Response field is
+ still named `files`; type filtering is client-side (view toggles). Friendly **403** for
+ unreadable dirs (e.g. macOS TCC).
+- `GET /dirs?path=` — immediate subdirectories (lazy tree); `GET /home` — home directory.
+- `POST /preview | /check | /rename` — `{path, files[], dirs[], config}`. `files` = selected
+ names **in on-screen list order**; `dirs` = the ones that are directories (optional —
+ omitted ⇒ all files, old clients keep working). `/rename` returns **409** if any would clobber.
+
+`config` is a plain JSON object matching `Config.to_dict()`; `Config.from_dict` accepts
+partial configs (unknown keys ignored, `null` → field default, so a cleared UI input never 500s).
+Workflow: UI calls `/check` (blocking warning) → its own confirm dialog → `/rename`.
+
+## 6. Frontend essentials
+
+- **The store is the single source of truth** — a module-level `$state` object (files,
+ selection, `showFiles`/`showDirs`, `treeVersion`, config, previews, `duplicateNames`,
+ `error`, `dialog`) plus actions: `loadDir, openHome, goUp, clearError, toggleSelect,
+ selectAll, clearSelection, setShowFiles, setShowDirs, reorderModifier, resetModifierOrder,
+ refreshPreview, showDialog, checkDuplicates, performRename, bumpTreeVersion`.
+ `selectAll` selects the *visible* entries only; hiding a type prunes it from selection,
+ previews and duplicate highlights the moment it's toggled off. Components read/write the
+ store and call `/api/*` — **never compute rename results in a component**.
+- **`$state` rune trap:** Svelte 5 parses `$state` as a *legacy store* auto-subscription
+ (`state.subscribe`) when a binding named `state` is in scope — this crashed every
+ TreeNode render. Alias the import (`state as appState`) in components; only plain dot
+ access (`state.x`) is safe.
+- Files using `$state` must use the **`.svelte.js`** extension (runes only compile in
+ `.svelte*` files).
+- **Live preview:** debounced `$effect` in `App.svelte` re-runs `/api/preview` on any
+ config / selection / directory change.
+- **Theming:** dark is the default; the header toggle flips `data-theme` on `` and
+ persists `"arenamer.theme"` to localStorage — `frontend/index.html` re-applies that key
+ in an inline pre-paint script so there is no flash. All colours come from the CSS
+ variables in `index.css` (the `DESIGN.md` token set); components use `var(--token)`
+ only — no off-system hex, no ad-hoc shadows.
+- `sanitizeConfig()` coerces every numeric field to int before each API call (Svelte binds
+ a cleared ` ` to `null`). Keep it in sync with `defaultConfig()`
+ and the backend `Config` — the three shapes must mirror each other.
+- **i18n:** all UI strings go through `t("key")` (reactive). `en.js` is the source of
+ truth; `de.js` has the identical key set (tested); wording is modernized standard German.
+ Startup: saved choice (localStorage) wins, else `navigator.language` (`de*` → German).
+ Backend error messages stay English (technical, not user-facing copy).
+- **DirectoryTree:** lazy under home (`/api/dirs` per expansion); clicking a node re-roots
+ the file list; watches `treeVersion` to re-fetch children of loaded nodes (renamed-dir
+ labels). **Lesson (fixed bug):** the refresh must run outside the `$effect` body —
+ running `refreshLoaded()` inside tracked node state and caused an endless re-fetch loop
+ (tree collapsed to its first level).
+
+## 7. Build, run & test
-## 6. Build, run & test
-
-### Backend / engine
```sh
python3 -m venv .venv && source .venv/bin/activate
-pip install -r requirements-dev.txt # fastapi, uvicorn, pywebview, pytest
-python run.py # desktop window (or web fallback at :8000)
-```
-
-### Tests (the engine is the verified core)
-```sh
-python3 -m pytest tests/ -v # from repo root (pytest suite lives in tests/backend/)
-```
+pip install -r requirements-dev.txt # fastapi, uvicorn, pywebview, pytest
+python run.py # desktop window (or web fallback :8000)
-`./do test` runs the pytest suite above plus the frontend vitest suite
-(`cd frontend && npm run test`) — one command covers both.
+python -m pytest tests/ -v # backend suite, from repo root
-### Frontend (Svelte)
-```sh
cd frontend && npm install
-npm run dev # Vite dev server on :5173 (proxies /api -> :8000)
-npm run build # emits to ../backend/static for the desktop app
-npm run test # vitest unit tests (also runs in CI, see below)
+npm run dev # Vite :5173 (proxies /api → :8000)
+npm run build # → ../backend/static
+npm run test # vitest: 7 files, 98 tests
+ # (lib/config, lib/api, lib/i18n/languages, lib/state/store,
+ # components/DirectoryTree, components smoke, modifiers smoke)
+./do test # pytest + vitest in one command
```
-### Frontend tests (vitest)
-
-`npm run test` in `frontend/` runs the vitest suite (currently 46 tests across
-four files): `lib/config.test.js` (defaultConfig/sanitizeConfig),
-`lib/i18n/languages.test.js` (en/de key parity, locale detection, `t()`),
-`lib/state/store.svelte.test.js` (selection, view toggles, pipeline order, dialogs, api-backed
-flows with `vi.mock` of `lib/api.js`), and `components/components.smoke.test.js`
-(FileList / Dialog / RenameButton render + interaction smoke tests against the
-real module-level store).
-
-Toolchain notes (keep these when changing the test setup):
-- `vitest.config.js` is deliberately separate from `vite.config.js` so the
- production build is untouched. It aliases bare `svelte` to its **client entry** —
- under Vitest's SSR-style transform it otherwise resolves to `index-server.js`
- and `mount()` throws `lifecycle_function_unavailable`.
-- `components.smoke.test.js` opts into **jsdom** via `// @vitest-environment jsdom`
- (happy-dom crashes inside Svelte 5 checkbox mounting); everything else runs on
- happy-dom.
-- Store/i18n are module-level `$state` singletons: unit tests get fresh state via
- `vi.resetModules()` + dynamic import (store) or explicit resets (component smoke tests).
-- Frontend tests stay **co-located under `frontend/src/`** (vitest convention). A shared
- `tests/frontend/` folder was tried and abandoned: Vite refuses to load test files outside
- its project root even with `root` lifted to the repo root. The Python suite lives in
- `tests/backend/`.
+Vitest notes (keep when changing test setup): `vitest.config.js` is separate from
+`vite.config.js` (production build untouched) and aliases bare `svelte` to its **client
+entry** — otherwise the SSR-style transform resolves `index-server.js` and `mount()`
+throws `lifecycle_function_unavailable`. The component smoke test opts into **jsdom**
+(`// @vitest-environment jsdom`; happy-dom crashes inside Svelte 5 checkbox mounting).
+Store/i18n are module-level singletons: tests get fresh state via `vi.resetModules()` +
+dynamic import (store) or explicit resets (smoke). Frontend tests stay **co-located under
+`frontend/src/`** — Vite refuses test files outside its project root (a shared
+`tests/frontend/` folder was tried and abandoned); the Python suite lives in
+`tests/backend/`.
### Packaging (distributable desktop app)
-The app is a pywebview window around the Svelte SPA + in-process FastAPI server. To ship it
-without requiring Python, `build/build.py` bundles everything with PyInstaller:
-
-```sh
-pip install -r requirements-build.txt # pyinstaller (add to your venv)
-python build/build.py # frontend -> PyInstaller -> versioned archive in dist/
-```
-
-`build/build.py` is a single cross-platform orchestrator (no shell differences). It builds the
-frontend, runs `build/arenamer.spec`, then packages the result. Because **PyInstaller cannot
-cross-compile, run it on each target OS** to get that OS's artifact:
-
-| OS | Artifact in `dist/` | pywebview driver | End-user system requirement |
-|----|---------------------|------------------|------------------------------|
-| macOS | `A-Renamer.app` + `-macOS.zip` | `cocoa` (WKWebView) | none (system WebKit); unsigned → right-click→Open past Gatekeeper |
-| Windows | `A-Renamer/` + `-win64.zip` | `edgechromium` (WebView2) | Edge WebView2 Runtime (preinstalled on Win10/11) |
-| Linux | `A-Renamer/` + `-linux.tar.gz` | `gtk` (WebKit2GTK) | `libwebkit2gtk-4.x` system libs |
-
-Packaging internals:
-- **`build/arenamer.spec`** — the PyInstaller spec (one-folder bundle; a `.app` on macOS).
- Reads name/version from `pyproject.toml`. Set its top-level `CONSOLE = False` for release
- builds (no console window on Windows).
-- **`build/_bundle.py`** — shared dependency collection: pywebview's JS bridge, uvicorn's
- dynamic loop modules, and (macOS only) the PyObjC frameworks behind WKWebView.
-- **`build/_smoke.py`** + `build/smoke.spec` — a headless frozen-bundle test (imports the full
- stack, serves `/api/health` + `/`, exits). Useful to validate a build on an OS where you can't
- easily see the window: `python -m PyInstaller build/smoke.spec && dist/arenamer-smoke`.
-
-The frozen app resolves its bundled SPA via `backend/main.py:_base_dir()` (uses `sys._MEIPASS`
-when frozen), and picks a free localhost port in `run.py` so instances don't clash.
-
-### CI & releases (GitHub Actions)
-
-Three workflows in `.github/workflows/` cover development and release; they reuse
-`do` (stdlib-only, so it runs in CI without installs) and `build/build.py` — keep both
-self-contained if you change them. No branch protection is configured (solo development).
-
-- **`ci.yml` — development pipeline.** Triggers: every PR + pushes to `develop`.
- Four parallel jobs: `backend-tests` (pytest, matrix over Python 3.10 + 3.12),
- `frontend-tests` (vitest, Node 22), `frontend-build` (production vite build), and
- `version-sync` (asserts `pyproject.toml` == `frontend/package.json`). pip/npm caching
- and cancel-in-progress concurrency are set.
-- **`release.yml` — version bump + tag on master.** Trigger: a merged PR whose head
- branch starts with `release/` and whose base is `master`. It checks out the merge
- commit with `fetch-depth: 0` (`do bump` needs `git describe --tags` and
- `git log tag..HEAD`), then: (1) `do bump` → semver decision from the conventional
- commits since the last tag, version + changelog commit; (2) if HEAD moved, `do tag`
- → `v` on that commit, and the commit + tag are pushed. Idempotent: nothing
- new since the last tag → no commit, no tag, job ends. Note: `do` imports `tomllib`,
- so the job sets up Python 3.12 first (runner system Python is older).
-- **`build.yml` — desktop builds + GitHub Release.** Trigger: tag push `v*`
- (fired by `release.yml`). Three parallel OS jobs — `macos-latest`, `windows-latest`,
- `ubuntu-latest` (PyInstaller cannot cross-compile): install runtime + PyInstaller
- deps, `python do build` (frontend → bundle → versioned archive), then run the
- headless smoke test (`build/smoke.spec` → `dist/arenamer-smoke[.exe]`) as proof the
- frozen bundle imports and serves — the only CI signal that the app actually starts.
- Linux additionally apt-installs the WebKit2GTK runtime + typelibs for the `gtk` driver.
- A final `release` job attaches the three artifacts to the GitHub Release, using the
- matching `changelog.md` section as the release notes.
+`python build/build.py` (or `./do build`): frontend → (re)generate native icons from
+`favicon.svg` (best effort via `make_icons.py`; committed icons are the fallback) →
+PyInstaller (`build/arenamer.spec`, one-folder bundle, `.app` on macOS; `CONSOLE=False`
+always — it's a GUI app, and on macOS console=True would hide the Dock icon) →
+versioned archive in `dist/`. `_bundle.py` collects
+pywebview's JS bridge, uvicorn's dynamic loop modules, and (macOS) the PyObjC frameworks
+behind WKWebView. Because **PyInstaller cannot cross-compile, run it on each target OS**:
+
+| OS | Artifact | pywebview driver | System requirement |
+|----|----------|------------------|--------------------|
+| macOS | `A-Renamer.app` + `-macOS.zip` | `cocoa` (WKWebView) | none; unsigned → right-click→Open past Gatekeeper |
+| Windows | `A-Renamer/` + `-windows.zip` | `edgechromium` (WebView2) | WebView2 Runtime (preinstalled Win10/11) |
+| Linux | `A-Renamer/` + `-linux.tar.gz` | `gtk` (WebKit2GTK) | `libwebkit2gtk-4.x` |
+
+Headless frozen-bundle smoke test: `python -m PyInstaller build/smoke.spec &&
+dist/arenamer-smoke[.exe]` (imports the full stack, serves `/api/health` + `/`). The frozen
+app resolves its bundled SPA via `backend/main.py:_base_dir()`; `run.py` picks a free
+localhost port so instances don't clash.
+
+## 8. CI & releases (GitHub Actions)
+
+- **`ci.yml`** (PRs + pushes to `develop`): 4 parallel jobs — backend-tests (pytest,
+ Python 3.10/3.12), frontend-tests (vitest, Node 22), frontend-build, version-sync
+ (asserts `pyproject.toml` == `frontend/package.json`).
+- **`release.yml`** (trigger: `pull_request` **closed** with `merged == true`, base
+ `master`, head `release/*` — so it only fires on actual merges; checks out `master`
+ with `fetch-depth: 0` because `do bump` needs `git describe`; optional
+ `RELEASE_PAT` token): `do bump` (semver from conventional commits since last tag;
+ commit version + changelog) → if HEAD moved, `do tag` (`v`), then push
+ `master --tags`. Idempotent: nothing new → no commit/tag. Job sets up Python 3.12
+ first (`do` imports `tomllib`).
+- **`build.yml`** (tag push `v*`): three parallel OS jobs install runtime +
+ PyInstaller deps, `python do build`, then run the headless smoke test (the only CI
+ signal that the app actually starts); Linux apt-installs WebKit2GTK + typelibs. Final
+ job attaches the artifacts to the GitHub Release, using the matching `changelog.md`
+ section as notes.
Release flow: branch `release/vX.Y.Z` off `develop` (conventional commits) → PR to
-`master` → `release.yml` bumps + tags → `build.yml` publishes all three OS artifacts.
-Artifacts are unsigned (Gatekeeper/SmartScreen notes from the Packaging section apply).
-
----
-
-## 7. Milestone plan & status
-
-| # | Scope | Status |
-|---|-------|--------|
-| 1 | Scaffold: FastAPI + pywebview backend, Vite+Svelte frontend, static mount, one-command run | ✅ done (scaffold) |
-| 2 | Engine: port all 6 modifiers + pipeline order, pure Python, pytest suite green (59 tests) | ✅ done |
-| 3 | API: `/api/list`, `/api/dirs`, `/api/preview`, `/api/check`, `/api/rename` (duplicate check + 409 safety net) | ✅ done |
-| 4 | Frontend core: central store (files, selection, config), live preview column | ✅ done |
-| 5 | UI: file list (multi-select), directory tree, select-all/clear, path bar, Rename button + dialogs | ✅ done |
-| 6 | Modifier panels: all six with live preview + active indicators (✓/✗) | ✅ done |
-| 7 | i18n: German + English, runtime switcher, system-locale auto-detect | ✅ done |
-| 8 | Polish: drag-and-drop reordering (ModifierCard), empty states, error handling done; dark mode + keyboard shortcuts still open | 🟡 partial |
-| 9 | CI: GitHub Actions (dev pipeline, automated bump + tag on master, 3-OS release builds) | ✅ done |
-| 10 | Folder editing: directories are renamable extension-less entries (engine + typed `/api/list` + `dirs` payload), Files/Directories view toggles, type badges, tree label refresh | ✅ done |
-| 11 | Dot-file fix: leading-dot names (`.bashrc`) are extension-less — the whole name is the base (`splitext` convention), all modifiers work | ✅ done |
-
-### Conventions for future work
-- CI reuses `do` and `build/build.py`: keep `do` stdlib-only and the build orchestrator
- self-contained so the workflows keep working; job display names in `ci.yml` /
- `release.yml` should stay stable.
-- Keep the engine **pure** (no web/fs-side-effect deps beyond what a rename needs); put
- HTTP concerns in `backend/api/`. Add engine behavior changes **with tests**.
-- The frontend is a single source of truth in `src/lib/state/`; components read/write the
- store and call `/api/*`. Never let a component compute rename results locally.
-- Preserve the pipeline order (§2) and the behavior decisions in §4 unless explicitly told otherwise.
-- **Never import `state` into a component and write `$state` in it.** Svelte 5 parses
- `$state` as the *legacy store* auto-subscription (`state.subscribe`) when a `state`
- binding is in scope, not as the `$state` rune — this crashed every `TreeNode` render
- (tree pane never appeared). Alias the import (`state as appState`) or use a plain
- `let` for DOM bindings.
+`master` → auto bump + tag → release with all three OS artifacts. Artifacts are unsigned.
+
+## 9. Conventions & open work
+
+- Keep the engine **pure** and the pipeline order / §4 decisions intact; engine behavior
+ changes always **with tests**.
+- CI reuses `do` and `build/build.py` — keep `do` stdlib-only and the orchestrator
+ self-contained; job display names in the workflows should stay stable.
+- New config fields: add them to backend `Config`, `defaultConfig()` **and**
+ `sanitizeConfig()` (numeric ones) so all three shapes stay in sync.
+- Never import the store as bare `state` + write `$state` in a component (§6 trap).
+- **Open:** keyboard shortcuts (only remaining polish item; dark mode shipped with the
+ modern UI — `DESIGN.md` token set + header theme toggle, dark default).
\ No newline at end of file
diff --git a/DESIGN.md b/DESIGN.md
new file mode 100644
index 0000000..0aa152d
--- /dev/null
+++ b/DESIGN.md
@@ -0,0 +1,242 @@
+# A-Renamer — Design System
+
+Dark-first desktop web utility (pywebview / WKWebView, mouse + keyboard). Batch file &
+directory renamer: directory tree · file list with live previews · 7-step modifier
+pipeline. Accent family is derived from the product logo (`frontend/public/favicon.svg`,
+purple folder gradient `#a855f7 → #6d28d9`).
+
+Reference implementation of these tokens: `frontend/src/index.css` (the committed SPA's stylesheet).
+
+```yaml
+name: A-Renamer
+description: >
+ Dark-first desktop renaming utility. Layered dark surfaces, one logo-purple accent,
+ mono filenames, dense 13px working grid, numbered modifier pipeline.
+colors:
+ bg: "#0e0f12" # light: #f2f3f6
+ surface: "#16181d" # light: #ffffff
+ surface-2: "#1d2026" # light: #f6f7f9
+ surface-3: "#242830" # light: #eceef2
+ border: "#262b33" # light: #e4e7ee
+ border-strong: "#333945" # light: #d3d8e2
+ primary: "#8b36e8" # filled button bg; light: #7c3aed
+ primary-hover: "#6d28d9" # both themes
+ primary-contrast: "#ffffff" # both themes
+ accent: "#a855f7" # light: #7c3aed
+ accent-deep: "#8b36e8" # light: #6d28d9
+ accent-bright: "#e9d5ff" # light: #6523d6
+ accent-contrast: "#0e0f12" # light: #ffffff
+ accent-soft: "#a855f724" # 14% alpha; light: #7c3aed17 (9%)
+ sel: "#a855f71a" # 10% alpha; light: #7c3aed0f (6%)
+ row-hover: "#ffffff09" # 3.5% alpha; light: #14182809
+ success: "#70e4a1" # light: #157a49
+ danger: "#ffc1b8" # light: #b42c21
+ text: "#edeff2" # light: #181b21
+ muted: "#d0d6df" # light: #545e6e
+ faint: "#7c8694" # light: #4e5767
+typography:
+ font-family: [system-ui, -apple-system, Segoe UI, Roboto, sans-serif]
+ mono: [ui-monospace, SF Mono, Menlo, monospace]
+ text-scale: [10.5, 11, 11.5, 12, 12.5, 13, 14] # dense utility grid (not 1.25 modular)
+rounded:
+ sm: 8px
+ md: 10px
+ lg: 14px
+ pill: 99px
+spacing:
+ unit: 4px # grid 4/8/12/16/20; 2px subgrid for tight control padding
+components:
+ button-primary:
+ backgroundColor: "{colors.primary}"
+ textColor: "{colors.accent-contrast}"
+ rounded: "{rounded.sm}"
+ padding: 7px 14px
+ button-ghost:
+ backgroundColor: "{colors.surface}"
+ textColor: "{colors.muted}"
+ rounded: "{rounded.sm}"
+ padding: 6px 12px
+ chip-on:
+ backgroundColor: "{colors.accent-soft}"
+ textColor: "{colors.accent-bright}"
+ rounded: "{rounded.sm}"
+ input:
+ backgroundColor: "{colors.bg}"
+ textColor: "{colors.text}"
+ rounded: "{rounded.sm}"
+ pane:
+ backgroundColor: "{colors.surface}"
+ textColor: "{colors.text}"
+ rounded: "{rounded.lg}"
+ step-card:
+ backgroundColor: "{colors.surface-2}"
+ textColor: "{colors.text}"
+ rounded: "{rounded.md}"
+ step-number-chip:
+ backgroundColor: "{colors.surface-3}"
+ textColor: "{colors.muted}"
+ rounded: "{rounded.pill}"
+ step-number-chip-active:
+ backgroundColor: "{colors.primary}"
+ textColor: "{colors.primary-contrast}"
+ rounded: "{rounded.pill}"
+ badge:
+ backgroundColor: "{colors.accent-soft}"
+ textColor: "{colors.accent-bright}"
+ rounded: "{rounded.pill}"
+ hint:
+ textColor: "{colors.faint}"
+ warning:
+ textColor: "{colors.danger}"
+ changed-name:
+ textColor: "{colors.success}"
+ row-hover:
+ backgroundColor: "{colors.row-hover}"
+ row-selected:
+ backgroundColor: "{colors.sel}"
+```
+
+## Colors
+
+Two themes, same token names. All values live in `:root` / `[data-theme]` blocks;
+components use `var(--token)` only.
+
+| Token | Dark | Light | Role |
+|---|---|---|---|
+| `--bg` | `#0e0f12` | `#f2f3f6` | page background |
+| `--surface` | `#16181d` | `#ffffff` | panes, cards |
+| `--surface-2` | `#1d2026` | `#f6f7f9` | inactive step cards, segment wells |
+| `--surface-3` | `#242830` | `#eceef2` | step number chips (off) |
+| `--border` | `#262b33` | `#e4e7ee` | hairlines, card borders |
+| `--border-strong` | `#333945` | `#d3d8e2` | inactive status dots |
+| `--text` / `--muted` / `--faint` | see frontmatter | see frontmatter | 3 text tiers |
+| `--primary` / `--primary-hover` | `#8b36e8` / `#6d28d9` | `#7c3aed` / `#6d28d9` | filled primary button + hover (white text, APCA 82/89) |
+| `--accent` / `--accent-deep` | `#a855f7` / `#8b36e8` | `#7c3aed` / `#6d28d9` | logo gradient stops |
+| `--accent-bright` | `#e9d5ff` | `#6523d6` | accent text on soft fills (APCA ≥75 in both themes) |
+| `--accent-soft` / `--sel` | 14% / 10% | 9% / 6% | tints, selection |
+| `--success` | `#70e4a1` | `#157a49` | changed-name highlight |
+| `--danger` | `#ffc1b8` | `#b42c21` | errors, warnings |
+
+**Contrast (measured, gate: WCAG AA 4.5 hard floor, APCA 75 target for primary text):**
+
+| Pair | Dark | Light |
+|---|---|---|
+| body (`--text` on `--surface`) | WCAG 15.4 · APCA 96 ✓ | WCAG 17.3 · APCA 104 ✓ |
+| `--muted` on `--surface` | WCAG 12.1 · APCA 80 ✓ | WCAG 6.6 · APCA 82.5 ✓ |
+| `--faint` on `--surface` | WCAG 4.8 · APCA 36.1* | WCAG 7.3 · APCA 85 ✓ (worst fill `--surface-3`: 75.1) |
+| crumb (`--muted` on `--bg`) | WCAG 13.1 · APCA 81 ✓ | WCAG 5.9 · APCA 75 ✓ |
+| primary button (white on `--primary`) | WCAG 5.5 · APCA 81.9 ✓ | WCAG 5.7 · APCA 82.8 ✓ |
+| primary hover (white on `--primary-hover`) | WCAG 7.1 · APCA 88.3 ✓ | WCAG 7.3 · APCA 89.4 ✓ |
+| changed name (`--success`) | WCAG 11.2 · APCA 75.8 ✓ | WCAG 5.4 · APCA 76.3 ✓ |
+| badge (`--accent-bright` on soft) | WCAG 10.3 · APCA 81.7 ✓ | WCAG 6.6 · APCA 75.1 ✓ |
+| `--danger` | WCAG 11.5 · APCA 76.9 ✓ | WCAG 6.3 · APCA 80.0 ✓ |
+
+\* One documented deviation remains: dark `--faint` (APCA 36.1, WCAG 4.8 ✓).
+It is the metadata tier (table headers, step numbers, 10.5–11.5px labels,
+placeholders, hints) and only reads on `--surface`/`--surface-2`. Raising it to
+APCA 75 would flatten it into `--muted` (APCA 80) and kill the 3-tier hierarchy —
+the known APCA dark-mode tradeoff. (Duplicate-row *hover* is transient: danger
+text on the 12% danger tint reaches APCA 67–73, WCAG ≥4.5 — accepted.) Every
+other pair in both themes passes APCA 75 at the sizes where it is used.
+
+**Known ceiling:** `data:` URI glyphs (e.g. the `` chevron) cannot use
+`var()` — their stroke color duplicates `--muted` per theme as a hardcoded value.
+Kept to exactly that one exception.
+
+**`@google/design.md lint` notes (0 errors, 5 accepted warnings):** the frontmatter
+models one flat palette (dark values + light in comments), so (a) `chip-on`/`badge`
+contrast warnings composite dark-theme ink over an alpha tint on a white canvas — the
+per-theme measured values in the table above are the real ones; (b) `--border`/`--border-strong`
+are reported orphaned because the schema's component sub-tokens (`backgroundColor`,
+`textColor`, …) cannot express borders — they are used throughout per the Elevation rules; (c) `accent-deep`
+is the logo's second gradient stop (`favicon.svg`) — brand vocabulary, kept for completeness.
+`--primary-hover` is now an explicit token (both themes share `#6d28d9`).
+
+## Typography
+
+- UI: system stack. Filenames & paths: mono. Never a third family.
+- Dense utility scale (14 base): 10.5 (field labels) · 11 (section headings, uppercase) ·
+ 11.5 (hints) · 12 (checkboxes) · 12.5 (mono names, crumbs) · 13 (rows, buttons) ·
+ 14 (default). We deliberately do not use a 1.25 modular scale — a renaming tool's
+ value is rows-per-screen, not display typography.
+- Uppercase + `letter-spacing: 0.06–0.08em` reserved for section headings only.
+- Tabular figures (`font-variant-numeric: tabular-nums`) for counts.
+
+## Layout
+
+Three panes: tree (232px fixed) · file list (flex) · pipeline (332px fixed), 12px gutters,
+12/16/20px outer padding. Spacing snaps to the 4px grid (4/8/12/16/20); tight
+control-internal padding may use the 2px subgrid. Below 980px the
+panes stack. The window never scrolls vertically; panes scroll internally.
+
+## Elevation
+
+Three named levels, no ad-hoc shadows:
+
+| Level | Recipe (dark) | Use |
+|---|---|---|
+| `--shadow` (resting) | `inset 0 1px 0 rgba(255,255,255,.04), 0 8px 28px rgba(0,0,0,.38)` | panes, dialogs |
+| `--shadow-btn` (raised) | `0 1px 2px rgba(0,0,0,.4), inset 0 0 0 1px rgba(255,255,255,.06)` | primary button |
+| focus ring | `0 0 0 3px var(--accent-soft)` | focused inputs, active status dots |
+
+Light theme uses `rgba(16,24,40,…)` at 5–12%. Separation order: whitespace → background
+shift (`--surface-2`) → elevation. A border is the last resort; panes combine a 1px
+`--border` with `--shadow` (border + elevation together is the pane recipe, never a
+border alone as a card).
+
+## Shapes
+
+`--r-sm: 8px` (inputs, buttons, chips) · `--r-md: 10px` (step cards) · `--r-lg: 14px`
+(panes, dialogs) · `99px` (pills, dots, badges). No other radii.
+
+## Components & states
+
+Every interactive element declares the full state set. Global contract:
+
+```css
+:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+:disabled { opacity: 0.5; cursor: not-allowed; }
+.primary:active { transform: translateY(1px); } /* pressed: physical dip on pill controls */
+.ghost:active, .icon-btn:active, .chip:active, .lang:active { transform: translateY(1px); }
+.crumb:active, .node:active { background: var(--surface-3); } /* flat surfaces deepen instead */
+```
+
+Inputs override the outline with the 3px `--accent-soft` ring (their focus-visible
+treatment); everything else keeps the outline.
+
+- **Button (primary)** — `--primary` / `--primary-contrast` (white in both themes) /
+ `--r-sm` / `--shadow-btn`; hover → `--primary-hover`. The dark button uses
+ `--accent-deep` as its base stop: white on `--accent` is APCA 37, on `--accent-deep`
+ 82. Disabled → 45% opacity; selection count in a pill (dark: `--text` bg / `--bg`
+ text, APCA 96; light: `--accent-contrast` bg / `--accent` text, 77.5).
+- **Button (ghost)** — `--surface` + `--border` / `--muted`; hover → `--surface-2` +
+ `--text`; disabled → 45%.
+- **Chip (view toggle)** — off: `--surface`/`--faint`; on: `--accent-soft` fill,
+ `--accent-bright`, border mixed 45% accent.
+- **Step card (modifier)** — off: `--surface-2`, collapsed to one line, status dot
+ `--border-strong`, number chip `--surface-3`/`--faint`; on: `--surface`, expanded,
+ dot `--accent` + 3px `--accent-soft` ring, number chip `--accent`/`--accent-contrast`.
+- **Table row** — hover `--row-hover`; selected `--sel` (hover: `--accent-soft`);
+ changed names `--success` semibold + 5px dot indicator.
+- **Tree node** — `--muted`; hover → `--text`; current → `--accent-soft` + `--text`.
+- **Input/Select** — `--bg` field on `--surface` cards (the one deliberate inversion,
+ light field on light card); focus ring per contract.
+- **Dialog** — `--surface`, `--r-lg`, `--shadow`; confirm → primary, abort → ghost;
+ warning icon/title `--danger`.
+
+## Do's & Don'ts
+
+Do:
+- Use tokens only; two themes are the whole palette story.
+- Mono for anything that is a filename or a path.
+- Hierarchy by tier (`text` → `muted` → `faint`) and weight, not by color.
+- Semantic color only: `--success` = name changed, `--danger` = error/warning, accent =
+ interactive/selected — nothing else.
+
+Don't:
+- No gradients except the logo. No glassmorphism, no glow (the one allowed "glow" is
+ the 3px focus/soft ring on status dots).
+- No shadows as texture — only `--shadow`, `--shadow-btn`, focus ring.
+- No off-scale sizes, no off-grid spacing, no new accent hues.
+- No 1px-border-only cards; no borders in place of a background shift.
+- Don't introduce a light-default reflex: dark is the default theme.
\ No newline at end of file
diff --git a/README.md b/README.md
index 4737f4d..06b57fe 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,5 @@
+
+
# A-Renamer Tool
The A (or Adrian's) – Renamer is a tool for bulk renaming files **and directories** in various ways. Its graphical user interface lets the user easily select a file directory and then select the entries (files and/or folders) to bulk rename. In the file selection view it also provides a live preview of the new name for each selected entry, and Files/Directories toggles control which entry types are shown (default: files only, the historical view).
@@ -14,43 +16,50 @@ The user has various ways of adding, removing and replacing content in a filenam
- **Date** — add a date (created, last modified, today, or custom) in several formats
- **Counting** — add a running number (start value, zero-padding) in list order
-Modifiers are applied in a fixed pipeline order (`Replace → Case → If-Then → Remove → Add → Counting → Date`), each independently toggleable, with an instant per-file preview. Renaming is safe: a duplicate check runs first (blocking warning if any new name would clobber an existing entry — cross-type, so renaming a folder onto a file or vice versa is caught too), followed by a confirmation dialog. Directories are treated as *extension-less* entries: a folder named `backup.tar` renames to `x_backup.tar` (nothing is stripped). The UI is internationalized (German + English) with a runtime language switcher.
+Modifiers are applied in a pipeline (`Replace → Case → If-Then → Remove → Add → Counting → Date`) whose order you can also customize by dragging the modifier cards, each modifier independently toggleable, with an instant per-entry preview. Renaming is safe: a duplicate check runs first (blocking warning if any new name would clobber an existing entry — cross-type, so renaming a folder onto a file or vice versa is caught too), followed by a confirmation dialog. Directories are treated as *extension-less* entries: a folder named `backup.tar` renames to `x_backup.tar` (nothing is stripped). The UI is internationalized (German + English) with a runtime language switcher, and offers a light/dark theme toggle (dark is the default).
## Quick start
```sh
python3 -m venv .venv && source .venv/bin/activate
-pip install -r requirements-dev.txt # fastapi, uvicorn, pywebview, pytest
+pip install -r requirements-dev.txt # fastapi, uvicorn, pywebview, pytest (+httpx2 for the API tests)
python run.py # opens a desktop window (or web fallback)
```
+(Or `pip install -e .` instead of the requirements files — `pyproject.toml` makes the
+project pip-installable and provides the `arenamer` command.)
+
`run.py` starts the backend and tries to open a native desktop window; in headless environments it falls back to serving the web UI at `http://127.0.0.1:`.
## Development
```sh
-# Backend / engine tests (the rename engine is the verified core)
-python3 -m pytest tests/ -v
+# Both test suites in one command (pytest engine + API, then vitest frontend)
+./do test
-# Frontend (Svelte + Vite)
+# Or individually:
+python3 -m pytest tests/ -v # backend / engine (the verified core)
cd frontend && npm install
npm run dev # dev server on :5173, proxies /api -> :8000
npm run build # emits to ../backend/static for the desktop app
+npm run test # frontend vitest suite
```
+`do` is a small stdlib-only release tool (`do bump` / `do tag` / `do changelog` / `do build` / `do test`), also used by the CI workflows.
+
## Packaging (distributable desktop app)
The app is bundled with PyInstaller into a self-contained desktop application (no Python required by end users):
```sh
-pip install -r requirements-build.txt # pyinstaller
-python build/build.py # frontend -> PyInstaller -> versioned archive in dist/
+pip install -r requirements-build.txt # pyinstaller (+ cairosvg/pillow for optional icon regen)
+python build/build.py # frontend -> icons -> PyInstaller -> versioned archive in dist/
```
| OS | Artifact in `dist/` | Web engine | End-user requirement |
|----|---------------------|------------|----------------------|
| macOS | `A-Renamer.app` + `-macOS.zip` | WKWebView (cocoa) | none; unsigned → right-click → Open past Gatekeeper |
-| Windows | `A-Renamer/` + `-win64.zip` | WebView2 (edgechromium) | Edge WebView2 Runtime (preinstalled on Win 10/11) |
+| Windows | `A-Renamer/` + `-windows.zip` | WebView2 (edgechromium) | Edge WebView2 Runtime (preinstalled on Win 10/11) |
| Linux | `A-Renamer/` + `-linux.tar.gz` | WebKit2GTK (gtk) | `libwebkit2gtk-4.x` system libraries |
PyInstaller cannot cross-compile, so run the build on each target OS to get that OS's artifact.
@@ -68,14 +77,19 @@ So a release is simply: branch `release/vX.Y.Z` off `develop` → pull request t
```
arenamer/
├── run.py # one-command launcher (desktop window, or web fallback)
+├── do # stdlib-only release tooling (bump/tag/changelog/build/test)
+├── pyproject.toml # single source of truth for name + version
+├── DESIGN.md # design system: tokens, themes, elevation, state contract
+├── changelog.md # per-version sections (written by `do bump`)
├── backend/
│ ├── main.py # FastAPI app: static mount, /api/* routes, pywebview bootstrap
│ ├── api/ # API routes + Pydantic schemas (list/dirs/preview/check/rename)
│ ├── engine/ # PURE rename engine (no web deps) — the correctness core
│ └── static/ # built SPA (gitignored; `npm run build` output)
├── frontend/ # Svelte SPA (Vite): file list, directory tree, modifier panels
-├── build/ # PyInstaller packaging (spec + orchestrator)
-└── tests/ # pytest suites for engine and API
+├── build/ # PyInstaller packaging (spec, orchestrator, icons)
+├── tests/ # pytest suites for engine and API
+└── .github/workflows/ # ci.yml, release.yml, build.yml
```
See [AGENTS.md](AGENTS.md) for the full feature checklist, behavior decisions vs. the original, and architecture details.
@@ -88,5 +102,5 @@ This project is licensed under the [GNU General Public License v3](LICENSE) —
---
-*This project (v0.1.0) was entirely ported from Qt/C++ by Qwen 3.8 27B running locally, development continues exclusively with the use of local LLMs.*
-*Setup: Apple MacBook Pro M5 Pro 48GB with Opencode, LMStudio or oMLX*
+*This project (v0.1.0) was entirely ported from Qt/C++ by Qwen 3.8 27B running locally, development continues using local LLMs.*
+*Setup: Apple MacBook Pro M5 Pro 48GB with Pi Agent, LMStudio or oMLX*
diff --git a/frontend/index.html b/frontend/index.html
index 6eeb84b..2a2c82e 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1,9 +1,15 @@
-
+
+
A-Renamer Tool
+
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index 3968145..118d7c9 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -12,7 +12,10 @@
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, clearError, refreshPreview, resetModifierOrder, defaultConfig } from "./lib/state/store.svelte.js";
+ import {
+ state as appState, loadDir, openHome, clearError, refreshPreview,
+ resetModifierOrder, defaultConfig,
+ } 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`.
@@ -26,6 +29,16 @@
date: DateModifier,
};
+ // Theme: "dark" (default) or "light", persisted, applied to before
+ // paint (the inline script in index.html reads the same key to avoid a flash).
+ let theme = $state(
+ (typeof localStorage !== "undefined" && localStorage.getItem("arenamer.theme")) || "dark"
+ );
+ $effect(() => {
+ document.documentElement.dataset.theme = theme;
+ localStorage.setItem("arenamer.theme", theme);
+ });
+
// 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("");
@@ -35,6 +48,13 @@
appState.config.pipeline_order.some((id, i) => id !== defaultConfig().pipeline_order[i])
);
+ // Path segments for the breadcrumb bar ("" segments filtered — "/" → []).
+ const pathParts = $derived((appState.currentPath || "").split("/").filter(Boolean));
+
+ function loadCrumb(i) {
+ loadDir("/" + pathParts.slice(0, i + 1).join("/"));
+ }
+
function commitPath() {
const p = pathInput.trim();
if (p) loadDir(p);
@@ -74,31 +94,66 @@
-
-
+
+
{t("app.title")}
-
{t("app.subtitle")}
-
- {t("lang.label")}
- setLanguage(e.target.value)}>
- {#each languages as l (l.code)}
- {l.label}
- {/each}
-
-
+
+
+ {t("lang.label")}
+ setLanguage(e.target.value)}>
+ {#each languages as l (l.code)}
+ {l.label}
+ {/each}
+
+
+
(theme = theme === "dark" ? "light" : "dark")}>
+ {#if theme === "dark"}
+
+
+
+
+
+ {:else}
+
+
+
+
+ {/if}
+
+
-
-
{t("app.home")}
-
{t("app.up")}
-
e.key === "Enter" && commitPath()} />
-
{t("app.open")}
+
+
+
+
+
+
+
+
+ {#each pathParts as part, i (part + i)}
+
/
+
loadCrumb(i)}>
+ {part}
+
+ {/each}
+
+
+ e.key === "Enter" && commitPath()} />
+ {t("app.open")}
+
{#if appState.error}
-
+
{appState.error}
@@ -110,25 +165,28 @@
{/if}
diff --git a/frontend/src/components/FileList.svelte b/frontend/src/components/FileList.svelte
index ba4ed17..72b0b16 100644
--- a/frontend/src/components/FileList.svelte
+++ b/frontend/src/components/FileList.svelte
@@ -27,18 +27,20 @@
@@ -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,131 @@
+
\ No newline at end of file
diff --git a/frontend/src/components/ModifierCard.svelte b/frontend/src/components/ModifierCard.svelte
index db8bbad..baf741d 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())}>
+
{index + 1}
+
{name}
+
+
(e.stopPropagation(), resetModifier())}>
+
+
+
+
+
+
+
e.stopPropagation()}>
+
+
+
+
+
+
+
+
+ {@render children()}
+
+
\ No newline at end of file
diff --git a/frontend/src/components/RenameButton.svelte b/frontend/src/components/RenameButton.svelte
index cf66fba..b73ae1f 100644
--- a/frontend/src/components/RenameButton.svelte
+++ b/frontend/src/components/RenameButton.svelte
@@ -87,13 +87,45 @@
- {state.renaming ? t("rename.renaming") : `${t("rename.button")}${state.selection.length ? ` (${state.selection.length})` : ""}`}
+ {state.renaming ? t("rename.renaming") : t("rename.button")}
+ {#if !state.renaming && state.selection.length}
+ {state.selection.length}
+ {/if}
+ 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;
+ }
+ /* Light-theme pill override lives in index.css (global): an html-attribute
+ ancestor in scoped CSS trips svelte's css_unused_selector check. */
+
\ 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/components.smoke.test.js b/frontend/src/components/components.smoke.test.js
index b32eda6..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 },
@@ -47,7 +49,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(() => {
@@ -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, 250)); // let the async check flow settle
+ 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, 250));
+ 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 .
+ const panel = () => createRawSnippet(() => ({ render: () => "panel" }));
+
+ it("renders the grip handle and the slot content; the card is only draggable while the grip is pressed", () => {
+ const { container } = render(ModifierCard, {
+ id: "replace",
+ index: 0,
+ children: panel(),
+ });
+ const card = container.querySelector(".card");
+ expect(container.querySelector(".grip")).toBeTruthy();
+ expect(screen.getByText("panel")).toBeTruthy();
+ expect(card.draggable).toBe(false);
+ fireEvent.mouseDown(container.querySelector(".grip"));
+ expect(card.draggable).toBe(true);
+ });
+
+ it("dragstart / dragend toggle the dragging state", async () => {
+ const { container } = render(ModifierCard, {
+ id: "replace",
+ index: 0,
+ children: panel(),
+ });
+ const card = container.querySelector(".card");
+ card.dispatchEvent(dragEvent("dragstart", null));
+ await settle(); // the class:dragging update flushes asynchronously
+ expect(card.className).toContain("dragging");
+ card.dispatchEvent(dragEvent("dragend"));
+ await settle();
+ expect(card.className).not.toContain("dragging");
+ });
+
+ // jsdom rects are all 0×0, so clientY < 0 hits the "above" half and
+ // clientY > 0 the "below" half of the card.
+ it("a drop in the gap above this card inserts the dragged card before it", async () => {
+ const { container } = render(ModifierCard, { id: "case", index: 1, children: panel() });
+ const card = container.querySelector(".card");
+ card.dispatchEvent(dragEvent("dragover", "ifthen", { clientX: 0, clientY: -1 }));
+ await settle(); // the class:over update flushes asynchronously
+ expect(card.className).toContain("over"); // the marker shows the insertion gap
+ card.dispatchEvent(dragEvent("drop", "ifthen"));
+ await settle();
+ expect(state.config.pipeline_order).toEqual(["replace", "ifthen", "case"]);
+ });
+
+ it("a drop in the gap below this card is a no-op when the card was originally adjacent", () => {
+ const { container } = render(ModifierCard, { id: "case", index: 1, children: panel() });
+ const card = container.querySelector(".card");
+ card.dispatchEvent(dragEvent("dragover", "ifthen", { clientX: 0, clientY: 1 }));
+ card.dispatchEvent(dragEvent("drop", "ifthen"));
+ // "ifthen" (index 2) into slot 2 == its original position -> unchanged
+ expect(state.config.pipeline_order).toEqual(["replace", "case", "ifthen"]);
+ });
+
+ it("ignores a drop of the card onto itself", () => {
+ const { container } = render(ModifierCard, { id: "case", index: 1, children: panel() });
+ const card = container.querySelector(".card");
+ card.dispatchEvent(dragEvent("dragover", "case", { clientX: 0, clientY: -1 }));
+ card.dispatchEvent(dragEvent("drop", "case"));
+ expect(state.config.pipeline_order).toEqual(["replace", "case", "ifthen"]);
+ });
});
diff --git a/frontend/src/components/modifiers/AddModifier.svelte b/frontend/src/components/modifiers/AddModifier.svelte
index 22d9a8a..e07cf62 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.
-
-
-
-
- {t("add.title")} {state.config.add.enabled ? "✓" : "✗"}
+
+ {t("add.prefix")}
+
+
+ {t("add.suffix")}
+
+
+
+
{t("add.insert")}
+
-
-
-
-
-
-
+
\ No newline at end of file
diff --git a/frontend/src/components/modifiers/CaseModifier.svelte b/frontend/src/components/modifiers/CaseModifier.svelte
index c714d0b..41e51cb 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.
-
-
-
-
- {t("case.title")} {state.config.case.enabled ? "✓" : "✗"}
+
+
+
{t("case.mode")}
+
+ {t("case.upper")}
+ {t("case.lower")}
+ {t("case.titleCase")}
+ {t("case.sentenceCase")}
+ {t("case.camel")}
+ {t("case.pascal")}
+ {t("case.snake")}
+ {t("case.kebab")}
+ {t("case.constant")}
+ {t("case.train")}
+
-
-
-
-
- {t("case.mode")}
-
- {t("case.upper")}
- {t("case.lower")}
- {t("case.titleCase")}
- {t("case.sentenceCase")}
- {t("case.camel")}
- {t("case.pascal")}
- {t("case.snake")}
- {t("case.kebab")}
- {t("case.constant")}
- {t("case.train")}
-
-
-
-
-
-
+
diff --git a/frontend/src/components/modifiers/CountingModifier.svelte b/frontend/src/components/modifiers/CountingModifier.svelte
index 7f68b28..306743a 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.
-
-
-
-
- {t("counting.title")} {state.config.counting.enabled ? "✓" : "✗"}
+
+
+
+ {t("position.prefix")}
+ {t("position.suffix")}
+ {t("position.insert")}
+
+
{t("counting.start")}
+
+
+
{t("counting.pad")}
+
-
-
-
-
-
-
+ {#if state.config.counting.position === "insert"}
+
{t("counting.insertAt")}
+
+
+ {/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..eb87ec3 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.
-
-
-
-
- {t("date.title")} {state.config.date.enabled ? "✓" : "✗"}
+
+
+
+
+ DD-MM-YYYY
+ YYYY-MM-DD
+ MM-DD-YYYY
+
+
{t("date.separator")}
+
-
-
-
-
+
+ .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..ecac2b7 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")}
- (state.config.ifthen.contains_not = e.target.value === "not")}>
- {t("ifthen.contains")}
- {t("ifthen.notContains")}
-
-
-
-
- {t("common.regex")}
- {t("common.caseSensitive")}
-
-
- {t("ifthen.thenTag")}
-
-
- {t("ifthen.asPrefix")}
- {t("ifthen.atPosition")}
- {t("ifthen.asSuffix")}
-
- {#if state.config.ifthen.action === "insert"}
- {t("common.pos")}
-
-
- {/if}
-
+
+
+ {t("ifthen.ifTag")}
+ (state.config.ifthen.contains_not = e.target.value === "not")}>
+ {t("ifthen.contains")}
+ {t("ifthen.notContains")}
+
+
-
-
-
+
+ {t("common.regex")}
+ {t("common.caseSensitive")}
+
+
+ {t("ifthen.thenTag")}
+
+
+ {t("ifthen.asPrefix")}
+ {t("ifthen.atPosition")}
+ {t("ifthen.asSuffix")}
+
+ {#if state.config.ifthen.action === "insert"}
+ {t("common.pos")}
+
+
+ {/if}
+
+
diff --git a/frontend/src/components/modifiers/RemoveModifier.svelte b/frontend/src/components/modifiers/RemoveModifier.svelte
index 51fa4b4..d90af67 100644
--- a/frontend/src/components/modifiers/RemoveModifier.svelte
+++ b/frontend/src/components/modifiers/RemoveModifier.svelte
@@ -2,55 +2,33 @@
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.
-
-
-
-
- {t("remove.title")} {state.config.remove.enabled ? "✓" : "✗"}
+
+
+
{t("remove.first")}
+
-
-
-
+
+
{t("remove.range")}
+
+ {t("remove.from")}
+
+
+ {t("remove.to")}
+
+
+ {t("remove.untilEnd")}
-
+
diff --git a/frontend/src/components/modifiers/ReplaceModifier.svelte b/frontend/src/components/modifiers/ReplaceModifier.svelte
index 928841b..e090686 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.
-
-
-
-
-
{t("replace.search")}
-
-
-
{t("replace.replaceWith")}
-
-
-
- {t("common.regex")}
- {t("common.caseSensitive")}
-
+
+ {t("replace.search")}
+
+
+ {t("replace.replaceWith")}
+
+
+
+ {t("common.regex")}
+ {t("common.caseSensitive")}
-
-
-
+
diff --git a/frontend/src/components/modifiers/modifiers.smoke.test.js b/frontend/src/components/modifiers/modifiers.smoke.test.js
new file mode 100644
index 0000000..757e870
--- /dev/null
+++ b/frontend/src/components/modifiers/modifiers.smoke.test.js
@@ -0,0 +1,253 @@
+// @vitest-environment jsdom
+/**
+ * Smoke tests for the seven modifier panels (Replace, Case, If-Then, Remove,
+ * Add, Counting, Date). Every panel is a `
` whose
+ * disabled attribute mirrors `state.config..enabled` (the enable toggle
+ * itself lives in ModifierCard) — so the generic disabled-state behaviour is
+ * checked for all seven at once, plus a few panel-specific bindings
+ * (including the conditional inputs: If-Then / Counting / Date insert
+ * position, Remove range, Date custom picker).
+ *
+ * happy-dom crashes inside Svelte 5 checkbox mounting, hence jsdom.
+ */
+import { describe, it, expect, beforeEach } from "vitest";
+import { render, fireEvent, cleanup } from "@testing-library/svelte";
+import { state } from "../../lib/state/store.svelte.js";
+import { setLanguage } from "../../lib/i18n/index.svelte.js";
+import { defaultConfig } from "../../lib/config.js";
+
+// Store bindings write synchronously, but Svelte 5 flushes the resulting DOM
+// updates (classes, {#if} visibility, disabled attributes) on a later tick —
+// flush before asserting on the DOM.
+const settle = () => new Promise((r) => setTimeout(r, 0));
+
+import ReplaceModifier from "./ReplaceModifier.svelte";
+import CaseModifier from "./CaseModifier.svelte";
+import IfThenModifier from "./IfThenModifier.svelte";
+import RemoveModifier from "./RemoveModifier.svelte";
+import AddModifier from "./AddModifier.svelte";
+import CountingModifier from "./CountingModifier.svelte";
+import DateModifier from "./DateModifier.svelte";
+
+// The panels bind directly to the module-level store singleton; hand every
+// test a fresh default config (all seven modifiers start disabled).
+beforeEach(() => {
+ cleanup();
+ setLanguage("en");
+ state.config = defaultConfig();
+});
+
+// ---- generic panel behaviour (all seven) ----------------------------------
+
+const PANELS = [
+ ["replace", ReplaceModifier],
+ ["case", CaseModifier],
+ ["ifthen", IfThenModifier],
+ ["remove", RemoveModifier],
+ ["add", AddModifier],
+ ["counting", CountingModifier],
+ ["date", DateModifier],
+];
+
+it.each(PANELS)(
+ "%s panel: the controls fieldset is disabled while the modifier is off",
+ async (key, Comp) => {
+ const { container } = render(Comp);
+ const controls = container.querySelector(".controls");
+ expect(controls).toBeInstanceOf(HTMLFieldSetElement);
+
+ // fresh default config: disabled
+ expect(state.config[key].enabled).toBe(false);
+ await settle(); // the disabled attribute flushes asynchronously
+ expect(controls.disabled).toBe(true);
+
+ state.config[key].enabled = true; // what the card's toggle writes
+ await settle();
+ expect(controls.disabled).toBe(false);
+ }
+);
+
+// ---- panel-specific bindings -----------------------------------------------
+
+describe("ReplaceModifier", () => {
+ it("writes the search/replace inputs and the option checkboxes into the store", () => {
+ const { container } = render(ReplaceModifier);
+ const [search, replace] = container.querySelectorAll('input[type="text"]');
+ fireEvent.input(search, { target: { value: "foo" } });
+ fireEvent.input(replace, { target: { value: "bar" } });
+ expect(state.config.replace.search).toBe("foo");
+ expect(state.config.replace.replace).toBe("bar");
+
+ const [regex, caseSensitive] = container.querySelectorAll(".check input");
+ fireEvent.click(regex);
+ fireEvent.click(caseSensitive);
+ expect(state.config.replace.regex).toBe(true);
+ expect(state.config.replace.case_sensitive).toBe(true);
+ });
+});
+
+describe("CaseModifier", () => {
+ it("lists all ten case modes and writes the choice into the store", () => {
+ const { container } = render(CaseModifier);
+ const select = container.querySelector("select");
+ expect(select.querySelectorAll("option")).toHaveLength(10);
+ expect(select.value).toBe("upper"); // the default
+ fireEvent.change(select, { target: { value: "snake" } });
+ expect(state.config.case.mode).toBe("snake");
+ });
+
+ it("disables the controls again when the modifier is disabled", async () => {
+ const { container } = render(CaseModifier);
+ state.config.case.enabled = true; // enable
+ await settle();
+ state.config.case.enabled = false; // disable again
+ await settle();
+ // cascades a real disabled state to every control
+ expect(container.querySelector(".controls").disabled).toBe(true);
+ });
+});
+
+describe("IfThenModifier", () => {
+ it("writes the condition and consequence into the store", () => {
+ const { container } = render(IfThenModifier);
+ const [condSel, actionSel] = container.querySelectorAll("select");
+ const [expr, str] = container.querySelectorAll('input[type="text"]');
+
+ // the condition select is one-way (value + inline onchange), not bind:value
+ fireEvent.change(condSel, { target: { value: "not" } });
+ expect(state.config.ifthen.contains_not).toBe(true);
+
+ fireEvent.input(expr, { target: { value: ".log" } });
+ fireEvent.input(str, { target: { value: "x_" } });
+ fireEvent.change(actionSel, { target: { value: "suffix" } });
+ expect(state.config.ifthen.expression).toBe(".log");
+ expect(state.config.ifthen.string).toBe("x_");
+ expect(state.config.ifthen.action).toBe("suffix");
+ });
+
+ it("shows the position input only when the consequence is an insert", async () => {
+ const { container } = render(IfThenModifier);
+ // the panel has two selects: the one-way condition select, then the action select
+ const [, actionSel] = container.querySelectorAll("select");
+ expect(container.querySelector("label.pos input")).toBeNull();
+
+ fireEvent.change(actionSel, { target: { value: "insert" } });
+ await settle(); // the {#if} flushes asynchronously
+ const pos = container.querySelector("label.pos input");
+ expect(pos).toBeTruthy();
+ fireEvent.input(pos, { target: { value: "3" } });
+ expect(state.config.ifthen.insert_pos).toBe(3);
+ });
+});
+
+describe("RemoveModifier", () => {
+ it("writes the front/back counts into the store", () => {
+ const { container } = render(RemoveModifier);
+ const [front, back] = container.querySelectorAll('input[type="number"]');
+ fireEvent.input(front, { target: { value: "2" } });
+ fireEvent.input(back, { target: { value: "3" } });
+ expect(state.config.remove.front).toBe(2);
+ expect(state.config.remove.back).toBe(3);
+ });
+
+ it("enables the range inputs only while range is on; until-end disables the end field", async () => {
+ const { container } = render(RemoveModifier);
+ const [rangeCb, untilCb] = container.querySelectorAll(".check input");
+ const [, , start, end] = container.querySelectorAll('input[type="number"]');
+
+ expect(rangeCb.checked).toBe(false);
+ expect(start.disabled).toBe(true);
+ expect(end.disabled).toBe(true);
+ expect(untilCb.disabled).toBe(true);
+
+ fireEvent.click(rangeCb);
+ await settle(); // disabled attributes flush asynchronously
+ expect(start.disabled).toBe(false);
+ expect(end.disabled).toBe(false);
+ expect(untilCb.disabled).toBe(false);
+
+ fireEvent.click(untilCb);
+ await settle();
+ expect(end.disabled).toBe(true); // "until end" replaces the explicit end
+ });
+});
+
+describe("AddModifier", () => {
+ it("writes the prefix/suffix/insert texts and position into the store", () => {
+ const { container } = render(AddModifier);
+ const [prefix, suffix, insert] = container.querySelectorAll('input[type="text"]');
+ fireEvent.input(prefix, { target: { value: "p-" } });
+ fireEvent.input(suffix, { target: { value: "-s" } });
+ fireEvent.input(insert, { target: { value: "x" } });
+ fireEvent.input(container.querySelector('input[type="number"]'), { target: { value: "1" } });
+ expect(state.config.add).toMatchObject({ prefix: "p-", suffix: "-s", insert: "x", insert_pos: 1 });
+ });
+});
+
+describe("CountingModifier", () => {
+ it("writes the position/start/padding into the store", () => {
+ const { container } = render(CountingModifier);
+ const select = container.querySelector("select");
+ fireEvent.change(select, { target: { value: "suffix" } });
+ const [start, pad] = container.querySelectorAll('input[type="number"]');
+ fireEvent.input(start, { target: { value: "5" } });
+ fireEvent.input(pad, { target: { value: "2" } });
+ expect(state.config.counting).toMatchObject({ position: "suffix", start: 5, padding: 2 });
+ });
+
+ it("shows the insert position only in insert mode", async () => {
+ const { container } = render(CountingModifier);
+ const select = container.querySelector("select");
+ expect(container.querySelectorAll('input[type="number"]')).toHaveLength(2); // start, padding
+ fireEvent.change(select, { target: { value: "insert" } });
+ await settle(); // the {#if} flushes asynchronously
+ expect(container.querySelectorAll('input[type="number"]')).toHaveLength(3); // + insert position
+ });
+
+ it("disables the controls again when the modifier is disabled", async () => {
+ const { container } = render(CountingModifier);
+ state.config.counting.enabled = true; // enable
+ await settle();
+ state.config.counting.enabled = false; // disable again
+ await settle();
+ // cascades a real disabled state to every control
+ expect(container.querySelector(".controls").disabled).toBe(true);
+ });
+});
+
+describe("DateModifier", () => {
+ it("writes the separators into the store and reveals the picker in custom mode", async () => {
+ const { container } = render(DateModifier);
+ const [sep, nameSep] = container.querySelectorAll('input[type="text"]');
+ fireEvent.input(sep, { target: { value: "." } });
+ fireEvent.input(nameSep, { target: { value: " " } });
+ expect(state.config.date.separator).toBe(".");
+ expect(state.config.date.name_separator).toBe(" ");
+
+ // the panel has three selects: format, source, position
+ const [, sourceSel] = container.querySelectorAll("select");
+ expect(container.querySelector('input[type="date"]')).toBeNull();
+ fireEvent.change(sourceSel, { target: { value: "custom" } });
+ await settle(); // the {#if} flushes asynchronously
+ expect(container.querySelector('input[type="date"]')).toBeTruthy();
+ });
+
+ it("shows the insert position only in insert mode", async () => {
+ const { container } = render(DateModifier);
+ const [, , positionSel] = container.querySelectorAll("select");
+ expect(container.querySelectorAll('input[type="number"]')).toHaveLength(0); // default: suffix/today
+ fireEvent.change(positionSel, { target: { value: "insert" } });
+ await settle(); // the {#if} flushes asynchronously
+ expect(container.querySelectorAll('input[type="number"]')).toHaveLength(1); // + insert position
+ });
+
+ it("disables the controls again when the modifier is disabled", async () => {
+ const { container } = render(DateModifier);
+ state.config.date.enabled = true; // enable
+ await settle();
+ state.config.date.enabled = false; // disable again
+ await settle();
+ // cascades a real disabled state to every control
+ expect(container.querySelector(".controls").disabled).toBe(true);
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/index.css b/frontend/src/index.css
index a3a1105..3534a4c 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -1,15 +1,198 @@
-/* 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 root is a
+ — the reset neutralises the browser's
+ default fieldset border/padding; `disabled` cascades a real disabled
+ state to every control while the modifier is off). */
+.controls {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ border: 0;
+ padding: 0;
+ margin: 0;
+ min-width: 0;
+}
+.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;
+}
+
+/* RenameButton's pill: light theme uses white-on-purple (APCA 77.5) instead of
+ the dark theme's text/bg inversion. Global (unscoped) on purpose — an html
+ attribute ancestor inside component CSS trips svelte's unused-selector check. */
+[data-theme="light"] .pill {
+ background: var(--accent-contrast);
+ color: var(--accent);
}
diff --git a/frontend/src/lib/api.test.js b/frontend/src/lib/api.test.js
new file mode 100644
index 0000000..26b5c69
--- /dev/null
+++ b/frontend/src/lib/api.test.js
@@ -0,0 +1,107 @@
+/**
+ * lib/api.js — the fetch wrapper. Every other module (and test) mocks the
+ * exported functions, so this is the only place the raw HTTP behavior is
+ * pinned: error normalization (the `Error.message` contract the UI and
+ * RenameButton's 409 parsing depend on) and URL encoding.
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import * as api from "./api.js";
+
+/** A minimal `Response` stand-in (only the members api.js touches). */
+function res(status, body, statusText = "") {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ statusText,
+ json: async () => body,
+ };
+}
+
+const fetchMock = vi.fn();
+beforeEach(() => {
+ fetchMock.mockReset();
+ globalThis.fetch = fetchMock;
+});
+
+describe("success paths", () => {
+ it("returns the parsed JSON body", async () => {
+ fetchMock.mockResolvedValue(res(200, { files: [] }));
+ await expect(api.listFiles("/x")).resolves.toEqual({ files: [] });
+ expect(fetchMock).toHaveBeenCalledWith(
+ "/api/list?path=%2Fx",
+ expect.objectContaining({ method: "GET" })
+ );
+ });
+
+ it("URL-encodes the path (spaces, unicode)", async () => {
+ fetchMock.mockResolvedValue(res(200, { files: [] }));
+ await api.listFiles("/a b");
+ expect(fetchMock.mock.calls[0][0]).toBe("/api/list?path=%2Fa%20b");
+ });
+
+ it("sends a JSON body with Content-Type on POST, none on GET", async () => {
+ fetchMock.mockResolvedValue(res(200, { path: "", previews: {} }));
+ await api.preview({ path: "/x", files: ["a.txt"], dirs: [], config: {} });
+ const [url, init] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/preview");
+ expect(init.method).toBe("POST");
+ expect(init.headers["Content-Type"]).toBe("application/json");
+ expect(JSON.parse(init.body)).toEqual({ path: "/x", files: ["a.txt"], dirs: [], config: {} });
+ });
+
+ it("GET requests carry no body and no Content-Type", async () => {
+ fetchMock.mockResolvedValue(res(200, { path: "/x", files: [] }));
+ await api.listFiles("/x");
+ const [, init] = fetchMock.mock.calls[0];
+ expect(init.body).toBeUndefined();
+ expect(init.headers).toBeUndefined();
+ });
+
+ it("returns null for 204", async () => {
+ fetchMock.mockResolvedValue(res(204, null));
+ await expect(api.rename({})).resolves.toBeNull();
+ });
+
+ it("homeDir hits /api/home (no query)", async () => {
+ fetchMock.mockResolvedValue(res(200, { path: "/home" }));
+ await expect(api.homeDir()).resolves.toEqual({ path: "/home" });
+ expect(fetchMock.mock.calls[0][0]).toBe("/api/home");
+ });
+});
+
+describe("error normalization", () => {
+ it("uses the backend's string detail as the message (e.g. friendly 403)", async () => {
+ fetchMock.mockResolvedValue(res(403, { detail: "Permission denied" }, "Forbidden"));
+ await expect(api.listFiles("/x")).rejects.toThrow("Permission denied");
+ });
+
+ it("stringifies an object detail — the 409 body RenameButton parses back", async () => {
+ const detail = { duplicates: 2, names: ["a.txt", "b.log"] };
+ fetchMock.mockResolvedValue(res(409, { detail }, "Conflict"));
+ await api
+ .rename({})
+ .catch((e) => {
+ // api.js: non-string details become JSON.stringify(detail), so
+ // RenameButton.dupeNamesFromError(e) can JSON.parse(e.message).
+ expect(e.message).toBe(JSON.stringify(detail));
+ expect(JSON.parse(e.message)).toEqual(detail);
+ });
+ });
+
+ it("falls back to status text for non-JSON error bodies", async () => {
+ fetchMock.mockResolvedValue({
+ ok: false,
+ status: 500,
+ statusText: "Internal Server Error",
+ json: async () => {
+ throw new Error("not json");
+ },
+ });
+ await expect(api.preview({})).rejects.toThrow("500 Internal Server Error");
+ });
+
+ it("propagates network failures as-is", async () => {
+ fetchMock.mockRejectedValue(new TypeError("Failed to fetch"));
+ await expect(api.listFiles("/x")).rejects.toThrow("Failed to fetch");
+ });
+});
\ No newline at end of file
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",
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..952d796 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.
@@ -127,6 +128,22 @@ describe("dialogs", () => {
showDialog({ title: "T", message: "M" });
expect(state.dialog.variant).toBe("info");
});
+
+ // RenameButton drives the whole flow off this promise — `await showDialog()`
+ // must settle with the clicked button id (or dismissId). (The Dialog component
+ // separately sets `open=false` on click/Escape — covered by the component test.)
+ it("showDialog resolves with the chosen button id", async () => {
+ const { state, showDialog } = await freshStore();
+ const p = showDialog({
+ title: "T",
+ message: "M",
+ buttons: [{ id: "ok", label: "Ok" }, { id: "abort", label: "Abort" }],
+ dismissId: "abort",
+ });
+ expect(state.dialog.open).toBe(true);
+ state.dialog.resolve("abort");
+ await expect(p).resolves.toBe("abort");
+ });
});
describe("api-backed flows (mocked api)", () => {
@@ -148,47 +165,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: [] });
@@ -199,6 +208,66 @@ describe("api-backed flows (mocked api)", () => {
});
});
+describe("navigation", () => {
+ it("openHome loads the home directory", async () => {
+ const { state, openHome } = await freshStore();
+ api.homeDir.mockResolvedValue({ path: "/home" });
+ api.listFiles.mockResolvedValue({ files: [] });
+ await openHome();
+ expect(api.homeDir).toHaveBeenCalledTimes(1);
+ expect(state.currentPath).toBe("/home");
+ });
+
+ it("openHome records the error on failure", async () => {
+ const { state, openHome } = await freshStore();
+ api.homeDir.mockRejectedValue(new Error("boom"));
+ await openHome();
+ expect(state.error).toContain("boom");
+ });
+
+ it("goUp moves to the parent directory", async () => {
+ const { state, goUp } = await freshStore();
+ api.listFiles.mockResolvedValue({ files: [] });
+ state.currentPath = "/tmp/somewhere";
+ goUp();
+ await new Promise((r) => setTimeout(r, 0)); // settle the fire-and-forget loadDir
+ expect(api.listFiles).toHaveBeenCalledWith("/tmp");
+ expect(state.currentPath).toBe("/tmp");
+ });
+
+ it("goUp tolerates trailing slashes", async () => {
+ const { state, goUp } = await freshStore();
+ api.listFiles.mockResolvedValue({ files: [] });
+ state.currentPath = "/tmp/somewhere/";
+ goUp();
+ await new Promise((r) => setTimeout(r, 0));
+ expect(state.currentPath).toBe("/tmp");
+ });
+
+ it("goUp is a no-op at the filesystem root and on an empty path", async () => {
+ const { state, goUp } = await freshStore();
+ api.listFiles.mockResolvedValue({ files: [] });
+ state.currentPath = "/";
+ goUp();
+ expect(api.listFiles).not.toHaveBeenCalled();
+ state.currentPath = "";
+ goUp();
+ expect(api.listFiles).not.toHaveBeenCalled();
+ });
+});
+
+describe("preview guards", () => {
+ it("refreshPreview with no selection clears previews without calling the API", async () => {
+ const { state, refreshPreview } = await freshStore();
+ state.files = [...FILES];
+ state.currentPath = "/tmp";
+ state.previews = { "a.txt": {} };
+ await refreshPreview();
+ expect(api.preview).not.toHaveBeenCalled();
+ expect(state.previews).toEqual({});
+ });
+});
+
describe("view toggles (files / directories)", () => {
it("defaults: files shown, dirs hidden (historical view)", async () => {
const { state } = await freshStore();
@@ -257,7 +326,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
diff --git a/plan.md b/plan.md
deleted file mode 100644
index 96685f5..0000000
--- a/plan.md
+++ /dev/null
@@ -1,211 +0,0 @@
-# Plan: Directory (folder) renaming + show/hide toggles
-
-Branch: `feat/folder-editing` (checked out).
-
-## Goal
-
-Today only *files* are listed and can be selected/renamed. Extend the app so that
-**directories are first-class entries**: they show up in the file list, can be
-selected, and are transformed by the exact same seven modifiers. The list view gets
-two toggles — **show files** / **show directories**. Default: files shown,
-directories hidden (i.e. the default view is exactly today's behavior).
-
-## Key design decisions
-
-1. **Typed entries from `/api/list`.** The list endpoint returns *both* files and
- subdirectories, each entry gaining `type: "file" | "dir"` (dirs report
- `size: 0`, real `mtime`). Filtering is done **client-side** by the two view
- toggles — no extra requests, instant toggling. Backend stays authoritative
- about what exists; the UI only filters visibility.
-2. **Engine: directories have no extension.** New `RenameFile.is_dir: bool`.
- For directories `base = name` and `ext = ""` *always* — a dir named
- `backup.tar` renames to `x_backup.tar` (whole name is the base). The
- file rule (split at last dot) is unchanged. All seven modifiers then work
- unchanged, since they operate on `base`.
-3. **Request contract extension (backward compatible).** Preview/check/rename
- requests gain an optional `dirs: list[str]` (names in `files` that are
- directories). `files` still carries *all* selected names **in on-screen
- list order**, so numbering/rows keep following list order, now across a
- mixed file+dir selection. Omitted `dirs` ⇒ everything is a file (old
- clients/tests keep working).
-4. **Duplicate detection covers cross-type collisions.** `os.path.exists(target)`
- already returns true whether the target is a file *or* a directory, so the
- existing safety net catches dir→existing-file, dir→existing-dir and
- file→existing-dir. No engine change needed beyond `is_dir` bookkeeping;
- document it in §4.
-5. **Selection safety.** "Select all" selects only *visible* entries; toggling a
- type off **prunes** its entries from the selection (hidden entries must never
- be renamed invisibly).
-6. **Dialog copy generalizes** "File(s)" → "Item(s)" (selection may mix files and
- dirs); i18n gets fresh keys in en + de (identical key sets, per convention).
-7. **Tree stays as-is, refreshed after rename.** The directory tree uses the
- unchanged `/api/dirs`. Renaming a dir makes cached tree node labels stale,
- so the store bumps a `treeVersion`; the tree re-fetches children of its
- loaded nodes (cheap, keeps labels fresh).
-8. **Default view = files only.** `showFiles: true`, `showDirs: false` in the
- store — a *view* preference, not modifier config, so it does **not** enter
- `config.js`/`defaultConfig()` (that shape must mirror the backend recipe).
-
-## API surface (after this work)
-
-| Endpoint | Change |
-|---|---|
-| `GET /api/list` | returns files **and** dirs; entries gain `type` (`"file"`/`"dir"`) |
-| `GET /api/dirs` | unchanged (tree navigation) |
-| `POST /api/preview` | request: + optional `dirs: []`; response item: + `type` |
-| `POST /api/check` | request: + optional `dirs: []` |
-| `POST /api/rename` | request: + optional `dirs: []`; `renamed` counts files **and** dirs |
-
-## Milestones & commit plan
-
-Five commits, one per concern (all `feat`/`docs` conventional types ⇒ release
-tooling bumps **minor**). Each commit leaves `./do test` green (pytest + vitest).
-
-### M1 — Engine: directory-aware rename core
-**Commit: `feat(engine): support renaming directories as extension-less entries`**
-
-- `backend/engine/models.py`
- - `RenameFile.is_dir: bool = False`; `__post_init__`/`set_name` apply the
- no-extension rule for dirs; `new_full_name` unchanged (empty ext).
-- `backend/engine/pipeline.py`
- - `build_files(path, names, dirs=None)` — `dirs` = names marked as
- directories (list or set; `row` = position in `names`).
- - `preview()` result items gain `"type": "file" | "dir"`.
-- `backend/engine/__init__.py` — docstring note (exports unchanged:
- `build_files` signature stays source-compatible).
-- **Tests** `tests/backend/test_engine.py` — new `TestDirectories` class:
- - dir with dot in name → `base == name`, `ext == ""` (`backup.tar` → `x_backup.tar`);
- - `build_files(..., dirs=[...])` marks only the named entries; row order preserved;
- - preview of a dir (add prefix / case / remove: remove acts on the *full* name);
- - numbering across a **mixed** files+dirs selection follows combined list order;
- - date modifier reads a real directory's mtime (`tmp_path`);
- - If-Then condition tests the full dir name (original base);
- - `find_duplicates` on tmp dirs: dir→existing dir **and** dir→existing file both detected;
- - `perform_rename` actually renames a dir (content preserved), returns `renamed` count;
- - `set_name` re-derives base (no ext) after rename.
- - Existing tests untouched (files keep old behavior).
-
-### M2 — API: typed listing + `dirs` in rename workflow
-**Commit: `feat(api): list directories with type, accept `dirs` in preview/check/rename`**
-
-- `backend/api/schemas.py` — `FileEntry.type: str = "file"`; `PreviewItem.type`;
- `dirs: list[str] = Field(default_factory=list)` on the three POST requests.
-- `backend/api/routes.py`
- - `/list` includes subdirs (stat each entry; dir ⇒ `type="dir"`, `size=0`).
- - `/preview`, `/check`, `/rename` pass `dirs` through to `build_files`.
-- **Tests** `tests/backend/test_api.py`:
- - `/list` returns dirs with `type` + files with `type`, both sorted by name;
- - `/preview` with `dirs` (dir preview is extension-less);
- - `/check` collision matrix: dir→existing dir, dir→existing file, file→existing dir;
- - `/rename` on a dir succeeds (dir + content present), unchanged-dir is a no-op;
- - `/rename` refuses (409) a dir rename onto an existing file;
- - backward compat: requests **without** `dirs` still work (existing tests cover it).
-
-### M3 — Frontend store + client + i18n
-**Commit: `feat(frontend): show/hide toggles for files and directories in the store`**
-
-- `frontend/src/lib/api.js` — `listFiles` docs/shape note (typed entries);
- preview/check/rename payloads carry `dirs`.
-- `frontend/src/lib/state/store.svelte.js`
- - `state.files` entries now `{name, type, size, mtime}`.
- - new state: `showFiles: true`, `showDirs: false`, `treeVersion: 0`.
- - new actions: `toggleShowFiles()` / `toggleShowDirs()` (toggling off prunes
- that type from `selection`); `bumpTree()`.
- - derived `visibleFiles` (filter by toggles); `selectAll()` selects visible
- entries only; `selectedInOrder()` + `selectedDirs()`; preview/check/rename
- send `{path, files, dirs, config}`.
- - after successful rename flow the store bumps `treeVersion` (RenameButton
- triggers it via the existing `loadDir` + a new call, M4 wires the tree).
-- `frontend/src/lib/i18n/en.js` + `de.js` (identical key sets):
- - `fileList.showFiles`, `fileList.showDirs`, `fileList.dirBadge`,
- `fileList.emptyDirs` (empty state when all toggles off / no matches);
- - rename dialog copy: `File(s)` → `Item(s)` (`rename.confirmMsg`,
- `rename.successMsg`, `rename.errorNote`, `rename.dupMsg`), de equivalents.
-- **Tests** `frontend/src/lib/state/store.svelte.test.js`:
- - `loadDir` keeps typed entries;
- - `selectAll` respects toggles (dirs hidden ⇒ only files selected);
- - turning `showFiles` off prunes selected files (and keeps dirs selection if shown);
- - preview/check/rename payloads include the `dirs` subset in list order;
- - `toggleShow*` defaults are files-on/dirs-off.
-
-### M4 — Frontend UI: toggles, dir rows, tree refresh
-**Commit: `feat(frontend): directory rows with type badge, view toggles, tree refresh`**
-
-- `frontend/src/components/FileList.svelte`
- - toolbar: two checkbox-style toggles (Files / Directories), default checked
- state per M3; `allSelected` computed over **visible** entries.
- - rows: folder glyph/badge on dir entries (monospace-safe icon or `📁`);
- empty-state text adapts to active toggles.
-- `frontend/src/components/DirectoryTree.svelte` — watch
- `appState.treeVersion`; on bump, re-fetch children of all loaded nodes
- (keeps renamed dir labels fresh). Small: keep a node registry in the
- component.
-- `frontend/src/components/RenameButton.svelte` — no logic change (copy comes
- from i18n, M3); success flow triggers `bumpTree()` + `loadDir`.
-- **Tests** `frontend/src/components/components.smoke.test.js`:
- - toggles render with correct default states;
- - hiding directories removes dir rows (visible list shrinks);
- - `Select all` with one type hidden selects only the visible type;
- - dir rows show the badge; renaming a dir (mocked) completes the dialog flow.
-
-### M5 — Documentation
-**Commit: `docs: document directory renaming and list view toggles`**
-
-- `AGENTS.md`
- - §1 What this program is: files **and directories**; view toggles.
- - §2 Pipeline: dir extension rule (whole name is base, ext empty).
- - §3 Features: toggles, select-all/clear over visible entries, "N Item(s)"
- copy, mixed-list numbering.
- - §4 Behavior decisions (new entries): *Dirs are extension-less — the whole
- name is the base*; *numbering spans files and dirs in list order*;
- *duplicate check is cross-type via os.path.exists*; *default view shows
- files only*; *tree refresh after rename*.
- - §5 Layout: `/list` typed-entry contract, `dirs` request field, store state
- (`showFiles`/`showDirs`/`treeVersion`), FileList toggles + badge, updated
- test-file descriptions and test counts.
- - §7 Milestones: add row 10 (directory renaming) ✅.
-- `README.md` — feature list note (dirs selectable, view toggles).
-
-## Verification
-
-After each commit (at minimum M1/M2/M4):
-
-```sh
-./do test # pytest (engine + api) + frontend vitest
-cd frontend && npm run build # production build, after M4
-```
-
-Manual smoke (macOS, `python run.py`): enable "Directories" in a folder →
-select a dir → Add prefix + Case UPPER → preview shows extension-less new name
-→ duplicate warning when target exists (file or dir) → rename succeeds, tree
-label refreshes, contents intact.
-
-## Release notes
-
-- All commits are `feat`/`docs` ⇒ `do bump` on the `release/*` PR decides a
- **minor** version bump. Suggested release title: *"Directory renaming"*.
-- Breaking-ish note for the API (not for the app, which ships both sides):
- `/api/list` entries gain `type`, and the POST requests accept `dirs`.
- Unknown/absent fields stay safe (`Config.from_dict` semantics unchanged).
-
-## Explicitly out of scope (follow-ups)
-
-- Nested/recursive directory selection (list always shows immediate children).
-- Drag-drop reordering of list rows (numbering follows current order only).
-- Persisting the show/hide preference across sessions (per session, like
- pipeline order — could join milestone 8 polish).
-- Per-file vs per-dir numbering sequences (single combined sequence by list
- order is the documented behavior).
----
-
-## Execution log (completed)
-
-| # | Commit | Notes vs plan |
-|---|--------|---------------|
-| M1 | `020a12f` `feat(engine): support renaming directories as extension-less entries` | As planned. 20 new engine tests (suite: 116). |
-| M2 | `c237921` `feat(api): list directories with type, accept dirs in preview/check/rename` | As planned. 7 new API tests (suite: 17). |
-| M3 | `7400b3a` `feat(frontend): view toggles for files and directories in the store` | Deviations: actions are `setShowFiles(on)` / `setShowDirs(on)` (setter, not toggle) — a toggle is just `setShowFiles(!state.showFiles)`; pruning is a shared `pruneHidden()`; badge key is `fileList.typeDir` (not `dirBadge`); no separate `emptyDirs` key (empty state generalized to "entries"); no `fileList.showFiles/showDirs` label keys — the toggle labels live in `fileList.toggleFiles/toggleDirs`. 8 new store tests (suite: 43). |
-| M4 | `83ebeed` `feat(frontend): directory rows with type badge, view toggles, tree refresh` | As planned; tree refresh re-fetches children of every loaded node on `treeVersion` bump; `bumpTreeVersion()` called from RenameButton after a successful rename. 3 new smoke tests (suite: 46). |
-| M5 | this commit | AGENTS.md + README updated; plan finalized. |
-
-Verification: `./do test` green at every commit (133 backend + 46 frontend tests total).