2026-08-29 02:06:14 +00:00
import { useEffect , useMemo , useRef , useState , type ReactNode } from "react" ;
2026-08-29 01:55:54 +00:00
import { invoke } from "@tauri-apps/api/core" ;
import {
ClearIcon ,
DownIcon ,
FetchIcon ,
GearIcon ,
HistoryIcon ,
2026-08-29 03:01:19 +00:00
KeyIcon ,
2026-08-29 01:55:54 +00:00
LoadIcon ,
PresetsIcon ,
RemoveIcon ,
UpIcon ,
} from "./Icons" ;
2026-08-29 02:06:14 +00:00
interface Item {
2026-08-29 01:55:54 +00:00
id : string ;
originalName : string ;
ext : string ;
2026-08-29 02:06:14 +00:00
bindings : Record < string , string > | null ; // null = no data to preview with (rendered muted)
isSample : boolean ;
2026-08-29 04:04:16 +00:00
// What kind of media Match recognized this file as - "tv"/"movie" once Match (or
// Fetch Data) has run, null before that or if nothing was recognized. Kept outside
// `bindings` so it doesn't leak into the naming-scheme field-reference list.
mediaKind : "tv" | "movie" | null ;
2026-08-29 01:55:54 +00:00
}
2026-08-29 02:06:14 +00:00
interface FileEntry {
path : string ;
name : string ; // filename without extension
ext : string ;
}
2026-08-29 10:07:09 +00:00
// Result of the local, filename-only season/episode matcher (aurora-curator-core's
2026-08-29 02:42:31 +00:00
// `matching` module, exposed via the `guess_episode` Tauri command). No network calls -
// this only recognizes patterns already present in the filename text itself.
interface EpisodeGuess {
seriesName : string ;
season : number ;
episode : number ;
}
2026-08-29 10:07:09 +00:00
// Result of the local, filename-only movie title/year matcher (aurora-curator-core's
2026-08-29 04:04:16 +00:00
// `matching::guess_movie`, exposed via the `guess_movie` Tauri command). Tried as a
// fallback whenever `guess_episode` finds no season/episode pattern - a release name
// is either a TV episode or a movie, never both.
interface MovieGuess {
title : string ;
year : number | null ;
}
// What runMatching resolved a file to, before it's written into the item's bindings.
type MatchGuess = { kind : "tv" ; episode : EpisodeGuess } | { kind : "movie" ; movie : MovieGuess };
2026-08-29 10:07:09 +00:00
// Result of the local, filename-only release-tag matcher (aurora-curator-core's
2026-08-29 09:29:14 +00:00
// `matching::guess_release_info`, exposed via the `guess_release_info` Tauri command).
// Independent of the TV/movie guess above - a release name carries (or doesn't carry)
// these tags regardless of what kind of media it is, so this runs alongside whichever
// of `guess_episode`/`guess_movie` applied. Every field is optional on its own.
interface ReleaseInfo {
resolution : string | null ;
source : string | null ;
videoCodec : string | null ;
audioCodec : string | null ;
group : string | null ;
}
2026-08-29 02:42:31 +00:00
function pad2 ( n : number ) : string {
return n . toString (). padStart ( 2 , "0" );
}
// One row of the on-disk rename plan shown in the confirmation modal before anything
// touches the filesystem. `id`/`source` are the file's current full path.
interface RenamePlanEntry {
id : string ;
source : string ;
currentName : string ;
newName : string ;
}
2026-08-29 10:07:09 +00:00
// Mirrors aurora-curator-core's `RenameOutcome` (serde-tagged on "status"). Conflict policy is
2026-08-29 02:42:31 +00:00
// fixed at skip - a colliding target is never overwritten, just reported.
type RenameOutcome =
| { status : "renamed" ; newPath : string }
| { status : "unchanged" }
| { status : "skippedConflict" ; existingPath : string }
| { status : "failed" ; error : string };
interface RenameResult {
source : string ;
outcome : RenameOutcome ;
}
2026-08-29 10:07:09 +00:00
// Mirrors aurora-curator-core's `RenameAction` - what actually happens at each computed target.
// "move" relocates the file in place (the only action Aurora Curator had before); "copy" and
2026-08-29 04:31:16 +00:00
// "symlink" both leave the source file exactly where it is and produce a new file
// elsewhere, which is why only "move" updates the loaded item's own path/name (see
// confirmRename below).
type RenameAction = "move" | "copy" | "symlink" ;
2026-08-29 10:07:09 +00:00
// Mirrors aurora-curator-core's `ConflictPolicy` - what happens when a computed target already
// exists. "skip" (the default, and the only behavior Aurora Curator had before) leaves the
2026-08-29 09:50:36 +00:00
// conflicting file alone; "autoNumber" saves alongside it as "Name (1).ext" instead;
// "override" replaces it outright (irreversible for whatever gets overwritten).
type ConflictPolicy = "skip" | "autoNumber" | "override" ;
2026-08-29 10:44:12 +00:00
// Mirrors aurora-curator-core's `TvMetadataSource` - which online source Fetch Data
// resolves TV episodes against. "tmdb" is the default (matches Aurora Curator's original and
// only source before TVmaze existed); "tvmaze" needs no API token at all. Movies always
// use TMDB regardless of this setting - TVmaze has no movie catalog to pick between.
type TvMetadataSource = "tmdb" | "tvmaze" ;
2026-08-29 03:01:19 +00:00
// Sent to `fetch_metadata`: one file's local guess (from Match) to resolve against
2026-08-29 10:44:12 +00:00
// real online TV metadata (TMDB or TVmaze, per the stored TvMetadataSource). Needs a
// series name + season/episode to search with - exactly what Match already produces
// offline, which is why Fetch Data requires Match to have run first rather than doing
// its own from-scratch identification.
2026-08-29 03:01:19 +00:00
interface FetchQuery {
id : string ;
seriesQuery : string ;
season : number ;
episode : number ;
}
2026-08-29 10:07:09 +00:00
// Mirrors aurora-curator-core's `FetchOutcome` (serde-tagged on "status").
2026-08-29 03:01:19 +00:00
type FetchOutcome =
| { status : "found" ; seriesName : string ; episodeTitle : string ; airDate : string | null }
| { status : "notFound" }
| { status : "failed" ; error : string };
interface FetchResult {
id : string ;
outcome : FetchOutcome ;
}
2026-08-29 04:04:16 +00:00
// Sent to `fetch_movie_metadata`: one file's local movie-title guess (from Match) to
// resolve against real TMDB data. Mirrors FetchQuery, but for movies - a year is
// optional since not every release name carries one.
interface MovieFetchQuery {
id : string ;
titleQuery : string ;
year : number | null ;
}
2026-08-29 10:07:09 +00:00
// Mirrors aurora-curator-core's `MovieFetchOutcome` (serde-tagged on "status").
2026-08-29 04:04:16 +00:00
type MovieFetchOutcome =
| { status : "found" ; title : string ; releaseDate : string | null }
| { status : "notFound" }
| { status : "failed" ; error : string };
interface MovieFetchResult {
id : string ;
outcome : MovieFetchOutcome ;
}
2026-08-29 10:07:09 +00:00
// Rename history / undo. Mirrors aurora-curator-core's `history` module (serde-tagged on
2026-08-29 03:44:38 +00:00
// "status" for the outcome, same convention as RenameOutcome/FetchOutcome above).
interface HistoryEntry {
source : string ;
newPath : string ;
}
interface HistoryBatch {
id : string ;
/// Unix epoch milliseconds - formatted client-side with `Date`.
timestamp : number ;
2026-08-29 04:31:16 +00:00
// What action produced every entry in this batch. Older history entries recorded
// before this field existed come back as "move" (the Rust side's serde default),
// which is also correct - that was the only action that ever existed back then.
action : RenameAction ;
2026-08-29 03:44:38 +00:00
entries : HistoryEntry [];
}
type RevertOutcome =
| { status : "reverted" ; restoredPath : string }
| { status : "skippedConflict" ; existingPath : string }
| { status : "failed" ; error : string };
interface RevertResult {
source : string ;
outcome : RevertOutcome ;
}
2026-08-29 02:42:31 +00:00
function fileNameFromPath ( path : string ) : string {
const parts = path . split ( /[\\/]/ );
return parts [ parts . length - 1 ] ?? path ;
}
2026-08-29 02:06:14 +00:00
// Shown until the user loads real files, so the two-pane layout and live naming-scheme
// preview are visible immediately. Item 4 demonstrates the "no data" muted row style.
// This is what full metadata-matched renaming will look like once the matching engine
// (see the porting roadmap) is ported — real loaded files use their own filename fields
// instead (see realFileBindings below) since there's no online identification yet.
const SAMPLE_ITEMS : Item [] = [
2026-08-29 01:55:54 +00:00
{
2026-08-29 02:06:14 +00:00
id : "sample-1" ,
2026-08-29 01:55:54 +00:00
originalName : "Breaking.Bad.S05E14.720p.HDTV.x264-KILLERS.mkv" ,
ext : "mkv" ,
2026-08-29 03:15:21 +00:00
bindings : {
n : "Breaking Bad" ,
s00e00 : "S05E14" ,
t : "Ozymandias" ,
"show name" : "Breaking Bad" ,
"season number" : "5" ,
"episode number" : "14" ,
"episode name" : "Ozymandias" ,
2026-08-29 09:29:14 +00:00
vf : "720p" ,
source : "HDTV" ,
vc : "x264" ,
group : "KILLERS" ,
resolution : "720p" ,
"video codec" : "x264" ,
"release group" : "KILLERS" ,
2026-08-29 03:15:21 +00:00
},
2026-08-29 02:06:14 +00:00
isSample : true ,
2026-08-29 04:04:16 +00:00
mediaKind : "tv" ,
2026-08-29 01:55:54 +00:00
},
{
2026-08-29 02:06:14 +00:00
id : "sample-2" ,
2026-08-29 01:55:54 +00:00
originalName : "Breaking.Bad.S05E15.720p.HDTV.x264-KILLERS.mkv" ,
ext : "mkv" ,
2026-08-29 03:15:21 +00:00
bindings : {
n : "Breaking Bad" ,
s00e00 : "S05E15" ,
t : "Granite State" ,
"show name" : "Breaking Bad" ,
"season number" : "5" ,
"episode number" : "15" ,
"episode name" : "Granite State" ,
2026-08-29 09:29:14 +00:00
vf : "720p" ,
source : "HDTV" ,
vc : "x264" ,
group : "KILLERS" ,
resolution : "720p" ,
"video codec" : "x264" ,
"release group" : "KILLERS" ,
2026-08-29 03:15:21 +00:00
},
2026-08-29 02:06:14 +00:00
isSample : true ,
2026-08-29 04:04:16 +00:00
mediaKind : "tv" ,
2026-08-29 01:55:54 +00:00
},
{
2026-08-29 02:06:14 +00:00
id : "sample-3" ,
2026-08-29 01:55:54 +00:00
originalName : "The.Office.US.S03E01.DVDRip.XviD-SAiNTS.avi" ,
ext : "avi" ,
2026-08-29 03:15:21 +00:00
bindings : {
n : "The Office (US)" ,
s00e00 : "S03E01" ,
t : "Gay Witch Hunt" ,
"show name" : "The Office (US)" ,
"season number" : "3" ,
"episode number" : "01" ,
"episode name" : "Gay Witch Hunt" ,
2026-08-29 09:29:14 +00:00
source : "DVDRip" ,
vc : "XviD" ,
group : "SAiNTS" ,
"video codec" : "XviD" ,
"release group" : "SAiNTS" ,
2026-08-29 03:15:21 +00:00
},
2026-08-29 02:06:14 +00:00
isSample : true ,
2026-08-29 04:04:16 +00:00
mediaKind : "tv" ,
2026-08-29 01:55:54 +00:00
},
{
2026-08-29 02:06:14 +00:00
id : "sample-4" ,
2026-08-29 01:55:54 +00:00
originalName : "random_video_file.mp4" ,
ext : "mp4" ,
bindings : null ,
2026-08-29 02:06:14 +00:00
isSample : true ,
2026-08-29 04:04:16 +00:00
mediaKind : null ,
2026-08-29 01:55:54 +00:00
},
];
2026-08-29 03:15:21 +00:00
// The user's own preferred naming scheme (their words: "I personally like {show name} -
2026-08-29 10:07:09 +00:00
// {season number}x{episode number} - {episode name}") - this is Aurora Curator's default now,
2026-08-29 03:15:21 +00:00
// not just an example. `{show name}`/`{season number}`/etc. are multi-word plain-field
2026-08-29 10:07:09 +00:00
// binding names, which work via aurora-curator-core's ExpressionFormat plain-field-name
2026-08-29 03:15:21 +00:00
// fallback (ExpressionError-free even though they aren't valid Rhai identifiers).
const SAMPLE_EXPRESSION = "{show name} - {season number}x{episode number} - {episode name}" ;
2026-08-29 02:06:14 +00:00
// The matching engine isn't ported yet, so real loaded files only have their own
// filename-derived fields to preview with (mirrors the original's plain FileNameFormatter,
// used whenever a file has no online match).
const REAL_FILE_EXPRESSION = "{name}" ;
2026-08-29 03:15:21 +00:00
// What Match alone can fill in (no episode title yet - that needs Fetch Data).
const MATCHED_EXPRESSION = "{show name} - {season number}x{episode number}" ;
// What Match + Fetch Data together can fill in.
const FETCHED_EXPRESSION = "{show name} - {season number}x{episode number} - {episode name}" ;
2026-08-29 02:06:14 +00:00
2026-08-29 04:31:16 +00:00
// Copy for each RenameAction, used in the confirmation modal's segmented selector,
// subtitle, confirm-button label, and the post-confirm status message.
const RENAME_ACTION_LABELS : Record <
RenameAction ,
{ button : string ; verb : string ; pastTense : string ; progressive : string }
> = {
move : { button : "Rename" , verb : "rename" , pastTense : "Renamed" , progressive : "Renaming…" },
copy : { button : "Copy" , verb : "copy" , pastTense : "Copied" , progressive : "Copying…" },
symlink : {
button : "Symlink" ,
verb : "create symlinks for" ,
pastTense : "Symlinked" ,
progressive : "Creating symlinks…" ,
},
};
2026-08-29 09:50:36 +00:00
// Copy for each ConflictPolicy, used in the confirmation modal's second segmented
// selector and the modal's conflict-behavior note.
const CONFLICT_POLICY_LABELS : Record < ConflictPolicy , { button : string ; description : string }> = {
skip : {
button : "Skip" ,
description : "If a computed name already exists on disk, that file is left alone and skipped." ,
},
autoNumber : {
button : "Auto-number" ,
description :
'If a computed name already exists on disk, the new one is saved alongside it as "Name (1).ext" instead (counting up until a free name is found).' ,
},
override : {
button : "Override" ,
description :
"If a computed name already exists on disk, it's replaced outright. This can't be undone — undoing this batch later can restore the source file, but not whatever got overwritten." ,
},
};
2026-08-29 10:44:12 +00:00
// Copy for each TvMetadataSource, used in the Fetch Data Settings modal's segmented
// selector and its description note.
const TV_SOURCE_LABELS : Record < TvMetadataSource , { button : string ; description : string }> = {
tmdb : {
button : "TMDB" ,
description : "Uses TheMovieDB for TV episode titles and air dates — needs the API token below." ,
},
tvmaze : {
button : "TVmaze" ,
description : "Uses TVmaze for TV episode titles and air dates — free, no API token needed at all." ,
},
};
2026-08-29 02:06:14 +00:00
function realFileToItem ( entry : FileEntry ) : Item {
return {
id : entry.path ,
originalName : entry.ext ? ` ${ entry . name } . ${ entry . ext } ` : entry . name ,
ext : entry.ext ,
bindings : { name : entry.name , ext : entry.ext },
isSample : false ,
2026-08-29 04:04:16 +00:00
mediaKind : null , // unknown until Match runs
2026-08-29 02:06:14 +00:00
};
}
2026-08-29 01:55:54 +00:00
function ListButton ({
icon ,
label ,
onClick ,
2026-08-29 03:01:19 +00:00
disabled ,
2026-08-29 01:55:54 +00:00
} : {
icon : JSX.Element ;
label : string ;
onClick : () => void ;
2026-08-29 03:01:19 +00:00
disabled? : boolean ;
2026-08-29 01:55:54 +00:00
}) {
return (
2026-08-29 03:01:19 +00:00
< button className = "list-icon-button" title = { label } onClick = { onClick } disabled = { disabled }>
2026-08-29 01:55:54 +00:00
{ icon }
</ button >
);
}
2026-08-29 02:06:14 +00:00
function LoadMenuButton ({ onFiles , onFolder } : { onFiles : () => void ; onFolder : () => void }) {
const [ open , setOpen ] = useState ( false );
const ref = useRef < HTMLDivElement >( null );
useEffect (() => {
if ( ! open ) return ;
const onOutside = ( e : MouseEvent ) => {
if ( ref . current && ! ref . current . contains ( e . target as Node )) setOpen ( false );
};
document . addEventListener ( "mousedown" , onOutside );
return () => document . removeEventListener ( "mousedown" , onOutside );
}, [ open ]);
return (
< div className = "load-menu" ref = { ref }>
< ListButton icon = {< LoadIcon />} label = "Load Files" onClick = {() => setOpen (( v ) => ! v )} />
{ open && (
< div className = "load-menu-popup" >
< button
onClick = {() => {
setOpen ( false );
onFiles ();
}}
>
Select Files …
</ button >
< button
onClick = {() => {
setOpen ( false );
onFolder ();
}}
>
Select Folder …
</ button >
</ div >
)}
</ div >
);
}
2026-08-29 01:55:54 +00:00
export default function RenamePanel() {
2026-08-29 02:06:14 +00:00
const [ expression , setExpression ] = useState ( SAMPLE_EXPRESSION );
2026-08-29 01:55:54 +00:00
const [ editingFormat , setEditingFormat ] = useState ( false );
2026-08-29 02:06:14 +00:00
const [ items , setItems ] = useState < Item [] >( SAMPLE_ITEMS );
2026-08-29 01:55:54 +00:00
const [ selected , setSelected ] = useState < string | null >( SAMPLE_ITEMS [ 0 ]. id );
const [ computedNames , setComputedNames ] = useState < Record < string , string >>({});
const [ computeError , setComputeError ] = useState < string | null >( null );
const [ status , setStatus ] = useState < string | null >( null );
2026-08-29 02:42:31 +00:00
// Non-null while the "confirm before touching disk" modal is open.
const [ renamePlan , setRenamePlan ] = useState < RenamePlanEntry [] | null >( null );
const [ renamePlanSkipped , setRenamePlanSkipped ] = useState ( 0 );
const [ renaming , setRenaming ] = useState ( false );
2026-08-29 04:31:16 +00:00
// What to actually do at each computed target - chosen in the confirmation modal,
// remembered across modal opens within the session (defaults to "move", matching
2026-08-29 10:07:09 +00:00
// Aurora Curator's original and only behavior before copy/symlink existed).
2026-08-29 04:31:16 +00:00
const [ renameAction , setRenameAction ] = useState < RenameAction >( "move" );
2026-08-29 09:50:36 +00:00
// What to do when a computed target already exists - same remembered-across-opens
2026-08-29 10:07:09 +00:00
// pattern as renameAction, defaults to "skip" (Aurora Curator's original and only behavior
2026-08-29 09:50:36 +00:00
// before auto-number/override existed).
const [ conflictPolicy , setConflictPolicy ] = useState < ConflictPolicy >( "skip" );
2026-08-29 03:01:19 +00:00
// TMDB token state for "Fetch Data". `null` = not checked yet, `false` = checked and
// nothing saved, `true` = a token is on file (the value itself never comes back to
// the frontend after saving - see Settings modal below).
const [ hasTmdbToken , setHasTmdbToken ] = useState < boolean | null >( null );
2026-08-29 10:44:12 +00:00
// Which online source Fetch Data uses for TV episodes - persisted on disk (see
// `src-tauri/src/settings.rs`), defaults to "tmdb" until loaded. Movies always use
// TMDB regardless of this, so it only ever gates the TV half of Fetch Data.
const [ tvSource , setTvSource ] = useState < TvMetadataSource >( "tmdb" );
2026-08-29 03:01:19 +00:00
const [ settingsOpen , setSettingsOpen ] = useState ( false );
const [ tmdbTokenInput , setTmdbTokenInput ] = useState ( "" );
const [ savingSettings , setSavingSettings ] = useState ( false );
const [ fetching , setFetching ] = useState ( false );
2026-08-29 03:44:38 +00:00
// Rename history / undo. `null` batches = not loaded yet (also used to show a
// loading state the first time the History panel is opened).
const [ historyOpen , setHistoryOpen ] = useState ( false );
const [ historyBatches , setHistoryBatches ] = useState < HistoryBatch [] | null >( null );
const [ undoingBatchId , setUndoingBatchId ] = useState < string | null >( null );
2026-08-29 09:39:52 +00:00
// Naming-scheme presets: up to 9 saved expressions, one per digit slot, persisted on
// disk (see `src-tauri/src/presets.rs`) and recallable with a single 1-9 keypress
// (see the keydown listener below) as well as from the Presets panel. `null` = not
// loaded yet.
const [ presets , setPresets ] = useState < Record < string , string > | null >( null );
const [ presetsOpen , setPresetsOpen ] = useState ( false );
const [ presetsBusySlot , setPresetsBusySlot ] = useState < string | null >( null );
const loadPresets = () => {
invoke < Record < string , string >>( "list_presets" )
. then ( setPresets )
. catch (() => setPresets ({}));
};
useEffect (() => {
loadPresets ();
}, []);
2026-08-29 03:01:19 +00:00
useEffect (() => {
invoke < string | null >( "get_tmdb_token" )
. then (( token ) => setHasTmdbToken ( !! token ))
. catch (() => setHasTmdbToken ( false ));
}, []);
2026-08-29 01:55:54 +00:00
2026-08-29 10:44:12 +00:00
useEffect (() => {
invoke < TvMetadataSource >( "get_tv_metadata_source" )
. then ( setTvSource )
. catch (() => setTvSource ( "tmdb" ));
}, []);
2026-08-29 02:06:14 +00:00
// Refs so the drag-and-drop listener (registered once on mount) always sees the
// latest expression/items instead of the stale values from its first render.
const expressionRef = useRef ( expression );
useEffect (() => {
expressionRef . current = expression ;
}, [ expression ]);
2026-08-29 01:55:54 +00:00
useEffect (() => {
let cancelled = false ;
Promise . all (
2026-08-29 02:06:14 +00:00
items . map ( async ( item ) => {
2026-08-29 01:55:54 +00:00
if ( ! item . bindings ) return [ item . id , null ] as const ;
2026-08-29 02:06:14 +00:00
const rendered = await invoke < string >( "render_expression" , {
expression ,
bindings : item.bindings ,
});
return [ item . id , ` ${ rendered }${ item . ext ? `. ${ item . ext } ` : "" } ` ] as const ;
2026-08-29 01:55:54 +00:00
})
)
. then (( entries ) => {
if ( cancelled ) return ;
const next : Record < string , string > = {};
for ( const [ id , name ] of entries ) {
if ( name ) next [ id ] = name ;
}
setComputedNames ( next );
setComputeError ( null );
})
. catch (( err ) => {
if ( cancelled ) return ;
setComputeError ( String ( err ));
});
return () => {
cancelled = true ;
};
2026-08-29 02:06:14 +00:00
}, [ expression , items ]);
2026-08-29 01:55:54 +00:00
const notImplemented = ( feature : string ) => {
setStatus ( ` ${ feature } isn't implemented yet — see the porting roadmap in the README.` );
window . clearTimeout (( notImplemented as any ). _t );
( notImplemented as any ). _t = window . setTimeout (() => setStatus ( null ), 3200 );
};
2026-08-29 02:06:14 +00:00
const addRealFiles = ( entries : FileEntry []) => {
if ( entries . length === 0 ) return ;
// Only auto-switch the naming scheme the first time real files replace the demo,
// and only if the user hasn't already customized it.
if ( expressionRef . current === SAMPLE_EXPRESSION ) {
setExpression ( REAL_FILE_EXPRESSION );
}
setItems (( prev ) => {
const real = prev . filter (( i ) => ! i . isSample );
const existing = new Set ( real . map (( i ) => i . id ));
const added = entries . map ( realFileToItem ). filter (( i ) => ! existing . has ( i . id ));
const next = [... real , ... added ];
if ( added . length > 0 ) setSelected ( added [ 0 ]. id );
return next ;
});
};
const loadFiles = () => {
invoke < FileEntry [] >( "pick_files" )
. then ( addRealFiles )
. catch (( err ) => setStatus ( `Couldn't open the file picker: ${ err } ` ));
};
const loadFolder = () => {
invoke < FileEntry [] >( "pick_folder" )
. then ( addRealFiles )
. catch (( err ) => setStatus ( `Couldn't open the folder picker: ${ err } ` ));
};
// OS drag-and-drop of files/folders onto the window.
useEffect (() => {
let unlisten : (() => void ) | undefined ;
let cancelled = false ;
import ( "@tauri-apps/api/webview" )
. then (({ getCurrentWebview }) =>
getCurrentWebview (). onDragDropEvent (( event ) => {
if ( event . payload . type === "drop" ) {
invoke < FileEntry [] >( "expand_dropped_paths" , { paths : event.payload.paths })
. then ( addRealFiles )
. catch (( err ) => setStatus ( `Couldn't read dropped files: ${ err } ` ));
}
})
)
. then (( fn ) => {
if ( cancelled ) fn ();
else unlisten = fn ;
})
. catch (() => {
// Not running inside Tauri (e.g. a plain browser preview) - drag-and-drop
// just won't be available there; the Load button still works via invoke.
});
return () => {
cancelled = true ;
unlisten ? .();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
2026-08-29 10:07:09 +00:00
// Local, filename-based season/episode/movie detection (aurora-curator-core's `matching`
2026-08-29 04:04:16 +00:00
// module). Tries `guess_episode` first; when a filename carries no season/episode
// marker, falls back to `guess_movie` (a release name is either a TV episode or a
2026-08-29 10:07:09 +00:00
// movie, never both - see the doc comment on `guess_movie` in aurora-curator-core). Populates
2026-08-29 04:04:16 +00:00
// TV bindings (`{n}`/`{s00e00}`/`{season}`/`{episode}`/etc.) or movie bindings
// (`{n}`/`{y}`/etc.) accordingly - no network calls, no metadata lookup, so it's
// distinct from (and much cheaper than) "Fetch Data" online matching.
2026-08-29 02:42:31 +00:00
const runMatching = async () => {
const targets = items . filter (( i ) => ! i . isSample );
if ( targets . length === 0 ) {
2026-08-29 04:04:16 +00:00
setStatus ( "Load some files first, then Match will scan their names for season/episode or movie info." );
2026-08-29 02:42:31 +00:00
window . clearTimeout (( runMatching as any ). _t );
( runMatching as any ). _t = window . setTimeout (() => setStatus ( null ), 3200 );
return ;
}
2026-08-29 09:29:14 +00:00
const results : Array < readonly [ string , MatchGuess | null , ReleaseInfo | null ] > = await Promise . all (
targets . map ( async ( item ) : Promise < readonly [ string , MatchGuess | null , ReleaseInfo | null ] > => {
// Release tags (resolution/source/codecs/group) are independent of TV-vs-movie,
// so kick that guess off alongside whichever of the two below applies.
const releasePromise = invoke < ReleaseInfo | null >( "guess_release_info" , {
filename : item.originalName ,
}). catch (() => null );
let mediaGuess : MatchGuess | null = null ;
2026-08-29 02:42:31 +00:00
try {
2026-08-29 04:04:16 +00:00
const episode = await invoke < EpisodeGuess | null >( "guess_episode" , {
2026-08-29 02:42:31 +00:00
filename : item.originalName ,
});
2026-08-29 09:29:14 +00:00
if ( episode ) mediaGuess = { kind : "tv" , episode };
2026-08-29 02:42:31 +00:00
} catch {
2026-08-29 04:04:16 +00:00
// Fall through and try a movie guess instead.
2026-08-29 02:42:31 +00:00
}
2026-08-29 09:29:14 +00:00
if ( ! mediaGuess ) {
try {
const movie = await invoke < MovieGuess | null >( "guess_movie" , {
filename : item.originalName ,
});
if ( movie ) mediaGuess = { kind : "movie" , movie };
} catch {
// Neither guess worked - leave this item unmatched.
}
2026-08-29 04:04:16 +00:00
}
2026-08-29 09:29:14 +00:00
return [ item . id , mediaGuess , await releasePromise ];
2026-08-29 02:42:31 +00:00
})
);
2026-08-29 09:29:14 +00:00
const guesses = new Map ( results . map (([ id , guess ]) => [ id , guess ] as const ));
const releaseGuesses = new Map ( results . map (([ id , , release ]) => [ id , release ] as const ));
2026-08-29 02:42:31 +00:00
// Count matches from the results directly, not inside the setItems updater below:
// React doesn't run a functional updater synchronously, so a counter mutated in
// there wouldn't be ready yet for the status message/auto-upgrade check right after.
2026-08-29 04:04:16 +00:00
const matchedTv = results . filter (([, guess ]) => guess ? . kind === "tv" ). length ;
const matchedMovie = results . filter (([, guess ]) => guess ? . kind === "movie" ). length ;
const matched = matchedTv + matchedMovie ;
2026-08-29 09:29:14 +00:00
const tagged = results . filter (([, , release ]) => release !== null ). length ;
2026-08-29 02:42:31 +00:00
setItems (( prev ) =>
prev . map (( item ) => {
const guess = guesses . get ( item . id );
2026-08-29 09:29:14 +00:00
const release = releaseGuesses . get ( item . id );
// Release-tag fields apply on top of whatever the TV/movie guess below
// contributes (or nothing at all, for a file with no season/episode or movie
// title recognized but a quality tag in its name).
const releaseBindings : Record < string , string > | null = release
? {
...( release . resolution ? { vf : release.resolution , resolution : release.resolution } : {}),
...( release . source ? { source : release.source } : {}),
...( release . videoCodec ? { vc : release.videoCodec , "video codec" : release . videoCodec } : {}),
...( release . audioCodec ? { ac : release.audioCodec , "audio codec" : release . audioCodec } : {}),
...( release . group ? { group : release.group , "release group" : release . group } : {}),
}
: null ;
if ( ! guess ) {
return releaseBindings ? { ... item , bindings : { ... item . bindings , ... releaseBindings } } : item ;
}
2026-08-29 04:04:16 +00:00
if ( guess . kind === "tv" ) {
const { episode } = guess ;
const seriesName = episode . seriesName || item . bindings ? . name || item . originalName ;
return {
... item ,
mediaKind : "tv" ,
bindings : {
... item . bindings ,
// FileBot-style short names, for compatibility with expressions that use them.
n : seriesName ,
s00e00 : `S ${ pad2 ( episode . season ) } E ${ pad2 ( episode . episode ) } ` ,
season : String ( episode . season ),
episode : String ( episode . episode ),
// Human-readable aliases (this is what the default naming scheme uses).
"show name" : seriesName ,
"season number" : String ( episode . season ),
"episode number" : pad2 ( episode . episode ),
2026-08-29 09:29:14 +00:00
... releaseBindings ,
2026-08-29 04:04:16 +00:00
},
};
}
const { movie } = guess ;
const title = movie . title || item . bindings ? . name || item . originalName ;
2026-08-29 02:42:31 +00:00
return {
... item ,
2026-08-29 04:04:16 +00:00
mediaKind : "movie" ,
2026-08-29 02:42:31 +00:00
bindings : {
... item . bindings ,
2026-08-29 04:04:16 +00:00
// FileBot-style short name, for compatibility (same `{n}` field TV uses).
n : title ,
...( movie . year ? { y : String ( movie . year ) } : {}),
// Human-readable aliases.
"movie name" : title ,
...( movie . year ? { year : String ( movie . year ) } : {}),
2026-08-29 09:29:14 +00:00
... releaseBindings ,
2026-08-29 02:42:31 +00:00
},
};
})
);
// Showcase the newly-populated fields, but only the first time and only if the
// user hasn't already customized the naming scheme away from the plain-filename
2026-08-29 04:04:16 +00:00
// default. Movies deliberately don't drive this auto-upgrade - a single "best
// expression" heuristic doesn't generalize across a batch mixing TV and movies.
if ( expressionRef . current === REAL_FILE_EXPRESSION && matchedTv > 0 ) {
2026-08-29 03:15:21 +00:00
setExpression ( MATCHED_EXPRESSION );
2026-08-29 02:42:31 +00:00
}
setStatus (
matched > 0
2026-08-29 09:29:14 +00:00
? `Matched ${ matched } of ${ targets . length } file ${ targets . length === 1 ? "" : "s" } ( ${ matchedTv } TV, ${ matchedMovie } movie ${ matchedMovie === 1 ? "" : "s" }${ tagged > 0 ? `, ${ tagged } with release tags` : "" } ; filename-based, local only).`
2026-08-29 04:04:16 +00:00
: "No season/episode or movie title recognized in the loaded filenames."
2026-08-29 02:42:31 +00:00
);
window . clearTimeout (( runMatching as any ). _t );
( runMatching as any ). _t = window . setTimeout (() => setStatus ( null ), 4000 );
};
// Step 1 of Rename: build the plan (old path -> new filename) from the live preview
// that's already on screen, and open the confirmation modal. Nothing on disk is
// touched here - only `confirmRename` below actually calls the Rust rename command,
// and only after the user explicitly approves the plan they can see.
const prepareRename = () => {
const targets = items . filter (( i ) => ! i . isSample );
if ( targets . length === 0 ) {
setStatus ( "Load some real files first — Rename needs files on disk to work with." );
window . clearTimeout (( prepareRename as any ). _t );
( prepareRename as any ). _t = window . setTimeout (() => setStatus ( null ), 3200 );
return ;
}
const plan : RenamePlanEntry [] = [];
let skippedNoPreview = 0 ;
for ( const item of targets ) {
const newName = computedNames [ item . id ];
if ( ! newName ) {
skippedNoPreview += 1 ;
continue ;
}
if ( newName === item . originalName ) continue ; // already correct, nothing to do
plan . push ({ id : item.id , source : item.id , currentName : item.originalName , newName });
}
if ( plan . length === 0 ) {
setStatus (
skippedNoPreview > 0
? "Nothing to rename yet — the naming scheme couldn't be computed for those files (check the naming scheme for errors)."
: "Nothing to rename — the computed names already match the current filenames."
);
window . clearTimeout (( prepareRename as any ). _t );
( prepareRename as any ). _t = window . setTimeout (() => setStatus ( null ), 4000 );
return ;
}
setRenamePlanSkipped ( skippedNoPreview );
setRenamePlan ( plan );
};
2026-08-29 04:31:16 +00:00
// Step 2: the user approved the plan (and chose an action) shown in the modal -
// actually run it on disk via the Rust `execute_rename` command, then reconcile
// local state and report the outcome. Only "move" relocates the source file, so only
// "move" updates a loaded item's own id/name/bindings to point at the new path -
// "copy"/"symlink" leave the source exactly where it was and produce an additional
2026-08-29 10:07:09 +00:00
// file elsewhere that Aurora Curator doesn't separately track as a loaded item.
2026-08-29 02:42:31 +00:00
const confirmRename = async () => {
2026-08-29 04:12:35 +00:00
if ( ! renamePlan || renamePlan . length === 0 ) return ;
2026-08-29 02:42:31 +00:00
setRenaming ( true );
try {
const results = await invoke < RenameResult [] >( "execute_rename" , {
items : renamePlan.map (( p ) => ({ source : p.source , newName : p.newName })),
2026-08-29 04:31:16 +00:00
action : renameAction ,
2026-08-29 09:50:36 +00:00
conflict : conflictPolicy ,
2026-08-29 02:42:31 +00:00
});
const bySource = new Map ( results . map (( r ) => [ r . source , r ] as const ));
// Compute counts from `results` directly, not inside the setItems updater below:
// React doesn't run a functional updater synchronously, so counters mutated in
// there aren't ready yet when building the status message right after (this bit
// runMatching earlier - same fix applies here).
2026-08-29 04:31:16 +00:00
const succeeded = results . filter (( r ) => r . outcome . status === "renamed" ). length ;
2026-08-29 02:42:31 +00:00
const skipped = results . filter (( r ) => r . outcome . status === "skippedConflict" ). length ;
const failed = results . filter (( r ) => r . outcome . status === "failed" ). length ;
2026-08-29 04:31:16 +00:00
if ( renameAction === "move" ) {
setItems (( prev ) =>
prev . map (( item ) => {
const result = bySource . get ( item . id );
if ( ! result ) return item ;
2026-08-29 02:42:31 +00:00
2026-08-29 04:31:16 +00:00
if ( result . outcome . status === "renamed" ) {
const newFileName = fileNameFromPath ( result . outcome . newPath );
const [ base , ext ] = splitExt ( newFileName );
return {
... item ,
id : result.outcome.newPath ,
originalName : newFileName ,
ext ,
bindings : item.bindings ? { ... item . bindings , name : base , ext } : item . bindings ,
};
}
return item ;
})
);
2026-08-29 02:42:31 +00:00
2026-08-29 04:31:16 +00:00
// Keep the current selection pointing at the same logical file if its id
// (path) changed underneath it.
setSelected (( prevSelected ) => {
if ( ! prevSelected ) return prevSelected ;
const result = bySource . get ( prevSelected );
return result ? . outcome . status === "renamed" ? result.outcome.newPath : prevSelected ;
});
}
2026-08-29 02:42:31 +00:00
2026-08-29 04:31:16 +00:00
const parts = [ ` ${ RENAME_ACTION_LABELS [ renameAction ]. pastTense } ${ succeeded } file ${ succeeded === 1 ? "" : "s" } ` ];
2026-08-29 02:42:31 +00:00
if ( skipped > 0 ) parts . push ( ` ${ skipped } skipped (name already exists)` );
if ( failed > 0 ) parts . push ( ` ${ failed } failed` );
setStatus ( parts . join ( ", " ) + "." );
} catch ( err ) {
setStatus ( `Rename failed: ${ err } ` );
} finally {
setRenaming ( false );
setRenamePlan ( null );
window . clearTimeout (( confirmRename as any ). _t );
( confirmRename as any ). _t = window . setTimeout (() => setStatus ( null ), 5000 );
}
};
const cancelRename = () => {
if ( renaming ) return ;
setRenamePlan ( null );
};
2026-08-29 04:12:35 +00:00
// Per-item exclude, inside the confirmation modal itself: drops one row from the
// plan without closing the modal or having to cancel and re-select beforehand. Every
// bit of the modal's copy (file counts, the confirm button's label) reads directly
// from `renamePlan.length`, so removing a row here updates all of it for free.
const excludeFromPlan = ( id : string ) => {
if ( renaming ) return ;
setRenamePlan (( prev ) => ( prev ? prev . filter (( p ) => p . id !== id ) : prev ));
};
2026-08-29 10:07:09 +00:00
// Rename history / undo - Aurora Curator's only safety net beyond the confirm-before-execute
2026-08-29 03:44:38 +00:00
// step, since a filesystem rename has no OS-level undo. `execute_rename` records a
// batch of every successfully-renamed file after each Rename; this panel lists those
// batches and can revert one back to its original paths.
const openHistory = () => {
setHistoryOpen ( true );
invoke < HistoryBatch [] >( "list_rename_history" )
. then ( setHistoryBatches )
. catch (( err ) => {
setHistoryBatches ([]);
setStatus ( `Couldn't load rename history: ${ err } ` );
});
};
const closeHistory = () => {
if ( undoingBatchId ) return ;
setHistoryOpen ( false );
};
const undoBatch = async ( batch : HistoryBatch ) => {
setUndoingBatchId ( batch . id );
try {
const results = await invoke < RevertResult [] >( "undo_rename_batch" , { batchId : batch.id });
// Compute counts from `results` directly, not inside the setItems updater below -
// same rule as runMatching/confirmRename/runFetch above (React doesn't run a
// functional updater synchronously, so a counter mutated in there wouldn't be
// ready yet when building the status message right after).
const bySource = new Map ( results . map (( r ) => [ r . source , r ] as const ));
const reverted = results . filter (( r ) => r . outcome . status === "reverted" ). length ;
const skipped = results . filter (( r ) => r . outcome . status === "skippedConflict" ). length ;
const failed = results . filter (( r ) => r . outcome . status === "failed" ). length ;
// Reconcile any currently-loaded item that was part of this batch back to its
// pre-rename path/filename - mirrors confirmRename's post-rename reconciliation.
setItems (( prev ) =>
prev . map (( item ) => {
const result = bySource . get ( item . id );
if ( ! result || result . outcome . status !== "reverted" ) return item ;
const restoredFileName = fileNameFromPath ( result . outcome . restoredPath );
const [ base , ext ] = splitExt ( restoredFileName );
return {
... item ,
id : result.outcome.restoredPath ,
originalName : restoredFileName ,
ext ,
bindings : item.bindings ? { ... item . bindings , name : base , ext } : item . bindings ,
};
})
);
setSelected (( prevSelected ) => {
if ( ! prevSelected ) return prevSelected ;
const result = bySource . get ( prevSelected );
return result ? . outcome . status === "reverted" ? result.outcome.restoredPath : prevSelected ;
});
setHistoryBatches (( prev ) => ( prev ? prev . filter (( b ) => b . id !== batch . id ) : prev ));
const parts = [ `Reverted ${ reverted } file ${ reverted === 1 ? "" : "s" } ` ];
if ( skipped > 0 ) parts . push ( ` ${ skipped } skipped (something else is there now)` );
if ( failed > 0 ) parts . push ( ` ${ failed } failed` );
setStatus ( parts . join ( ", " ) + "." );
} catch ( err ) {
setStatus ( `Undo failed: ${ err } ` );
} finally {
setUndoingBatchId ( null );
window . clearTimeout (( undoBatch as any ). _t );
( undoBatch as any ). _t = window . setTimeout (() => setStatus ( null ), 5000 );
}
};
2026-08-29 03:01:19 +00:00
const openSettings = () => {
setTmdbTokenInput ( "" );
setSettingsOpen ( true );
};
const saveTmdbToken = async () => {
const trimmed = tmdbTokenInput . trim ();
if ( ! trimmed ) {
// Nothing typed - just close, leaving whatever was already saved untouched.
setSettingsOpen ( false );
return ;
}
setSavingSettings ( true );
try {
await invoke ( "save_tmdb_token" , { token : trimmed });
setHasTmdbToken ( true );
setTmdbTokenInput ( "" );
setSettingsOpen ( false );
setStatus ( "TMDB token saved." );
window . clearTimeout (( saveTmdbToken as any ). _t );
( saveTmdbToken as any ). _t = window . setTimeout (() => setStatus ( null ), 3000 );
} catch ( err ) {
setStatus ( `Couldn't save the token: ${ err } ` );
} finally {
setSavingSettings ( false );
}
};
const clearTmdbToken = async () => {
setSavingSettings ( true );
try {
await invoke ( "save_tmdb_token" , { token : "" });
setHasTmdbToken ( false );
setTmdbTokenInput ( "" );
setStatus ( "TMDB token removed." );
window . clearTimeout (( clearTmdbToken as any ). _t );
( clearTmdbToken as any ). _t = window . setTimeout (() => setStatus ( null ), 3000 );
} catch ( err ) {
setStatus ( `Couldn't remove the token: ${ err } ` );
} finally {
setSavingSettings ( false );
}
};
2026-08-29 10:44:12 +00:00
// Persists the TV-source choice (see Fetch Data Settings modal below). Applied
// immediately in the UI regardless of whether the save round-trip succeeds, same as
// the rest of this app's settings - a failed disk write shouldn't block using the
// app for the rest of the session, just mean the choice doesn't survive a restart.
const saveTvSource = async ( source : TvMetadataSource ) => {
setTvSource ( source );
try {
await invoke ( "save_tv_metadata_source" , { source });
} catch ( err ) {
setStatus ( `Couldn't save the TV source setting: ${ err } ` );
window . clearTimeout (( saveTvSource as any ). _t );
( saveTvSource as any ). _t = window . setTimeout (() => setStatus ( null ), 4000 );
}
};
// "Fetch Data": resolve each already-Matched file's local guess against real online
2026-08-29 04:04:16 +00:00
// metadata. Requires Match to have already populated either a series name + season/
// episode (TV) or a movie title (movie) - that's the search query - so this is
2026-08-29 10:44:12 +00:00
// deliberately layered on top of local matching, not a replacement for it. TV items
// resolve against whichever source `tvSource` currently picks (TMDB or TVmaze); movie
// items always resolve against TMDB. Both run via separate Tauri commands,
// concurrently, and merge back into a single set of counts/status message.
2026-08-29 03:01:19 +00:00
const runFetch = async () => {
2026-08-29 04:04:16 +00:00
const tvTargets = items . filter (
( i ) => ! i . isSample && i . mediaKind === "tv" && i . bindings ? . n && i . bindings ? . season && i . bindings ? . episode
);
const movieTargets = items . filter (( i ) => ! i . isSample && i . mediaKind === "movie" && i . bindings ? . n );
const targetCount = tvTargets . length + movieTargets . length ;
if ( targetCount === 0 ) {
setStatus ( "Run Match first — Fetch Data needs a guessed series/episode or movie title to look up." );
2026-08-29 03:01:19 +00:00
window . clearTimeout (( runFetch as any ). _t );
( runFetch as any ). _t = window . setTimeout (() => setStatus ( null ), 4000 );
return ;
}
2026-08-29 10:44:12 +00:00
// TVmaze needs no token at all, so a TMDB token is only actually required when
// there are movie targets (always TMDB) or TV targets while TMDB is the chosen
// source - a TV-only batch on TVmaze can run with no TMDB token configured at all.
const needsTmdbToken = movieTargets . length > 0 || ( tvTargets . length > 0 && tvSource === "tmdb" );
if ( needsTmdbToken && hasTmdbToken === false ) {
setStatus ( "Add a free TMDB API token in Settings first, then Fetch Data will work." );
setSettingsOpen ( true );
window . clearTimeout (( runFetch as any ). _t );
( runFetch as any ). _t = window . setTimeout (() => setStatus ( null ), 4000 );
return ;
}
2026-08-29 03:01:19 +00:00
setFetching ( true );
try {
2026-08-29 04:04:16 +00:00
const [ tvResults , movieResults ] = await Promise . all ([
tvTargets . length > 0
? invoke < FetchResult [] >( "fetch_metadata" , {
items : tvTargets.map (
( item ) : FetchQuery => ({
id : item.id ,
seriesQuery : item.bindings ! . n ,
season : Number ( item . bindings ! . season ),
episode : Number ( item . bindings ! . episode ),
})
),
})
: Promise . resolve < FetchResult [] >([]),
movieTargets . length > 0
? invoke < MovieFetchResult [] >( "fetch_movie_metadata" , {
items : movieTargets.map (
( item ) : MovieFetchQuery => ({
id : item.id ,
titleQuery : item.bindings ! . n ,
year : item.bindings ! . y ? Number ( item . bindings ! . y ) : null ,
})
),
})
: Promise . resolve < MovieFetchResult [] >([]),
]);
2026-08-29 03:01:19 +00:00
2026-08-29 04:04:16 +00:00
const tvById = new Map ( tvResults . map (( r ) => [ r . id , r ] as const ));
const movieById = new Map ( movieResults . map (( r ) => [ r . id , r ] as const ));
// Compute counts from the raw results directly, not inside the setItems updater
// below - same rule as runMatching/confirmRename above (React doesn't run a
// functional updater synchronously, so a counter mutated in there wouldn't be
// ready yet).
const found =
tvResults . filter (( r ) => r . outcome . status === "found" ). length +
movieResults . filter (( r ) => r . outcome . status === "found" ). length ;
const notFound =
tvResults . filter (( r ) => r . outcome . status === "notFound" ). length +
movieResults . filter (( r ) => r . outcome . status === "notFound" ). length ;
const failed =
tvResults . filter (( r ) => r . outcome . status === "failed" ). length +
movieResults . filter (( r ) => r . outcome . status === "failed" ). length ;
2026-08-29 03:01:19 +00:00
setItems (( prev ) =>
prev . map (( item ) => {
2026-08-29 04:04:16 +00:00
const tvResult = tvById . get ( item . id );
if ( tvResult && tvResult . outcome . status === "found" ) {
const { seriesName , episodeTitle , airDate } = tvResult . outcome ;
return {
... item ,
bindings : {
... item . bindings ,
// FileBot-style short names, for compatibility.
n : seriesName ,
t : episodeTitle ,
...( airDate ? { airdate : airDate } : {}),
// Human-readable aliases.
"show name" : seriesName ,
"episode name" : episodeTitle ,
},
};
}
const movieResult = movieById . get ( item . id );
if ( movieResult && movieResult . outcome . status === "found" ) {
const { title , releaseDate } = movieResult . outcome ;
// Only overwrite the locally-guessed year if Match didn't already find one
// in the filename itself - a filename-carried year is usually more precise
// than TMDB's release date for disambiguating remakes.
const guessedYear = item . bindings ? . y ;
const releaseYear = releaseDate ? . slice ( 0 , 4 );
return {
... item ,
bindings : {
... item . bindings ,
// FileBot-style short name, for compatibility (same `{n}` field TV uses).
n : title ,
// Reuses the same `{airdate}` field TV uses - matches FileBot's own
// unified binding, which covers both episode air dates and movie
// release dates.
...( releaseDate ? { airdate : releaseDate } : {}),
...( ! guessedYear && releaseYear ? { y : releaseYear , year : releaseYear } : {}),
// Human-readable alias.
"movie name" : title ,
},
};
}
return item ;
2026-08-29 03:01:19 +00:00
})
);
// Showcase the episode title field, but only the first time and only if the user
// hasn't already customized the naming scheme away from the Match-set default.
2026-08-29 03:15:21 +00:00
if ( expressionRef . current === MATCHED_EXPRESSION && found > 0 ) {
setExpression ( FETCHED_EXPRESSION );
2026-08-29 03:01:19 +00:00
}
2026-08-29 04:04:16 +00:00
const parts = [ `Fetched real metadata for ${ found } of ${ targetCount } file ${ targetCount === 1 ? "" : "s" } ` ];
2026-08-29 10:44:12 +00:00
if ( notFound > 0 ) parts . push ( ` ${ notFound } not found` );
2026-08-29 03:01:19 +00:00
if ( failed > 0 ) parts . push ( ` ${ failed } failed` );
setStatus ( parts . join ( ", " ) + "." );
} catch ( err ) {
setStatus ( `Fetch failed: ${ err } ` );
} finally {
setFetching ( false );
window . clearTimeout (( runFetch as any ). _t );
( runFetch as any ). _t = window . setTimeout (() => setStatus ( null ), 5000 );
}
};
2026-08-29 02:06:14 +00:00
const removeSelected = () => {
if ( ! selected ) return notImplemented ( "Excluding items" );
setItems (( prev ) => {
const idx = prev . findIndex (( i ) => i . id === selected );
const next = prev . filter (( i ) => i . id !== selected );
const fallback = next [ Math . min ( idx , next . length - 1 )];
setSelected ( fallback ? fallback.id : null );
return next ;
});
};
const clearAll = () => {
setItems ([]);
setSelected ( null );
};
2026-08-29 09:39:52 +00:00
// "Align Up"/"Align Down": swaps the selected file with its neighbor in `items`.
// Both list panes render the same underlying array in the same order (that's how a
// row in Original Files stays paired with its New Names preview), so this one
// handler reorders both panes at once - matching the original FileBot's "align"
// buttons, used to manually line files up when local matching gets them out of order.
const moveSelected = ( direction : "up" | "down" ) => {
if ( ! selected ) {
setStatus ( "Select a file first, then Align Up/Down moves it within the list." );
window . clearTimeout (( moveSelected as any ). _t );
( moveSelected as any ). _t = window . setTimeout (() => setStatus ( null ), 3200 );
return ;
}
setItems (( prev ) => {
const idx = prev . findIndex (( i ) => i . id === selected );
if ( idx === - 1 ) return prev ;
const swapWith = direction === "up" ? idx - 1 : idx + 1 ;
if ( swapWith < 0 || swapWith >= prev . length ) return prev ; // already at that edge
const next = [... prev ];
[ next [ idx ], next [ swapWith ]] = [ next [ swapWith ], next [ idx ]];
return next ;
});
};
// Presets panel + 1-9 hotkeys. Applying a preset just swaps the naming-scheme
// expression (same as typing a new one into the format editor) - it doesn't touch
// `items` at all, so the live preview recomputes for whatever's already loaded.
const openPresets = () => {
setPresetsOpen ( true );
loadPresets ();
};
const closePresets = () => {
if ( presetsBusySlot ) return ;
setPresetsOpen ( false );
};
const applyPreset = ( slot : string ) => {
const value = presets ? .[ slot ];
if ( ! value ) return ;
setExpression ( value );
setStatus ( `Applied preset ${ slot } .` );
window . clearTimeout (( applyPreset as any ). _t );
( applyPreset as any ). _t = window . setTimeout (() => setStatus ( null ), 2500 );
};
const saveCurrentAsPreset = async ( slot : string ) => {
setPresetsBusySlot ( slot );
try {
await invoke ( "save_preset" , { slot , expression });
setPresets (( prev ) => ({ ...( prev ?? {}), [ slot ] : expression }));
} catch ( err ) {
setStatus ( `Couldn't save preset ${ slot } : ${ err } ` );
} finally {
setPresetsBusySlot ( null );
}
};
const deletePreset = async ( slot : string ) => {
setPresetsBusySlot ( slot );
try {
await invoke ( "delete_preset" , { slot });
setPresets (( prev ) => {
if ( ! prev ) return prev ;
const { [ slot ] : _removed , ... rest } = prev ;
return rest ;
});
} catch ( err ) {
setStatus ( `Couldn't clear preset ${ slot } : ${ err } ` );
} finally {
setPresetsBusySlot ( null );
}
};
// Global 1-9 hotkeys, matching the original's preset shortcuts. Ignored while any
// modal is open or while the user is typing in a text field (the naming-scheme
// input itself is the obvious case - typing "5" into it shouldn't jump presets).
useEffect (() => {
const onKeyDown = ( e : KeyboardEvent ) => {
if ( ! /^[1-9]$/ . test ( e . key )) return ;
if ( e . metaKey || e . ctrlKey || e . altKey ) return ;
const target = e . target as HTMLElement | null ;
if ( target && ( target . tagName === "INPUT" || target . tagName === "TEXTAREA" || target . isContentEditable )) {
return ;
}
if ( renamePlan || settingsOpen || historyOpen || presetsOpen ) return ;
applyPreset ( e . key );
};
window . addEventListener ( "keydown" , onKeyDown );
return () => window . removeEventListener ( "keydown" , onKeyDown );
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ presets , renamePlan , settingsOpen , historyOpen , presetsOpen ]);
2026-08-29 02:06:14 +00:00
const matchedCount = useMemo (() => items . filter (( i ) => i . bindings ). length , [ items ]);
const renderOriginalRow = ( item : Item ) => (
< Row
key = { item . id }
selected = { selected === item . id }
muted = { ! item . bindings }
onClick = {() => setSelected ( item . id )}
text = { item . originalName }
badge = { item . ext }
/>
);
const renderNameRow = ( item : Item ) => {
const name = computedNames [ item . id ];
const [ base , ext ] = name ? splitExt ( name ) : [ null , item . ext ];
return (
< Row
key = { item . id }
selected = { selected === item . id }
muted = { ! item . bindings }
onClick = {() => setSelected ( item . id )}
text = { item . bindings ? base ?? "…" : "No data" }
badge = { ext }
/>
);
};
2026-08-29 01:55:54 +00:00
return (
< div className = "rename-panel" >
< div className = "rename-lists" >
< ListCard
title = "Original Files"
2026-08-29 02:06:14 +00:00
items = { items }
renderRow = { renderOriginalRow }
emptyHint = "Drop files or folders here, or click Load"
2026-08-29 01:55:54 +00:00
buttonRow = {
<>
2026-08-29 09:39:52 +00:00
< ListButton icon = {< DownIcon />} label = "Align Down" onClick = {() => moveSelected ( "down" )} />
< ListButton icon = {< UpIcon />} label = "Align Up" onClick = {() => moveSelected ( "up" )} />
2026-08-29 02:06:14 +00:00
< LoadMenuButton onFiles = { loadFiles } onFolder = { loadFolder } />
2026-08-29 01:55:54 +00:00
< span className = "list-button-gap" />
2026-08-29 02:06:14 +00:00
< ListButton icon = {< RemoveIcon />} label = "Exclude Selected" onClick = { removeSelected } />
< ListButton icon = {< ClearIcon />} label = "Clear" onClick = { clearAll } />
2026-08-29 03:44:38 +00:00
< ListButton icon = {< HistoryIcon />} label = "History" onClick = { openHistory } />
2026-08-29 09:39:52 +00:00
< ListButton icon = {< PresetsIcon />} label = "Presets" onClick = { openPresets } />
2026-08-29 01:55:54 +00:00
</>
}
/>
< div className = "rename-actions" >
2026-08-29 02:42:31 +00:00
< button className = "action-button" onClick = { runMatching }>
2026-08-29 01:55:54 +00:00
Match
</ button >
2026-08-29 02:42:31 +00:00
< button className = "action-button primary" onClick = { prepareRename }>
2026-08-29 01:55:54 +00:00
Rename
</ button >
</ div >
< ListCard
title = "New Names"
2026-08-29 02:06:14 +00:00
items = { items }
renderRow = { renderNameRow }
emptyHint = "Naming-scheme preview appears here"
2026-08-29 01:55:54 +00:00
buttonRow = {
<>
2026-08-29 09:39:52 +00:00
< ListButton icon = {< DownIcon />} label = "Align Down" onClick = {() => moveSelected ( "down" )} />
< ListButton icon = {< UpIcon />} label = "Align Up" onClick = {() => moveSelected ( "up" )} />
2026-08-29 02:06:14 +00:00
< LoadMenuButton onFiles = { loadFiles } onFolder = { loadFolder } />
2026-08-29 01:55:54 +00:00
< span className = "list-button-gap" />
2026-08-29 03:01:19 +00:00
< ListButton icon = {< FetchIcon />} label = "Fetch Data" onClick = { runFetch } disabled = { fetching } />
2026-08-29 10:44:12 +00:00
< ListButton icon = {< KeyIcon />} label = "Fetch Data Settings" onClick = { openSettings } />
2026-08-29 01:55:54 +00:00
< ListButton icon = {< GearIcon />} label = "Naming Scheme" onClick = {() => setEditingFormat (( v ) => ! v )} />
</>
}
/>
</ div >
{ editingFormat && (
< div className = "format-editor" >
< label htmlFor = "expression" > Naming scheme </ label >
< input
id = "expression"
value = { expression }
spellCheck = { false }
onChange = {( e ) => setExpression ( e . target . value )}
/>
{ computeError && < p className = "format-error" >{ computeError }</ p >}
< p className = "format-hint" >
2026-08-29 03:15:21 +00:00
This field is fully yours to edit — type any combination of literal text and { " " }
< code >{ "{field}" }</ code > placeholders and the preview updates live against { matchedCount } of { " " }
{ items . length } file { items . length === 1 ? "" : "s" } ( the real Rust naming - expression engine , through
Tauri ). Every field below works as a plain < code >{ "{field name}" }</ code >, spaces and all — no coding
required — plus the whole thing also accepts real { " " }
< a href = "https://rhai.rs/" target = "_blank" rel = "noreferrer" >
Rhai
</ a >{ " " }
script for anyone who wants it ( e . g . < code >{ "{show name.to_upper()}" }</ code >).
</ p >
< div className = "format-fields" >
< div className = "format-fields-group" >
< span className = "format-fields-group-title" > Always available </ span >
< code >{ "{name}" }</ code >
< code >{ "{ext}" }</ code >
</ div >
< div className = "format-fields-group" >
< span className = "format-fields-group-title" >
2026-08-29 04:04:16 +00:00
TV , after < strong > Match </ strong > ( local , offline )
2026-08-29 03:15:21 +00:00
</ span >
< code >{ "{show name}" }</ code >
< code >{ "{season number}" }</ code >
< code >{ "{episode number}" }</ code >
</ div >
< div className = "format-fields-group" >
< span className = "format-fields-group-title" >
2026-08-29 04:04:16 +00:00
TV , after < strong > Fetch Data </ strong > ( real TMDB metadata )
2026-08-29 03:15:21 +00:00
</ span >
< code >{ "{episode name}" }</ code >
< code >{ "{airdate}" }</ code >
</ div >
2026-08-29 04:04:16 +00:00
< div className = "format-fields-group" >
< span className = "format-fields-group-title" >
Movies , after < strong > Match </ strong > or < strong > Fetch Data </ strong >
</ span >
< code >{ "{movie name}" }</ code >
< code >{ "{year}" }</ code >
< code >{ "{airdate}" }</ code >
</ div >
2026-08-29 09:29:14 +00:00
< div className = "format-fields-group" >
< span className = "format-fields-group-title" >
Release tags , after < strong > Match </ strong > ( TV or movies , local , offline )
</ span >
< code >{ "{resolution}" }</ code >
< code >{ "{source}" }</ code >
< code >{ "{video codec}" }</ code >
< code >{ "{audio codec}" }</ code >
< code >{ "{release group}" }</ code >
</ div >
2026-08-29 03:15:21 +00:00
</ div >
< p className = "format-hint" >
2026-08-29 09:29:14 +00:00
Match scans filenames locally for season / episode or movie title / year patterns , plus release - tag text
like resolution , source ( BluRay / HDTV / WEB - DL / … ), video / audio codec , and release group — none of this
2026-08-29 10:07:09 +00:00
needs a network call , since it 's all parsed straight from the filename itself (Aurora Curator has no video/audio
2026-08-29 09:29:14 +00:00
file prober, so a tag that isn' t in the filename text just won 't be available). Fetch Data resolves the
2026-08-29 10:44:12 +00:00
season/episode or movie guess against real online metadata for the real title, episode title, and
air/release date. TV episodes can come from either TMDB or TVmaze — pick which in{" "}
<strong>Fetch Data Settings</strong> (TVmaze is free and needs no token at all); movies always use TMDB
and need a free API token set there first. A batch can
2026-08-29 09:29:14 +00:00
freely mix TV and movie files — each file only fills in the fields that apply to it. FileBot' s own short
field names (< code >{ "{n}" }</ code >, < code >{ "{s00e00}" }</ code >, < code >{ "{t}" }</ code >, < code >{ "{y}" }</ code >,
< code >{ "{vf}" }</ code >, < code >{ "{vc}" }</ code >, < code >{ "{ac}" }</ code >, < code >{ "{group}" }</ code >, etc .) still
2026-08-29 04:04:16 +00:00
work too , side by side with the names above .
2026-08-29 01:55:54 +00:00
</ p >
2026-08-29 03:36:06 +00:00
< p className = "format-hint" >
Use < code >{ "/" }</ code > in the scheme to organize files into subfolders as part of renaming , e . g .{ " " }
< code >{ "{show name}/{episode name}" }</ code > puts each show in its own folder . Missing folders are created
automatically ; nothing outside the file 's own folder tree is ever touched.
</p>
2026-08-29 01:55:54 +00:00
</div>
)}
{status && <div className="status-bar">{status}</div>}
2026-08-29 02:42:31 +00:00
{renamePlan && (
<div className="modal-overlay" onClick={cancelRename}>
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
<h2 className="modal-title">Confirm Rename</h2>
2026-08-29 04:31:16 +00:00
<div className="action-segmented" role="radiogroup" aria-label="What to do with these files">
{(["move", "copy", "symlink"] as const).map((a) => (
<button
key={a}
type="button"
className={`action-segmented-option${renameAction === a ? " selected" : ""}`}
onClick={() => setRenameAction(a)}
disabled={renaming}
>
{RENAME_ACTION_LABELS[a].button}
</button>
))}
</div>
2026-08-29 09:50:36 +00:00
<span className="modal-field-label">On conflict</span>
<div
className="action-segmented"
role="radiogroup"
aria-label="What to do when the computed name already exists"
>
{(["skip", "autoNumber", "override"] as const).map((c) => (
<button
key={c}
type="button"
className={`action-segmented-option${conflictPolicy === c ? " selected" : ""}${
c === "override" ? " action-segmented-option-danger" : ""
}`}
onClick={() => setConflictPolicy(c)}
disabled={renaming}
>
{CONFLICT_POLICY_LABELS[c].button}
</button>
))}
</div>
2026-08-29 02:42:31 +00:00
<p className="modal-subtitle">
2026-08-29 04:31:16 +00:00
This will {RENAME_ACTION_LABELS[renameAction].verb} {renamePlan.length} file
{renamePlan.length === 1 ? "" : "s"} on disk right now. Review the list below before continuing — use
the ✕ on any row to drop just that file from this batch, or undo the whole batch afterward from the
History panel.
{renameAction !== "move" &&
" The original file is left exactly where it is; only the new copy/symlink is created."}
2026-08-29 02:42:31 +00:00
{renamePlanSkipped > 0 &&
` (${renamePlanSkipped} other file${renamePlanSkipped === 1 ? "" : "s"} excluded: no valid preview.)`}
2026-08-29 03:36:06 +00:00
{renamePlan.some((p) => p.newName.includes("/")) &&
2026-08-29 04:31:16 +00:00
` Some of these also ${
renameAction === "move" ? "move" : "place"
} the file into a new subfolder — any missing folders are created automatically.`}
2026-08-29 02:42:31 +00:00
</p>
<div className="modal-plan-list">
2026-08-29 04:12:35 +00:00
{renamePlan.length === 0 ? (
<div className="list-empty-hint">Every file was excluded — nothing left to rename.</div>
) : (
renamePlan.map((p) => (
<div className="modal-plan-row" key={p.id}>
<span className="modal-plan-old" title={p.currentName}>
{p.currentName}
</span>
<span className="modal-plan-arrow">→</span>
<span className="modal-plan-new" title={p.newName}>
{p.newName}
</span>
<button
className="modal-plan-remove"
title="Exclude this file from the rename"
onClick={() => excludeFromPlan(p.id)}
disabled={renaming}
>
<RemoveIcon />
</button>
</div>
))
)}
2026-08-29 02:42:31 +00:00
</div>
2026-08-29 09:50:36 +00:00
<p className={`modal-note${conflictPolicy === "override" ? " modal-note-danger" : ""}`}>
{CONFLICT_POLICY_LABELS[conflictPolicy].description}
2026-08-29 02:42:31 +00:00
</p>
<div className="modal-actions">
<button className="action-button" onClick={cancelRename} disabled={renaming}>
Cancel
</button>
2026-08-29 04:12:35 +00:00
<button
className="action-button primary"
onClick={confirmRename}
disabled={renaming || renamePlan.length === 0}
>
2026-08-29 04:31:16 +00:00
{renaming
? RENAME_ACTION_LABELS[renameAction].progressive
: `${RENAME_ACTION_LABELS[renameAction].button} ${renamePlan.length} File${
renamePlan.length === 1 ? "" : "s"
}`}
2026-08-29 02:42:31 +00:00
</button>
</div>
</div>
</div>
)}
2026-08-29 03:01:19 +00:00
{settingsOpen && (
<div className="modal-overlay" onClick={() => !savingSettings && setSettingsOpen(false)}>
<div className="modal-card modal-card-compact" onClick={(e) => e.stopPropagation()}>
2026-08-29 10:44:12 +00:00
<h2 className="modal-title">Fetch Data Settings</h2>
<span className="modal-field-label">TV episode source</span>
<div className="action-segmented" role="radiogroup" aria-label="Which source Fetch Data uses for TV episodes">
{(["tmdb", "tvmaze"] as const).map((s) => (
<button
key={s}
type="button"
className={`action-segmented-option${tvSource === s ? " selected" : ""}`}
onClick={() => saveTvSource(s)}
>
{TV_SOURCE_LABELS[s].button}
</button>
))}
</div>
<p className="modal-subtitle">{TV_SOURCE_LABELS[tvSource].description}</p>
2026-08-29 03:01:19 +00:00
<p className="modal-subtitle">
2026-08-29 10:44:12 +00:00
Movies always resolve against{" "}
2026-08-29 03:01:19 +00:00
<a href="https://www.themoviedb.org/" target="_blank" rel="noreferrer">
TheMovieDB
</a>{" "}
2026-08-29 10:44:12 +00:00
regardless of the TV source above — TVmaze has no movie catalog. Get a free{" "}
2026-08-29 03:01:19 +00:00
<strong>API Read Access Token</strong> from your TMDB account under Settings → API, then paste it below.
The token is stored locally on this machine and never leaves it except to call TMDB directly.
</p>
<label htmlFor="tmdb-token" className="modal-field-label">
API Read Access Token
</label>
<input
id="tmdb-token"
className="modal-text-input"
type="password"
autoComplete="off"
spellCheck={false}
value={tmdbTokenInput}
onChange={(e) => setTmdbTokenInput(e.target.value)}
placeholder={hasTmdbToken ? "•••••••••• (already saved — enter a new token to replace it)" : "Paste your TMDB token here"}
/>
<div className="modal-actions modal-actions-split">
{hasTmdbToken ? (
<button className="action-button" onClick={clearTmdbToken} disabled={savingSettings}>
Remove Token
</button>
) : (
<span />
)}
<div className="modal-actions">
<button className="action-button" onClick={() => setSettingsOpen(false)} disabled={savingSettings}>
Cancel
</button>
<button className="action-button primary" onClick={saveTmdbToken} disabled={savingSettings}>
{savingSettings ? "Saving…" : "Save"}
</button>
</div>
</div>
</div>
</div>
)}
2026-08-29 03:44:38 +00:00
{historyOpen && (
<div className="modal-overlay" onClick={closeHistory}>
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
<h2 className="modal-title">Rename History</h2>
<p className="modal-subtitle">
2026-08-29 04:31:16 +00:00
Every completed batch is logged here so it can be undone. For a Move batch, undoing moves each file
back to its original path — if something else now exists there, that one file is skipped rather than
overwritten. For a Copy or Symlink batch, undoing just removes the copy/symlink that was created; the
original file was never touched, so there' s nothing to move back . The log keeps the most recent 50
operations .
2026-08-29 03:44:38 +00:00
</ p >
{ historyBatches === null ? (
< p className = "modal-note" > Loading … </ p >
) : historyBatches . length === 0 ? (
< p className = "modal-note" > No renames yet — this fills in after you use Rename .</ p >
) : (
< div className = "modal-plan-list history-batch-list" >
{ historyBatches . map (( batch ) => (
< div className = "history-batch" key = { batch . id }>
< div className = "history-batch-header" >
< span className = "history-batch-when" >{ new Date ( batch . timestamp ). toLocaleString ()}</ span >
< span className = "history-batch-count" >
{ batch . entries . length } file { batch . entries . length === 1 ? "" : "s" }
2026-08-29 04:31:16 +00:00
{ batch . action !== "move" && ` · ${ RENAME_ACTION_LABELS [ batch . action ]. button } ` }
2026-08-29 03:44:38 +00:00
</ span >
< button
className = "action-button history-undo-button"
onClick = {() => undoBatch ( batch )}
disabled = { undoingBatchId !== null }
>
{ undoingBatchId === batch . id ? "Undoing…" : "Undo" }
</ button >
</ div >
< div className = "history-batch-entries" >
{ batch . entries . map (( entry , i ) => (
< div className = "modal-plan-row" key = { i }>
< span className = "modal-plan-old" title = { entry . newPath }>
{ fileNameFromPath ( entry . newPath )}
</ span >
< span className = "modal-plan-arrow" > → </ span >
< span className = "modal-plan-new" title = { entry . source }>
{ fileNameFromPath ( entry . source )}
</ span >
</ div >
))}
</ div >
</ div >
))}
</ div >
)}
< div className = "modal-actions" >
< button className = "action-button" onClick = { closeHistory } disabled = { undoingBatchId !== null }>
Close
</ button >
</ div >
</ div >
</ div >
)}
2026-08-29 09:39:52 +00:00
{ presetsOpen && (
< div className = "modal-overlay" onClick = { closePresets }>
< div className = "modal-card" onClick = {( e ) => e . stopPropagation ()}>
< h2 className = "modal-title" > Naming Scheme Presets </ h2 >
< p className = "modal-subtitle" >
Save up to 9 naming schemes and jump between them instantly — click a saved one below , or just press
its number key (< code > 1 </ code > - < code > 9 </ code >) anywhere in the app while no text field is focused .{ " " }
< strong > Save Current </ strong > stores whatever naming scheme is active right now into that slot ,
overwriting anything already there .
</ p >
< div className = "modal-plan-list history-batch-list" >
{ Array . from ({ length : 9 }, ( _ , i ) => String ( i + 1 )). map (( slot ) => {
const saved = presets ? .[ slot ];
const busy = presetsBusySlot === slot ;
return (
< div className = "preset-row" key = { slot }>
< span className = "preset-slot-badge" >{ slot }</ span >
{ saved ? (
< button
className = "preset-expression"
title = { `Apply: ${ saved } ` }
onClick = {() => applyPreset ( slot )}
disabled = { busy }
>
{ saved }
</ button >
) : (
< span className = "preset-expression preset-expression-empty" > Unused </ span >
)}
< div className = "preset-row-actions" >
< button
className = "action-button preset-save-button"
onClick = {() => saveCurrentAsPreset ( slot )}
disabled = { busy }
>
{ busy ? "…" : "Save Current" }
</ button >
{ saved && (
< button
className = "list-icon-button preset-clear-button"
title = { `Clear preset ${ slot } ` }
onClick = {() => deletePreset ( slot )}
disabled = { busy }
>
< RemoveIcon />
</ button >
)}
</ div >
</ div >
);
})}
</ div >
< div className = "modal-actions" >
< button className = "action-button" onClick = { closePresets } disabled = { presetsBusySlot !== null }>
Close
</ button >
</ div >
</ div >
</ div >
)}
2026-08-29 01:55:54 +00:00
</ div >
);
}
function ListCard ({
title ,
2026-08-29 02:06:14 +00:00
items ,
renderRow ,
emptyHint ,
2026-08-29 01:55:54 +00:00
buttonRow ,
} : {
title : string ;
2026-08-29 02:06:14 +00:00
items : Item [];
renderRow : ( item : Item ) => ReactNode ;
emptyHint : string ;
2026-08-29 01:55:54 +00:00
buttonRow : ReactNode ;
}) {
return (
< div className = "list-card" >
< span className = "list-card-title" >{ title }</ span >
2026-08-29 02:06:14 +00:00
< div className = "list-card-body" >
{ items . length === 0 ? < div className = "list-empty-hint" >{ emptyHint }</ div > : items . map ( renderRow )}
</ div >
2026-08-29 01:55:54 +00:00
< div className = "list-button-row" >{ buttonRow }</ div >
</ div >
);
}
function Row ({
text ,
badge ,
selected ,
muted ,
onClick ,
} : {
text : string ;
badge : string | null ;
selected : boolean ;
muted : boolean ;
onClick : () => void ;
}) {
return (
2026-08-29 02:06:14 +00:00
< div className = { `list-row ${ selected ? " selected" : "" }${ muted ? " muted" : "" } ` } onClick = { onClick }>
2026-08-29 01:55:54 +00:00
< span className = "list-row-text" >{ text }</ span >
{ badge && < span className = "list-row-badge" >{ badge }</ span >}
</ div >
);
}
function splitExt ( name : string ) : [ string , string ] {
const i = name . lastIndexOf ( "." );
if ( i <= 0 ) return [ name , "" ];
return [ name . slice ( 0 , i ), name . slice ( i + 1 )];
}