Files
librarian/REFACTOR_TICKETS.md
2026-06-17 16:40:22 -05:00

319 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Librarian Refactor — Ticket Log (self-implemented)
## Context
`crates/librarian-app/src/main.rs` has grown to **4308 lines** — nearly half the
whole workspace in one file. Everything else is already reasonably modularized
(next-largest source file is 646 lines, and most of *that* is tests). The goal is
to break `main.rs` into ~9–10 focused modules following SRP, then a final DRY
pass to remove the repeated patterns the analysis found — **without changing any
behavior**.
You (not Claude) are implementing this to learn Rust, ticket by ticket, asking
questions as you go. The tickets are ordered **safest-first**: pure mechanical
moves you can verify with `cargo build` before anything that could change runtime
behavior. DRY consolidation is deliberately saved for the end (Phase 5) so every
earlier ticket is a "no behavior change" move.
Scope: `main.rs` + `tree.rs` (test relocation only). `librarian-win/icon.rs` is
out of scope.
---
## The 3 Rust mechanics you need (read this first)
Everything below uses one of three moves. Learn these and the tickets are
mechanical.
**1. Leaf module (for *pure functions* — no `self`).**
The styling functions, keyboard predicates, and column-math functions don't touch
the app struct. Move them to a new file, add `mod foo;` to `main.rs`, and call
them as `foo::bar(...)`. This is exactly how `metrics.rs` / `rows.rs` already
work. *Zero behavioral risk.*
**2. Split `impl` block (for *methods* — `&self` / `&mut self`).**
`view()`, `update()`, and all the `view_*` / handler methods take `self`. Rust
lets an `impl Librarian { … }` block live in **any file in the same crate**. So
you create `view.rs`, write `use crate::*;` then `impl Librarian { pub(crate) fn
view_toolbar(&self) -> … { … } }`, and the call site `self.view_toolbar()` in
`main.rs` is **unchanged**. You are physically moving the method, not rewriting
the call.
**3. The one visibility rule that will bite you.**
A child module (e.g. `view`) can freely *see* private items of `main.rs` —
descendants can read their ancestors' privates. But `main.rs` **cannot** see a
private item of its child. So:
- Any method/type you move *out* of `main.rs` must become **`pub(crate)`** (so
`main.rs` and sibling modules can still call it).
- Anything that *stays* in `main.rs` (e.g. `recompute_rows`, the `Librarian`
struct, the `Message` enum, constants) needs **no change** — the new child
modules can already see it.
Rule of thumb: **the thing you move gets `pub(crate)`; the things it calls back
into don't.**
> Shrinking `update()` (a 600-line `match`): you can't split one `match` across
> files. Instead, move each complex arm's *body* into a `pub(crate)` handler
> method in the right module, and replace the arm with a one-liner, e.g.
> `Message::Delete => return self.delete_selected(),`. The `match` stays in
> `main.rs` but collapses to a dispatch table.
---
## Target module layout (`crates/librarian-app/src/`)
Existing (leave as-is): `columns config ellipsis icons metrics rows search
selection theme thumbs tree`.
New files this refactor adds:
| New file | Responsibility | Mechanism |
|---|---|---|
| `styles.rs` | All `*_style()` fns + tiny pure element helpers (dividers, tooltip, `loading_overlay`, `view_header`, `gap`) | Leaf |
| `keyboard.rs` | `key_to_message`, `is_input_shortcut`, `is_text_edit`, `clear_focus` | Leaf |
| `columns.rs` (extend) | `compute_col_px`, `measure_columns`, `sample_widest`, `width_bounds`, `sort_key_of` | Leaf (into existing) |
| `view.rs` | Window chrome: `view`, tabs, toolbar, status, command bar, tree view, context menu, empty-state | impl-split |
| `view_details.rs` | Details list: `view_details`, `view_list`, `view_row` | impl-split |
| `view_grid.rs` | Icon grid: `view_body`, `view_grid`, `view_tile` | impl-split |
| `grid_session.rs` | Thumbnail session coordination + `ThumbsCached/ThumbsLoaded` arms | impl-split |
| `tabs.rs` | `TabState` + snapshot/restore/switch/new/close + tab arms | impl-split + struct |
| `fileops.rs` | `dispatch_op`, file-op arms, selection-path helpers, `unique_folder_name` | impl-split + free fn |
| `navload.rs` | `load_current`/`navigate`, folder-load arms, dir-watch, tree building/reveal | impl-split + free fns |
`main.rs` keeps: the `Librarian` struct, the `Message` enum, shared small enums
(`Content`, `ViewMode`, `PaneKind`, `Nav`, …), constants, `new()`/`title()`/
`subscription()`, the `update()` dispatch `match`, and a handful of cross-cutting
orchestration methods (`recompute_rows`, `request_icons`, `settings`,
`apply_sort`, `scroll_to`, `ensure_visible`, `move_selection`, `on_click`,
`activate`). Target: **~600–800 lines**.
> Line numbers below are anchors from the current file and *will drift* as you
> edit. Navigate by **function name** (search), not by line number.
---
## Conventions to follow (match the existing code)
- Files `snake_case`; types `PascalCase`; free fns `snake_case`.
- One `mod foo;` per new file, alphabetically grouped with the others at the top
of `main.rs`. Add `use crate::foo::{…}` imports next to the existing block.
- Modules don't define `Message` and don't take `&mut Librarian` as a *parameter*
— instead they're either leaf free-fns or `impl Librarian` blocks (methods).
- Keep each module's doc-comment header (`//! …`) describing its one job, like
`tree.rs` and `thumbs.rs` do.
- Colocate any moved `#[cfg(test)]` with its code.
---
## Phase 0 — Baseline (do once, before Ticket 1)
**T0. Establish a green baseline & checkpoint discipline.**
- Run `cargo build -p librarian-app`, `cargo clippy --workspace`,
`cargo test --workspace`, and launch `cargo run -p librarian-app` once. Note the
current warning count.
- Commit the clean state. **After every ticket: `cargo build` + `cargo clippy` +
launch the app + commit.** A ticket isn't done until it builds clean and the app
still looks/behaves identically.
---
## Phase 1 — Pure leaf extractions (lowest risk, do first)
These move *only* free functions. If it compiles, it's correct.
**T1. Extract `styles.rs`.**
Move the styling block (~`3029–3432`): every `*_style()` fn plus the small pure
element/builder helpers in that range — `gap`, `full_path_text`, `path_tooltip`,
`header_rule`, `tree_section_divider`, `column_divider`, `view_header`,
`loading_overlay`. Add `mod styles;`, mark each moved fn `pub(crate)`, add
`use crate::styles::*;` (or explicit names) in `main.rs`. Some take `&Theme` /
`spinner_frame: usize` — pass-through, no `self`.
*Verify:* compiles; app visuals identical (selection highlight, dividers, tabs,
loading overlay).
**T2. Extract `keyboard.rs`.**
Move `is_input_shortcut`, `is_text_edit`, `clear_focus`, `key_to_message`
(~`3556–3650`). All free fns over `&Message` / `Key`. Mark `pub(crate)`; fix the
call sites in `subscription()` and at the top of `update()`.
*Verify:* compiles; keyboard nav, Ctrl-shortcuts, and typing in the address/search
/rename fields all still work (the overlay-suppression check at `update` line
~1065 still calls `is_input_shortcut`/`is_text_edit`).
**T3. Consolidate column math into `columns.rs`.**
Move `compute_col_px`, `width_bounds`, `sort_key_of` (~`3815–3870`),
`measure_columns` (~`2167–2220`), and `sample_widest` out of `main.rs` into the
existing `columns.rs` (their natural home — they already operate on
`ColumnLayout`/`Column`). Make them `pub` in `columns.rs`. If any need
`measure_width`/`LIST_TEXT_SIZE`, import from `metrics` there.
*Verify:* compiles; column auto-fit, drag-resize, and header sort-arrow widths
behave identically.
---
## Phase 2 — View impl-split (low risk: moving `&self` methods)
Do **T1 first** — these call the now-`pub(crate)` style fns. Pure render methods,
no state mutation, so behavior can't change if it compiles; spot-check visuals.
**T4. Extract `view.rs` (chrome).**
Move methods: `view`, `view_tabs`, `view_tab`, `view_toolbar`, `view_status`,
`view_command_bar`, `view_tree`, `view_tree_row`, `view_context_menu`,
`empty_list_message`. Create `view.rs` with `use crate::*;` and `impl Librarian {
… }`; mark each `pub(crate)`. (`view` itself is required by the Iced `update`/`view`
wiring — confirm it's still found; if the framework needs it on the original impl,
keep just `view`'s signature delegating, or move it too and ensure the trait/inherent
lookup still resolves.)
*Verify:* compiles; toolbar, tabs, status bar, command bar, tree sidebar, and
right-click menu all render and respond.
**T5. Extract `view_details.rs`.**
Move `view_details`, `view_list`, `view_row` (~`2547–2587`, `2729–2844`). These
call `compute_col_px`/`measure_columns` (now in `columns.rs` from T3) and styles
(T1) — import them.
*Verify:* details list renders; virtualization (scroll), inline rename field, and
per-column ellipsis all intact.
**T6. Extract `view_grid.rs`.**
Move `view_body`, `view_grid`, `view_tile` (~`2594–2597`, `2850–2953`).
*Verify:* all icon-grid sizes (Tiny→ExtraLarge) render; thumbnails appear; tile
rename field works.
---
## Phase 3 — Stateful impl-split (moves `&mut self` methods + update arms)
Higher care: these mutate state and own update-arm logic. Move the methods, then
collapse each related `update` arm to a one-line delegating call.
**T7. Extract `grid_session.rs` (thumbnail coordination).**
Move `begin_grid_session`, `prefetch_thumbs`, `retain_visible_misses`,
`extract_full_task`, `seed_background`, `next_background_chunk`,
`visible_thumb_keys` (~`2243–2366`) and the `thumb_key` free fn. Add a
`pub(crate) fn handle_thumbs_cached/loaded(...)` and move the `ThumbsCached` /
`ThumbsLoaded` arm bodies (~`1335–1397`) into them; replace arms with delegating
calls. Keep `ThumbKind` where it's referenced (move to this module as `pub(crate)`
if only used here).
*Verify:* switch to a grid view in an image folder — loading overlay raises/clears,
viewport thumbs load first, background pre-cache fills in on scroll.
**T8. Extract `tabs.rs`.**
Move `TabState` (~`745–819`) and `snapshot_flat`/`restore_flat`/`reset_flat`/
`show_active`/`switch_tab`/`new_tab`/`close_tab`. Move the tab arm bodies
(`NewTab`/`SelectTab`/`CloseTab`/`CloseActiveTab`/`NextTab`/`PrevTab`/
`OpenInNewTab`, ~`1401–1430`) into `pub(crate)` handler methods. `TabState` and its
fields used by `main.rs` become `pub(crate)`.
*Note:* the active tab's state lives directly on `Librarian`; only parked tabs are
`Some(TabState)`. Preserve that exactly — `snapshot_flat`/`restore_flat` are the
only places that convert between the two.
*Verify:* open/close/switch/cycle tabs; each tab restores its own folder, scroll,
selection, columns.
**T9. Extract `fileops.rs`.**
Move `dispatch_op` and the file-op arm bodies (`OpenSelected`, `NewFolder`,
`DeleteSelected`, `Copy`, `Cut`, `Paste`, `RenameStart/Changed/Commit`,
`OpFinished`, ~`1560–1657`) into `pub(crate)` handler methods; move
`unique_folder_name` (~`3544–3554`), `selected_paths`, `current_dir`, `lead_path`,
`restore_selection`, `begin_pending_rename`. Keep calling `librarian-win`
(`copy_items`, `move_items`, `delete_to_recycle`, `rename`, `create_folder`) from
here — that's the right layering.
*Verify:* create folder (+ auto-rename), rename, delete-to-recycle, cut/copy/paste,
open — and the listing auto-refreshes after each (`OpFinished`).
**T10. Extract `navload.rs` (navigation + async loading + dir watch + tree build).**
Move `load_current`/`navigate`/reset helpers (~`1754–1831`), the load arm bodies
(`ThisPcLoaded`, `WslLoaded`, `Loaded`, `Reloaded`, `DirChanged`, ~`1192–1313`),
`watch_stream` (~`3684–3725`), and the tree-building free fns + factories
(`fetch_tree_roots`, `fetch_tree_children`, `home/this_pc/wsl_tree_child`,
`tree_child_from_*`, ~`3444–3542`) plus tree arm bodies (`TreeToggle`,
`TreeNavigate`, `TreeChildrenLoaded`, `PaneResized`, ~`1432–1473`) and
`load_tree_roots`/`load_tree_children`/`drive_reveal`.
*This is the biggest stateful ticket — consider splitting into T10a (folder load +
watch) and T10b (tree build/reveal) if it feels too large.*
*Verify:* navigate via address bar, tree clicks, back/forward/up; This PC + WSL
listings; external file changes auto-refresh; revealing a deep path expands the
tree.
---
## Phase 4 — `tree.rs` (test relocation only)
**T11. Move `tree.rs` tests into `src/tree/tests.rs`.**
`tree.rs` logic (lines 1–371) is already SRP-clean — **leave it alone.** Only the
275-line `#[cfg(test)] mod tests` block (372–646) inflates it. Replace that block
in `tree.rs` with `#[cfg(test)] mod tests;`, create `src/tree/tests.rs`, paste the
test bodies there with `use super::*;` at the top. (A file-module `tree.rs` can own
a child module file at `src/tree/tests.rs` — no `mod.rs` needed.)
*Verify:* `cargo test -p librarian-app` runs the same tree tests, all green.
---
## Phase 5 — DRY consolidation (separate final phase)
Now that concerns live in the right modules, remove the repeated patterns the
analysis found. **These change code paths**, so do them one at a time with a build
+ run between each. Each becomes a small helper on `Librarian` (or a free fn)
placed in the owning module.
**T12. `recompute_rows` + status string (4×).** Sites: load/reload arms
(~`1196`, `1205`, `1221`, `1289`). Extract `fn refresh_listing_status(&mut self)`
that does `recompute_rows()` + `self.status = format!("{n} items")`.
**T13. Selection preserve/restore (2×).** Sites ~`1154`, `1288`. Extract
`fn recompute_preserving_selection(&mut self)` wrapping
`selected_paths` → `recompute_rows` → `restore_selection`.
**T14. Icon+thumbnail warm batches (5×).** Sites ~`1095`, `1158`, `1178`, `1199`,
`1224`. Extract small helpers like `warm_content(lock)` / `warm_after_search()`
returning the `Task::batch([...])` so handlers read intent, not wiring. (Land this
in `grid_session.rs`.)
**T15. File-op status strings (3×).** Sites ~`1582/1590/1598`. Extract
`fn op_label(verb: &str, count: usize) -> String` (uses existing `plural`). Lives
in `fileops.rs`.
**T16. Icon-or-placeholder render (2×).** Sites `view_tree_row` ~`2469`, `view_row`
~`2766`. Extract `fn icon_element(&self, key, size) -> Element<'_, Message>`.
**T17. Inline rename field (2×).** Sites `view_row` ~`2775`, `view_tile` ~`2923`.
Extract `fn rename_field(value) -> Element` (free fn; uses `RENAME_ID`,
`RenameChanged`, `RenameCommit`).
**T18. Tree-child factories (3×).** `tree_child_from_drive/distro/known`
(~`3527–3542`) are identical `row → TreeChild::lazy(label, icon, target)`. Collapse
to one `tree_child_from_row(row: Row) -> TreeChild`. Lives in `navload.rs`.
**T19. (Optional / stretch) Async token-supersession guard (9×).** The
`if token != self.X_token { return Task::none() }` pattern recurs in every async
arm. A macro or `fn is_stale(token, current) -> bool` helper trims it, but each
arm checks a *different* token field — only worth it if it reads cleaner to you.
Skip if it feels forced.
---
## Global verification
After **each** ticket:
1. `cargo build -p librarian-app` — clean.
2. `cargo clippy --workspace` — no new warnings vs. the T0 baseline.
3. `cargo test --workspace` — all green (esp. after T3, T10, T11).
4. `cargo run -p librarian-app` — exercise the area the ticket touched (see each
ticket's *Verify*).
5. `git commit` — one commit per ticket, so any regression is trivially bisectable.
At the end: `main.rs` should be ~600–800 lines and read as *struct + Message +
dispatch*, with each feature's logic one `mod` away. Confirm the full app still
behaves identically to the T0 baseline (navigation, tabs, search, grid/details,
file ops, rename, tree reveal, WSL, external-change refresh).
---
## Suggested order & dependencies
`T0 → T1 → T2 → T3` (independent leaf moves; T1 before Phase 2) `→ T4 → T5 → T6`
(views; need T1/T3) `→ T7 → T8 → T9 → T10` (stateful; independent of each other,
any order) `→ T11` (tree tests; independent — can be done anytime) `→ Phase 5`
(T12–T19; after their owning module exists).
Lowest-risk first stop if you want a quick early win and a feel for the pattern:
**T1 (styles)** removes ~400 lines with zero behavioral risk.