From 96ea856dfdc44dabf19bd6e61cb3eff943321b4f Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 29 Sep 2026 13:18:40 -0700 Subject: [PATCH] Remove the organizer's Preview and apply section The preview/apply flow on the Organize tab did not work as expected, so it is removed along with its backend: the stored per-pair preview, the /organize/preview, /organize/plan and /organize/apply endpoints and their UI and types. The File naming card stays, and renaming now happens only through "Organize automatically" during scans, which keeps the underlying planning/moving code (now internal), undo, crash recovery and dry run. The rename-folders warning is unchanged. Tests now drive the kept planning and moving code directly. Co-Authored-By: Claude Opus 5.5 --- README.md | 22 ++- internal/api/server.go | 54 ------- internal/engine/engine.go | 3 - internal/engine/organize.go | 152 +++----------------- internal/engine/organize_test.go | 59 ++++---- web/src/api.ts | 13 -- web/src/pages/Organize.tsx | 234 ++----------------------------- web/src/pages/PairPage.tsx | 1 - 8 files changed, 76 insertions(+), 462 deletions(-) diff --git a/README.md b/README.md index 0707d4b..d401082 100644 --- a/README.md +++ b/README.md @@ -83,20 +83,16 @@ tags** buttons. Edits are safe with hardlinks: - Each edit is listed in History (old → new values) and can be undone; an in-place edit interrupted by a crash is rolled back on startup. Dry run applies. -**Organize (rename files from tags).** The *Organize* tab renames files into one layout, by default -`{artist}/{album}/{track} {title}` → `Avenged Sevenfold/Nightmare/01 Nightmare.flac` (`{artist}` is the album artist, falling back to -the artist). Tokens: `{artist}`, `{album}`, `{track}`, `{title}`, `{year}`, `{disc}`, `{track_artist}`, `{genre}`. Multi-disc albums get -a disc prefix (`2-04 Title`) or a `Disc 2` folder. Names are made safe for Windows/SMB shares (`AC/DC` → `AC-DC`, `Title: Sub` → -`Title - Sub`). +**Organize (rename files from tags).** The *Organize* tab sets a naming layout, by default `{artist}/{album}/{track} {title}` → +`Avenged Sevenfold/Nightmare/01 Nightmare.flac` (`{artist}` is the album artist, falling back to the artist). Tokens: `{artist}`, +`{album}`, `{track}`, `{title}`, `{year}`, `{disc}`, `{track_artist}`, `{genre}`. Multi-disc albums get a disc prefix (`2-04 Title`) or +a `Disc 2` folder, and names are made safe for Windows/SMB shares (`AC/DC` → `AC-DC`, `Title: Sub` → `Title - Sub`). -- **Preview first:** every file with its new name, what is already organized, and what is skipped and why (missing tags, with a - *Fix tags* button that opens the tag editor, or two files that would get the same name). -- Files are only moved within their library, so contents and hardlinks never change. Lyrics (`.lrc`) move with their song, cover art - follows when a whole folder moves, and emptied folders are removed. -- Organizing **both libraries** keeps paths in step. A slave file that is a hardlink of a master file at a different path is still - recognised as linked. -- Each rename is in History and can be undone; the library is rescanned afterwards. Optionally, a pair can organize the slave, the - master or both automatically during every scan (off by default). Dry run applies. +With **Organize automatically** set to the slave, the master or both (off by default), every scan renames files that don't follow the +layout. Files are only moved within their library, so contents and hardlinks never change; lyrics (`.lrc`) move with their song, cover +art follows a whole folder, and emptied folders are removed. Files with missing tags or clashing names are left alone (fix their tags +in the *Tags* tab). Each rename is in History and can be undone, and dry run applies. A slave file that is a hardlink of a master file +at a different path is still recognised as linked. **Export.** The Review tab's **Export CSV** button downloads either the current view (filter and search applied) or the entire scan. Each row has the match status and type, a recommendation, both paths, where linking would place the file, codec, bitrate, sample rate, diff --git a/internal/api/server.go b/internal/api/server.go index a331a03..ba93dd3 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -91,9 +91,6 @@ func (s *Server) Handler() http.Handler { r.Get("/files", s.handleFiles) r.Post("/tags/read", s.handleReadTags) r.Post("/tags", s.handleEditTags) - r.Post("/organize/preview", s.handleOrganizePreview) - r.Get("/organize/plan", s.handleOrganizePlan) - r.Post("/organize/apply", s.handleOrganizeApply) r.Get("/renames", s.handleRenamePreview) r.Post("/renames", s.handleRenames) r.Post("/link-identical", s.handleLinkIdentical) @@ -746,54 +743,3 @@ func (s *Server) handleEditTags(w http.ResponseWriter, r *http.Request) { s.log.Info("tag edit started", "pair", id, "side", body.Side, "files", len(body.Paths), "fields", len(body.Set), "separate", body.Separate) writeJSON(w, 202, j) } - -// ---- organizer ---- - -func (s *Server) handleOrganizePreview(w http.ResponseWriter, r *http.Request) { - id, _ := idParam(r) - var body struct { - Side string `json:"side"` // slave, master or both - } - if !readJSON(w, r, &body) { - return - } - j, err := s.eng.StartOrganizePlan(id, body.Side) - if err != nil { - opErr(w, err) - return - } - writeJSON(w, 202, j) -} - -func (s *Server) handleOrganizePlan(w http.ResponseWriter, r *http.Request) { - id, _ := idParam(r) - q := r.URL.Query() - offset, _ := strconv.Atoi(q.Get("offset")) - limit, _ := strconv.Atoi(q.Get("limit")) - plan, items, total := s.eng.OrganizePlanFor(id, q.Get("filter"), offset, limit) - if plan == nil { - writeJSON(w, 200, map[string]any{"plan": nil}) - return - } - if items == nil { - items = []engine.OrganizeItem{} - } - writeJSON(w, 200, map[string]any{"plan": plan, "items": items, "total": total}) -} - -func (s *Server) handleOrganizeApply(w http.ResponseWriter, r *http.Request) { - id, _ := idParam(r) - var body struct { - IDs []int `json:"ids"` // empty: every rename in the preview - } - if !readJSON(w, r, &body) { - return - } - j, err := s.eng.StartOrganizeApply(id, body.IDs) - if err != nil { - opErr(w, err) - return - } - s.log.Info("organize started", "pair", id, "selected", len(body.IDs)) - writeJSON(w, 202, j) -} diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 18ef710..72af934 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -45,9 +45,6 @@ type Engine struct { tagTools tags.Tools tagCache tagCache - plansMu sync.Mutex - plans map[int64]*OrganizePlan // latest organize preview per pair - jobsMu sync.Mutex jobs map[int64]*Job nextID int64 diff --git a/internal/engine/organize.go b/internal/engine/organize.go index 789c3b0..f9495a9 100644 --- a/internal/engine/organize.go +++ b/internal/engine/organize.go @@ -14,7 +14,6 @@ import ( "strconv" "strings" "sync" - "time" "unicode" "unicode/utf8" @@ -233,31 +232,26 @@ func (c *tagCache) get(ctx context.Context, t tags.Tools, abs string, in fsops.I // ---- planning ---- -// OrganizeItem is one file in an organize plan. -type OrganizeItem struct { - ID int `json:"id"` - Side string `json:"side"` - From string `json:"from"` - To string `json:"to,omitempty"` - Move bool `json:"move"` - Reason string `json:"reason,omitempty"` // why it is skipped - Sidecars []string `json:"sidecars,omitempty"` // e.g. lyrics files moving along +// organizeItem is one file the organizer looked at. +type organizeItem struct { + Side string + From string + To string + Move bool + Reason string // why it is skipped + Sidecars []string // e.g. lyrics files moving along ino uint64 size, mtime int64 } -// OrganizePlan is the result of a preview. -type OrganizePlan struct { - PairID int64 `json:"pair_id"` - Sides []string `json:"sides"` - Template string `json:"template"` - CreatedAt int64 `json:"created_at"` - Scanned int `json:"scanned"` - Conforming int `json:"conforming"` - Moves int `json:"moves"` - Skips map[string]int `json:"skips"` - Items []OrganizeItem `json:"-"` +// organizePlan is what organizing would do right now. +type organizePlan struct { + Scanned int + Conforming int + Moves int + Skips map[string]int + Items []organizeItem } // sidecarExts move together with the song of the same name. @@ -277,9 +271,8 @@ func sidesOf(s string) []string { return nil } -func (e *Engine) planOrganize(ctx context.Context, j *Job, l *libs, sides []string) (*OrganizePlan, error) { - plan := &OrganizePlan{PairID: l.pair.ID, Sides: sides, Template: l.pair.OrganizeTemplate, CreatedAt: time.Now().Unix(), Skips: map[string]int{}} - id := 0 +func (e *Engine) planOrganize(ctx context.Context, j *Job, l *libs, sides []string) (*organizePlan, error) { + plan := &organizePlan{Skips: map[string]int{}} for _, side := range sides { root, _ := sideRoot(l, side) files := map[string]fsops.Info{} @@ -295,7 +288,7 @@ func (e *Engine) planOrganize(ctx context.Context, j *Job, l *libs, sides []stri j.tick() plan.Scanned++ in := files[rel] - item := OrganizeItem{Side: side, From: rel, ino: in.Ino, size: in.Size, mtime: in.MtimeNs} + item := organizeItem{Side: side, From: rel, ino: in.Ino, size: in.Size, mtime: in.MtimeNs} if _, ok := tags.FormatOf(rel); !ok { item.Reason = "file type without editable tags" } else if fields, err := e.tagCache.get(ctx, e.tagTools, filepath.Join(root.Path, rel), in); err != nil { @@ -319,8 +312,6 @@ func (e *Engine) planOrganize(ctx context.Context, j *Job, l *libs, sides []stri item.Sidecars = sidecarsOf(root, rel) } } - id++ - item.ID = id if item.Move { plan.Moves++ } else { @@ -361,112 +352,13 @@ func sidecarsOf(root fsops.Root, rel string) []string { return out } -// StartOrganizePlan previews organizing one or both libraries. -func (e *Engine) StartOrganizePlan(pairID int64, side string) (*Job, error) { - if !e.TagEditing() { - return nil, ErrNoTagTools - } - sides := sidesOf(side) - if sides == nil { - return nil, fmt.Errorf("%w: side must be slave, master or both", ErrInvalidBulk) - } - return e.startJob(pairID, "organize-preview", func(ctx context.Context, j *Job) error { - l, err := e.loadPair(ctx, pairID) - if err != nil { - return err - } - plan, err := e.planOrganize(ctx, j, l, sides) - if err != nil { - return err - } - e.plansMu.Lock() - if e.plans == nil { - e.plans = map[int64]*OrganizePlan{} - } - e.plans[pairID] = plan - e.plansMu.Unlock() - j.update(func(j *Job) { - j.Message = fmt.Sprintf("%d to rename, %d already organized, %d skipped", plan.Moves, plan.Conforming, len(plan.Items)-plan.Moves) - }) - return nil - }) -} - -// OrganizePlanFor returns the latest preview (items filtered by "move" or -// "skip"), newest first. -func (e *Engine) OrganizePlanFor(pairID int64, filter string, offset, limit int) (*OrganizePlan, []OrganizeItem, int) { - e.plansMu.Lock() - defer e.plansMu.Unlock() - plan := e.plans[pairID] - if plan == nil { - return nil, nil, 0 - } - var items []OrganizeItem - for _, it := range plan.Items { - if filter == "" || (filter == "move") == it.Move { - items = append(items, it) - } - } - total := len(items) - if limit <= 0 || limit > 1000 { - limit = 200 - } - if offset > total { - offset = total - } - end := min(offset+limit, total) - out := append([]OrganizeItem{}, items[offset:end]...) - return plan, out, total -} - -// StartOrganizeApply performs the moves of the latest preview (all, or only -// the given ids), then rescans. Each move is re-checked first. -func (e *Engine) StartOrganizeApply(pairID int64, ids []int) (*Job, error) { - e.plansMu.Lock() - plan := e.plans[pairID] - e.plansMu.Unlock() - if plan == nil { - return nil, fmt.Errorf("%w: preview the changes first", ErrInvalidBulk) - } - want := map[int]bool{} - for _, id := range ids { - want[id] = true - } - var items []OrganizeItem - for _, it := range plan.Items { - if it.Move && (len(want) == 0 || want[it.ID]) { - items = append(items, it) - } - } - if len(items) == 0 { - return nil, fmt.Errorf("%w: nothing to rename", ErrInvalidBulk) - } - return e.startJob(pairID, "organize", func(ctx context.Context, j *Job) error { - l, err := e.loadPair(ctx, pairID) - if err != nil { - return err - } - if err := e.applyOrganize(ctx, j, l, items, "organize"); err != nil { - return err - } - e.plansMu.Lock() - delete(e.plans, pairID) // paths changed; the preview is stale - e.plansMu.Unlock() - if e.st.DryRun(ctx) { - j.update(func(j *Job) { j.Message = "Dry run: renames recorded as planned in Activity" }) - return nil - } - return e.scan(ctx, j, pairID, "organize") - }) -} - type moveDetail struct { Side string `json:"side"` Sidecars [][2]string `json:"sidecars,omitempty"` // [from, to] Art bool `json:"art,omitempty"` // a folder art file following its album } -func (e *Engine) applyOrganize(ctx context.Context, j *Job, l *libs, items []OrganizeItem, trigger string) error { +func (e *Engine) applyOrganize(ctx context.Context, j *Job, l *libs, items []organizeItem, trigger string) error { j.phase("Renaming files", int64(len(items))) // Remember where each source folder's songs went, to move cover art along. type dirMove struct { @@ -527,7 +419,7 @@ func (e *Engine) moveFolderArt(ctx context.Context, l *libs, root fsops.Root, si if !en.Type().IsRegular() || !folderArt[strings.ToLower(en.Name())] { continue } - item := OrganizeItem{Side: side, From: filepath.Join(from, en.Name()), To: filepath.Join(to, en.Name())} + item := organizeItem{Side: side, From: filepath.Join(from, en.Name()), To: filepath.Join(to, en.Name())} e.mu.Lock() dst, err := root.Resolve(item.To) if err == nil { @@ -552,7 +444,7 @@ func setSidePath(o *store.Operation, side, rel string) { } } -func (e *Engine) moveOne(ctx context.Context, l *libs, it OrganizeItem, trigger string) error { +func (e *Engine) moveOne(ctx context.Context, l *libs, it organizeItem, trigger string) error { e.mu.Lock() defer e.mu.Unlock() root, err := sideRoot(l, it.Side) @@ -715,7 +607,7 @@ func (e *Engine) autoOrganize(ctx context.Context, j *Job, l *libs) error { if err != nil { return err } - var moves []OrganizeItem + var moves []organizeItem for _, it := range plan.Items { if it.Move { moves = append(moves, it) diff --git a/internal/engine/organize_test.go b/internal/engine/organize_test.go index a1892af..6335acd 100644 --- a/internal/engine/organize_test.go +++ b/internal/engine/organize_test.go @@ -60,14 +60,34 @@ func mkTagged(t *testing.T, path string, kv ...string) { } } -func (ev *env) organize(t *testing.T, side string) (*OrganizePlan, []OrganizeItem) { +// plan and apply run the same code as automatic organizing. +func (ev *env) plan(t *testing.T, sides ...string) *organizePlan { t.Helper() - if _, err := ev.e.StartOrganizePlan(ev.pair.ID, side); err != nil { + l, err := ev.e.loadPair(ctx, ev.pair.ID) + if err != nil { t.Fatal(err) } - ev.wait() - plan, items, _ := ev.e.OrganizePlanFor(ev.pair.ID, "", 0, 1000) - return plan, items + p, err := ev.e.planOrganize(ctx, &Job{e: ev.e, Results: map[string]int{}}, l, sides) + if err != nil { + t.Fatal(err) + } + return p +} + +func (ev *env) apply(t *testing.T, p *organizePlan) *Job { + t.Helper() + l, _ := ev.e.loadPair(ctx, ev.pair.ID) + j := &Job{e: ev.e, Results: map[string]int{}} + var moves []organizeItem + for _, it := range p.Items { + if it.Move { + moves = append(moves, it) + } + } + if err := ev.e.applyOrganize(ctx, j, l, moves, "test"); err != nil { + t.Fatal(err) + } + return j } func TestOrganizeSlaveLibrary(t *testing.T) { @@ -84,17 +104,14 @@ func TestOrganizeSlaveLibrary(t *testing.T) { mkTagged(t, filepath.Join(s, "dupes/a.mp3"), "title", "Same", "artist", "X", "album", "Y", "track", "1") mkTagged(t, filepath.Join(s, "dupes/b.mp3"), "title", "Same", "artist", "X", "album", "Y", "track", "1") - plan, items := ev.organize(t, SideSlave) + plan := ev.plan(t, SideSlave) if plan.Conforming != 1 || plan.Moves != 2 { - t.Fatalf("want 1 conforming and 2 moves, got %+v %+v", plan, items) + t.Fatalf("want 1 conforming and 2 moves, got %+v", plan) } if plan.Skips["missing artist or album artist tag"] != 1 || len(plan.Skips) != 2 { t.Fatalf("unexpected skips %+v", plan.Skips) } - if _, err := ev.e.StartOrganizeApply(ev.pair.ID, nil); err != nil { - t.Fatal(err) - } - j := ev.wait() + j := ev.apply(t, plan) if j.Results["renamed"] != 2 { t.Fatalf("apply: %+v %v", j.Results, j.Skipped) } @@ -139,11 +156,8 @@ func TestOrganizeKeepsHardlinks(t *testing.T) { if got := ev.match("Chris Picks/Nightmare.flac"); got.Status != store.StatusLinked || got.Kind != store.KindInode { t.Fatalf("a hardlink at another path should be recognised, got %s/%s", got.Status, got.Kind) } - ev.organize(t, SideSlave) - if _, err := ev.e.StartOrganizeApply(ev.pair.ID, nil); err != nil { - t.Fatal(err) - } - ev.wait() + ev.apply(t, ev.plan(t, SideSlave)) + ev.scan() moved := filepath.Join(ev.slave, "Avenged Sevenfold/Nightmare/01 Nightmare.flac") if !sameInode(t, m, moved) { t.Fatal("the moved slave file must still be the master's file") @@ -157,11 +171,7 @@ func TestOrganizeDryRunAndChangedFile(t *testing.T) { ev := tagEnv(t, true) p := filepath.Join(ev.slave, "x/song.mp3") mkTagged(t, p, "title", "Song", "artist", "A", "album", "B", "track", "1") - ev.organize(t, SideSlave) - if _, err := ev.e.StartOrganizeApply(ev.pair.ID, nil); err != nil { - t.Fatal(err) - } - if j := ev.wait(); j.Results["renamed"] != 1 { + if j := ev.apply(t, ev.plan(t, SideSlave)); j.Results["renamed"] != 1 { t.Fatalf("dry run should plan the rename: %+v", j.Results) } if _, err := os.Stat(p); err != nil { @@ -169,11 +179,10 @@ func TestOrganizeDryRunAndChangedFile(t *testing.T) { } ev.st.SetSetting(ctx, "dry_run", "0") - ev.organize(t, SideSlave) + plan := ev.plan(t, SideSlave) mkTagged(t, p, "title", "Song", "artist", "A", "album", "B", "track", "1", "genre", "changed") - ev.e.StartOrganizeApply(ev.pair.ID, nil) - if j := ev.wait(); j.Results["renamed"] != 0 || j.Results["skipped"] != 1 { - t.Fatalf("a file changed after the preview must be skipped: %+v", j.Results) + if j := ev.apply(t, plan); j.Results["renamed"] != 0 || j.Results["skipped"] != 1 { + t.Fatalf("a file changed after planning must be skipped: %+v", j.Results) } } diff --git a/web/src/api.ts b/web/src/api.ts index 9a1d0bc..bcdd1de 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -240,16 +240,3 @@ export type TagFile = { links: number; size: number; }; - -export type OrganizeItem = { id: number; side: Side; from: string; to?: string; move: boolean; reason?: string; sidecars?: string[] }; - -export type OrganizePlan = { - pair_id: number; - sides: Side[]; - template: string; - created_at: number; - scanned: number; - conforming: number; - moves: number; - skips: Record; -}; diff --git a/web/src/pages/Organize.tsx b/web/src/pages/Organize.tsx index d9145a1..9f86a26 100644 --- a/web/src/pages/Organize.tsx +++ b/web/src/pages/Organize.tsx @@ -1,11 +1,10 @@ import { useEffect, useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { AlertTriangle, ArrowRight, FolderTree, Tags as TagsIcon } from "lucide-react"; -import { api, type OrganizeItem, type OrganizePlan, type Pair, type Side } from "../api"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { AlertTriangle, FolderTree } from "lucide-react"; +import { api, type Pair } from "../api"; import { useInfo } from "../App"; -import { Alert, Badge, Button, Card, Empty, Field, Modal, Spinner, cx, formatAgo, inputCls } from "../ui"; +import { Alert, Button, Card, Field, inputCls } from "../ui"; import { useToast } from "../toast"; -import { TagEditor } from "./Tags"; const TOKENS: [string, string][] = [ ["artist", "album artist, or artist"], @@ -39,8 +38,6 @@ function example(tpl: string, multi?: Pair["organize_multidisc"]) { return parts.join("/") + ".flac"; } -type Scope = Side | "both"; - export function OrganizeTab({ pair }: { pair: Pair }) { const info = useInfo(); if (info.data && !info.data.tag_editing) @@ -48,7 +45,6 @@ export function OrganizeTab({ pair }: { pair: Pair }) { return (
-
); } @@ -99,7 +95,10 @@ function NamingSettings({ pair }: { pair: Pair }) {

File naming

-

Files are renamed from their tags into this layout. Nothing else about a file changes.

+

+ Files that don't follow this layout are renamed from their tags during scans, when Organize automatically is on. Nothing else about + a file changes. +

- - - - {running && pair.job?.kind.startsWith("organize") && ( -
- {pair.job.phase || "Working"}… {pair.job.total > 0 && `${pair.job.done} / ${pair.job.total}`} -
- )} - - {!p && !running && ( - } title="No preview yet"> - Choose which library to organize and click Preview renames. Organizing both keeps their paths in step, so songs keep matching. - - )} - - {p && ( - <> -
- - - - -
-
- Preview of the {p.sides.join(" and ")} {both ? "libraries" : "library"} · {formatAgo(p.created_at)} · pattern{" "} - {p.template} -
- -
- {(["move", "skip"] as const).map((v) => ( - - ))} -
- - {view === "skip" && skipped > 0 && ( -
    - {Object.entries(p.skips).map(([why, n]) => ( -
  • - {n} {why} -
  • - ))} -
- )} - -
- {view === "move" && items.length > 0 && ( - - )} -
- {items.length === 0 &&
Nothing here.
} - {items.map((it) => ( -
- {it.move && ( - - setSel((s) => { - const n = new Set(s ?? items.filter((x) => x.move).map((x) => x.id)); - if (e.target.checked) n.add(it.id); - else n.delete(it.id); - return n; - }) - } - /> - )} - {both && {it.side}} -
- {it.move ? ( -
- {it.from} - - {it.to} -
- ) : ( -
{it.from}
- )} - {it.sidecars?.length ?
+ lyrics file moves along
: null} - {!it.move &&
{it.reason}
} -
- {!it.move && it.reason?.startsWith("missing") && ( - - )} -
- ))} -
-
- {(plan.data?.total ?? 0) > items.length && ( -

Showing the first {items.length} of {plan.data?.total}. Applying all includes every file.

- )} - - {p.moves > 0 && ( -
- -
- )} - - )} - - setConfirm(false)} title={`Rename ${chosen} file${chosen !== 1 ? "s" : ""}?`}> -
    -
  • Files are moved to their new names inside the same library. Their contents and hardlinks don't change.
  • -
  • Lyrics files with the same name move along; cover art follows when a whole folder moves. Emptied folders are removed.
  • -
  • - Playlists or apps that point at the old paths will need updating. -
  • - {p && !both &&
  • Only the {p.sides[0]} library is renamed. Hardlinked songs stay paired, but paths may differ from the other library.
  • } -
  • Each rename is listed in History and can be undone. The library is rescanned when it finishes.
  • -
- {dry && ( -
- Dry run is on, so the renames are recorded as planned in Activity. No files move. -
- )} -
- - -
-
- {editTags && setEditTags(null)} />} - - ); -} - -function Stat({ label, value, tone }: { label: string; value: number; tone?: string }) { - return ( -
-
{value}
-
{label}
-
- ); -} diff --git a/web/src/pages/PairPage.tsx b/web/src/pages/PairPage.tsx index ff8d3c9..3badacb 100644 --- a/web/src/pages/PairPage.tsx +++ b/web/src/pages/PairPage.tsx @@ -40,7 +40,6 @@ function useJobFinished(pair: Pair | undefined) { qc.invalidateQueries({ queryKey: ["tree"] }); qc.invalidateQueries({ queryKey: ["ops"] }); qc.invalidateQueries({ queryKey: ["pairs"] }); - qc.invalidateQueries({ queryKey: ["organize"] }); qc.invalidateQueries({ queryKey: ["files"] }); const j = pair.job!; if (st === "done") toast("ok", `${j.kind} finished${j.message ? `: ${j.message}` : ""}`);