pipeline cards, context menus, presets, settings overhaul

rewrote pipeline as draggable card strip with per-rule config popovers,
added right-click menus to pipeline cards, sidebar tree, and file list,
preset import/export with BRU format support, new rules (hash, swap,
truncate, sanitize, padding, randomize, text editor, folder name,
transliterate), settings dialog with all sections, overlay collision
containment, tooltips on icon buttons, empty pipeline default
This commit is contained in:
2026-03-14 19:04:35 +02:00
parent 9dca2bedfa
commit 6f5b862234
105 changed files with 17257 additions and 1369 deletions

437
Cargo.lock generated
View File

@@ -396,6 +396,16 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.10.1" version = "0.10.1"
@@ -419,9 +429,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.0",
"core-foundation", "core-foundation 0.10.1",
"core-graphics-types", "core-graphics-types",
"foreign-types", "foreign-types 0.5.0",
"libc", "libc",
] ]
@@ -432,7 +442,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.0",
"core-foundation", "core-foundation 0.10.1",
"libc", "libc",
] ]
@@ -626,6 +636,12 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "deunicode"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04"
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@@ -918,6 +934,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared 0.1.1",
]
[[package]] [[package]]
name = "foreign-types" name = "foreign-types"
version = "0.5.0" version = "0.5.0"
@@ -925,7 +950,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [ dependencies = [
"foreign-types-macros", "foreign-types-macros",
"foreign-types-shared", "foreign-types-shared 0.3.1",
] ]
[[package]] [[package]]
@@ -939,6 +964,12 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]] [[package]]
name = "foreign-types-shared" name = "foreign-types-shared"
version = "0.3.1" version = "0.3.1"
@@ -1348,6 +1379,25 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "h2"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.13.0",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.12.3" version = "0.12.3"
@@ -1458,6 +1508,7 @@ dependencies = [
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"h2",
"http", "http",
"http-body", "http-body",
"httparse", "httparse",
@@ -1469,6 +1520,38 @@ dependencies = [
"want", "want",
] ]
[[package]]
name = "hyper-rustls"
version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]] [[package]]
name = "hyper-util" name = "hyper-util"
version = "0.1.20" version = "0.1.20"
@@ -1487,9 +1570,11 @@ dependencies = [
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"socket2", "socket2",
"system-configuration",
"tokio", "tokio",
"tower-service", "tower-service",
"tracing", "tracing",
"windows-registry",
] ]
[[package]] [[package]]
@@ -1822,9 +1907,9 @@ dependencies = [
[[package]] [[package]]
name = "kamadak-exif" name = "kamadak-exif"
version = "0.5.5" version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef4fc70d0ab7e5b6bafa30216a6b48705ea964cdfc29c050f2412295eba58077" checksum = "1130d80c7374efad55a117d715a3af9368f0fa7a2c54573afc15a188cd984837"
dependencies = [ dependencies = [
"mutate_once", "mutate_once",
] ]
@@ -1985,6 +2070,16 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
[[package]]
name = "md-5"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest",
]
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.0" version = "2.8.0"
@@ -2054,6 +2149,23 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af" checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af"
[[package]]
name = "native-tls"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]] [[package]]
name = "natord" name = "natord"
version = "1.0.9" version = "1.0.9"
@@ -2112,6 +2224,7 @@ dependencies = [
"env_logger", "env_logger",
"log", "log",
"nomina-core", "nomina-core",
"reqwest 0.12.28",
"serde", "serde",
"serde_json", "serde_json",
"tauri", "tauri",
@@ -2120,6 +2233,7 @@ dependencies = [
"tauri-plugin-shell", "tauri-plugin-shell",
"tokio", "tokio",
"uuid", "uuid",
"winreg",
] ]
[[package]] [[package]]
@@ -2127,17 +2241,23 @@ name = "nomina-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"deunicode",
"filetime", "filetime",
"glob", "glob",
"kamadak-exif", "kamadak-exif",
"log", "log",
"md-5",
"natord", "natord",
"rand 0.8.5",
"rayon", "rayon",
"regex", "regex",
"serde", "serde",
"serde_json", "serde_json",
"sha1",
"sha2",
"tempfile", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"unicode-normalization",
"uuid", "uuid",
"walkdir", "walkdir",
] ]
@@ -2326,6 +2446,50 @@ dependencies = [
"pathdiff", "pathdiff",
] ]
[[package]]
name = "openssl"
version = "0.10.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf"
dependencies = [
"bitflags 2.11.0",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.112"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]] [[package]]
name = "option-ext" name = "option-ext"
version = "0.2.0" version = "0.2.0"
@@ -2977,6 +3141,46 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-core",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"native-tls",
"percent-encoding",
"pin-project-lite",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.13.2" version = "0.13.2"
@@ -3035,6 +3239,20 @@ dependencies = [
"windows-sys 0.60.2", "windows-sys 0.60.2",
] ]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.1" version = "2.1.1"
@@ -3063,12 +3281,51 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "rustls"
version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
"once_cell",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.22" version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]] [[package]]
name = "same-file" name = "same-file"
version = "1.0.6" version = "1.0.6"
@@ -3078,6 +3335,15 @@ dependencies = [
"winapi-util", "winapi-util",
] ]
[[package]]
name = "schannel"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "schemars" name = "schemars"
version = "0.8.22" version = "0.8.22"
@@ -3135,6 +3401,29 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.11.0",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "selectors" name = "selectors"
version = "0.24.0" version = "0.24.0"
@@ -3277,6 +3566,18 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]] [[package]]
name = "serde_with" name = "serde_with"
version = "3.18.0" version = "3.18.0"
@@ -3349,6 +3650,17 @@ dependencies = [
"stable_deref_trait", "stable_deref_trait",
] ]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]] [[package]]
name = "sha2" name = "sha2"
version = "0.10.9" version = "0.10.9"
@@ -3557,6 +3869,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "swift-rs" name = "swift-rs"
version = "1.0.7" version = "1.0.7"
@@ -3610,6 +3928,27 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.11.0",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "system-deps" name = "system-deps"
version = "6.2.2" version = "6.2.2"
@@ -3631,7 +3970,7 @@ checksum = "6e06d52c379e63da659a483a958110bbde891695a0ecb53e48cc7786d5eda7bb"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.0",
"block2", "block2",
"core-foundation", "core-foundation 0.10.1",
"core-graphics", "core-graphics",
"crossbeam-channel", "crossbeam-channel",
"dispatch2", "dispatch2",
@@ -3708,7 +4047,7 @@ dependencies = [
"percent-encoding", "percent-encoding",
"plist", "plist",
"raw-window-handle", "raw-window-handle",
"reqwest", "reqwest 0.13.2",
"serde", "serde",
"serde_json", "serde_json",
"serde_repr", "serde_repr",
@@ -4075,6 +4414,21 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "tinyvec"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.50.0" version = "1.50.0"
@@ -4103,6 +4457,26 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.18" version = "0.7.18"
@@ -4372,6 +4746,15 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-normalization"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
dependencies = [
"tinyvec",
]
[[package]] [[package]]
name = "unicode-segmentation" name = "unicode-segmentation"
version = "1.12.0" version = "1.12.0"
@@ -4384,6 +4767,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"
@@ -4439,6 +4828,12 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]] [[package]]
name = "version-compare" name = "version-compare"
version = "0.2.1" version = "0.2.1"
@@ -4877,6 +5272,17 @@ dependencies = [
"windows-link 0.1.3", "windows-link 0.1.3",
] ]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.3.4" version = "0.3.4"
@@ -4922,6 +5328,15 @@ dependencies = [
"windows-targets 0.42.2", "windows-targets 0.42.2",
] ]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.59.0" version = "0.59.0"
@@ -5404,6 +5819,12 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"
version = "0.2.3" version = "0.2.3"

View File

@@ -17,7 +17,9 @@ log = "0.4"
env_logger = "0.11" env_logger = "0.11"
directories = "6" directories = "6"
anyhow = "1" anyhow = "1"
winreg = "0.55"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json"] }
[build-dependencies] [build-dependencies]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }

View File

@@ -4,7 +4,20 @@
"windows": ["main"], "windows": ["main"],
"permissions": [ "permissions": [
"core:default", "core:default",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-close",
"core:window:allow-start-dragging",
"core:window:allow-set-size",
"core:window:allow-set-position",
"core:window:allow-outer-position",
"core:window:allow-outer-size",
"core:window:allow-is-maximized",
"core:window:allow-maximize",
"core:window:allow-set-always-on-top",
"core:window:allow-request-user-attention",
"dialog:default", "dialog:default",
"shell:default" "shell:default",
"shell:allow-open"
] ]
} }

View File

@@ -1 +1 @@
{"default":{"identifier":"default","description":"Default permissions for Nomina","local":true,"windows":["main"],"permissions":["core:default","dialog:default","shell:default"]}} {"default":{"identifier":"default","description":"Default permissions for Nomina","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-minimize","core:window:allow-toggle-maximize","core:window:allow-close","core:window:allow-start-dragging","core:window:allow-set-size","core:window:allow-set-position","core:window:allow-outer-position","core:window:allow-outer-size","core:window:allow-is-maximized","core:window:allow-maximize","core:window:allow-set-always-on-top","core:window:allow-request-user-attention","dialog:default","shell:default","shell:allow-open"]}}

View File

@@ -0,0 +1,128 @@
use std::path::PathBuf;
#[cfg(target_os = "windows")]
use winreg::enums::*;
#[cfg(target_os = "windows")]
use winreg::RegKey;
#[tauri::command]
pub fn get_launch_args() -> Vec<String> {
std::env::args().skip(1).collect()
}
#[tauri::command]
pub fn register_context_menu() -> Result<(), String> {
#[cfg(target_os = "windows")]
{
let exe = std::env::current_exe()
.map_err(|e| format!("Failed to get exe path: {}", e))?;
let exe_str = exe.to_string_lossy().to_string();
let icon = format!("{},0", exe_str);
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let base = r"Software\Classes";
// Context menu for files: *\shell\Nomina
let (key, _) = hkcu
.create_subkey(format!(r"{}\*\shell\Nomina", base))
.map_err(|e| format!("Registry error: {}", e))?;
key.set_value("", &"Edit in Nomina")
.map_err(|e| format!("Registry error: {}", e))?;
key.set_value("Icon", &icon)
.map_err(|e| format!("Registry error: {}", e))?;
let (cmd, _) = hkcu
.create_subkey(format!(r"{}\*\shell\Nomina\command", base))
.map_err(|e| format!("Registry error: {}", e))?;
cmd.set_value("", &format!("\"{}\" \"%1\"", exe_str))
.map_err(|e| format!("Registry error: {}", e))?;
// Context menu for folders: Directory\shell\Nomina
let (key, _) = hkcu
.create_subkey(format!(r"{}\Directory\shell\Nomina", base))
.map_err(|e| format!("Registry error: {}", e))?;
key.set_value("", &"Edit in Nomina")
.map_err(|e| format!("Registry error: {}", e))?;
key.set_value("Icon", &icon)
.map_err(|e| format!("Registry error: {}", e))?;
let (cmd, _) = hkcu
.create_subkey(format!(r"{}\Directory\shell\Nomina\command", base))
.map_err(|e| format!("Registry error: {}", e))?;
cmd.set_value("", &format!("\"{}\" \"%1\"", exe_str))
.map_err(|e| format!("Registry error: {}", e))?;
// Context menu for folder background: Directory\Background\shell\Nomina
let (key, _) = hkcu
.create_subkey(format!(r"{}\Directory\Background\shell\Nomina", base))
.map_err(|e| format!("Registry error: {}", e))?;
key.set_value("", &"Edit in Nomina")
.map_err(|e| format!("Registry error: {}", e))?;
key.set_value("Icon", &icon)
.map_err(|e| format!("Registry error: {}", e))?;
let (cmd, _) = hkcu
.create_subkey(format!(r"{}\Directory\Background\shell\Nomina\command", base))
.map_err(|e| format!("Registry error: {}", e))?;
// %V gives the current folder path when right-clicking background
cmd.set_value("", &format!("\"{}\" \"%V\"", exe_str))
.map_err(|e| format!("Registry error: {}", e))?;
Ok(())
}
#[cfg(not(target_os = "windows"))]
Err("Context menu registration is only supported on Windows".to_string())
}
#[tauri::command]
pub fn unregister_context_menu() -> Result<(), String> {
#[cfg(target_os = "windows")]
{
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let base = r"Software\Classes";
let _ = hkcu.delete_subkey_all(format!(r"{}\*\shell\Nomina", base));
let _ = hkcu.delete_subkey_all(format!(r"{}\Directory\shell\Nomina", base));
let _ = hkcu.delete_subkey_all(format!(r"{}\Directory\Background\shell\Nomina", base));
Ok(())
}
#[cfg(not(target_os = "windows"))]
Err("Context menu registration is only supported on Windows".to_string())
}
/// Given CLI args (paths), figure out what folder to open and which files to select.
/// Returns (folder_path, selected_file_paths).
#[tauri::command]
pub fn resolve_launch_paths(args: Vec<String>) -> Option<(String, Vec<String>)> {
if args.is_empty() {
return None;
}
let paths: Vec<PathBuf> = args.iter().map(PathBuf::from).filter(|p| p.exists()).collect();
if paths.is_empty() {
return None;
}
// If single directory passed (from background click or folder right-click), open it
if paths.len() == 1 && paths[0].is_dir() {
let folder = paths[0].to_string_lossy().to_string();
return Some((folder, vec![]));
}
// For files (or mix), use the parent of the first path as the folder
// and collect all paths as the selection
let folder = paths[0]
.parent()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
if folder.is_empty() {
return None;
}
let selected: Vec<String> = paths.iter().map(|p| p.to_string_lossy().to_string()).collect();
Some((folder, selected))
}

View File

@@ -48,3 +48,55 @@ pub async fn get_file_metadata(path: String) -> Result<FileEntry, String> {
accessed: meta.accessed().ok().map(|t| chrono::DateTime::<chrono::Utc>::from(t)), accessed: meta.accessed().ok().map(|t| chrono::DateTime::<chrono::Utc>::from(t)),
}) })
} }
#[tauri::command]
pub async fn reveal_in_explorer(path: String) -> Result<(), String> {
let p = PathBuf::from(&path);
#[cfg(target_os = "windows")]
{
if p.is_dir() {
std::process::Command::new("explorer")
.arg(&path)
.spawn()
.map_err(|e| e.to_string())?;
} else {
std::process::Command::new("explorer")
.args(["/select,", &path])
.spawn()
.map_err(|e| e.to_string())?;
}
}
#[cfg(target_os = "macos")]
{
if p.is_dir() {
std::process::Command::new("open")
.arg(&path)
.spawn()
.map_err(|e| e.to_string())?;
} else {
std::process::Command::new("open")
.args(["-R", &path])
.spawn()
.map_err(|e| e.to_string())?;
}
}
#[cfg(target_os = "linux")]
{
let target = if p.is_dir() {
path.clone()
} else {
p.parent()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or(path.clone())
};
std::process::Command::new("xdg-open")
.arg(&target)
.spawn()
.map_err(|e| e.to_string())?;
}
Ok(())
}

View File

@@ -2,3 +2,5 @@ pub mod files;
pub mod rename; pub mod rename;
pub mod presets; pub mod presets;
pub mod undo; pub mod undo;
pub mod context_menu;
pub mod updates;

View File

@@ -1,5 +1,6 @@
use std::path::PathBuf; use std::path::PathBuf;
use nomina_core::bru;
use nomina_core::preset::NominaPreset; use nomina_core::preset::NominaPreset;
#[tauri::command] #[tauri::command]
@@ -19,6 +20,11 @@ pub async fn load_preset(path: String) -> Result<NominaPreset, String> {
NominaPreset::load(&PathBuf::from(path)).map_err(|e| e.to_string()) NominaPreset::load(&PathBuf::from(path)).map_err(|e| e.to_string())
} }
#[tauri::command]
pub async fn delete_preset(path: String) -> Result<(), String> {
std::fs::remove_file(&path).map_err(|e| e.to_string())
}
#[tauri::command] #[tauri::command]
pub async fn list_presets() -> Result<Vec<PresetInfo>, String> { pub async fn list_presets() -> Result<Vec<PresetInfo>, String> {
let dir = get_presets_dir(); let dir = get_presets_dir();
@@ -70,3 +76,21 @@ fn sanitize_filename(name: &str) -> String {
}) })
.collect() .collect()
} }
#[tauri::command]
pub async fn export_preset(source_path: String, dest_path: String) -> Result<(), String> {
std::fs::copy(&source_path, &dest_path).map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn import_preset(path: String) -> Result<NominaPreset, String> {
let p = PathBuf::from(&path);
let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase();
match ext.as_str() {
"nomina" => NominaPreset::load(&p).map_err(|e| e.to_string()),
"bru" => bru::parse_bru_file(&p).map_err(|e| e.to_string()),
_ => Err(format!("Unsupported preset format: .{}", ext)),
}
}

View File

@@ -29,30 +29,62 @@ fn build_rule(cfg: &RuleConfig) -> Option<Box<dyn RenameRule>> {
if !cfg.enabled { if !cfg.enabled {
return None; return None;
} }
let val = &cfg.config; // Re-inject `enabled` - #[serde(flatten)] on RuleConfig consumes it
// before individual rule structs can see it
let mut val = cfg.config.clone();
if let serde_json::Value::Object(ref mut map) = val {
map.insert("enabled".to_string(), serde_json::Value::Bool(cfg.enabled));
}
match cfg.rule_type.as_str() { match cfg.rule_type.as_str() {
"replace" => serde_json::from_value::<rules::ReplaceRule>(val.clone()) "replace" => serde_json::from_value::<rules::ReplaceRule>(val)
.ok() .ok()
.map(|r| Box::new(r) as Box<dyn RenameRule>), .map(|r| Box::new(r) as Box<dyn RenameRule>),
"regex" => serde_json::from_value::<rules::RegexRule>(val.clone()) "regex" => serde_json::from_value::<rules::RegexRule>(val)
.ok() .ok()
.map(|r| Box::new(r) as Box<dyn RenameRule>), .map(|r| Box::new(r) as Box<dyn RenameRule>),
"remove" => serde_json::from_value::<rules::RemoveRule>(val.clone()) "remove" => serde_json::from_value::<rules::RemoveRule>(val)
.ok() .ok()
.map(|r| Box::new(r) as Box<dyn RenameRule>), .map(|r| Box::new(r) as Box<dyn RenameRule>),
"add" => serde_json::from_value::<rules::AddRule>(val.clone()) "add" => serde_json::from_value::<rules::AddRule>(val)
.ok() .ok()
.map(|r| Box::new(r) as Box<dyn RenameRule>), .map(|r| Box::new(r) as Box<dyn RenameRule>),
"case" => serde_json::from_value::<rules::CaseRule>(val.clone()) "case" => serde_json::from_value::<rules::CaseRule>(val)
.ok() .ok()
.map(|r| Box::new(r) as Box<dyn RenameRule>), .map(|r| Box::new(r) as Box<dyn RenameRule>),
"numbering" => serde_json::from_value::<rules::NumberingRule>(val.clone()) "numbering" => serde_json::from_value::<rules::NumberingRule>(val)
.ok() .ok()
.map(|r| Box::new(r) as Box<dyn RenameRule>), .map(|r| Box::new(r) as Box<dyn RenameRule>),
"date" => serde_json::from_value::<rules::DateRule>(val.clone()) "date" => serde_json::from_value::<rules::DateRule>(val)
.ok() .ok()
.map(|r| Box::new(r) as Box<dyn RenameRule>), .map(|r| Box::new(r) as Box<dyn RenameRule>),
"move_parts" => serde_json::from_value::<rules::MovePartsRule>(val.clone()) "move_parts" => serde_json::from_value::<rules::MovePartsRule>(val)
.ok()
.map(|r| Box::new(r) as Box<dyn RenameRule>),
"text_editor" => serde_json::from_value::<rules::TextEditorRule>(val)
.ok()
.map(|r| Box::new(r) as Box<dyn RenameRule>),
"hash" => serde_json::from_value::<rules::HashRule>(val)
.ok()
.map(|r| Box::new(r) as Box<dyn RenameRule>),
"folder_name" => serde_json::from_value::<rules::FolderNameRule>(val)
.ok()
.map(|r| Box::new(r) as Box<dyn RenameRule>),
"transliterate" => serde_json::from_value::<rules::TransliterateRule>(val)
.ok()
.map(|r| Box::new(r) as Box<dyn RenameRule>),
"padding" => serde_json::from_value::<rules::PaddingRule>(val)
.ok()
.map(|r| Box::new(r) as Box<dyn RenameRule>),
"truncate" => serde_json::from_value::<rules::TruncateRule>(val)
.ok()
.map(|r| Box::new(r) as Box<dyn RenameRule>),
"randomize" => serde_json::from_value::<rules::RandomizeRule>(val)
.ok()
.map(|r| Box::new(r) as Box<dyn RenameRule>),
"swap" => serde_json::from_value::<rules::SwapRule>(val)
.ok()
.map(|r| Box::new(r) as Box<dyn RenameRule>),
"sanitize" => serde_json::from_value::<rules::SanitizeRule>(val)
.ok() .ok()
.map(|r| Box::new(r) as Box<dyn RenameRule>), .map(|r| Box::new(r) as Box<dyn RenameRule>),
_ => None, _ => None,
@@ -64,12 +96,20 @@ pub async fn preview_rename(
rules: Vec<RuleConfig>, rules: Vec<RuleConfig>,
directory: String, directory: String,
filters: Option<FilterConfig>, filters: Option<FilterConfig>,
selected_paths: Option<Vec<String>>,
) -> Result<Vec<PreviewResult>, String> { ) -> Result<Vec<PreviewResult>, String> {
let scanner = FileScanner::new( let scanner = FileScanner::new(
PathBuf::from(&directory), PathBuf::from(&directory),
filters.unwrap_or_default(), filters.unwrap_or_default(),
); );
let files = scanner.scan(); let all_files = scanner.scan();
let files = if let Some(ref paths) = selected_paths {
let file_map: std::collections::HashMap<PathBuf, _> = all_files.into_iter().map(|f| (f.path.clone(), f)).collect();
paths.iter().filter_map(|p| file_map.get(&PathBuf::from(p)).cloned()).collect()
} else {
all_files
};
let mut pipeline = Pipeline::new(); let mut pipeline = Pipeline::new();
for cfg in &rules { for cfg in &rules {
@@ -77,7 +117,11 @@ pub async fn preview_rename(
pipeline.add_step(rule, cfg.step_mode.clone()); pipeline.add_step(rule, cfg.step_mode.clone());
} }
if cfg.rule_type == "extension" { if cfg.rule_type == "extension" {
if let Ok(ext_rule) = serde_json::from_value(cfg.config.clone()) { let mut ext_val = cfg.config.clone();
if let serde_json::Value::Object(ref mut map) = ext_val {
map.insert("enabled".to_string(), serde_json::Value::Bool(cfg.enabled));
}
if let Ok(ext_rule) = serde_json::from_value(ext_val) {
pipeline.extension_rule = Some(ext_rule); pipeline.extension_rule = Some(ext_rule);
} }
} }
@@ -87,12 +131,40 @@ pub async fn preview_rename(
} }
#[tauri::command] #[tauri::command]
pub async fn execute_rename(operations: Vec<PreviewResult>) -> Result<RenameReport, String> { pub async fn execute_rename(
let valid: Vec<&PreviewResult> = operations operations: Vec<PreviewResult>,
create_backup: Option<bool>,
undo_limit: Option<usize>,
skip_read_only: Option<bool>,
conflict_strategy: Option<String>,
backup_path: Option<String>,
) -> Result<RenameReport, String> {
let skip_ro = skip_read_only.unwrap_or(true);
let strategy = conflict_strategy.unwrap_or_else(|| "suffix".to_string());
let mut valid: Vec<&PreviewResult> = operations
.iter() .iter()
.filter(|op| !op.has_conflict && !op.has_error && op.original_name != op.new_name) .filter(|op| !op.has_conflict && !op.has_error && op.original_name != op.new_name)
.collect(); .collect();
// skip read-only files if enabled
if skip_ro {
valid.retain(|op| {
if let Ok(meta) = std::fs::metadata(&op.original_path) {
!meta.permissions().readonly()
} else {
true
}
});
}
// sort deepest paths first so children are renamed before parents
valid.sort_by(|a, b| {
let depth_a = a.original_path.components().count();
let depth_b = b.original_path.components().count();
depth_b.cmp(&depth_a)
});
// validation pass // validation pass
for op in &valid { for op in &valid {
if !op.original_path.exists() { if !op.original_path.exists() {
@@ -100,6 +172,32 @@ pub async fn execute_rename(operations: Vec<PreviewResult>) -> Result<RenameRepo
} }
} }
// create backups if requested
if create_backup.unwrap_or(false) && !valid.is_empty() {
let backup_dir = if let Some(ref bp) = backup_path {
if !bp.is_empty() {
PathBuf::from(bp)
} else {
let first_parent = valid[0].original_path.parent().unwrap();
first_parent.join("_nomina_backup")
}
} else {
let first_parent = valid[0].original_path.parent().unwrap();
first_parent.join("_nomina_backup")
};
std::fs::create_dir_all(&backup_dir)
.map_err(|e| format!("Failed to create backup directory: {}", e))?;
for op in &valid {
if op.original_path.is_file() {
let dest = backup_dir.join(&op.original_name);
if let Err(e) = std::fs::copy(&op.original_path, &dest) {
return Err(format!("Failed to backup {}: {}", op.original_name, e));
}
}
}
}
let mut succeeded = 0; let mut succeeded = 0;
let mut failed = Vec::new(); let mut failed = Vec::new();
let mut undo_entries = Vec::new(); let mut undo_entries = Vec::new();
@@ -108,11 +206,17 @@ pub async fn execute_rename(operations: Vec<PreviewResult>) -> Result<RenameRepo
let parent = op.original_path.parent().unwrap(); let parent = op.original_path.parent().unwrap();
let new_path = parent.join(&op.new_name); let new_path = parent.join(&op.new_name);
// if target exists and isn't another file we're renaming, use temp // if target exists and isn't another file we're renaming
let needs_temp = new_path.exists() let target_conflict = new_path.exists()
&& !valid.iter().any(|other| other.original_path == new_path); && !valid.iter().any(|other| other.original_path == new_path);
let result = if needs_temp { if target_conflict && strategy == "skip" {
failed.push(format!("{}: target already exists (skipped)", op.original_name));
continue;
}
let result = if target_conflict {
// suffix strategy - use temp rename
let tmp_name = format!("__nomina_tmp_{}", uuid::Uuid::new_v4()); let tmp_name = format!("__nomina_tmp_{}", uuid::Uuid::new_v4());
let tmp_path = parent.join(&tmp_name); let tmp_path = parent.join(&tmp_name);
std::fs::rename(&op.original_path, &tmp_path) std::fs::rename(&op.original_path, &tmp_path)
@@ -145,7 +249,8 @@ pub async fn execute_rename(operations: Vec<PreviewResult>) -> Result<RenameRepo
let undo_path = get_undo_log_path(); let undo_path = get_undo_log_path();
let mut log = UndoLog::load(&undo_path).unwrap_or_else(|_| UndoLog::new()); let mut log = UndoLog::load(&undo_path).unwrap_or_else(|_| UndoLog::new());
log.add_batch(batch.clone()); let max = undo_limit.unwrap_or(nomina_core::undo::DEFAULT_MAX_UNDO_BATCHES);
log.add_batch_with_limit(batch.clone(), max);
let _ = log.save(&undo_path); let _ = log.save(&undo_path);
Ok(RenameReport { Ok(RenameReport {

View File

@@ -0,0 +1,75 @@
use serde::Serialize;
#[derive(Debug, Serialize)]
pub struct UpdateInfo {
pub available: bool,
pub current_version: String,
pub latest_version: String,
pub url: String,
}
#[tauri::command]
pub async fn check_for_updates() -> Result<UpdateInfo, String> {
let current = env!("CARGO_PKG_VERSION").to_string();
let api_url = "https://git.lashman.live/api/v1/repos/lashman/nomina/releases?limit=1";
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| e.to_string())?;
let resp = client
.get(api_url)
.header("Accept", "application/json")
.send()
.await
.map_err(|e| format!("Network error: {}", e))?;
if !resp.status().is_success() {
return Err(format!("Server returned {}", resp.status()));
}
let releases: Vec<serde_json::Value> = resp
.json()
.await
.map_err(|e| format!("Parse error: {}", e))?;
let latest = releases.first().ok_or("No releases found")?;
let tag = latest["tag_name"]
.as_str()
.unwrap_or("0.0.0")
.trim_start_matches('v')
.to_string();
let url = latest["html_url"]
.as_str()
.unwrap_or("")
.to_string();
let available = version_newer(&tag, &current);
Ok(UpdateInfo {
available,
current_version: current,
latest_version: tag,
url,
})
}
fn version_newer(latest: &str, current: &str) -> bool {
let parse = |s: &str| -> Vec<u32> {
s.split('.').filter_map(|p| p.parse().ok()).collect()
};
let l = parse(latest);
let c = parse(current);
for i in 0..l.len().max(c.len()) {
let lv = l.get(i).copied().unwrap_or(0);
let cv = c.get(i).copied().unwrap_or(0);
if lv > cv {
return true;
}
if lv < cv {
return false;
}
}
false
}

View File

@@ -1,4 +1,4 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![windows_subsystem = "windows"]
mod commands; mod commands;
@@ -9,15 +9,24 @@ fn main() {
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
commands::files::scan_directory, commands::files::scan_directory,
commands::files::get_file_metadata, commands::files::get_file_metadata,
commands::files::reveal_in_explorer,
commands::rename::preview_rename, commands::rename::preview_rename,
commands::rename::execute_rename, commands::rename::execute_rename,
commands::presets::save_preset, commands::presets::save_preset,
commands::presets::load_preset, commands::presets::load_preset,
commands::presets::list_presets, commands::presets::list_presets,
commands::presets::delete_preset,
commands::presets::export_preset,
commands::presets::import_preset,
commands::undo::undo_last, commands::undo::undo_last,
commands::undo::undo_batch, commands::undo::undo_batch,
commands::undo::get_undo_history, commands::undo::get_undo_history,
commands::undo::clear_undo_history, commands::undo::clear_undo_history,
commands::context_menu::get_launch_args,
commands::context_menu::register_context_menu,
commands::context_menu::unregister_context_menu,
commands::context_menu::resolve_launch_paths,
commands::updates::check_for_updates,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error running nomina"); .expect("error running nomina");

View File

@@ -6,8 +6,8 @@
"build": { "build": {
"frontendDist": "../../ui/dist", "frontendDist": "../../ui/dist",
"devUrl": "http://localhost:5173", "devUrl": "http://localhost:5173",
"beforeDevCommand": "cd ../../ui && npm run dev", "beforeDevCommand": { "script": "npm run dev", "cwd": "../../ui" },
"beforeBuildCommand": "cd ../../ui && npm run build" "beforeBuildCommand": { "script": "npm run build", "cwd": "../../ui" }
}, },
"app": { "app": {
"windows": [ "windows": [
@@ -18,7 +18,10 @@
"minWidth": 900, "minWidth": 900,
"minHeight": 600, "minHeight": 600,
"resizable": true, "resizable": true,
"fullscreen": false "fullscreen": false,
"decorations": false,
"shadow": false,
"transparent": true
} }
], ],
"security": { "security": {

View File

@@ -17,7 +17,13 @@ thiserror = "2"
glob = "0.3" glob = "0.3"
natord = "1" natord = "1"
log = "0.4" log = "0.4"
kamadak-exif = "0.5" kamadak-exif = "0.6"
unicode-normalization = "0.1"
sha2 = "0.10"
md-5 = "0.10"
sha1 = "0.10"
deunicode = "1"
rand = "0.8"
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"

View File

@@ -0,0 +1,208 @@
use std::collections::HashMap;
use std::path::Path;
use crate::NominaError;
use crate::preset::{NominaPreset, PresetRule};
/// Parse a Bulk Rename Utility (.bru) preset file into a NominaPreset.
/// BRU files use an INI-like format with sections for each operation.
pub fn parse_bru_file(path: &Path) -> crate::Result<NominaPreset> {
let data = std::fs::read_to_string(path).map_err(|e| NominaError::Filesystem {
path: path.to_path_buf(),
source: e,
})?;
let sections = parse_ini(&data);
let mut rules = Vec::new();
// RegEx(1) section
if let Some(sec) = sections.get("regexmatch(1)") {
let pattern = sec.get("match").cloned().unwrap_or_default();
let replace = sec.get("replace").cloned().unwrap_or_default();
let case = sec.get("casesensitive").map(|v| v == "1").unwrap_or(false);
if !pattern.is_empty() {
rules.push(make_rule("regex", serde_json::json!({
"pattern": pattern,
"replacement": replace,
"case_sensitive": case,
"global": true,
"target": "Name",
})));
}
}
// Replace sections - BRU supports multiple replace slots
for i in 1..=14 {
let key = if i == 1 { "replace(1)".to_string() } else { format!("replace({})", i) };
if let Some(sec) = sections.get(&key) {
let search = sec.get("find").or(sec.get("search")).cloned().unwrap_or_default();
let replace = sec.get("replace").cloned().unwrap_or_default();
let case = sec.get("casesensitive").or(sec.get("matchcase")).map(|v| v == "1").unwrap_or(false);
if !search.is_empty() {
rules.push(make_rule("replace", serde_json::json!({
"search": search,
"replace_with": replace,
"match_case": case,
"use_regex": false,
"target": "Name",
})));
}
}
}
// Remove section
if let Some(sec) = sections.get("remove(1)") {
let first_n: usize = sec.get("first").and_then(|v| v.parse().ok()).unwrap_or(0);
let last_n: usize = sec.get("last").and_then(|v| v.parse().ok()).unwrap_or(0);
let from: usize = sec.get("from").and_then(|v| v.parse().ok()).unwrap_or(0);
let to: usize = sec.get("to").and_then(|v| v.parse().ok()).unwrap_or(0);
if first_n > 0 || last_n > 0 || from != to {
rules.push(make_rule("remove", serde_json::json!({
"first_n": first_n,
"last_n": last_n,
"from": from,
"to": to,
"crop_before": "",
"crop_after": "",
"remove_pattern": "",
"collapse_chars": false,
"trim": { "spaces": false, "dots": false, "dashes": false, "underscores": false },
})));
}
}
// Add / Prefix / Suffix section
if let Some(sec) = sections.get("add(1)") {
let prefix = sec.get("prefix").cloned().unwrap_or_default();
let suffix = sec.get("suffix").cloned().unwrap_or_default();
let insert = sec.get("insert").cloned().unwrap_or_default();
let at_pos: usize = sec.get("atpos").or(sec.get("at")).and_then(|v| v.parse().ok()).unwrap_or(0);
if !prefix.is_empty() || !suffix.is_empty() || !insert.is_empty() {
rules.push(make_rule("add", serde_json::json!({
"prefix": prefix,
"suffix": suffix,
"insert": insert,
"position": at_pos,
"inserts": [],
})));
}
}
// Case section
if let Some(sec) = sections.get("case(1)") {
let mode_num: u32 = sec.get("type").or(sec.get("mode")).and_then(|v| v.parse().ok()).unwrap_or(0);
let mode = match mode_num {
1 => "Lower",
2 => "Upper",
3 => "Title",
4 => "Sentence",
5 => "Invert",
_ => "Same",
};
if mode != "Same" {
rules.push(make_rule("case", serde_json::json!({
"mode": mode,
"target": "Name",
})));
}
}
// Numbering section
if let Some(sec) = sections.get("number(1)") {
let mode_num: u32 = sec.get("mode").and_then(|v| v.parse().ok()).unwrap_or(0);
if mode_num > 0 {
let start: i64 = sec.get("start").and_then(|v| v.parse().ok()).unwrap_or(1);
let step: i64 = sec.get("incr").or(sec.get("step")).and_then(|v| v.parse().ok()).unwrap_or(1);
let pad: usize = sec.get("pad").and_then(|v| v.parse().ok()).unwrap_or(0);
let mode = match mode_num {
1 => "Prefix",
2 => "Suffix",
3 => "Both",
_ => "Prefix",
};
rules.push(make_rule("numbering", serde_json::json!({
"mode": mode,
"start": start,
"step": step,
"padding": pad,
"separator": "",
"format": "Decimal",
})));
}
}
// Extension section
if let Some(sec) = sections.get("extension(1)") {
let mode_num: u32 = sec.get("type").or(sec.get("mode")).and_then(|v| v.parse().ok()).unwrap_or(0);
let new_ext = sec.get("new").or(sec.get("extension")).cloned().unwrap_or_default();
let mode = match mode_num {
1 => "Lower",
2 => "Upper",
3 => "Title",
4 if !new_ext.is_empty() => "Fixed",
5 => "Remove",
_ => "Same",
};
if mode != "Same" {
let mut json = serde_json::json!({
"mode": mode,
"new_extension": new_ext,
"mapping": [],
});
if mode == "Fixed" {
json["new_extension"] = serde_json::Value::String(new_ext);
}
rules.push(make_rule("extension", json));
}
}
let name = path.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "Imported BRU preset".to_string());
Ok(NominaPreset {
version: 1,
name,
description: "Imported from Bulk Rename Utility".to_string(),
created: chrono::Utc::now().to_rfc3339(),
rules,
filters: None,
})
}
fn make_rule(rule_type: &str, config: serde_json::Value) -> PresetRule {
let mut map = config.as_object().cloned().unwrap_or_default();
map.insert("type".to_string(), serde_json::Value::String(rule_type.to_string()));
map.insert("step_mode".to_string(), serde_json::Value::String("Simultaneous".to_string()));
map.insert("enabled".to_string(), serde_json::Value::Bool(true));
serde_json::from_value(serde_json::Value::Object(map)).unwrap()
}
/// Parse INI-like BRU format into section -> key/value maps.
/// BRU uses `[SectionName]` headers and `Key=Value` pairs.
/// Section names are normalized to lowercase for easier lookup.
fn parse_ini(text: &str) -> HashMap<String, HashMap<String, String>> {
let mut sections: HashMap<String, HashMap<String, String>> = HashMap::new();
let mut current_section = String::new();
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with(';') || trimmed.starts_with('#') {
continue;
}
if trimmed.starts_with('[') && trimmed.ends_with(']') {
current_section = trimmed[1..trimmed.len() - 1].trim().to_lowercase();
sections.entry(current_section.clone()).or_default();
} else if let Some((key, value)) = trimmed.split_once('=') {
if !current_section.is_empty() {
sections
.entry(current_section.clone())
.or_default()
.insert(key.trim().to_lowercase(), value.trim().to_string());
}
}
}
sections
}

View File

@@ -3,6 +3,7 @@ pub mod pipeline;
pub mod filter; pub mod filter;
pub mod metadata; pub mod metadata;
pub mod preset; pub mod preset;
pub mod bru;
pub mod undo; pub mod undo;
pub mod scanner; pub mod scanner;
@@ -54,6 +55,7 @@ pub struct RenameContext {
pub size: u64, pub size: u64,
pub created: Option<DateTime<Utc>>, pub created: Option<DateTime<Utc>>,
pub modified: Option<DateTime<Utc>>, pub modified: Option<DateTime<Utc>>,
pub accessed: Option<DateTime<Utc>>,
pub date_taken: Option<DateTime<Utc>>, pub date_taken: Option<DateTime<Utc>>,
pub parent_folder: String, pub parent_folder: String,
} }
@@ -69,6 +71,7 @@ impl RenameContext {
size: 0, size: 0,
created: None, created: None,
modified: None, modified: None,
accessed: None,
date_taken: None, date_taken: None,
parent_folder: String::new(), parent_folder: String::new(),
} }

View File

@@ -47,6 +47,7 @@ impl Pipeline {
size: file.size, size: file.size,
created: file.created, created: file.created,
modified: file.modified, modified: file.modified,
accessed: file.accessed,
date_taken: None, date_taken: None,
parent_folder: file parent_folder: file
.path .path
@@ -91,27 +92,11 @@ impl Pipeline {
fn apply_rules(&self, stem: &str, ctx: &RenameContext) -> String { fn apply_rules(&self, stem: &str, ctx: &RenameContext) -> String {
let mut working = stem.to_string(); let mut working = stem.to_string();
// collect simultaneous rules
let simultaneous: Vec<&PipelineStep> = self
.steps
.iter()
.filter(|s| s.mode == StepMode::Simultaneous && s.rule.is_enabled())
.collect();
// apply simultaneous rules (last one wins for the full output)
if !simultaneous.is_empty() {
let mut sim_result = stem.to_string();
for step in &simultaneous {
sim_result = step.rule.apply(stem, ctx);
}
working = sim_result;
}
// apply sequential rules in order
for step in &self.steps { for step in &self.steps {
if step.mode == StepMode::Sequential && step.rule.is_enabled() { if !step.rule.is_enabled() {
working = step.rule.apply(&working, ctx); continue;
} }
working = step.rule.apply(&working, ctx);
} }
working working
@@ -126,6 +111,18 @@ impl Pipeline {
} }
for r in &mut results { for r in &mut results {
// check for empty filename (stem is everything before the last dot)
let stem = if let Some(dot_pos) = r.new_name.rfind('.') {
&r.new_name[..dot_pos]
} else {
&r.new_name[..]
};
if stem.is_empty() {
r.has_error = true;
r.error_message = Some("Empty filename - add a rule to set a new name".into());
continue;
}
if let Some(&count) = name_counts.get(&r.new_name.to_lowercase()) { if let Some(&count) = name_counts.get(&r.new_name.to_lowercase()) {
if count > 1 { if count > 1 {
r.has_conflict = true; r.has_conflict = true;
@@ -177,6 +174,10 @@ mod tests {
match_case: true, match_case: true,
first_only: false, first_only: false,
enabled: true, enabled: true,
use_regex: false,
scope_start: None,
scope_end: None,
occurrence: None,
}), }),
StepMode::Simultaneous, StepMode::Simultaneous,
); );
@@ -197,6 +198,10 @@ mod tests {
match_case: true, match_case: true,
first_only: false, first_only: false,
enabled: true, enabled: true,
use_regex: false,
scope_start: None,
scope_end: None,
occurrence: None,
}), }),
StepMode::Sequential, StepMode::Sequential,
); );
@@ -224,6 +229,10 @@ mod tests {
match_case: true, match_case: true,
first_only: false, first_only: false,
enabled: false, enabled: false,
use_regex: false,
scope_start: None,
scope_end: None,
occurrence: None,
}), }),
StepMode::Simultaneous, StepMode::Simultaneous,
); );

View File

@@ -2,6 +2,12 @@ use serde::{Deserialize, Serialize};
use crate::{RenameContext, RenameRule}; use crate::{RenameContext, RenameRule};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InsertOp {
pub position: usize,
pub text: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AddRule { pub struct AddRule {
pub prefix: String, pub prefix: String,
@@ -10,6 +16,23 @@ pub struct AddRule {
pub insert_at: usize, pub insert_at: usize,
pub word_space: bool, pub word_space: bool,
pub enabled: bool, pub enabled: bool,
#[serde(default)]
pub inserts: Vec<InsertOp>,
}
fn expand_vars(template: &str, ctx: &RenameContext) -> String {
let date_str = ctx
.modified
.map(|d| d.format("%Y-%m-%d").to_string())
.unwrap_or_default();
template
.replace("{name}", &ctx.original_name)
.replace("{ext}", &ctx.extension)
.replace("{folder}", &ctx.parent_folder)
.replace("{date}", &date_str)
.replace("{index}", &ctx.index.to_string())
.replace("{total}", &ctx.total.to_string())
} }
impl AddRule { impl AddRule {
@@ -21,39 +44,60 @@ impl AddRule {
insert_at: 0, insert_at: 0,
word_space: false, word_space: false,
enabled: true, enabled: true,
inserts: Vec::new(),
} }
} }
} }
impl RenameRule for AddRule { impl RenameRule for AddRule {
fn apply(&self, filename: &str, _context: &RenameContext) -> String { fn apply(&self, filename: &str, context: &RenameContext) -> String {
let prefix = expand_vars(&self.prefix, context);
let suffix = expand_vars(&self.suffix, context);
let insert = expand_vars(&self.insert, context);
let mut result = filename.to_string(); let mut result = filename.to_string();
if !self.insert.is_empty() { if !insert.is_empty() {
let chars: Vec<char> = result.chars().collect(); let chars: Vec<char> = result.chars().collect();
let pos = self.insert_at.min(chars.len()); let pos = self.insert_at.min(chars.len());
let before: String = chars[..pos].iter().collect(); let before: String = chars[..pos].iter().collect();
let after: String = chars[pos..].iter().collect(); let after: String = chars[pos..].iter().collect();
if self.word_space { if self.word_space {
result = format!("{} {} {}", before.trim_end(), self.insert, after.trim_start()); result = format!("{} {} {}", before.trim_end(), insert, after.trim_start());
} else { } else {
result = format!("{}{}{}", before, self.insert, after); result = format!("{}{}{}", before, insert, after);
} }
} }
if !self.prefix.is_empty() { if !self.inserts.is_empty() {
if self.word_space { let mut ops: Vec<&InsertOp> = self.inserts.iter().collect();
result = format!("{} {}", self.prefix, result); ops.sort_by(|a, b| b.position.cmp(&a.position));
} else { for op in ops {
result = format!("{}{}", self.prefix, result); let text = expand_vars(&op.text, context);
if text.is_empty() {
continue;
}
let chars: Vec<char> = result.chars().collect();
let pos = op.position.min(chars.len());
let before: String = chars[..pos].iter().collect();
let after: String = chars[pos..].iter().collect();
result = format!("{}{}{}", before, text, after);
} }
} }
if !self.suffix.is_empty() { if !prefix.is_empty() {
if self.word_space { if self.word_space {
result = format!("{} {}", result, self.suffix); result = format!("{} {}", prefix, result);
} else { } else {
result = format!("{}{}", result, self.suffix); result = format!("{}{}", prefix, result);
}
}
if !suffix.is_empty() {
if self.word_space {
result = format!("{} {}", result, suffix);
} else {
result = format!("{}{}", result, suffix);
} }
} }
@@ -107,4 +151,39 @@ mod tests {
let ctx = RenameContext::dummy(0); let ctx = RenameContext::dummy(0);
assert_eq!(rule.apply("abcd", &ctx), "ab-x-cd"); assert_eq!(rule.apply("abcd", &ctx), "ab-x-cd");
} }
#[test]
fn expand_name_var() {
let rule = AddRule {
prefix: "{name}_copy".into(),
..AddRule::new()
};
let mut ctx = RenameContext::dummy(0);
ctx.original_name = "photo".into();
assert_eq!(rule.apply("file", &ctx), "photo_copyfile");
}
#[test]
fn expand_index_total() {
let rule = AddRule {
suffix: "_{index}_of_{total}".into(),
..AddRule::new()
};
let mut ctx = RenameContext::dummy(3);
ctx.total = 10;
assert_eq!(rule.apply("file", &ctx), "file_3_of_10");
}
#[test]
fn multi_inserts_reverse_order() {
let rule = AddRule {
inserts: vec![
InsertOp { position: 1, text: "X".into() },
InsertOp { position: 3, text: "Y".into() },
],
..AddRule::new()
};
let ctx = RenameContext::dummy(0);
assert_eq!(rule.apply("abcd", &ctx), "aXbcYd");
}
} }

View File

@@ -11,6 +11,12 @@ pub enum CaseMode {
Sentence, Sentence,
Invert, Invert,
Random, Random,
CamelCase,
PascalCase,
SnakeCase,
KebabCase,
DotCase,
SmartTitle,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -38,6 +44,62 @@ impl CaseRule {
} }
} }
fn split_words(s: &str) -> Vec<String> {
let mut words = Vec::new();
let mut current = String::new();
let chars: Vec<char> = s.chars().collect();
for i in 0..chars.len() {
let c = chars[i];
if !c.is_alphanumeric() {
if !current.is_empty() {
words.push(std::mem::take(&mut current));
}
continue;
}
let should_split = if i > 0 && chars[i - 1].is_alphanumeric() {
if c.is_uppercase() {
// don't split between consecutive uppercase unless next is lowercase
// e.g. "XMLParser" -> split before 'P' not before 'M' or 'L'
let prev_upper = chars[i - 1].is_uppercase();
if prev_upper {
i + 1 < chars.len() && chars[i + 1].is_lowercase()
} else {
true
}
} else {
false
}
} else {
false
};
if should_split && !current.is_empty() {
words.push(std::mem::take(&mut current));
}
current.push(c);
}
if !current.is_empty() {
words.push(current);
}
words
}
const SMALL_WORDS: &[&str] = &[
"a", "an", "the", "and", "but", "or", "for", "nor",
"in", "on", "at", "to", "by", "of", "up", "as",
"is", "it", "if", "so", "no", "not", "yet",
];
fn capitalize(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_uppercase().to_string() + chars.as_str(),
}
}
impl RenameRule for CaseRule { impl RenameRule for CaseRule {
fn apply(&self, filename: &str, _context: &RenameContext) -> String { fn apply(&self, filename: &str, _context: &RenameContext) -> String {
match self.mode { match self.mode {
@@ -99,6 +161,73 @@ impl RenameRule for CaseRule {
}) })
.collect() .collect()
} }
CaseMode::CamelCase => {
let words = split_words(filename);
words
.iter()
.enumerate()
.map(|(i, w)| {
if i == 0 {
w.to_lowercase()
} else {
capitalize(&w.to_lowercase())
}
})
.collect()
}
CaseMode::PascalCase => {
split_words(filename)
.iter()
.map(|w| capitalize(&w.to_lowercase()))
.collect()
}
CaseMode::SnakeCase => {
split_words(filename)
.iter()
.map(|w| w.to_lowercase())
.collect::<Vec<_>>()
.join("_")
}
CaseMode::KebabCase => {
split_words(filename)
.iter()
.map(|w| w.to_lowercase())
.collect::<Vec<_>>()
.join("-")
}
CaseMode::DotCase => {
split_words(filename)
.iter()
.map(|w| w.to_lowercase())
.collect::<Vec<_>>()
.join(".")
}
CaseMode::SmartTitle => {
let exceptions = self.exception_words();
filename
.split_inclusive(|c: char| !c.is_alphanumeric())
.enumerate()
.map(|(i, word)| {
let trimmed = word.trim();
let lower = trimmed.to_lowercase();
if i > 0
&& (exceptions.contains(&lower)
|| SMALL_WORDS.contains(&lower.as_str()))
{
word.to_lowercase()
} else {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(c) => {
c.to_uppercase().to_string()
+ &chars.as_str().to_lowercase()
}
}
}
})
.collect()
}
} }
} }
@@ -146,4 +275,53 @@ mod tests {
let ctx = RenameContext::dummy(0); let ctx = RenameContext::dummy(0);
assert_eq!(rule.apply("Hello", &ctx), "hELLO"); assert_eq!(rule.apply("Hello", &ctx), "hELLO");
} }
#[test]
fn camel_case() {
let rule = CaseRule { mode: CaseMode::CamelCase, ..CaseRule::new() };
let ctx = RenameContext::dummy(0);
assert_eq!(rule.apply("some_file-name", &ctx), "someFileName");
assert_eq!(rule.apply("hello world", &ctx), "helloWorld");
assert_eq!(rule.apply("XMLParser", &ctx), "xmlParser");
}
#[test]
fn pascal_case() {
let rule = CaseRule { mode: CaseMode::PascalCase, ..CaseRule::new() };
let ctx = RenameContext::dummy(0);
assert_eq!(rule.apply("some_file-name", &ctx), "SomeFileName");
assert_eq!(rule.apply("hello world", &ctx), "HelloWorld");
}
#[test]
fn snake_case() {
let rule = CaseRule { mode: CaseMode::SnakeCase, ..CaseRule::new() };
let ctx = RenameContext::dummy(0);
assert_eq!(rule.apply("camelCase", &ctx), "camel_case");
assert_eq!(rule.apply("XMLParser", &ctx), "xml_parser");
assert_eq!(rule.apply("some-file name", &ctx), "some_file_name");
}
#[test]
fn kebab_case() {
let rule = CaseRule { mode: CaseMode::KebabCase, ..CaseRule::new() };
let ctx = RenameContext::dummy(0);
assert_eq!(rule.apply("camelCase", &ctx), "camel-case");
assert_eq!(rule.apply("some_file name", &ctx), "some-file-name");
}
#[test]
fn dot_case() {
let rule = CaseRule { mode: CaseMode::DotCase, ..CaseRule::new() };
let ctx = RenameContext::dummy(0);
assert_eq!(rule.apply("camelCase", &ctx), "camel.case");
}
#[test]
fn smart_title() {
let rule = CaseRule { mode: CaseMode::SmartTitle, ..CaseRule::new() };
let ctx = RenameContext::dummy(0);
assert_eq!(rule.apply("the lord of the rings", &ctx), "The Lord of the Rings");
assert_eq!(rule.apply("a tale of two cities", &ctx), "A Tale of Two Cities");
}
} }

View File

@@ -75,7 +75,7 @@ impl DateRule {
match self.source { match self.source {
DateSource::Created => context.created, DateSource::Created => context.created,
DateSource::Modified => context.modified, DateSource::Modified => context.modified,
DateSource::Accessed => None, DateSource::Accessed => context.accessed,
DateSource::ExifTaken | DateSource::ExifDigitized => context.date_taken, DateSource::ExifTaken | DateSource::ExifDigitized => context.date_taken,
DateSource::Current => Some(chrono::Utc::now()), DateSource::Current => Some(chrono::Utc::now()),
} }

View File

@@ -18,6 +18,10 @@ pub struct ExtensionRule {
pub mode: ExtensionMode, pub mode: ExtensionMode,
pub fixed_value: String, pub fixed_value: String,
pub enabled: bool, pub enabled: bool,
#[serde(default)]
pub mapping: Option<Vec<(String, String)>>,
#[serde(default)]
pub multi_extension: bool,
} }
impl ExtensionRule { impl ExtensionRule {
@@ -26,10 +30,21 @@ impl ExtensionRule {
mode: ExtensionMode::Same, mode: ExtensionMode::Same,
fixed_value: String::new(), fixed_value: String::new(),
enabled: true, enabled: true,
mapping: None,
multi_extension: false,
} }
} }
pub fn transform_extension(&self, ext: &str) -> String { pub fn transform_extension(&self, ext: &str) -> String {
if let Some(ref mappings) = self.mapping {
let ext_lower = ext.to_lowercase();
for (from, to) in mappings {
if ext_lower == from.to_lowercase() {
return to.clone();
}
}
}
match self.mode { match self.mode {
ExtensionMode::Same => ext.to_string(), ExtensionMode::Same => ext.to_string(),
ExtensionMode::Lower => ext.to_lowercase(), ExtensionMode::Lower => ext.to_lowercase(),

View File

@@ -0,0 +1,127 @@
use serde::{Deserialize, Serialize};
use crate::{RenameContext, RenameRule};
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub enum FolderMode {
#[default]
None,
Prefix,
Suffix,
Replace,
}
fn default_level() -> usize {
1
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FolderNameRule {
pub mode: FolderMode,
#[serde(default = "default_level")]
pub level: usize,
pub separator: String,
pub enabled: bool,
}
impl FolderNameRule {
pub fn new() -> Self {
Self {
mode: FolderMode::None,
level: 1,
separator: String::new(),
enabled: true,
}
}
fn get_folder_name(&self, context: &RenameContext) -> Option<String> {
if self.level == 1 {
if context.parent_folder.is_empty() {
return None;
}
return Some(context.parent_folder.clone());
}
context
.path
.ancestors()
.nth(self.level)
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().to_string())
}
}
impl RenameRule for FolderNameRule {
fn apply(&self, filename: &str, context: &RenameContext) -> String {
if self.mode == FolderMode::None {
return filename.to_string();
}
let folder = match self.get_folder_name(context) {
Some(f) => f,
None => return filename.to_string(),
};
match self.mode {
FolderMode::None => unreachable!(),
FolderMode::Prefix => format!("{}{}{}", folder, self.separator, filename),
FolderMode::Suffix => format!("{}{}{}", filename, self.separator, folder),
FolderMode::Replace => folder,
}
}
fn display_name(&self) -> &str {
"Folder Name"
}
fn rule_type(&self) -> &str {
"folder_name"
}
fn is_enabled(&self) -> bool {
self.enabled
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn prefix_with_parent() {
let rule = FolderNameRule {
mode: FolderMode::Prefix,
separator: " - ".into(),
..FolderNameRule::new()
};
let mut ctx = RenameContext::dummy(0);
ctx.parent_folder = "Photos".into();
assert_eq!(rule.apply("sunset", &ctx), "Photos - sunset");
}
#[test]
fn suffix_mode() {
let rule = FolderNameRule {
mode: FolderMode::Suffix,
separator: "_".into(),
..FolderNameRule::new()
};
let mut ctx = RenameContext::dummy(0);
ctx.parent_folder = "2024".into();
assert_eq!(rule.apply("photo", &ctx), "photo_2024");
}
#[test]
fn grandparent_level() {
let rule = FolderNameRule {
mode: FolderMode::Prefix,
level: 2,
separator: "_".into(),
..FolderNameRule::new()
};
let mut ctx = RenameContext::dummy(0);
ctx.path = PathBuf::from("/home/user/docs/sub/file.txt");
assert_eq!(rule.apply("file", &ctx), "docs_file");
}
}

View File

@@ -0,0 +1,142 @@
use serde::{Deserialize, Serialize};
use sha2::Digest;
use crate::{RenameContext, RenameRule};
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub enum HashMode {
#[default]
None,
Prefix,
Suffix,
Replace,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub enum HashAlgorithm {
MD5,
SHA1,
#[default]
SHA256,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HashRule {
pub mode: HashMode,
pub algorithm: HashAlgorithm,
pub length: usize,
pub separator: String,
pub uppercase: bool,
pub enabled: bool,
}
impl HashRule {
pub fn new() -> Self {
Self {
mode: HashMode::None,
algorithm: HashAlgorithm::SHA256,
length: 0,
separator: "_".into(),
uppercase: false,
enabled: true,
}
}
fn compute_hash(&self, data: &[u8]) -> String {
let raw = match self.algorithm {
HashAlgorithm::MD5 => {
let mut h = md5::Md5::new();
h.update(data);
format!("{:x}", h.finalize())
}
HashAlgorithm::SHA1 => {
let mut h = sha1::Sha1::new();
h.update(data);
format!("{:x}", h.finalize())
}
HashAlgorithm::SHA256 => {
let mut h = sha2::Sha256::new();
h.update(data);
format!("{:x}", h.finalize())
}
};
let truncated = if self.length > 0 && self.length < raw.len() {
&raw[..self.length]
} else {
&raw
};
if self.uppercase {
truncated.to_uppercase()
} else {
truncated.to_string()
}
}
}
impl RenameRule for HashRule {
fn apply(&self, filename: &str, context: &RenameContext) -> String {
if self.mode == HashMode::None {
return filename.to_string();
}
let data = match std::fs::read(&context.path) {
Ok(d) => d,
Err(_) => return filename.to_string(),
};
let hash = self.compute_hash(&data);
match self.mode {
HashMode::None => filename.to_string(),
HashMode::Prefix => format!("{}{}{}", hash, self.separator, filename),
HashMode::Suffix => format!("{}{}{}", filename, self.separator, hash),
HashMode::Replace => hash,
}
}
fn display_name(&self) -> &str {
"Hash"
}
fn rule_type(&self) -> &str {
"hash"
}
fn is_enabled(&self) -> bool {
self.enabled
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mode_none_returns_unchanged() {
let rule = HashRule::new();
let ctx = RenameContext::dummy(0);
assert_eq!(rule.apply("photo.jpg", &ctx), "photo.jpg");
}
#[test]
fn file_read_fails_returns_unchanged() {
let mut rule = HashRule::new();
rule.mode = HashMode::Prefix;
let ctx = RenameContext::dummy(0);
// dummy context points to a nonexistent file, so read fails
assert_eq!(rule.apply("photo.jpg", &ctx), "photo.jpg");
}
#[test]
fn suffix_mode_read_fail_unchanged() {
let mut rule = HashRule::new();
rule.mode = HashMode::Suffix;
rule.algorithm = HashAlgorithm::MD5;
rule.length = 8;
rule.uppercase = true;
let ctx = RenameContext::dummy(0);
assert_eq!(rule.apply("doc.pdf", &ctx), "doc.pdf");
}
}

View File

@@ -7,13 +7,31 @@ pub mod numbering;
pub mod date; pub mod date;
pub mod move_parts; pub mod move_parts;
pub mod extension; pub mod extension;
pub mod text_editor;
pub mod hash;
pub mod folder_name;
pub mod transliterate;
pub mod padding;
pub mod truncate;
pub mod randomize;
pub mod swap;
pub mod sanitize;
pub use replace::ReplaceRule; pub use replace::ReplaceRule;
pub use self::regex::RegexRule; pub use self::regex::RegexRule;
pub use remove::RemoveRule; pub use remove::RemoveRule;
pub use add::AddRule; pub use add::{AddRule, InsertOp};
pub use case::{CaseMode, CaseRule}; pub use case::{CaseMode, CaseRule};
pub use numbering::{NumberBase, NumberMode, NumberingRule}; pub use numbering::{NumberBase, NumberMode, NumberingRule};
pub use date::{DateMode, DateRule, DateSource}; pub use date::{DateMode, DateRule, DateSource};
pub use move_parts::{MovePartsRule, MoveTarget}; pub use move_parts::{MovePartsRule, MoveTarget};
pub use extension::{ExtensionMode, ExtensionRule}; pub use extension::{ExtensionMode, ExtensionRule};
pub use text_editor::TextEditorRule;
pub use hash::HashRule;
pub use folder_name::FolderNameRule;
pub use transliterate::TransliterateRule;
pub use padding::PaddingRule;
pub use truncate::TruncateRule;
pub use randomize::RandomizeRule;
pub use swap::SwapRule;
pub use sanitize::SanitizeRule;

View File

@@ -1,3 +1,4 @@
use regex::Regex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{RenameContext, RenameRule}; use crate::{RenameContext, RenameRule};
@@ -10,6 +11,19 @@ pub enum MoveTarget {
End, End,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum SelectionMode {
Chars,
Words,
Regex,
}
impl Default for SelectionMode {
fn default() -> Self {
SelectionMode::Chars
}
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MovePartsRule { pub struct MovePartsRule {
pub source_from: usize, pub source_from: usize,
@@ -18,6 +32,16 @@ pub struct MovePartsRule {
pub separator: String, pub separator: String,
pub copy_mode: bool, pub copy_mode: bool,
pub enabled: bool, pub enabled: bool,
#[serde(default)]
pub selection_mode: SelectionMode,
#[serde(default)]
pub regex_pattern: Option<String>,
#[serde(default)]
pub regex_group: usize,
#[serde(default)]
pub swap_with_from: Option<usize>,
#[serde(default)]
pub swap_with_length: Option<usize>,
} }
impl MovePartsRule { impl MovePartsRule {
@@ -29,12 +53,62 @@ impl MovePartsRule {
separator: String::new(), separator: String::new(),
copy_mode: false, copy_mode: false,
enabled: true, enabled: true,
selection_mode: SelectionMode::Chars,
regex_pattern: None,
regex_group: 0,
swap_with_from: None,
swap_with_length: None,
} }
} }
} }
fn split_words(s: &str) -> Vec<(usize, usize)> {
let mut spans = Vec::new();
let chars: Vec<char> = s.chars().collect();
let mut i = 0;
while i < chars.len() {
if chars[i].is_whitespace() || matches!(chars[i], '_' | '-' | '.') {
i += 1;
continue;
}
let start = i;
while i < chars.len() && !chars[i].is_whitespace() && !matches!(chars[i], '_' | '-' | '.') {
i += 1;
}
spans.push((start, i));
}
spans
}
impl RenameRule for MovePartsRule { impl RenameRule for MovePartsRule {
fn apply(&self, filename: &str, _context: &RenameContext) -> String { fn apply(&self, filename: &str, _context: &RenameContext) -> String {
// swap mode
if let (Some(sw_from), Some(sw_len)) = (self.swap_with_from, self.swap_with_length) {
return self.apply_swap(filename, sw_from, sw_len);
}
match self.selection_mode {
SelectionMode::Chars => self.apply_chars(filename),
SelectionMode::Words => self.apply_words(filename),
SelectionMode::Regex => self.apply_regex(filename),
}
}
fn display_name(&self) -> &str {
"Move/Copy"
}
fn rule_type(&self) -> &str {
"move_parts"
}
fn is_enabled(&self) -> bool {
self.enabled
}
}
impl MovePartsRule {
fn apply_chars(&self, filename: &str) -> String {
if self.target == MoveTarget::None || self.source_length == 0 { if self.target == MoveTarget::None || self.source_length == 0 {
return filename.to_string(); return filename.to_string();
} }
@@ -59,8 +133,132 @@ impl RenameRule for MovePartsRule {
r r
}; };
self.place_at_target(&extracted, &remaining)
}
fn apply_words(&self, filename: &str) -> String {
if self.target == MoveTarget::None || self.source_length == 0 {
return filename.to_string();
}
let words = split_words(filename);
if self.source_from >= words.len() {
return filename.to_string();
}
let word_end = (self.source_from + self.source_length).min(words.len());
let byte_start = words[self.source_from].0;
let byte_end = words[word_end - 1].1;
let chars: Vec<char> = filename.chars().collect();
let extracted: String = chars[byte_start..byte_end].iter().collect();
let remaining: String = if self.copy_mode {
filename.to_string()
} else {
// remove extracted span, trimming adjacent separators
let mut r = String::new();
let mut skip_start = byte_start;
let mut skip_end = byte_end;
// extend skip to eat one adjacent separator
if skip_end < chars.len() && (chars[skip_end].is_whitespace() || matches!(chars[skip_end], '_' | '-' | '.')) {
skip_end += 1;
} else if skip_start > 0 && (chars[skip_start - 1].is_whitespace() || matches!(chars[skip_start - 1], '_' | '-' | '.')) {
skip_start -= 1;
}
for (i, c) in chars.iter().enumerate() {
if i < skip_start || i >= skip_end {
r.push(*c);
}
}
r
};
self.place_at_target(&extracted, &remaining)
}
fn apply_regex(&self, filename: &str) -> String {
let pat = match &self.regex_pattern {
Some(p) if !p.is_empty() => p,
_ => return filename.to_string(),
};
let re = match Regex::new(pat) {
Ok(r) => r,
Err(_) => return filename.to_string(),
};
let caps = match re.captures(filename) {
Some(c) => c,
None => return filename.to_string(),
};
let full_match = match caps.get(0) {
Some(m) => m,
None => return filename.to_string(),
};
let extracted = match caps.get(self.regex_group) {
Some(m) => m.as_str().to_string(),
None => return filename.to_string(),
};
if self.target == MoveTarget::None {
return filename.to_string();
}
// remove the full match from filename
let remaining = if self.copy_mode {
filename.to_string()
} else {
let mut r = String::new();
r.push_str(&filename[..full_match.start()]);
r.push_str(&filename[full_match.end()..]);
r
};
self.place_at_target(&extracted, &remaining)
}
fn apply_swap(&self, filename: &str, sw_from: usize, sw_len: usize) -> String {
let chars: Vec<char> = filename.chars().collect();
let len = chars.len();
let a_start = self.source_from;
let a_end = (a_start + self.source_length).min(len);
let b_start = sw_from;
let b_end = (b_start + sw_len).min(len);
if a_start >= len || b_start >= len || self.source_length == 0 || sw_len == 0 {
return filename.to_string();
}
// ranges must not overlap
if a_start < b_end && b_start < a_end {
return filename.to_string();
}
// ensure first < second
let (r1_start, r1_end, r2_start, r2_end) = if a_start < b_start {
(a_start, a_end, b_start, b_end)
} else {
(b_start, b_end, a_start, a_end)
};
let part1: String = chars[r1_start..r1_end].iter().collect();
let part2: String = chars[r2_start..r2_end].iter().collect();
let mut result = String::new();
let before: String = chars[..r1_start].iter().collect();
let mid: String = chars[r1_end..r2_start].iter().collect();
let after: String = chars[r2_end..].iter().collect();
result.push_str(&before);
result.push_str(&part2);
result.push_str(&mid);
result.push_str(&part1);
result.push_str(&after);
result
}
fn place_at_target(&self, extracted: &str, remaining: &str) -> String {
match &self.target { match &self.target {
MoveTarget::None => filename.to_string(), MoveTarget::None => remaining.to_string(),
MoveTarget::Start => { MoveTarget::Start => {
if self.separator.is_empty() { if self.separator.is_empty() {
format!("{}{}", extracted, remaining) format!("{}{}", extracted, remaining)
@@ -84,18 +282,6 @@ impl RenameRule for MovePartsRule {
} }
} }
} }
fn display_name(&self) -> &str {
"Move/Copy"
}
fn rule_type(&self) -> &str {
"move_parts"
}
fn is_enabled(&self) -> bool {
self.enabled
}
} }
#[cfg(test)] #[cfg(test)]

View File

@@ -18,6 +18,7 @@ pub enum NumberBase {
Octal, Octal,
Binary, Binary,
Alpha, Alpha,
Roman,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -32,6 +33,29 @@ pub struct NumberingRule {
pub per_folder: bool, pub per_folder: bool,
pub insert_at: usize, pub insert_at: usize,
pub enabled: bool, pub enabled: bool,
#[serde(default)]
pub custom_format: Option<String>,
#[serde(default)]
pub reverse: bool,
}
fn to_roman(mut n: i64) -> Option<String> {
if n <= 0 {
return None;
}
let table = [
(1000, "M"), (900, "CM"), (500, "D"), (400, "CD"),
(100, "C"), (90, "XC"), (50, "L"), (40, "XL"),
(10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I"),
];
let mut result = String::new();
for &(val, sym) in &table {
while n >= val {
result.push_str(sym);
n -= val;
}
}
Some(result)
} }
impl NumberingRule { impl NumberingRule {
@@ -47,6 +71,8 @@ impl NumberingRule {
per_folder: false, per_folder: false,
insert_at: 0, insert_at: 0,
enabled: true, enabled: true,
custom_format: None,
reverse: false,
} }
} }
@@ -71,6 +97,9 @@ impl NumberingRule {
} }
result result
} }
NumberBase::Roman => {
return to_roman(n).unwrap_or_else(|| format!("{}", n));
}
}; };
if self.base != NumberBase::Alpha && s.len() < self.padding { if self.base != NumberBase::Alpha && s.len() < self.padding {
@@ -87,7 +116,11 @@ impl RenameRule for NumberingRule {
return filename.to_string(); return filename.to_string();
} }
let idx = context.index as i64; let idx = if self.reverse {
context.total as i64 - 1 - context.index as i64
} else {
context.index as i64
};
let n = if self.break_at > 0 { let n = if self.break_at > 0 {
self.start + (idx % self.break_at as i64) * self.increment self.start + (idx % self.break_at as i64) * self.increment
} else { } else {
@@ -95,6 +128,10 @@ impl RenameRule for NumberingRule {
}; };
let num = self.format_number(n); let num = self.format_number(n);
if let Some(ref fmt) = self.custom_format {
return fmt.replace("{n}", &num);
}
match self.mode { match self.mode {
NumberMode::None => filename.to_string(), NumberMode::None => filename.to_string(),
NumberMode::Prefix => format!("{}{}{}", num, self.separator, filename), NumberMode::Prefix => format!("{}{}{}", num, self.separator, filename),
@@ -173,4 +210,63 @@ mod tests {
let ctx = RenameContext::dummy(0); let ctx = RenameContext::dummy(0);
assert_eq!(rule.apply("file", &ctx), "file_a"); assert_eq!(rule.apply("file", &ctx), "file_a");
} }
#[test]
fn roman_numbering() {
let rule = NumberingRule {
mode: NumberMode::Prefix,
start: 1,
increment: 1,
separator: "_".into(),
base: NumberBase::Roman,
..NumberingRule::new()
};
assert_eq!(rule.apply("track", &RenameContext::dummy(0)), "I_track");
assert_eq!(rule.apply("track", &RenameContext::dummy(3)), "IV_track");
}
#[test]
fn roman_zero_falls_back() {
let rule = NumberingRule {
mode: NumberMode::Prefix,
start: 0,
increment: 1,
separator: "_".into(),
base: NumberBase::Roman,
..NumberingRule::new()
};
assert_eq!(rule.apply("x", &RenameContext::dummy(0)), "0_x");
}
#[test]
fn custom_format() {
let rule = NumberingRule {
mode: NumberMode::Prefix,
start: 1,
increment: 1,
padding: 3,
custom_format: Some("File_{n}".into()),
..NumberingRule::new()
};
let ctx = RenameContext::dummy(2);
assert_eq!(rule.apply("ignored", &ctx), "File_003");
}
#[test]
fn reverse_numbering() {
let rule = NumberingRule {
mode: NumberMode::Prefix,
start: 1,
increment: 1,
padding: 1,
separator: "_".into(),
reverse: true,
..NumberingRule::new()
};
let mut ctx = RenameContext::dummy(0);
ctx.total = 5;
assert_eq!(rule.apply("a", &ctx), "5_a");
ctx.index = 4;
assert_eq!(rule.apply("a", &ctx), "1_a");
}
} }

View File

@@ -0,0 +1,124 @@
use regex::Regex;
use serde::{Deserialize, Serialize};
use crate::{RenameContext, RenameRule};
fn default_width() -> usize {
3
}
fn default_pad_char() -> char {
'0'
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PaddingRule {
#[serde(default = "default_width")]
pub width: usize,
#[serde(default = "default_pad_char")]
pub pad_char: char,
#[serde(default)]
pub pad_all: bool,
pub enabled: bool,
}
impl PaddingRule {
pub fn new() -> Self {
Self {
width: default_width(),
pad_char: default_pad_char(),
pad_all: true,
enabled: true,
}
}
}
impl RenameRule for PaddingRule {
fn apply(&self, filename: &str, _context: &RenameContext) -> String {
let re = match Regex::new(r"\d+") {
Ok(r) => r,
Err(_) => return filename.to_string(),
};
let mut result = String::new();
let mut last = 0;
let mut count = 0;
for m in re.find_iter(filename) {
count += 1;
result.push_str(&filename[last..m.start()]);
if self.pad_all || count == 1 {
let num_str = m.as_str();
if num_str.len() < self.width {
let pad_count = self.width - num_str.len();
for _ in 0..pad_count {
result.push(self.pad_char);
}
}
result.push_str(num_str);
} else {
result.push_str(m.as_str());
}
last = m.end();
}
result.push_str(&filename[last..]);
result
}
fn display_name(&self) -> &str {
"Padding"
}
fn rule_type(&self) -> &str {
"padding"
}
fn is_enabled(&self) -> bool {
self.enabled
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rule(width: usize, pad_all: bool) -> PaddingRule {
PaddingRule {
width,
pad_char: '0',
pad_all,
enabled: true,
}
}
#[test]
fn pad_single_number() {
let r = rule(3, true);
let ctx = RenameContext::dummy(0);
assert_eq!(r.apply("file1", &ctx), "file001");
}
#[test]
fn pad_multiple_numbers() {
let r = rule(3, true);
let ctx = RenameContext::dummy(0);
assert_eq!(r.apply("img2_v10", &ctx), "img002_v010");
}
#[test]
fn pad_first_only() {
let r = rule(3, false);
let ctx = RenameContext::dummy(0);
assert_eq!(r.apply("file1", &ctx), "file001");
}
#[test]
fn already_wide_enough() {
let r = rule(3, true);
let ctx = RenameContext::dummy(0);
assert_eq!(r.apply("file100", &ctx), "file100");
}
}

View File

@@ -0,0 +1,123 @@
use rand::Rng;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{RenameContext, RenameRule};
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub enum RandomMode {
#[default]
Replace,
Prefix,
Suffix,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub enum RandomFormat {
#[default]
Hex,
Alpha,
AlphaNum,
UUID,
}
fn default_length() -> usize {
8
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RandomizeRule {
pub mode: RandomMode,
pub format: RandomFormat,
#[serde(default = "default_length")]
pub length: usize,
pub separator: String,
pub enabled: bool,
}
impl RandomizeRule {
pub fn new() -> Self {
Self {
mode: RandomMode::Replace,
format: RandomFormat::Hex,
length: 8,
separator: String::from("_"),
enabled: true,
}
}
fn generate_random(&self) -> String {
if self.format == RandomFormat::UUID {
return Uuid::new_v4().to_string();
}
let mut rng = rand::thread_rng();
let chars: &[u8] = match self.format {
RandomFormat::Hex => b"0123456789abcdef",
RandomFormat::Alpha => b"abcdefghijklmnopqrstuvwxyz",
RandomFormat::AlphaNum => b"abcdefghijklmnopqrstuvwxyz0123456789",
RandomFormat::UUID => unreachable!(),
};
(0..self.length)
.map(|_| chars[rng.gen_range(0..chars.len())] as char)
.collect()
}
}
impl RenameRule for RandomizeRule {
fn apply(&self, filename: &str, _context: &RenameContext) -> String {
let random = self.generate_random();
match self.mode {
RandomMode::Replace => random,
RandomMode::Prefix => format!("{}{}{}", random, self.separator, filename),
RandomMode::Suffix => format!("{}{}{}", filename, self.separator, random),
}
}
fn display_name(&self) -> &str {
"Randomize"
}
fn rule_type(&self) -> &str {
"randomize"
}
fn is_enabled(&self) -> bool {
self.enabled
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn replace_mode_length() {
let rule = RandomizeRule {
mode: RandomMode::Replace,
format: RandomFormat::Hex,
length: 12,
separator: String::new(),
enabled: true,
};
let ctx = RenameContext::dummy(0);
let result = rule.apply("anything", &ctx);
assert_eq!(result.len(), 12);
}
#[test]
fn prefix_mode_contains_filename() {
let rule = RandomizeRule {
mode: RandomMode::Prefix,
format: RandomFormat::Alpha,
length: 6,
separator: "-".into(),
enabled: true,
};
let ctx = RenameContext::dummy(0);
let result = rule.apply("photo", &ctx);
assert!(result.contains("photo"));
assert!(result.ends_with("photo"));
}
}

View File

@@ -9,6 +9,8 @@ pub struct RegexRule {
pub replace_with: String, pub replace_with: String,
pub case_insensitive: bool, pub case_insensitive: bool,
pub enabled: bool, pub enabled: bool,
#[serde(default)]
pub match_limit: Option<usize>,
} }
impl RegexRule { impl RegexRule {
@@ -18,9 +20,19 @@ impl RegexRule {
replace_with: String::new(), replace_with: String::new(),
case_insensitive: false, case_insensitive: false,
enabled: true, enabled: true,
match_limit: None,
} }
} }
pub fn validate(&self) -> Result<(), String> {
let pat = if self.case_insensitive {
format!("(?i){}", self.pattern)
} else {
self.pattern.clone()
};
Regex::new(&pat).map(|_| ()).map_err(|e| e.to_string())
}
fn build_regex(&self) -> std::result::Result<Regex, NominaError> { fn build_regex(&self) -> std::result::Result<Regex, NominaError> {
let pat = if self.case_insensitive { let pat = if self.case_insensitive {
format!("(?i){}", self.pattern) format!("(?i){}", self.pattern)
@@ -40,7 +52,10 @@ impl RenameRule for RegexRule {
return filename.to_string(); return filename.to_string();
} }
match self.build_regex() { match self.build_regex() {
Ok(re) => re.replace_all(filename, self.replace_with.as_str()).into_owned(), Ok(re) => match self.match_limit {
Some(n) => re.replacen(filename, n, self.replace_with.as_str()).into_owned(),
None => re.replace_all(filename, self.replace_with.as_str()).into_owned(),
},
Err(_) => filename.to_string(), Err(_) => filename.to_string(),
} }
} }
@@ -69,6 +84,7 @@ mod tests {
replace_with: "NUM".into(), replace_with: "NUM".into(),
case_insensitive: false, case_insensitive: false,
enabled: true, enabled: true,
match_limit: None,
}; };
let ctx = RenameContext::dummy(0); let ctx = RenameContext::dummy(0);
assert_eq!(rule.apply("file123", &ctx), "fileNUM"); assert_eq!(rule.apply("file123", &ctx), "fileNUM");
@@ -81,6 +97,7 @@ mod tests {
replace_with: "${2}_${1}".into(), replace_with: "${2}_${1}".into(),
case_insensitive: false, case_insensitive: false,
enabled: true, enabled: true,
match_limit: None,
}; };
let ctx = RenameContext::dummy(0); let ctx = RenameContext::dummy(0);
assert_eq!(rule.apply("hello-world", &ctx), "world_hello"); assert_eq!(rule.apply("hello-world", &ctx), "world_hello");
@@ -93,6 +110,7 @@ mod tests {
replace_with: "x".into(), replace_with: "x".into(),
case_insensitive: false, case_insensitive: false,
enabled: true, enabled: true,
match_limit: None,
}; };
let ctx = RenameContext::dummy(0); let ctx = RenameContext::dummy(0);
assert_eq!(rule.apply("test", &ctx), "test"); assert_eq!(rule.apply("test", &ctx), "test");

View File

@@ -1,9 +1,12 @@
use regex::Regex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use unicode_normalization::UnicodeNormalization;
use crate::{RenameContext, RenameRule}; use crate::{RenameContext, RenameRule};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub enum RemoveMode { pub enum RemoveMode {
#[default]
Chars, Chars,
Words, Words,
} }
@@ -29,16 +32,78 @@ impl Default for TrimOptions {
} }
} }
const WORD_SEPARATORS: &[char] = &[' ', '_', '-', '.'];
fn split_words(s: &str) -> Vec<(String, String)> {
// Returns pairs of (word, trailing_separator).
// The last word may have an empty separator.
let mut words: Vec<(String, String)> = Vec::new();
let mut word = String::new();
let mut sep = String::new();
let mut in_sep = false;
for c in s.chars() {
if WORD_SEPARATORS.contains(&c) {
if !in_sep && !word.is_empty() {
in_sep = true;
sep.push(c);
} else if in_sep {
sep.push(c);
} else {
// leading separator before any word
sep.push(c);
}
} else {
if in_sep {
words.push((std::mem::take(&mut word), std::mem::take(&mut sep)));
in_sep = false;
} else if !sep.is_empty() {
// leading separators - attach to first word
words.push((String::new(), std::mem::take(&mut sep)));
}
word.push(c);
}
}
// push whatever is left
if !word.is_empty() || !sep.is_empty() {
words.push((word, sep));
}
words
}
fn join_words(parts: &[(String, String)]) -> String {
let mut out = String::new();
for (w, s) in parts {
out.push_str(w);
out.push_str(s);
}
out
}
fn strip_accents(s: &str) -> String {
s.nfkd()
.filter(|c| !unicode_normalization::char::is_combining_mark(*c))
.collect()
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemoveRule { pub struct RemoveRule {
pub first_n: usize, pub first_n: usize,
pub last_n: usize, pub last_n: usize,
pub from: usize, pub from: usize,
pub to: usize, pub to: usize,
#[serde(default)]
pub mode: RemoveMode, pub mode: RemoveMode,
pub crop_before: Option<String>, pub crop_before: Option<String>,
pub crop_after: Option<String>, pub crop_after: Option<String>,
#[serde(default)]
pub trim: TrimOptions, pub trim: TrimOptions,
#[serde(default)]
pub collapse_chars: Option<String>,
#[serde(default)]
pub remove_pattern: Option<String>,
#[serde(default)]
pub allow_empty: bool,
pub enabled: bool, pub enabled: bool,
} }
@@ -53,6 +118,9 @@ impl RemoveRule {
crop_before: None, crop_before: None,
crop_after: None, crop_after: None,
trim: TrimOptions::default(), trim: TrimOptions::default(),
collapse_chars: None,
remove_pattern: None,
allow_empty: false,
enabled: true, enabled: true,
} }
} }
@@ -78,20 +146,23 @@ impl RenameRule for RemoveRule {
let s: String = result.iter().collect(); let s: String = result.iter().collect();
let mut result = s; let mut result = s;
// remove first N match self.mode {
if self.first_n > 0 && self.first_n < result.chars().count() { RemoveMode::Chars => {
if self.first_n > 0 {
let count = result.chars().count();
if self.first_n < count || self.allow_empty {
result = result.chars().skip(self.first_n).collect(); result = result.chars().skip(self.first_n).collect();
} }
}
// remove last N
if self.last_n > 0 { if self.last_n > 0 {
let count = result.chars().count(); let count = result.chars().count();
if self.last_n < count { if self.last_n < count || self.allow_empty {
result = result.chars().take(count - self.last_n).collect(); let take = count.saturating_sub(self.last_n);
result = result.chars().take(take).collect();
} }
} }
// remove from..to range
if self.to > self.from && self.from < result.chars().count() { if self.to > self.from && self.from < result.chars().count() {
let chars: Vec<char> = result.chars().collect(); let chars: Vec<char> = result.chars().collect();
let to = self.to.min(chars.len()); let to = self.to.min(chars.len());
@@ -103,6 +174,65 @@ impl RenameRule for RemoveRule {
} }
result = s; result = s;
} }
}
RemoveMode::Words => {
let mut parts = split_words(&result);
// count only non-empty words
let word_indices: Vec<usize> = parts
.iter()
.enumerate()
.filter(|(_, (w, _))| !w.is_empty())
.map(|(i, _)| i)
.collect();
if self.first_n > 0 && (self.first_n < word_indices.len() || self.allow_empty) {
// remove first N words and their trailing separators
for &idx in word_indices.iter().take(self.first_n) {
parts[idx].0.clear();
parts[idx].1.clear();
}
}
// recompute after removal
let word_indices: Vec<usize> = parts
.iter()
.enumerate()
.filter(|(_, (w, _))| !w.is_empty())
.map(|(i, _)| i)
.collect();
if self.last_n > 0 && (self.last_n < word_indices.len() || self.allow_empty) {
let start = word_indices.len() - self.last_n;
for &idx in &word_indices[start..] {
// remove separator before this word (from previous part)
if idx > 0 {
parts[idx - 1].1.clear();
}
parts[idx].0.clear();
parts[idx].1.clear();
}
}
// recompute for from/to
let word_indices: Vec<usize> = parts
.iter()
.enumerate()
.filter(|(_, (w, _))| !w.is_empty())
.map(|(i, _)| i)
.collect();
if self.to > self.from && self.from < word_indices.len() {
let to = self.to.min(word_indices.len());
for &idx in &word_indices[self.from..to] {
parts[idx].0.clear();
parts[idx].1.clear();
}
}
result = join_words(&parts);
}
}
// trim options // trim options
if self.trim.lead_dots { if self.trim.lead_dots {
@@ -120,6 +250,43 @@ impl RenameRule for RemoveRule {
.filter(|c| c.is_alphanumeric() || c.is_whitespace() || *c == '-' || *c == '_' || *c == '.') .filter(|c| c.is_alphanumeric() || c.is_whitespace() || *c == '-' || *c == '_' || *c == '.')
.collect(); .collect();
} }
if self.trim.accents {
result = strip_accents(&result);
}
// collapse runs of specified chars
if let Some(ref chars) = self.collapse_chars {
let set: Vec<char> = chars.chars().collect();
let mut collapsed = String::new();
let mut prev_was_target = false;
let mut prev_char: Option<char> = None;
for c in result.chars() {
if set.contains(&c) {
if prev_was_target && prev_char == Some(c) {
continue;
}
if prev_was_target && set.len() > 1 {
// different target char in a run - still collapse
continue;
}
prev_was_target = true;
prev_char = Some(c);
collapsed.push(c);
} else {
prev_was_target = false;
prev_char = Some(c);
collapsed.push(c);
}
}
result = collapsed;
}
// remove regex pattern matches
if let Some(ref pat) = self.remove_pattern {
if let Ok(re) = Regex::new(pat) {
result = re.replace_all(&result, "").into_owned();
}
}
result result
} }
@@ -170,4 +337,92 @@ mod tests {
let ctx = RenameContext::dummy(0); let ctx = RenameContext::dummy(0);
assert_eq!(rule.apply("prefix-content", &ctx), "-content"); assert_eq!(rule.apply("prefix-content", &ctx), "-content");
} }
#[test]
fn words_first_n() {
let rule = RemoveRule {
first_n: 1,
mode: RemoveMode::Words,
..RemoveRule::new()
};
let ctx = RenameContext::dummy(0);
assert_eq!(rule.apply("hello_world-foo bar", &ctx), "world-foo bar");
}
#[test]
fn words_last_n() {
let rule = RemoveRule {
last_n: 1,
mode: RemoveMode::Words,
..RemoveRule::new()
};
let ctx = RenameContext::dummy(0);
assert_eq!(rule.apply("hello_world_end", &ctx), "hello_world");
}
#[test]
fn words_from_to() {
let rule = RemoveRule {
from: 1,
to: 2,
mode: RemoveMode::Words,
..RemoveRule::new()
};
let ctx = RenameContext::dummy(0);
assert_eq!(rule.apply("aaa-bbb-ccc", &ctx), "aaa-ccc");
}
#[test]
fn trim_accents() {
let rule = RemoveRule {
trim: TrimOptions {
accents: true,
..TrimOptions::default()
},
..RemoveRule::new()
};
let ctx = RenameContext::dummy(0);
assert_eq!(rule.apply("cafe\u{0301}", &ctx), "cafe");
assert_eq!(rule.apply("\u{00e9}t\u{00e9}", &ctx), "ete");
}
#[test]
fn collapse_chars_single() {
let rule = RemoveRule {
collapse_chars: Some("-".into()),
..RemoveRule::new()
};
let ctx = RenameContext::dummy(0);
assert_eq!(rule.apply("file---name", &ctx), "file-name");
}
#[test]
fn collapse_chars_multi() {
let rule = RemoveRule {
collapse_chars: Some("-_".into()),
..RemoveRule::new()
};
let ctx = RenameContext::dummy(0);
assert_eq!(rule.apply("file-_-name", &ctx), "file-name");
}
#[test]
fn remove_pattern_basic() {
let rule = RemoveRule {
remove_pattern: Some(r"\d+".into()),
..RemoveRule::new()
};
let ctx = RenameContext::dummy(0);
assert_eq!(rule.apply("file123name456", &ctx), "filename");
}
#[test]
fn remove_pattern_complex() {
let rule = RemoveRule {
remove_pattern: Some(r"\s*\(copy\)".into()),
..RemoveRule::new()
};
let ctx = RenameContext::dummy(0);
assert_eq!(rule.apply("document (copy)", &ctx), "document");
}
} }

View File

@@ -1,3 +1,4 @@
use regex::Regex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{RenameContext, RenameRule}; use crate::{RenameContext, RenameRule};
@@ -9,6 +10,14 @@ pub struct ReplaceRule {
pub match_case: bool, pub match_case: bool,
pub first_only: bool, pub first_only: bool,
pub enabled: bool, pub enabled: bool,
#[serde(default)]
pub use_regex: bool,
#[serde(default)]
pub scope_start: Option<usize>,
#[serde(default)]
pub scope_end: Option<usize>,
#[serde(default)]
pub occurrence: Option<usize>,
} }
impl ReplaceRule { impl ReplaceRule {
@@ -19,37 +28,116 @@ impl ReplaceRule {
match_case: true, match_case: true,
first_only: false, first_only: false,
enabled: true, enabled: true,
use_regex: false,
scope_start: None,
scope_end: None,
occurrence: None,
} }
} }
}
impl RenameRule for ReplaceRule { fn effective_limit(&self) -> Option<usize> {
fn apply(&self, filename: &str, _context: &RenameContext) -> String { if let Some(n) = self.occurrence {
if self.search.is_empty() { Some(n)
return filename.to_string(); } else if self.first_only {
Some(1)
} else {
None
} }
}
fn replace_inner(&self, text: &str) -> String {
if self.use_regex {
self.replace_regex(text)
} else {
self.replace_literal(text)
}
}
fn replace_regex(&self, text: &str) -> String {
let pat = if self.match_case {
self.search.clone()
} else {
format!("(?i){}", self.search)
};
let re = match Regex::new(&pat) {
Ok(r) => r,
Err(_) => return text.to_string(),
};
let limit = self.effective_limit();
let mut result = String::new();
let mut last = 0;
let mut count = 0;
for m in re.find_iter(text) {
count += 1;
match limit {
Some(n) if count < n => {
result.push_str(&text[last..m.end()]);
last = m.end();
}
Some(n) if count == n => {
result.push_str(&text[last..m.start()]);
let replaced = re.replace(m.as_str(), self.replace_with.as_str());
result.push_str(&replaced);
result.push_str(&text[m.end()..]);
return result;
}
Some(_) => {
// past target, shouldn't happen since we return above
break;
}
None => {
result.push_str(&text[last..m.start()]);
let replaced = re.replace(m.as_str(), self.replace_with.as_str());
result.push_str(&replaced);
last = m.end();
}
}
}
result.push_str(&text[last..]);
result
}
fn replace_literal(&self, text: &str) -> String {
let limit = self.effective_limit();
if self.match_case { if self.match_case {
if self.first_only { match limit {
filename.replacen(&self.search, &self.replace_with, 1) Some(n) => self.replace_nth_literal(text, &self.search, n),
} else { None => text.replace(&self.search, &self.replace_with),
filename.replace(&self.search, &self.replace_with)
} }
} else { } else {
let lower_search = self.search.to_lowercase(); let lower_search = self.search.to_lowercase();
let mut result = String::new(); let mut result = String::new();
let mut remaining = filename; let mut remaining = text;
let mut count = 0;
loop { loop {
let lower_remaining = remaining.to_lowercase(); let lower_remaining = remaining.to_lowercase();
match lower_remaining.find(&lower_search) { match lower_remaining.find(&lower_search) {
Some(pos) => { Some(pos) => {
count += 1;
match limit {
Some(n) if count < n => {
result.push_str(&remaining[..pos + self.search.len()]);
remaining = &remaining[pos + self.search.len()..];
}
Some(n) if count == n => {
result.push_str(&remaining[..pos]);
result.push_str(&self.replace_with);
result.push_str(&remaining[pos + self.search.len()..]);
return result;
}
Some(_) => {
result.push_str(remaining);
return result;
}
None => {
result.push_str(&remaining[..pos]); result.push_str(&remaining[..pos]);
result.push_str(&self.replace_with); result.push_str(&self.replace_with);
remaining = &remaining[pos + self.search.len()..]; remaining = &remaining[pos + self.search.len()..];
if self.first_only { }
result.push_str(remaining);
break;
} }
} }
None => { None => {
@@ -62,6 +150,58 @@ impl RenameRule for ReplaceRule {
} }
} }
fn replace_nth_literal(&self, text: &str, search: &str, n: usize) -> String {
let mut result = String::new();
let mut remaining = text;
let mut count = 0;
loop {
match remaining.find(search) {
Some(pos) => {
count += 1;
if count == n {
result.push_str(&remaining[..pos]);
result.push_str(&self.replace_with);
result.push_str(&remaining[pos + search.len()..]);
return result;
}
result.push_str(&remaining[..pos + search.len()]);
remaining = &remaining[pos + search.len()..];
}
None => {
result.push_str(remaining);
break;
}
}
}
result
}
}
impl RenameRule for ReplaceRule {
fn apply(&self, filename: &str, _context: &RenameContext) -> String {
if self.search.is_empty() {
return filename.to_string();
}
let has_scope = self.scope_start.is_some() || self.scope_end.is_some();
if has_scope {
let chars: Vec<char> = filename.chars().collect();
let start = self.scope_start.unwrap_or(0).min(chars.len());
let end = self.scope_end.unwrap_or(chars.len()).min(chars.len());
if start >= end {
return filename.to_string();
}
let prefix: String = chars[..start].iter().collect();
let scoped: String = chars[start..end].iter().collect();
let suffix: String = chars[end..].iter().collect();
let replaced = self.replace_inner(&scoped);
format!("{}{}{}", prefix, replaced, suffix)
} else {
self.replace_inner(filename)
}
}
fn display_name(&self) -> &str { fn display_name(&self) -> &str {
"Replace" "Replace"
} }
@@ -79,6 +219,20 @@ impl RenameRule for ReplaceRule {
mod tests { mod tests {
use super::*; use super::*;
fn base_rule(search: &str, replace_with: &str) -> ReplaceRule {
ReplaceRule {
search: search.into(),
replace_with: replace_with.into(),
match_case: true,
first_only: false,
enabled: true,
use_regex: false,
scope_start: None,
scope_end: None,
occurrence: None,
}
}
#[test] #[test]
fn basic_replace() { fn basic_replace() {
let rule = ReplaceRule { let rule = ReplaceRule {
@@ -87,6 +241,10 @@ mod tests {
match_case: true, match_case: true,
first_only: false, first_only: false,
enabled: true, enabled: true,
use_regex: false,
scope_start: None,
scope_end: None,
occurrence: None,
}; };
let ctx = RenameContext::dummy(0); let ctx = RenameContext::dummy(0);
assert_eq!(rule.apply("IMG_001", &ctx), "photo-001"); assert_eq!(rule.apply("IMG_001", &ctx), "photo-001");
@@ -100,6 +258,10 @@ mod tests {
match_case: false, match_case: false,
first_only: false, first_only: false,
enabled: true, enabled: true,
use_regex: false,
scope_start: None,
scope_end: None,
occurrence: None,
}; };
let ctx = RenameContext::dummy(0); let ctx = RenameContext::dummy(0);
assert_eq!(rule.apply("IMG_001", &ctx), "photo-001"); assert_eq!(rule.apply("IMG_001", &ctx), "photo-001");
@@ -113,6 +275,10 @@ mod tests {
match_case: true, match_case: true,
first_only: true, first_only: true,
enabled: true, enabled: true,
use_regex: false,
scope_start: None,
scope_end: None,
occurrence: None,
}; };
let ctx = RenameContext::dummy(0); let ctx = RenameContext::dummy(0);
assert_eq!(rule.apply("aaa", &ctx), "baa"); assert_eq!(rule.apply("aaa", &ctx), "baa");
@@ -126,8 +292,120 @@ mod tests {
match_case: true, match_case: true,
first_only: false, first_only: false,
enabled: true, enabled: true,
use_regex: false,
scope_start: None,
scope_end: None,
occurrence: None,
}; };
let ctx = RenameContext::dummy(0); let ctx = RenameContext::dummy(0);
assert_eq!(rule.apply("test", &ctx), "test"); assert_eq!(rule.apply("test", &ctx), "test");
} }
#[test]
fn regex_replace() {
let mut rule = base_rule(r"\d+", "NUM");
rule.use_regex = true;
let ctx = RenameContext::dummy(0);
assert_eq!(rule.apply("file123-456", &ctx), "fileNUM-NUM");
}
#[test]
fn regex_first_only() {
let mut rule = base_rule(r"\d+", "NUM");
rule.use_regex = true;
rule.first_only = true;
let ctx = RenameContext::dummy(0);
assert_eq!(rule.apply("file123-456", &ctx), "fileNUM-456");
}
#[test]
fn regex_invalid_pattern() {
let mut rule = base_rule(r"[invalid", "x");
rule.use_regex = true;
let ctx = RenameContext::dummy(0);
assert_eq!(rule.apply("test", &ctx), "test");
}
#[test]
fn regex_case_insensitive() {
let mut rule = base_rule("hello", "HI");
rule.use_regex = true;
rule.match_case = false;
let ctx = RenameContext::dummy(0);
assert_eq!(rule.apply("HELLO world", &ctx), "HI world");
}
#[test]
fn scope_basic() {
let mut rule = base_rule("a", "X");
rule.scope_start = Some(2);
rule.scope_end = Some(5);
let ctx = RenameContext::dummy(0);
// "abcabc" - scope chars 2..5 = "cab" - replace 'a' -> 'X' = "cXb"
assert_eq!(rule.apply("abcabc", &ctx), "abcXbc");
}
#[test]
fn scope_no_start() {
let mut rule = base_rule("a", "X");
rule.scope_end = Some(3);
let ctx = RenameContext::dummy(0);
// scope 0..3 = "abc" -> "Xbc", rest = "abc"
assert_eq!(rule.apply("abcabc", &ctx), "Xbcabc");
}
#[test]
fn scope_no_end() {
let mut rule = base_rule("a", "X");
rule.scope_start = Some(3);
let ctx = RenameContext::dummy(0);
// prefix = "abc", scope 3.. = "abc" -> "Xbc"
assert_eq!(rule.apply("abcabc", &ctx), "abcXbc");
}
#[test]
fn occurrence_nth() {
let mut rule = base_rule("a", "X");
rule.occurrence = Some(2);
let ctx = RenameContext::dummy(0);
assert_eq!(rule.apply("ababa", &ctx), "abXba");
}
#[test]
fn occurrence_overrides_first_only() {
let mut rule = base_rule("a", "X");
rule.first_only = true;
rule.occurrence = Some(3);
let ctx = RenameContext::dummy(0);
// occurrence takes priority - replace 3rd 'a'
assert_eq!(rule.apply("ababa", &ctx), "ababX");
}
#[test]
fn occurrence_beyond_matches() {
let mut rule = base_rule("a", "X");
rule.occurrence = Some(10);
let ctx = RenameContext::dummy(0);
assert_eq!(rule.apply("aaa", &ctx), "aaa");
}
#[test]
fn scope_with_regex() {
let mut rule = base_rule(r"\d+", "N");
rule.use_regex = true;
rule.scope_start = Some(0);
rule.scope_end = Some(5);
let ctx = RenameContext::dummy(0);
// "abc12345" scope 0..5 = "abc12" -> "abcN", suffix = "345"
assert_eq!(rule.apply("abc12345", &ctx), "abcN345");
}
#[test]
fn occurrence_with_case_insensitive() {
let mut rule = base_rule("a", "X");
rule.match_case = false;
rule.occurrence = Some(2);
let ctx = RenameContext::dummy(0);
assert_eq!(rule.apply("aAbAa", &ctx), "aXbAa");
}
} }

View File

@@ -0,0 +1,324 @@
use serde::{Deserialize, Serialize};
use unicode_normalization::UnicodeNormalization;
use crate::{RenameContext, RenameRule};
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub enum SpaceMode {
#[default]
None,
Underscores,
Dashes,
Dots,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SanitizeRule {
#[serde(default = "default_true")]
pub illegal_chars: bool,
#[serde(default)]
pub spaces_to: SpaceMode,
#[serde(default)]
pub normalize_unicode: bool,
#[serde(default)]
pub strip_zero_width: bool,
#[serde(default)]
pub strip_diacritics: bool,
#[serde(default)]
pub collapse_whitespace: bool,
#[serde(default = "default_true")]
pub trim_dots_spaces: bool,
pub enabled: bool,
}
fn default_true() -> bool {
true
}
const ZERO_WIDTH: &[char] = &[
'\u{200B}', // zero-width space
'\u{200C}', // zero-width non-joiner
'\u{200D}', // zero-width joiner
'\u{FEFF}', // BOM / zero-width no-break space
'\u{00AD}', // soft hyphen
];
impl SanitizeRule {
pub fn new() -> Self {
Self {
illegal_chars: true,
spaces_to: SpaceMode::None,
normalize_unicode: false,
strip_zero_width: false,
strip_diacritics: false,
collapse_whitespace: false,
trim_dots_spaces: true,
enabled: true,
}
}
fn is_illegal(c: char) -> bool {
matches!(c, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*')
|| (c as u32) <= 0x1F
}
}
impl RenameRule for SanitizeRule {
fn apply(&self, filename: &str, _context: &RenameContext) -> String {
let mut result = filename.to_string();
if self.illegal_chars {
result = result.chars().filter(|c| !Self::is_illegal(*c)).collect();
}
if self.spaces_to != SpaceMode::None {
let replacement = match self.spaces_to {
SpaceMode::Underscores => '_',
SpaceMode::Dashes => '-',
SpaceMode::Dots => '.',
SpaceMode::None => unreachable!(),
};
result = result.chars().map(|c| if c == ' ' { replacement } else { c }).collect();
}
if self.normalize_unicode {
result = result.nfc().collect::<String>();
}
if self.strip_zero_width {
result = result.chars().filter(|c| !ZERO_WIDTH.contains(c)).collect();
}
if self.strip_diacritics {
result = result
.nfkd()
.filter(|c| !unicode_normalization::char::is_combining_mark(*c))
.collect();
}
if self.collapse_whitespace {
let mut collapsed = String::with_capacity(result.len());
let mut prev: Option<char> = Option::None;
for c in result.chars() {
let dominated = matches!(c, ' ' | '_' | '-');
if dominated {
if let Some(p) = prev {
if matches!(p, ' ' | '_' | '-') {
continue;
}
}
}
collapsed.push(c);
prev = Some(c);
}
result = collapsed;
}
if self.trim_dots_spaces {
let trimmed = result.trim_matches(|c: char| c == '.' || c == ' ');
result = trimmed.to_string();
}
result
}
fn display_name(&self) -> &str {
"Sanitize"
}
fn rule_type(&self) -> &str {
"sanitize"
}
fn is_enabled(&self) -> bool {
self.enabled
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx() -> RenameContext {
RenameContext::dummy(0)
}
fn base() -> SanitizeRule {
SanitizeRule {
illegal_chars: false,
spaces_to: SpaceMode::None,
normalize_unicode: false,
strip_zero_width: false,
strip_diacritics: false,
collapse_whitespace: false,
trim_dots_spaces: false,
enabled: true,
}
}
#[test]
fn illegal_chars_removed() {
let rule = SanitizeRule {
illegal_chars: true,
..base()
};
assert_eq!(rule.apply("file<name>:test", &ctx()), "filenametest");
assert_eq!(rule.apply("a\"b/c\\d|e?f*g", &ctx()), "abcdefg");
}
#[test]
fn control_chars_removed() {
let rule = SanitizeRule {
illegal_chars: true,
..base()
};
let input = format!("file{}name", '\x01');
assert_eq!(rule.apply(&input, &ctx()), "filename");
}
#[test]
fn spaces_to_underscores() {
let rule = SanitizeRule {
spaces_to: SpaceMode::Underscores,
..base()
};
assert_eq!(rule.apply("my file name", &ctx()), "my_file_name");
}
#[test]
fn spaces_to_dashes() {
let rule = SanitizeRule {
spaces_to: SpaceMode::Dashes,
..base()
};
assert_eq!(rule.apply("my file name", &ctx()), "my-file-name");
}
#[test]
fn spaces_to_dots() {
let rule = SanitizeRule {
spaces_to: SpaceMode::Dots,
..base()
};
assert_eq!(rule.apply("my file name", &ctx()), "my.file.name");
}
#[test]
fn normalize_unicode_nfc() {
let rule = SanitizeRule {
normalize_unicode: true,
..base()
};
// e + combining acute -> precomposed
let input = "caf\u{0065}\u{0301}";
let output = rule.apply(input, &ctx());
assert_eq!(output, "caf\u{00E9}");
}
#[test]
fn strip_zero_width_chars() {
let rule = SanitizeRule {
strip_zero_width: true,
..base()
};
let input = "fi\u{200B}le\u{FEFF}name\u{00AD}";
assert_eq!(rule.apply(&input, &ctx()), "filename");
}
#[test]
fn strip_diacritics_basic() {
let rule = SanitizeRule {
strip_diacritics: true,
..base()
};
assert_eq!(rule.apply("caf\u{00E9}", &ctx()), "cafe");
assert_eq!(rule.apply("\u{00FC}ber", &ctx()), "uber");
}
#[test]
fn strip_diacritics_combining() {
let rule = SanitizeRule {
strip_diacritics: true,
..base()
};
assert_eq!(rule.apply("cafe\u{0301}", &ctx()), "cafe");
}
#[test]
fn collapse_whitespace_spaces() {
let rule = SanitizeRule {
collapse_whitespace: true,
..base()
};
assert_eq!(rule.apply("file name", &ctx()), "file name");
}
#[test]
fn collapse_whitespace_underscores() {
let rule = SanitizeRule {
collapse_whitespace: true,
..base()
};
assert_eq!(rule.apply("file___name", &ctx()), "file_name");
}
#[test]
fn collapse_whitespace_dashes() {
let rule = SanitizeRule {
collapse_whitespace: true,
..base()
};
assert_eq!(rule.apply("file---name", &ctx()), "file-name");
}
#[test]
fn collapse_whitespace_mixed() {
let rule = SanitizeRule {
collapse_whitespace: true,
..base()
};
assert_eq!(rule.apply("file -_name", &ctx()), "file name");
}
#[test]
fn trim_dots_and_spaces() {
let rule = SanitizeRule {
trim_dots_spaces: true,
..base()
};
assert_eq!(rule.apply(" file.txt ", &ctx()), "file.txt");
assert_eq!(rule.apply("..file.txt..", &ctx()), "file.txt");
assert_eq!(rule.apply(". .file. .", &ctx()), "file");
}
#[test]
fn all_together() {
let rule = SanitizeRule {
illegal_chars: true,
spaces_to: SpaceMode::Underscores,
normalize_unicode: false,
strip_zero_width: true,
strip_diacritics: false,
collapse_whitespace: true,
trim_dots_spaces: true,
enabled: true,
};
let input = " <my file\u{200B}name>.txt..";
let output = rule.apply(input, &ctx());
assert_eq!(output, "_my_filename.txt");
}
#[test]
fn passthrough_when_nothing_enabled() {
let rule = base();
assert_eq!(rule.apply("anything goes <here>", &ctx()), "anything goes <here>");
}
#[test]
fn display_and_type() {
let rule = SanitizeRule::new();
assert_eq!(rule.display_name(), "Sanitize");
assert_eq!(rule.rule_type(), "sanitize");
assert!(rule.is_enabled());
}
}

View File

@@ -0,0 +1,116 @@
use serde::{Deserialize, Serialize};
use crate::{RenameContext, RenameRule};
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub enum SwapOccurrence {
#[default]
First,
Last,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SwapRule {
pub delimiter: String,
pub occurrence: SwapOccurrence,
#[serde(default)]
pub new_delimiter: Option<String>,
pub enabled: bool,
}
impl SwapRule {
pub fn new() -> Self {
Self {
delimiter: ", ".into(),
occurrence: SwapOccurrence::First,
new_delimiter: None,
enabled: true,
}
}
}
impl RenameRule for SwapRule {
fn apply(&self, filename: &str, _context: &RenameContext) -> String {
if self.delimiter.is_empty() {
return filename.to_string();
}
let pos = match self.occurrence {
SwapOccurrence::First => filename.find(&self.delimiter),
SwapOccurrence::Last => filename.rfind(&self.delimiter),
};
let pos = match pos {
Some(p) => p,
None => return filename.to_string(),
};
let left = &filename[..pos];
let right = &filename[pos + self.delimiter.len()..];
let joiner = self.new_delimiter.as_deref().unwrap_or(&self.delimiter);
format!("{}{}{}", right, joiner, left)
}
fn display_name(&self) -> &str {
"Swap"
}
fn rule_type(&self) -> &str {
"swap"
}
fn is_enabled(&self) -> bool {
self.enabled
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx() -> RenameContext {
RenameContext::dummy(0)
}
#[test]
fn swap_comma_space() {
let rule = SwapRule::new();
assert_eq!(rule.apply("LastName, FirstName", &ctx()), "FirstName, LastName");
}
#[test]
fn swap_with_new_delimiter() {
let rule = SwapRule {
new_delimiter: Some(" ".into()),
..SwapRule::new()
};
assert_eq!(rule.apply("LastName, FirstName", &ctx()), "FirstName LastName");
}
#[test]
fn swap_first_occurrence() {
let rule = SwapRule {
delimiter: "-".into(),
occurrence: SwapOccurrence::First,
..SwapRule::new()
};
assert_eq!(rule.apply("a-b-c", &ctx()), "b-c-a");
}
#[test]
fn swap_last_occurrence() {
let rule = SwapRule {
delimiter: "-".into(),
occurrence: SwapOccurrence::Last,
..SwapRule::new()
};
assert_eq!(rule.apply("a-b-c", &ctx()), "c-a-b");
}
#[test]
fn no_delimiter_found() {
let rule = SwapRule::new();
assert_eq!(rule.apply("nodelimiter", &ctx()), "nodelimiter");
}
}

View File

@@ -0,0 +1,82 @@
use serde::{Deserialize, Serialize};
use crate::{RenameContext, RenameRule};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TextEditorRule {
#[serde(default)]
pub names: Vec<String>,
pub enabled: bool,
}
impl TextEditorRule {
pub fn new() -> Self {
Self {
names: Vec::new(),
enabled: true,
}
}
}
impl RenameRule for TextEditorRule {
fn apply(&self, filename: &str, context: &RenameContext) -> String {
if let Some(name) = self.names.get(context.index) {
if !name.is_empty() {
return name.clone();
}
}
filename.to_string()
}
fn display_name(&self) -> &str {
"Text Editor"
}
fn rule_type(&self) -> &str {
"text_editor"
}
fn is_enabled(&self) -> bool {
self.enabled
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maps_by_index() {
let rule = TextEditorRule {
names: vec!["alpha".into(), "beta".into(), "gamma".into()],
enabled: true,
};
let mut ctx = RenameContext::dummy(0);
assert_eq!(rule.apply("file1", &ctx), "alpha");
ctx.index = 1;
assert_eq!(rule.apply("file2", &ctx), "beta");
ctx.index = 2;
assert_eq!(rule.apply("file3", &ctx), "gamma");
}
#[test]
fn out_of_bounds_keeps_original() {
let rule = TextEditorRule {
names: vec!["only".into()],
enabled: true,
};
let mut ctx = RenameContext::dummy(0);
ctx.index = 5;
assert_eq!(rule.apply("original", &ctx), "original");
}
#[test]
fn empty_line_keeps_original() {
let rule = TextEditorRule {
names: vec!["".into(), "renamed".into()],
enabled: true,
};
let ctx = RenameContext::dummy(0);
assert_eq!(rule.apply("original", &ctx), "original");
}
}

View File

@@ -0,0 +1,99 @@
use deunicode::deunicode;
use serde::{Deserialize, Serialize};
use crate::{RenameContext, RenameRule};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransliterateRule {
pub enabled: bool,
}
impl TransliterateRule {
pub fn new() -> Self {
Self { enabled: true }
}
}
impl RenameRule for TransliterateRule {
fn apply(&self, filename: &str, _context: &RenameContext) -> String {
deunicode(filename)
}
fn display_name(&self) -> &str {
"Transliterate"
}
fn rule_type(&self) -> &str {
"transliterate"
}
fn is_enabled(&self) -> bool {
self.enabled
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rule() -> TransliterateRule {
TransliterateRule::new()
}
#[test]
fn ascii_unchanged() {
let r = rule();
let ctx = RenameContext::dummy(0);
assert_eq!(r.apply("hello.txt", &ctx), "hello.txt");
}
#[test]
fn accented_chars() {
let r = rule();
let ctx = RenameContext::dummy(0);
assert_eq!(r.apply("caf\u{e9}", &ctx), "cafe");
}
#[test]
fn combining_accent() {
let r = rule();
let ctx = RenameContext::dummy(0);
assert_eq!(r.apply("cafe\u{0301}", &ctx), "cafe");
}
#[test]
fn cyrillic() {
let r = rule();
let ctx = RenameContext::dummy(0);
assert_eq!(r.apply("\u{41c}\u{43e}\u{441}\u{43a}\u{432}\u{430}", &ctx), "Moskva");
}
#[test]
fn cjk() {
let r = rule();
let ctx = RenameContext::dummy(0);
let result = r.apply("\u{4e16}\u{754c}", &ctx);
assert!(!result.is_empty());
assert!(result.is_ascii());
}
#[test]
fn german_umlauts() {
let r = rule();
let ctx = RenameContext::dummy(0);
assert_eq!(r.apply("\u{fc}ber", &ctx), "uber");
}
#[test]
fn disabled() {
let r = TransliterateRule { enabled: false };
assert!(!r.is_enabled());
}
#[test]
fn display_and_type() {
let r = rule();
assert_eq!(r.display_name(), "Transliterate");
assert_eq!(r.rule_type(), "transliterate");
}
}

View File

@@ -0,0 +1,110 @@
use serde::{Deserialize, Serialize};
use crate::{RenameContext, RenameRule};
fn default_max() -> usize {
50
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub enum TruncateFrom {
Start,
#[default]
End,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TruncateRule {
#[serde(default = "default_max")]
pub max_length: usize,
#[serde(default)]
pub from: TruncateFrom,
#[serde(default)]
pub suffix: String,
pub enabled: bool,
}
impl TruncateRule {
pub fn new() -> Self {
Self {
max_length: 50,
from: TruncateFrom::End,
suffix: String::new(),
enabled: true,
}
}
}
impl RenameRule for TruncateRule {
fn apply(&self, filename: &str, _context: &RenameContext) -> String {
let char_count = filename.chars().count();
if char_count <= self.max_length {
return filename.to_string();
}
let suffix_len = self.suffix.chars().count();
let keep = self.max_length.saturating_sub(suffix_len);
match self.from {
TruncateFrom::End => {
let truncated: String = filename.chars().take(keep).collect();
format!("{}{}", truncated, self.suffix)
}
TruncateFrom::Start => {
let truncated: String = filename.chars().skip(char_count - keep).collect();
format!("{}{}", self.suffix, truncated)
}
}
}
fn display_name(&self) -> &str {
"Truncate"
}
fn rule_type(&self) -> &str {
"truncate"
}
fn is_enabled(&self) -> bool {
self.enabled
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_truncation_when_short() {
let rule = TruncateRule {
max_length: 10,
..TruncateRule::new()
};
let ctx = RenameContext::dummy(0);
assert_eq!(rule.apply("short", &ctx), "short");
}
#[test]
fn truncate_from_end_with_suffix() {
let rule = TruncateRule {
max_length: 8,
from: TruncateFrom::End,
suffix: "...".into(),
enabled: true,
};
let ctx = RenameContext::dummy(0);
assert_eq!(rule.apply("a]very_long_filename", &ctx), "a]ver...");
}
#[test]
fn truncate_from_start() {
let rule = TruncateRule {
max_length: 7,
from: TruncateFrom::Start,
suffix: "~".into(),
enabled: true,
};
let ctx = RenameContext::dummy(0);
assert_eq!(rule.apply("some_long_name", &ctx), "~g_name");
}
}

View File

@@ -88,18 +88,25 @@ fn split_filename(name: &str) -> (String, String) {
} }
fn is_hidden_file(path: &Path) -> bool { fn is_hidden_file(path: &Path) -> bool {
if path.file_name()
.map(|n| n.to_string_lossy().starts_with('.'))
.unwrap_or(false)
{
return true;
}
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
use std::os::windows::fs::MetadataExt; use std::os::windows::fs::MetadataExt;
if let Ok(meta) = std::fs::metadata(path) { if let Ok(meta) = std::fs::metadata(path) {
const FILE_ATTRIBUTE_HIDDEN: u32 = 0x2; const FILE_ATTRIBUTE_HIDDEN: u32 = 0x2;
return meta.file_attributes() & FILE_ATTRIBUTE_HIDDEN != 0; if meta.file_attributes() & FILE_ATTRIBUTE_HIDDEN != 0 {
return true;
}
} }
} }
path.file_name() false
.map(|n| n.to_string_lossy().starts_with('.'))
.unwrap_or(false)
} }
fn file_created(meta: &std::fs::Metadata) -> Option<DateTime<Utc>> { fn file_created(meta: &std::fs::Metadata) -> Option<DateTime<Utc>> {

View File

@@ -25,7 +25,7 @@ pub struct UndoEntry {
pub renamed_path: PathBuf, pub renamed_path: PathBuf,
} }
const MAX_UNDO_BATCHES: usize = 50; pub const DEFAULT_MAX_UNDO_BATCHES: usize = 50;
impl UndoLog { impl UndoLog {
pub fn new() -> Self { pub fn new() -> Self {
@@ -64,8 +64,12 @@ impl UndoLog {
} }
pub fn add_batch(&mut self, batch: UndoBatch) { pub fn add_batch(&mut self, batch: UndoBatch) {
self.add_batch_with_limit(batch, DEFAULT_MAX_UNDO_BATCHES);
}
pub fn add_batch_with_limit(&mut self, batch: UndoBatch, max: usize) {
self.entries.push(batch); self.entries.push(batch);
while self.entries.len() > MAX_UNDO_BATCHES { while self.entries.len() > max {
self.entries.remove(0); self.entries.remove(0);
} }
} }

25
ui/components.json Normal file
View File

@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "tabler",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

5778
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,14 +9,32 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/geist": "^5.2.8",
"@tabler/icons-react": "^3.40.0",
"@tanstack/react-virtual": "^3.13.22", "@tanstack/react-virtual": "^3.13.22",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-shell": "^2.3.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.36.0",
"overlayscrollbars": "^2.14.0",
"overlayscrollbars-react": "^0.5.6",
"radix-ui": "^1.4.3",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"shadcn": "^4.0.6",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
"@tauri-apps/api": "^2.10.1", "@tauri-apps/api": "^2.10.1",
"@types/node": "^25.5.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.7.0", "@vitejs/plugin-react": "^4.7.0",

View File

@@ -1,7 +1,16 @@
import { AppShell } from "./components/layout/AppShell"; import { AppShell } from "@/components/layout/AppShell";
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts"; import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts";
import { useTheme } from "@/hooks/useTheme";
import { useWindowState } from "@/hooks/useWindowState";
import { TooltipProvider } from "@/components/ui/tooltip";
export default function App() { export default function App() {
useKeyboardShortcuts(); useKeyboardShortcuts();
return <AppShell />; useTheme();
useWindowState();
return (
<TooltipProvider delayDuration={300}>
<AppShell />
</TooltipProvider>
);
} }

View File

@@ -1,6 +1,40 @@
import { useRef } from "react"; import { useRef, useState, useCallback, useMemo, useEffect } from "react";
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from "@tanstack/react-virtual";
import { useFileStore } from "../../stores/fileStore"; import { useOverlayScrollbars } from "overlayscrollbars-react";
import { invoke } from "@tauri-apps/api/core";
import { useFileStore } from "@/stores/fileStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { Checkbox } from "@/components/ui/checkbox";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
IconCheck,
IconAlertTriangle,
IconX,
IconFileDescription,
IconArrowRight,
IconSelect,
IconDeselect,
IconCopy,
IconClipboard,
IconEye,
IconEyeOff,
IconArrowsSort,
IconFolder,
IconFile,
IconArrowUp,
IconChevronUp,
IconChevronDown,
IconExternalLink,
} from "@tabler/icons-react";
import { cn } from "@/lib/utils";
import { motion, AnimatePresence } from "framer-motion";
import { announce } from "@/hooks/useAnnounce";
function formatSize(bytes: number): string { function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
@@ -9,119 +43,844 @@ function formatSize(bytes: number): string {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
} }
function formatDateAbsolute(iso: string | null): string {
if (!iso) return "-";
const d = new Date(iso);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function formatDateRelative(iso: string | null): string {
if (!iso) return "-";
const d = new Date(iso);
const now = Date.now();
const diff = now - d.getTime();
const secs = Math.floor(diff / 1000);
if (secs < 60) return "just now";
const mins = Math.floor(secs / 60);
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
if (days < 30) return `${days}d ago`;
const months = Math.floor(days / 30);
if (months < 12) return `${months}mo ago`;
return `${Math.floor(months / 12)}y ago`;
}
const DEFAULT_COL_WIDTHS = {
original: 0, // flex
renamed: 0, // flex
size: 72,
date: 120,
status: 80,
};
export function FileList() { export function FileList() {
const files = useFileStore((s) => s.files); const allEntries = useFileStore((s) => s.files);
const previewResults = useFileStore((s) => s.previewResults); const previewResults = useFileStore((s) => s.previewResults);
const selectedFiles = useFileStore((s) => s.selectedFiles); const selectedFiles = useFileStore((s) => s.selectedFiles);
const toggleFileSelection = useFileStore((s) => s.toggleFileSelection); const toggleFileSelection = useFileStore((s) => s.toggleFileSelection);
const currentPath = useFileStore((s) => s.currentPath);
const scanDirectory = useFileStore((s) => s.scanDirectory);
const zoom = useSettingsStore((s) => s.zoom);
const doubleClickAction = useSettingsStore((s) => s.doubleClickAction);
const showFullPath = useSettingsStore((s) => s.showFullPath);
const zebraStriping = useSettingsStore((s) => s.zebraStriping);
const dateFormat = useSettingsStore((s) => s.dateFormat);
const parentRef = useRef<HTMLDivElement>(null); const parentRef = useRef<HTMLDivElement>(null);
const [initOverlayScrollbars] = useOverlayScrollbars({
options: { scrollbars: { autoHide: "move", autoHideDelay: 600 } },
defer: true,
});
useEffect(() => {
if (parentRef.current) initOverlayScrollbars(parentRef.current);
}, [initOverlayScrollbars]);
const [contextFile, setContextFile] = useState<string | null>(null);
const lastClickedIdx = useRef<number>(-1);
const previewMap = new Map(previewResults.map((r) => [r.original_path, r])); type SortKey = "original" | "renamed" | "size" | "status" | "date" | "type";
type SortDir = "asc" | "desc";
const defaultSort = useSettingsStore((s) => s.defaultSortOrder);
const locale = useSettingsStore((s) => s.locale);
const sortLocale = locale === "system" ? undefined : locale;
const sortKeyMap: Record<string, SortKey> = { name: "original", size: "size", date: "date", type: "type" };
const [sortKey, setSortKey] = useState<SortKey | null>(sortKeyMap[defaultSort] ?? null);
const [sortDir, setSortDir] = useState<SortDir>("asc");
// column widths (fixed columns only; original/renamed use flex)
const [colWidths, setColWidths] = useState({ ...DEFAULT_COL_WIDTHS });
const resizingCol = useRef<{ col: string; startX: number; startW: number } | null>(null);
function toggleSort(key: SortKey) {
if (sortKey === key) {
if (sortDir === "asc") {
setSortDir("desc");
announce(`Sorted by ${key}, descending`);
} else {
setSortKey(null);
setSortDir("asc");
announce("Sort cleared");
}
} else {
setSortKey(key);
setSortDir("asc");
announce(`Sorted by ${key}, ascending`);
}
}
const onResizePointerDown = useCallback((e: React.PointerEvent, col: string) => {
e.stopPropagation();
e.preventDefault();
resizingCol.current = { col, startX: e.clientX, startW: (colWidths as any)[col] };
(e.target as HTMLElement).setPointerCapture(e.pointerId);
}, [colWidths]);
const onResizePointerMove = useCallback((e: React.PointerEvent) => {
if (!resizingCol.current) return;
const { col, startX, startW } = resizingCol.current;
const z = useSettingsStore.getState().zoom;
const delta = (e.clientX - startX) / z;
const newW = Math.max(40, startW + delta);
setColWidths((prev) => ({ ...prev, [col]: newW }));
}, []);
const onResizePointerUp = useCallback(() => {
resizingCol.current = null;
}, []);
// marquee
const dragRef = useRef(false);
const didDragRef = useRef(false);
const startRef = useRef({ x: 0, y: 0 });
const [marqueeBox, setMarqueeBox] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
const previewMap = useMemo(
() => new Map(previewResults.map((r) => [r.original_path, r])),
[previewResults],
);
// separate folders and files, folders first
const folders = useMemo(() => allEntries.filter((f) => f.is_dir), [allEntries]);
const unsortedFiles = useMemo(() => allEntries.filter((f) => !f.is_dir), [allEntries]);
const sortFn = useCallback((a: typeof allEntries[0], b: typeof allEntries[0]) => {
if (!sortKey) return 0;
const dir = sortDir === "asc" ? 1 : -1;
switch (sortKey) {
case "original":
return dir * a.name.localeCompare(b.name, sortLocale, { sensitivity: "base" });
case "renamed": {
const aPreview = previewMap.get(a.path);
const bPreview = previewMap.get(b.path);
return dir * (aPreview?.new_name || a.name).localeCompare(bPreview?.new_name || b.name, sortLocale, { sensitivity: "base" });
}
case "size":
return dir * (a.size - b.size);
case "date": {
const aTime = a.modified ? new Date(a.modified).getTime() : 0;
const bTime = b.modified ? new Date(b.modified).getTime() : 0;
return dir * (aTime - bTime);
}
case "type":
return dir * (a.extension || "").localeCompare(b.extension || "", sortLocale, { sensitivity: "base" });
case "status": {
const aPreview = previewMap.get(a.path);
const bPreview = previewMap.get(b.path);
const aScore = aPreview?.has_conflict ? 3 : aPreview?.has_error ? 2 : (aPreview && aPreview.new_name !== aPreview.original_name) ? 1 : 0;
const bScore = bPreview?.has_conflict ? 3 : bPreview?.has_error ? 2 : (bPreview && bPreview.new_name !== bPreview.original_name) ? 1 : 0;
return dir * (aScore - bScore);
}
default:
return 0;
}
}, [sortKey, sortDir, previewMap, sortLocale]);
const sortedFolders = useMemo(() => {
if (!sortKey) return folders;
return [...folders].sort(sortFn);
}, [folders, sortFn, sortKey]);
const files = useMemo(() => {
if (!sortKey) return unsortedFiles;
return [...unsortedFiles].sort(sortFn);
}, [unsortedFiles, sortFn, sortKey]);
const setSortedFilePaths = useFileStore((s) => s.setSortedFilePaths);
const prevFileCountRef = useRef(0);
useEffect(() => {
const allPaths = [...sortedFolders, ...files].map((f) => f.path);
setSortedFilePaths(allPaths);
const total = sortedFolders.length + files.length;
if (total !== prevFileCountRef.current && total > 0) {
announce(`${files.length} files and ${sortedFolders.length} folders loaded`);
}
prevFileCountRef.current = total;
}, [sortedFolders, files, setSortedFilePaths]);
// build display rows: parent nav + folders + files
const hasParent = currentPath && currentPath.length > 3; // more than just "C:\"
const parentRow = hasParent ? 1 : 0;
const totalRows = parentRow + sortedFolders.length + files.length;
function getEntry(displayIndex: number) {
if (displayIndex < parentRow) return null; // parent row
const idx = displayIndex - parentRow;
if (idx < sortedFolders.length) return sortedFolders[idx];
return files[idx - sortedFolders.length];
}
const compactMode = useSettingsStore((s) => s.compactMode);
const rowHeight = compactMode ? 26 : 32;
const rowVirtualizer = useVirtualizer({ const rowVirtualizer = useVirtualizer({
count: files.length, count: totalRows,
getScrollElement: () => parentRef.current, getScrollElement: () => parentRef.current,
estimateSize: () => 28, estimateSize: () => rowHeight,
overscan: 20, overscan: 20,
}); });
if (files.length === 0) { const formatDate = useCallback(
(iso: string | null) => dateFormat === "relative" ? formatDateRelative(iso) : formatDateAbsolute(iso),
[dateFormat],
);
function handleFileDoubleClick(path: string) {
if (doubleClickAction === "open") {
invoke("reveal_in_explorer", { path }).catch(() => {});
}
}
function navigateUp() {
if (!currentPath) return;
const normalized = currentPath.replace(/\\/g, "/").replace(/\/$/, "");
const parent = normalized.replace(/\/[^/]+$/, "");
if (!parent) return;
// ensure drive roots keep trailing backslash (e.g. "D:\")
let result = parent.replace(/\//g, "\\");
if (/^[A-Za-z]:$/.test(result)) result += "\\";
scanDirectory(result);
}
function navigateToFolder(path: string) {
scanDirectory(path);
}
const onPointerDown = useCallback((e: React.PointerEvent) => {
if (e.button !== 0) return;
const target = e.target as HTMLElement;
if (target.closest("button") || target.closest("label") || target.closest("input") || target.tagName === "BUTTON") return;
if (target.closest("[role='checkbox']")) return;
dragRef.current = true;
didDragRef.current = false;
startRef.current = { x: e.clientX, y: e.clientY };
setMarqueeBox(null);
(e.target as HTMLElement).setPointerCapture(e.pointerId);
}, []);
const onPointerMove = useCallback((e: React.PointerEvent) => {
if (!dragRef.current) return;
const sx = startRef.current.x;
const sy = startRef.current.y;
const cx = e.clientX;
const cy = e.clientY;
const w = Math.abs(cx - sx);
const h = Math.abs(cy - sy);
if (w > 4 || h > 4) {
didDragRef.current = true;
// account for root padding and zoom - fixed positioning is relative
// to the nearest transformed ancestor (the window-frame)
const pad = document.documentElement.classList.contains("maximized") ? 0 : 20;
setMarqueeBox({
x: (Math.min(sx, cx) - pad) / zoom,
y: (Math.min(sy, cy) - pad) / zoom,
w: w / zoom,
h: h / zoom,
});
}
}, [zoom]);
const onPointerUp = useCallback((e: React.PointerEvent) => {
if (!dragRef.current) return;
dragRef.current = false;
const sx = startRef.current.x;
const sy = startRef.current.y;
const x1 = Math.min(sx, e.clientX);
const y1 = Math.min(sy, e.clientY);
const x2 = Math.max(sx, e.clientX);
const y2 = Math.max(sy, e.clientY);
setMarqueeBox(null);
if (!didDragRef.current) return; // handled by onClick instead
const scrollEl = parentRef.current;
if (!scrollEl) return;
const newSelected = new Set<string>();
const rows = scrollEl.querySelectorAll<HTMLElement>("[data-file-path], [data-folder-path]");
rows.forEach((row) => {
const r = row.getBoundingClientRect();
if (r.left < x2 && r.right > x1 && r.top < y2 && r.bottom > y1) {
const path = row.getAttribute("data-file-path") || row.getAttribute("data-folder-path");
if (path) newSelected.add(path);
}
});
// if marquee selected nothing, deselect all
useFileStore.setState({ selectedFiles: newSelected });
}, []);
// all entries in display order for shift-select across folders and files
const allDisplayEntries = useMemo(() => [...sortedFolders, ...files], [sortedFolders, files]);
function handleRowClick(e: React.MouseEvent, filePath: string, index: number) {
// don't handle if it was a drag
if (didDragRef.current) return;
const target = e.target as HTMLElement;
if (target.closest("[role='checkbox']") || target.closest("button") || target.closest("label")) return;
if (e.shiftKey && lastClickedIdx.current >= 0) {
const from = Math.min(lastClickedIdx.current, index);
const to = Math.max(lastClickedIdx.current, index);
const rangeSelected = new Set(e.ctrlKey ? selectedFiles : new Set<string>());
for (let i = from; i <= to; i++) {
rangeSelected.add(allDisplayEntries[i].path);
}
useFileStore.setState({ selectedFiles: rangeSelected });
} else if (e.ctrlKey) {
toggleFileSelection(filePath);
} else {
if (selectedFiles.size === 1 && selectedFiles.has(filePath)) {
useFileStore.setState({ selectedFiles: new Set<string>() });
} else {
useFileStore.setState({ selectedFiles: new Set([filePath]) });
}
}
lastClickedIdx.current = index;
}
function handleFolderDoubleClick(path: string) {
navigateToFolder(path);
}
function handleBackgroundClick(e: React.MouseEvent) {
if (didDragRef.current) return;
// only deselect when clicking actual empty space (the scroll container itself, not a row)
const target = e.target as HTMLElement;
if (target.closest("[data-file-path]") || target.closest("[data-folder-path]") || target.closest("[role='checkbox']")) return;
useFileStore.setState({ selectedFiles: new Set<string>() });
}
function handleSelectAll() {
useFileStore.getState().selectAll();
}
function handleDeselectAll() {
useFileStore.getState().deselectAll();
}
function handleSelectChanged() {
const changed = new Set<string>();
previewResults.forEach((r) => {
if (r.original_name !== r.new_name && !r.has_error && !r.has_conflict) {
changed.add(r.original_path);
}
});
useFileStore.setState({ selectedFiles: changed });
}
function handleInvertSelection() {
const inverted = new Set<string>();
allDisplayEntries.forEach((f) => {
if (!selectedFiles.has(f.path)) inverted.add(f.path);
});
useFileStore.setState({ selectedFiles: inverted });
}
function handleCopyNames() {
const names = files
.filter((f) => selectedFiles.has(f.path))
.map((f) => f.name)
.join("\n");
navigator.clipboard.writeText(names);
}
function handleCopyNewNames() {
const names = files
.filter((f) => selectedFiles.has(f.path))
.map((f) => {
const preview = previewMap.get(f.path);
return preview?.new_name || f.name;
})
.join("\n");
navigator.clipboard.writeText(names);
}
if (allEntries.length === 0 && !currentPath) {
return ( return (
<div <motion.div
className="flex items-center justify-center h-full text-sm" initial={{ opacity: 0, scale: 0.95 }}
style={{ color: "var(--text-secondary)" }} animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, ease: "easeOut" }}
className="flex flex-col items-center justify-center h-full gap-3 text-muted-foreground"
>
<motion.div
animate={{ y: [0, -6, 0], rotate: [0, -3, 3, 0] }}
transition={{ duration: 3, repeat: Infinity, ease: "easeInOut" }}
>
<IconFileDescription size={40} stroke={1} className="opacity-40" />
</motion.div>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.3 }}
className="text-sm"
> >
Navigate to a folder to see files Navigate to a folder to see files
</div> </motion.div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 0.6 }}
transition={{ delay: 0.4, duration: 0.3 }}
className="text-xs"
>
Use the sidebar to browse directories
</motion.div>
</motion.div>
); );
} }
return ( return (
<div className="flex flex-col h-full"> <ContextMenu>
<ContextMenuTrigger asChild>
<div className="flex flex-col h-full relative" role="grid" aria-label="File list" aria-rowcount={totalRows + 1} aria-colcount={7}>
{/* header */} {/* header */}
<div <motion.div
className="flex text-xs font-medium border-b px-2 py-1 shrink-0" initial={{ opacity: 0, y: -5 }}
style={{ animate={{ opacity: 1, y: 0 }}
background: "var(--bg-tertiary)", transition={{ duration: 0.2 }}
borderColor: "var(--border)", role="row"
color: "var(--text-secondary)", aria-label="Column headers"
}} className="flex items-center text-[11px] font-medium border-b px-1 py-1.5 shrink-0 bg-muted/50 text-muted-foreground uppercase tracking-wider"
> >
<div className="w-8" /> <div role="columnheader" aria-colindex={1} className="w-8 flex items-center justify-center">
<div className="flex-1 min-w-0 px-2">Original Name</div> <Checkbox
<div className="flex-1 min-w-0 px-2">New Name</div> checked={allDisplayEntries.length > 0 && selectedFiles.size === allDisplayEntries.length}
<div className="w-20 px-2 text-right">Size</div> onCheckedChange={(checked) => {
<div className="w-16 px-2 text-center">Status</div> if (checked) handleSelectAll();
else handleDeselectAll();
}}
aria-label="Select all files"
/>
</div> </div>
<button role="columnheader" aria-colindex={2} aria-sort={sortKey === "original" ? sortDir === "asc" ? "ascending" : "descending" : "none"} onClick={() => toggleSort("original")} aria-label={`Sort by original name${sortKey === "original" ? (sortDir === "asc" ? ", ascending" : ", descending") : ""}`} className="flex-[2] min-w-0 px-2 flex items-center gap-0.5 hover:text-foreground transition-colors cursor-pointer">
Original
{sortKey === "original" && (sortDir === "asc" ? <IconChevronUp size={10} stroke={2} /> : <IconChevronDown size={10} stroke={2} />)}
</button>
<div role="columnheader" aria-colindex={3} className="w-6 flex items-center justify-center" aria-hidden="true">
<IconArrowRight size={12} stroke={1.5} className="opacity-40" />
</div>
<button role="columnheader" aria-colindex={4} aria-sort={sortKey === "renamed" ? sortDir === "asc" ? "ascending" : "descending" : "none"} onClick={() => toggleSort("renamed")} aria-label={`Sort by renamed${sortKey === "renamed" ? (sortDir === "asc" ? ", ascending" : ", descending") : ""}`} className="flex-[2] min-w-0 px-2 flex items-center gap-0.5 hover:text-foreground transition-colors cursor-pointer">
Renamed
{sortKey === "renamed" && (sortDir === "asc" ? <IconChevronUp size={10} stroke={2} /> : <IconChevronDown size={10} stroke={2} />)}
</button>
<ColResizeHandle col="size" onPointerDown={(e) => onResizePointerDown(e, "size")} onPointerMove={onResizePointerMove} onPointerUp={onResizePointerUp} colWidths={colWidths} setColWidths={setColWidths} />
<button role="columnheader" aria-colindex={5} aria-sort={sortKey === "size" ? sortDir === "asc" ? "ascending" : "descending" : "none"} onClick={() => toggleSort("size")} aria-label={`Sort by size${sortKey === "size" ? (sortDir === "asc" ? ", ascending" : ", descending") : ""}`} style={{ width: colWidths.size }} className="px-2 flex items-center justify-end gap-0.5 hover:text-foreground transition-colors cursor-pointer shrink-0">
Size
{sortKey === "size" && (sortDir === "asc" ? <IconChevronUp size={10} stroke={2} /> : <IconChevronDown size={10} stroke={2} />)}
</button>
<ColResizeHandle col="date" onPointerDown={(e) => onResizePointerDown(e, "date")} onPointerMove={onResizePointerMove} onPointerUp={onResizePointerUp} colWidths={colWidths} setColWidths={setColWidths} />
<button role="columnheader" aria-colindex={6} aria-sort={sortKey === "date" ? sortDir === "asc" ? "ascending" : "descending" : "none"} onClick={() => toggleSort("date")} aria-label={`Sort by date${sortKey === "date" ? (sortDir === "asc" ? ", ascending" : ", descending") : ""}`} style={{ width: colWidths.date }} className="px-2 flex items-center justify-end gap-0.5 hover:text-foreground transition-colors cursor-pointer shrink-0">
Modified
{sortKey === "date" && (sortDir === "asc" ? <IconChevronUp size={10} stroke={2} /> : <IconChevronDown size={10} stroke={2} />)}
</button>
<ColResizeHandle col="status" onPointerDown={(e) => onResizePointerDown(e, "status")} onPointerMove={onResizePointerMove} onPointerUp={onResizePointerUp} colWidths={colWidths} setColWidths={setColWidths} />
<button role="columnheader" aria-colindex={7} aria-sort={sortKey === "status" ? sortDir === "asc" ? "ascending" : "descending" : "none"} onClick={() => toggleSort("status")} aria-label={`Sort by status${sortKey === "status" ? (sortDir === "asc" ? ", ascending" : ", descending") : ""}`} style={{ width: colWidths.status }} className="px-2 flex items-center justify-center gap-0.5 hover:text-foreground transition-colors cursor-pointer shrink-0">
Status
{sortKey === "status" && (sortDir === "asc" ? <IconChevronUp size={10} stroke={2} /> : <IconChevronDown size={10} stroke={2} />)}
</button>
</motion.div>
{/* virtual rows */} {/* virtual rows */}
<div ref={parentRef} className="flex-1 overflow-auto"> <div
ref={parentRef}
className="flex-1 overflow-auto relative"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onClick={handleBackgroundClick}
style={{ touchAction: "none" }}
>
<div style={{ height: `${rowVirtualizer.getTotalSize()}px`, position: "relative" }}> <div style={{ height: `${rowVirtualizer.getTotalSize()}px`, position: "relative" }}>
{rowVirtualizer.getVirtualItems().map((virtualRow) => { {rowVirtualizer.getVirtualItems().map((virtualRow) => {
const file = files[virtualRow.index]; // parent directory row
const preview = previewMap.get(file.path); if (virtualRow.index < parentRow) {
const isSelected = selectedFiles.has(file.path); return (
<div
key="__parent__"
role="row"
aria-rowindex={2}
aria-label="Go to parent directory"
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); navigateUp(); } }}
className="flex items-center text-[13px] px-1 absolute w-full border-b border-border/50 hover:bg-muted/50 cursor-pointer"
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
onClick={navigateUp}
>
<div className="w-8 flex items-center justify-center">
<IconArrowUp size={14} stroke={1.5} className="text-muted-foreground" />
</div>
<div className="flex-1 min-w-0 px-2 text-[12px] text-muted-foreground">..</div>
</div>
);
}
const entry = getEntry(virtualRow.index)!;
const isDir = entry.is_dir;
const preview = previewMap.get(entry.path);
const isSelected = selectedFiles.has(entry.path);
const changed = preview && preview.new_name !== preview.original_name; const changed = preview && preview.new_name !== preview.original_name;
const hasError = preview?.has_error; const hasError = preview?.has_error;
const hasConflict = preview?.has_conflict; const hasConflict = preview?.has_conflict;
let rowBg = virtualRow.index % 2 === 0 ? "var(--row-even)" : "var(--row-odd)"; // entry index relative to allDisplayEntries for shift-select
if (hasConflict) rowBg = "rgba(220, 38, 38, 0.1)"; const entryIndex = isDir
else if (hasError) rowBg = "rgba(217, 119, 6, 0.1)"; ? virtualRow.index - parentRow
else if (changed) rowBg = "rgba(22, 163, 74, 0.06)"; : virtualRow.index - parentRow;
const isOdd = virtualRow.index % 2 === 1;
// folder row - single click selects, double click navigates
if (isDir) {
return ( return (
<div <div
key={file.path} key={entry.path}
className="flex items-center text-xs px-2 absolute w-full" data-folder-path={entry.path}
role="row"
aria-rowindex={virtualRow.index + 2}
aria-selected={isSelected}
aria-label={`Folder: ${entry.name}${changed ? ", will be renamed to " + preview?.new_name : ""}`}
onContextMenu={() => setContextFile(entry.path)}
className={cn(
"flex items-center text-[13px] px-1 absolute w-full border-b border-border/50 transition-colors",
isSelected && "bg-primary/[0.06]",
hasConflict && "bg-destructive/5",
hasError && !hasConflict && "bg-warning/5",
changed && !hasError && !hasConflict && !isSelected && "bg-primary/[0.03]",
!changed && !hasError && !hasConflict && !isSelected && "hover:bg-muted/50",
zebraStriping && isOdd && !isSelected && !hasConflict && !hasError && !changed && "bg-muted/30",
)}
style={{ style={{
height: `${virtualRow.size}px`, height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`, transform: `translateY(${virtualRow.start}px)`,
background: rowBg,
borderBottom: "1px solid var(--border)",
}} }}
onClick={(e) => handleRowClick(e, entry.path, entryIndex)}
onDoubleClick={() => handleFolderDoubleClick(entry.path)}
> >
<div className="w-8 flex items-center justify-center"> <div className="w-8 flex items-center justify-center">
<input <Checkbox
type="checkbox"
checked={isSelected} checked={isSelected}
onChange={() => toggleFileSelection(file.path)} onCheckedChange={() => toggleFileSelection(entry.path)}
className="accent-[var(--accent)]" aria-label={`Select folder ${entry.name}`}
/> />
</div> </div>
<div <IconFolder size={14} stroke={1.5} className="text-primary/70 shrink-0" />
className="flex-1 min-w-0 px-2 truncate" <div className="flex-[2] min-w-0 px-2 truncate font-mono text-[12px] font-medium">
style={{ color: "var(--text-primary)" }} {entry.name}
>
{file.name}
</div> </div>
<div <div className="w-6 flex items-center justify-center">
className="flex-1 min-w-0 px-2 truncate" <AnimatePresence>
style={{ {changed && (
color: changed ? "var(--success)" : "var(--text-secondary)", <motion.div
fontWeight: changed ? 500 : 400, initial={{ opacity: 0, scale: 0 }}
}} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0 }}
transition={{ type: "spring", stiffness: 500, damping: 25 }}
> >
{preview?.new_name || file.name} <IconArrowRight size={12} stroke={1.5} className="text-primary/60" />
</div> </motion.div>
<div
className="w-20 px-2 text-right"
style={{ color: "var(--text-secondary)" }}
>
{file.is_dir ? "-" : formatSize(file.size)}
</div>
<div className="w-16 px-2 text-center">
{hasConflict && <span style={{ color: "var(--error)" }}>Conflict</span>}
{hasError && !hasConflict && (
<span style={{ color: "var(--warning)" }}>Error</span>
)} )}
{changed && !hasError && <span style={{ color: "var(--success)" }}>OK</span>} </AnimatePresence>
</div>
<div className={cn(
"flex-[2] min-w-0 px-2 truncate font-mono text-[12px]",
changed ? "text-primary font-medium" : "text-muted-foreground/50"
)}>
{preview?.new_name || entry.name}
</div>
<div style={{ width: colWidths.size }} className="px-2 text-right text-[11px] text-muted-foreground shrink-0">-</div>
<div style={{ width: colWidths.date }} className="px-2 text-right text-[11px] text-muted-foreground tabular-nums shrink-0">
{formatDate(entry.modified)}
</div>
<div style={{ width: colWidths.status }} className="px-2 flex items-center justify-center gap-1 shrink-0">
<AnimatePresence mode="wait">
{hasConflict && (
<motion.span key="conflict" initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.8 }} className="inline-flex items-center gap-1 text-[11px] text-destructive">
<IconX size={12} stroke={2} /> Conflict
</motion.span>
)}
{changed && !hasError && !hasConflict && (
<motion.span key="ok" initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.8 }} className="inline-flex items-center gap-1 text-[11px] text-primary">
<IconCheck size={12} stroke={2} /> OK
</motion.span>
)}
</AnimatePresence>
</div>
</div>
);
}
// file row
return (
<div
key={entry.path}
data-file-path={entry.path}
role="row"
aria-rowindex={virtualRow.index + 2}
aria-selected={isSelected}
aria-label={`File: ${entry.name}${changed ? ", will be renamed to " + preview?.new_name : ""}${hasConflict ? ", has conflict" : ""}${hasError ? ", has error" : ""}`}
onContextMenu={() => setContextFile(entry.path)}
className={cn(
"flex items-center text-[13px] px-1 absolute w-full border-b border-border/50 transition-colors",
isSelected && "bg-primary/[0.06]",
hasConflict && "bg-destructive/5",
hasError && !hasConflict && "bg-warning/5",
changed && !hasError && !hasConflict && !isSelected && "bg-primary/[0.03]",
!changed && !hasError && !hasConflict && !isSelected && "hover:bg-muted/50",
zebraStriping && isOdd && !isSelected && !hasConflict && !hasError && !changed && "bg-muted/30",
)}
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
onClick={(e) => handleRowClick(e, entry.path, entryIndex)}
onDoubleClick={() => handleFileDoubleClick(entry.path)}
>
<div className="w-8 flex items-center justify-center">
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleFileSelection(entry.path)}
aria-label={`Select file ${entry.name}`}
/>
</div>
<IconFile size={14} stroke={1.5} className="text-muted-foreground/50 shrink-0" />
<div className="flex-[2] min-w-0 px-2 truncate font-mono text-[12px]" title={showFullPath ? entry.path : undefined}>
{showFullPath ? entry.path : entry.name}
</div>
<div className="w-6 flex items-center justify-center">
<AnimatePresence>
{changed && (
<motion.div
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0 }}
transition={{ type: "spring", stiffness: 500, damping: 25 }}
>
<IconArrowRight size={12} stroke={1.5} className="text-primary/60" />
</motion.div>
)}
</AnimatePresence>
</div>
<div
className={cn(
"flex-[2] min-w-0 px-2 truncate font-mono text-[12px]",
changed ? "text-primary font-medium" : "text-muted-foreground/50"
)}
>
{preview?.new_name || entry.name}
</div>
<div style={{ width: colWidths.size }} className="px-2 text-right text-[11px] text-muted-foreground tabular-nums shrink-0">
{formatSize(entry.size)}
</div>
<div style={{ width: colWidths.date }} className="px-2 text-right text-[11px] text-muted-foreground tabular-nums shrink-0">
{formatDate(entry.modified)}
</div>
<div style={{ width: colWidths.status }} className="px-2 flex items-center justify-center gap-1 shrink-0">
<AnimatePresence mode="wait">
{hasConflict && (
<motion.span
key="conflict"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="inline-flex items-center gap-1 text-[11px] text-destructive"
>
<IconX size={12} stroke={2} />
Conflict
</motion.span>
)}
{hasError && !hasConflict && (
<motion.span
key="error"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="inline-flex items-center gap-1 text-[11px] text-warning"
>
<IconAlertTriangle size={12} stroke={2} />
Error
</motion.span>
)}
{changed && !hasError && !hasConflict && (
<motion.span
key="ok"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="inline-flex items-center gap-1 text-[11px] text-primary"
>
<IconCheck size={12} stroke={2} />
OK
</motion.span>
)}
</AnimatePresence>
</div> </div>
</div> </div>
); );
})} })}
</div> </div>
</div> </div>
{/* marquee selection overlay */}
{marqueeBox && (
<div
className="marquee-selection"
style={{
left: marqueeBox.x,
top: marqueeBox.y,
width: marqueeBox.w,
height: marqueeBox.h,
}}
/>
)}
</div> </div>
</ContextMenuTrigger>
<ContextMenuContent className="w-52">
<ContextMenuItem onClick={handleSelectAll} className="gap-2 text-[13px]">
<IconSelect size={15} stroke={1.5} />
Select all
</ContextMenuItem>
<ContextMenuItem onClick={handleDeselectAll} className="gap-2 text-[13px]">
<IconDeselect size={15} stroke={1.5} />
Deselect all
</ContextMenuItem>
<ContextMenuItem onClick={handleInvertSelection} className="gap-2 text-[13px]">
<IconArrowsSort size={15} stroke={1.5} />
Invert selection
</ContextMenuItem>
<ContextMenuItem onClick={handleSelectChanged} className="gap-2 text-[13px]">
<IconCheck size={15} stroke={1.5} />
Select changed only
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={handleCopyNames} className="gap-2 text-[13px]">
<IconCopy size={15} stroke={1.5} />
Copy original names
</ContextMenuItem>
<ContextMenuItem onClick={handleCopyNewNames} className="gap-2 text-[13px]">
<IconClipboard size={15} stroke={1.5} />
Copy new names
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
onClick={() => {
if (contextFile) {
const isSelected = selectedFiles.has(contextFile);
if (isSelected) {
const s = new Set(selectedFiles);
s.delete(contextFile);
useFileStore.setState({ selectedFiles: s });
} else {
const s = new Set(selectedFiles);
s.add(contextFile);
useFileStore.setState({ selectedFiles: s });
}
}
}}
className="gap-2 text-[13px]"
>
{contextFile && selectedFiles.has(contextFile) ? (
<>
<IconEyeOff size={15} stroke={1.5} />
Exclude from rename
</>
) : (
<>
<IconEye size={15} stroke={1.5} />
Include in rename
</>
)}
</ContextMenuItem>
{contextFile && (
<>
<ContextMenuSeparator />
<ContextMenuItem
onClick={() => invoke("reveal_in_explorer", { path: contextFile }).catch(() => {})}
className="gap-2 text-[13px]"
>
<IconExternalLink size={15} stroke={1.5} />
Open in file explorer
</ContextMenuItem>
<ContextMenuItem
onClick={() => navigator.clipboard.writeText(contextFile)}
className="gap-2 text-[13px]"
>
<IconCopy size={15} stroke={1.5} />
Copy path
</ContextMenuItem>
</>
)}
</ContextMenuContent>
</ContextMenu>
);
}
function ColResizeHandle({
col,
onPointerDown,
onPointerMove,
onPointerUp,
colWidths,
setColWidths,
}: {
col: string;
onPointerDown: (e: React.PointerEvent) => void;
onPointerMove: (e: React.PointerEvent) => void;
onPointerUp: (e: React.PointerEvent) => void;
colWidths: typeof DEFAULT_COL_WIDTHS;
setColWidths: React.Dispatch<React.SetStateAction<typeof DEFAULT_COL_WIDTHS>>;
}) {
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
const step = e.shiftKey ? 40 : 10;
if (e.key === "ArrowRight") {
e.preventDefault();
setColWidths((prev) => ({ ...prev, [col]: Math.max(40, ((prev as any)[col] || 80) + step) }));
} else if (e.key === "ArrowLeft") {
e.preventDefault();
setColWidths((prev) => ({ ...prev, [col]: Math.max(40, ((prev as any)[col] || 80) - step) }));
}
}, [col, setColWidths]);
return (
<div
role="separator"
aria-orientation="vertical"
aria-valuenow={(colWidths as any)[col] || 80}
aria-label={`Resize ${col} column`}
tabIndex={0}
className="w-1 shrink-0 self-stretch cursor-col-resize hover:bg-primary/30 active:bg-primary/50 focus-visible:bg-primary/40 transition-colors"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onKeyDown={handleKeyDown}
/>
); );
} }

View File

@@ -1,26 +1,153 @@
import { useState } from "react"; import { useState, useCallback, useEffect, useRef } from "react";
import { invoke } from "@tauri-apps/api/core";
import { Sidebar } from "./Sidebar"; import { Sidebar } from "./Sidebar";
import { StatusBar } from "./StatusBar"; import { StatusBar } from "./StatusBar";
import { Toolbar } from "./Toolbar"; import { Toolbar } from "./Toolbar";
import { FileList } from "../browser/FileList"; import { FileList } from "@/components/browser/FileList";
import { RulePanel } from "../rules/RulePanel"; import { PipelineStrip } from "@/components/pipeline/PipelineStrip";
import { Toaster } from "@/components/ui/sonner";
import { useSettingsStore } from "@/stores/settingsStore";
import { motion, AnimatePresence, MotionConfig } from "framer-motion";
import { PortalContainerProvider } from "@/lib/portal";
import { ResizeEdges } from "./ResizeEdges";
import { toast } from "sonner";
import { useAnnounceStore } from "@/hooks/useAnnounce";
export function AppShell() { export function AppShell() {
const [sidebarWidth, setSidebarWidth] = useState(240); const [sidebarOpen, setSidebarOpen] = useState(true);
const [sidebarWidth, setSidebarWidth] = useState(220);
const [portalContainer, setPortalContainer] = useState<HTMLDivElement | null>(null);
const zoom = useSettingsStore((s) => s.zoom);
const animationsEnabled = useSettingsStore((s) => s.animationsEnabled);
const announceMessage = useAnnounceStore((s) => s.message);
const [maximized, setMaximized] = useState(() => document.documentElement.classList.contains("maximized"));
const dragging = useRef(false);
useEffect(() => {
const observer = new MutationObserver(() => {
setMaximized(document.documentElement.classList.contains("maximized"));
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
return () => observer.disconnect();
}, []);
// suppress default browser context menu everywhere
useEffect(() => {
const handler = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (target.closest("[data-slot='context-menu-trigger']")) return;
e.preventDefault();
};
document.addEventListener("contextmenu", handler);
return () => document.removeEventListener("contextmenu", handler);
}, []);
// prevent scroll events on overlay menus from leaking to the app
useEffect(() => {
const handler = (e: WheelEvent) => {
const target = e.target as HTMLElement;
const overlay = target.closest(
"[data-slot='context-menu-content'], [data-slot='dropdown-menu-content'], [data-slot='popover-content']"
);
if (!overlay) return;
const el = overlay as HTMLElement;
const atTop = el.scrollTop <= 0 && e.deltaY < 0;
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight && e.deltaY > 0;
const notScrollable = el.scrollHeight <= el.clientHeight;
if (atTop || atBottom || notScrollable) {
e.preventDefault();
}
};
document.addEventListener("wheel", handler, { passive: false });
return () => document.removeEventListener("wheel", handler);
}, []);
// startup update check
useEffect(() => {
const { checkForUpdates } = useSettingsStore.getState();
if (!checkForUpdates) return;
invoke<{ available: boolean; latest_version: string; url: string }>("check_for_updates")
.then((info) => {
if (info.available) {
toast.info(`Update available: v${info.latest_version}`, {
description: "A new version of Nomina is available.",
duration: 8000,
});
}
})
.catch(() => {});
}, []);
const onDividerPointerDown = useCallback((e: React.PointerEvent) => {
e.preventDefault();
dragging.current = true;
(e.target as HTMLElement).setPointerCapture(e.pointerId);
}, []);
const onDividerPointerMove = useCallback((e: React.PointerEvent) => {
if (!dragging.current) return;
const z = useSettingsStore.getState().zoom;
const x = e.clientX / z;
setSidebarWidth(Math.max(140, Math.min(450, x)));
}, []);
const onDividerPointerUp = useCallback(() => {
dragging.current = false;
}, []);
return ( return (
<div className="flex flex-col h-screen"> <MotionConfig reducedMotion={animationsEnabled ? "never" : "always"}>
<Toolbar /> <PortalContainerProvider value={portalContainer}>
<div className="flex flex-1 min-h-0"> <div
<Sidebar width={sidebarWidth} onResize={setSidebarWidth} /> ref={setPortalContainer}
className="window-frame select-none"
style={{
transform: `scale(${zoom})`,
transformOrigin: "top left",
width: maximized ? `calc(100vw / ${zoom})` : `calc((100vw - 40px) / ${zoom})`,
height: maximized ? `calc(100vh / ${zoom})` : `calc((100vh - 40px) / ${zoom})`,
}}
>
<a href="#file-list" className="skip-nav">Skip to file list</a>
{/* live region for screen reader announcements */}
<div aria-live="assertive" aria-atomic="true" className="sr-only">{announceMessage}</div>
<ResizeEdges />
<Toolbar sidebarOpen={sidebarOpen} onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} />
<div className="flex flex-1 min-h-0 relative">
<AnimatePresence mode="popLayout">
{sidebarOpen && (
<motion.div
key="sidebar"
initial={{ width: 0, opacity: 0 }}
animate={{ width: sidebarWidth, opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ type: "spring", stiffness: 400, damping: 30 }}
className="overflow-hidden shrink-0"
>
<Sidebar />
</motion.div>
)}
</AnimatePresence>
{sidebarOpen && (
<div
className="absolute top-0 bottom-0 z-20 w-2 -ml-1 cursor-col-resize hover:bg-primary/20 active:bg-primary/30 transition-colors"
style={{ left: sidebarWidth }}
onPointerDown={onDividerPointerDown}
onPointerMove={onDividerPointerMove}
onPointerUp={onDividerPointerUp}
/>
)}
<div className="flex flex-col flex-1 min-w-0"> <div className="flex flex-col flex-1 min-w-0">
<div className="flex-1 min-h-0 overflow-auto"> <div id="file-list" className="flex-1 min-h-0">
<FileList /> <FileList />
</div> </div>
<RulePanel /> <PipelineStrip />
</div> </div>
</div> </div>
<StatusBar /> <StatusBar />
</div> </div>
</PortalContainerProvider>
<Toaster />
</MotionConfig>
); );
} }

View File

@@ -0,0 +1,95 @@
import { useRef } from "react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { PhysicalPosition, PhysicalSize } from "@tauri-apps/api/dpi";
type Dir = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw";
const edges: { dir: Dir; className: string }[] = [
{ dir: "n", className: "absolute -top-[3px] left-3 right-3 h-[6px] cursor-n-resize z-50" },
{ dir: "s", className: "absolute -bottom-[3px] left-3 right-3 h-[6px] cursor-s-resize z-50" },
{ dir: "w", className: "absolute top-3 -left-[3px] bottom-3 w-[6px] cursor-w-resize z-50" },
{ dir: "e", className: "absolute top-3 -right-[3px] bottom-3 w-[6px] cursor-e-resize z-50" },
{ dir: "nw", className: "absolute -top-[3px] -left-[3px] w-4 h-4 cursor-nw-resize z-50" },
{ dir: "ne", className: "absolute -top-[3px] -right-[3px] w-4 h-4 cursor-ne-resize z-50" },
{ dir: "sw", className: "absolute -bottom-[3px] -left-[3px] w-4 h-4 cursor-sw-resize z-50" },
{ dir: "se", className: "absolute -bottom-[3px] -right-[3px] w-4 h-4 cursor-se-resize z-50" },
];
interface DragState {
dir: Dir;
startX: number;
startY: number;
startPos: { x: number; y: number };
startSize: { width: number; height: number };
}
const MIN_W = 900;
const MIN_H = 600;
export function ResizeEdges() {
const drag = useRef<DragState | null>(null);
const onPointerDown = (dir: Dir) => async (e: React.PointerEvent) => {
e.preventDefault();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
const win = getCurrentWindow();
const pos = await win.outerPosition();
const size = await win.outerSize();
drag.current = {
dir,
startX: e.screenX,
startY: e.screenY,
startPos: { x: pos.x, y: pos.y },
startSize: { width: size.width, height: size.height },
};
};
const onPointerMove = async (e: React.PointerEvent) => {
if (!drag.current) return;
const d = drag.current;
const dx = e.screenX - d.startX;
const dy = e.screenY - d.startY;
let x = d.startPos.x;
let y = d.startPos.y;
let w = d.startSize.width;
let h = d.startSize.height;
if (d.dir.includes("e")) w = Math.max(MIN_W, d.startSize.width + dx);
if (d.dir.includes("s")) h = Math.max(MIN_H, d.startSize.height + dy);
if (d.dir.includes("w")) {
const newW = Math.max(MIN_W, d.startSize.width - dx);
x = d.startPos.x + (d.startSize.width - newW);
w = newW;
}
if (d.dir.includes("n")) {
const newH = Math.max(MIN_H, d.startSize.height - dy);
y = d.startPos.y + (d.startSize.height - newH);
h = newH;
}
const win = getCurrentWindow();
await win.setPosition(new PhysicalPosition(x, y));
await win.setSize(new PhysicalSize(w, h));
};
const onPointerUp = () => {
drag.current = null;
};
return (
<>
{edges.map(({ dir, className }) => (
<div
key={dir}
className={className}
onPointerDown={onPointerDown(dir)}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
/>
))}
</>
);
}

View File

@@ -1,57 +1,264 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { useFileStore } from "../../stores/fileStore"; import { useFileStore } from "@/stores/fileStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
IconFolder,
IconFolderOpen,
IconChevronRight,
IconArrowUp,
IconFolderPlus,
IconExternalLink,
IconCopy,
IconFolderSymlink,
} from "@tabler/icons-react";
import { cn } from "@/lib/utils";
import { motion, AnimatePresence } from "framer-motion";
interface SidebarProps { interface FolderNode {
width: number; path: string;
onResize: (width: number) => void; name: string;
children: FolderNode[];
loaded: boolean;
expanded: boolean;
} }
export function Sidebar({ width }: SidebarProps) { export function Sidebar() {
const [pathInput, setPathInput] = useState(""); const [pathInput, setPathInput] = useState("");
const [drives, setDrives] = useState<string[]>([]); const [tree, setTree] = useState<FolderNode[]>([]);
const [folders, setFolders] = useState<string[]>([]);
const scanDirectory = useFileStore((s) => s.scanDirectory); const scanDirectory = useFileStore((s) => s.scanDirectory);
const currentPath = useFileStore((s) => s.currentPath); const currentPath = useFileStore((s) => s.currentPath);
const restoredRef = useRef(false);
const treeRef = useRef<FolderNode[]>([]);
const scrollRef = useRef<HTMLDivElement>(null);
const expandGenRef = useRef(0);
// keep treeRef in sync so async functions see latest tree
treeRef.current = tree;
useEffect(() => { useEffect(() => {
// detect windows drives loadDrives().then(async () => {
const detected: string[] = []; if (restoredRef.current) return;
for (const letter of "CDEFGHIJKLMNOPQRSTUVWXYZ") { restoredRef.current = true;
detected.push(`${letter}:\\`);
// check if launched from context menu with paths
try {
const args = await invoke<string[]>("get_launch_args");
if (args.length > 0) {
const result = await invoke<[string, string[]] | null>("resolve_launch_paths", { args });
if (result) {
const [folder, selected] = result;
await scanDirectory(folder);
if (selected.length > 0) {
useFileStore.setState({ selectedFiles: new Set(selected) });
} else {
useFileStore.setState({ selectedFiles: new Set() });
} }
setDrives(detected); expandToPath(folder);
return;
}
}
} catch {
// no launch args support or failed - fall through
}
const { lastFolder, openLastFolder } = useSettingsStore.getState();
if (openLastFolder && lastFolder) {
scanDirectory(lastFolder);
expandToPath(lastFolder);
}
});
}, []); }, []);
useEffect(() => { useEffect(() => {
if (currentPath) { if (currentPath) {
setPathInput(currentPath); setPathInput(currentPath);
loadFolders(currentPath); useSettingsStore.getState().setLastFolder(currentPath);
expandToPath(currentPath);
} }
}, [currentPath]); }, [currentPath]);
async function loadFolders(path: string) { async function loadDrives() {
const drives: FolderNode[] = [];
for (const letter of "CDEFGHIJKLMNOPQRSTUVWXYZ") {
const path = `${letter}:\\`;
try { try {
const entries = await invoke<Array<{ path: string; name: string; is_dir: boolean }>>( const entries = await invoke<Array<{ path: string; name: string; is_dir: boolean }>>(
"scan_directory", "scan_directory",
{ {
path, path,
filters: { filters: {
mask: "*", mask: "*", regex_filter: null, min_size: null, max_size: null,
regex_filter: null, include_files: false, include_folders: true, include_hidden: false, subfolder_depth: 0,
min_size: null,
max_size: null,
include_files: false,
include_folders: true,
include_hidden: false,
subfolder_depth: 0,
}, },
}, },
); );
setFolders(entries.filter((e) => e.is_dir).map((e) => e.path)); if (entries.length >= 0) {
} catch { drives.push({ path, name: path, children: [], loaded: false, expanded: false });
setFolders([]);
} }
} catch {
// drive doesn't exist
}
}
setTree(drives);
treeRef.current = drives;
}
async function loadChildren(node: FolderNode): Promise<FolderNode[]> {
try {
const entries = await invoke<Array<{ path: string; name: string; is_dir: boolean }>>(
"scan_directory",
{
path: node.path,
filters: {
mask: "*", regex_filter: null, min_size: null, max_size: null,
include_files: false, include_folders: true, include_hidden: false, subfolder_depth: 0,
},
},
);
return entries
.filter((e) => e.is_dir)
.map((e) => ({
path: e.path,
name: e.name,
children: [],
loaded: false,
expanded: false,
}));
} catch {
return [];
}
}
// parse a Windows path into ancestor segments: "D:\foo\bar" -> ["D:\", "D:\foo", "D:\foo\bar"]
function pathSegments(p: string): string[] {
const normalized = p.replace(/\//g, "\\").replace(/\\$/, "");
const parts = normalized.split("\\");
const segments: string[] = [];
for (let i = 0; i < parts.length; i++) {
if (i === 0) {
segments.push(parts[0] + "\\");
} else {
segments.push(segments[i - 1] + (segments[i - 1].endsWith("\\") ? "" : "\\") + parts[i]);
}
}
return segments;
}
async function expandToPath(targetPath: string) {
const gen = ++expandGenRef.current;
const segments = pathSegments(targetPath);
if (segments.length === 0) return;
// build set of ancestor paths that should stay expanded
const ancestorSet = new Set(segments.map((s) => s.replace(/\\$/, "").toLowerCase()));
// collapse everything that isn't an ancestor of the target
setTree((prev) => {
function collapse(nodes: FolderNode[]): FolderNode[] {
return nodes.map((n) => {
const nNorm = n.path.replace(/\\$/, "").toLowerCase();
const isAncestor = ancestorSet.has(nNorm);
return {
...n,
expanded: isAncestor,
children: collapse(n.children),
};
});
}
const updated = collapse(prev);
treeRef.current = updated;
return updated;
});
for (const seg of segments) {
if (expandGenRef.current !== gen) return;
const segNorm = seg.replace(/\\$/, "").toLowerCase();
// find the node in the current tree
function findInTree(nodes: FolderNode[]): FolderNode | undefined {
for (const n of nodes) {
if (n.path.replace(/\\$/, "").toLowerCase() === segNorm) return n;
const found = findInTree(n.children);
if (found) return found;
}
return undefined;
}
const node = findInTree(treeRef.current);
if (!node) return;
// load children if needed (async)
let newChildren: FolderNode[] | null = null;
if (!node.loaded) {
newChildren = await loadChildren(node);
if (expandGenRef.current !== gen) return;
}
// update tree immutably, always from latest state
setTree((prev) => {
function update(nodes: FolderNode[]): FolderNode[] {
return nodes.map((n) => {
if (n.path.replace(/\\$/, "").toLowerCase() === segNorm) {
return {
...n,
expanded: true,
loaded: true,
children: newChildren || n.children,
};
}
return { ...n, children: update(n.children) };
});
}
const updated = update(prev);
treeRef.current = updated;
return updated;
});
}
// scroll active node into view after render
requestAnimationFrame(() => {
const el = scrollRef.current?.querySelector("[data-tree-active='true']");
if (el) el.scrollIntoView({ block: "center", behavior: "smooth" });
});
}
async function toggleExpand(path: string) {
async function updateNode(nodes: FolderNode[]): Promise<FolderNode[]> {
const result: FolderNode[] = [];
for (const node of nodes) {
if (node.path === path) {
if (!node.loaded) {
const children = await loadChildren(node);
result.push({ ...node, expanded: true, loaded: true, children });
} else {
result.push({ ...node, expanded: !node.expanded });
}
} else {
const updatedChildren = await updateNode(node.children);
result.push({ ...node, children: updatedChildren });
}
}
return result;
}
const updated = await updateNode(treeRef.current);
setTree(updated);
treeRef.current = updated;
}
function handleFolderClick(path: string) {
scanDirectory(path);
} }
function handlePathSubmit(e: React.FormEvent) { function handlePathSubmit(e: React.FormEvent) {
@@ -61,91 +268,255 @@ export function Sidebar({ width }: SidebarProps) {
} }
} }
function handleDriveClick(drive: string) { function handleGoUp() {
scanDirectory(drive); if (!currentPath) return;
const normalized = currentPath.replace(/\\/g, "/").replace(/\/$/, "");
const parent = normalized.replace(/\/[^/]+$/, "");
if (!parent) return;
let result = parent.replace(/\//g, "\\");
if (/^[A-Za-z]:$/.test(result)) result += "\\";
scanDirectory(result);
} }
function handleFolderClick(path: string) { // flatten visible tree for keyboard navigation
scanDirectory(path); const [focusedPath, setFocusedPath] = useState<string | null>(null);
}
function folderName(path: string): string { const flattenVisible = useCallback((nodes: FolderNode[]): FolderNode[] => {
const parts = path.replace(/\\/g, "/").split("/").filter(Boolean); const result: FolderNode[] = [];
return parts[parts.length - 1] || path; for (const n of nodes) {
result.push(n);
if (n.expanded) result.push(...flattenVisible(n.children));
}
return result;
}, []);
const findParent = useCallback((nodes: FolderNode[], targetPath: string, parent: FolderNode | null = null): FolderNode | null => {
for (const n of nodes) {
if (n.path === targetPath) return parent;
const found = findParent(n.children, targetPath, n);
if (found) return found;
}
return null;
}, []);
const handleTreeKeyDown = useCallback((e: React.KeyboardEvent) => {
const visible = flattenVisible(tree);
if (visible.length === 0) return;
const idx = focusedPath ? visible.findIndex((n) => n.path === focusedPath) : -1;
const current = idx >= 0 ? visible[idx] : null;
switch (e.key) {
case "ArrowDown": {
e.preventDefault();
const next = Math.min(idx + 1, visible.length - 1);
setFocusedPath(visible[Math.max(0, next)].path);
break;
}
case "ArrowUp": {
e.preventDefault();
const prev = Math.max(idx - 1, 0);
setFocusedPath(visible[prev].path);
break;
}
case "ArrowRight": {
e.preventDefault();
if (current && !current.expanded) {
toggleExpand(current.path);
} else if (current && current.expanded && current.children.length > 0) {
setFocusedPath(current.children[0].path);
}
break;
}
case "ArrowLeft": {
e.preventDefault();
if (current && current.expanded) {
toggleExpand(current.path);
} else if (current) {
const parent = findParent(tree, current.path);
if (parent) setFocusedPath(parent.path);
}
break;
}
case "Enter":
case " ": {
e.preventDefault();
if (current) handleFolderClick(current.path);
break;
}
case "Home": {
e.preventDefault();
if (visible.length > 0) setFocusedPath(visible[0].path);
break;
}
case "End": {
e.preventDefault();
if (visible.length > 0) setFocusedPath(visible[visible.length - 1].path);
break;
}
}
}, [tree, focusedPath, flattenVisible, findParent]);
// scroll focused node into view
useEffect(() => {
if (!focusedPath || !scrollRef.current) return;
const el = scrollRef.current.querySelector(`[data-tree-path="${CSS.escape(focusedPath)}"]`);
if (el) {
(el as HTMLElement).focus();
el.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
}, [focusedPath]);
function renderNode(node: FolderNode, depth: number) {
const isActive = node.path === currentPath;
const isFocused = node.path === focusedPath;
return (
<div key={node.path} role="treeitem" aria-expanded={node.expanded} aria-label={node.name}>
<ContextMenu>
<ContextMenuTrigger asChild>
<motion.div
data-tree-active={isActive ? "true" : undefined}
data-tree-path={node.path}
tabIndex={isFocused ? 0 : -1}
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
whileHover={{ x: 2, transition: { duration: 0.1 } }}
className={cn(
"flex items-center gap-0.5 py-0.5 pr-2 rounded-md cursor-pointer text-[13px] transition-colors group",
isActive
? "bg-primary/10 text-primary"
: "text-foreground/80 hover:bg-muted hover:text-foreground",
isFocused && "ring-2 ring-ring/50 ring-inset",
)}
style={{ paddingLeft: `${depth * 16 + 4}px` }}
onFocus={() => setFocusedPath(node.path)}
>
<button
onClick={(e) => { e.stopPropagation(); toggleExpand(node.path); }}
aria-label={node.expanded ? `Collapse ${node.name}` : `Expand ${node.name}`}
tabIndex={-1}
className="p-0.5 rounded hover:bg-muted-foreground/10 shrink-0"
>
<motion.div
animate={{ rotate: node.expanded ? 90 : 0 }}
transition={{ type: "spring", stiffness: 300, damping: 20 }}
>
<IconChevronRight size={14} stroke={1.5} />
</motion.div>
</button>
<button
onClick={() => handleFolderClick(node.path)}
aria-label={`Open folder ${node.name}`}
aria-current={isActive ? "location" : undefined}
tabIndex={-1}
className="flex items-center gap-1.5 flex-1 min-w-0 truncate"
>
{node.expanded
? <IconFolderOpen size={16} stroke={1.5} className="shrink-0 text-primary" />
: <IconFolder size={16} stroke={1.5} className="shrink-0 text-muted-foreground group-hover:text-foreground" />
}
<span className="truncate">{node.name}</span>
</button>
</motion.div>
</ContextMenuTrigger>
<ContextMenuContent className="w-52">
<ContextMenuItem onClick={() => handleFolderClick(node.path)} className="gap-2 text-[13px]">
<IconFolderSymlink size={15} stroke={1.5} /> Open in Nomina
</ContextMenuItem>
<ContextMenuItem onClick={() => invoke("reveal_in_explorer", { path: node.path }).catch(() => {})} className="gap-2 text-[13px]">
<IconExternalLink size={15} stroke={1.5} /> Open in file explorer
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => navigator.clipboard.writeText(node.path)} className="gap-2 text-[13px]">
<IconCopy size={15} stroke={1.5} /> Copy path
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => toggleExpand(node.path)} className="gap-2 text-[13px]">
<IconChevronRight size={15} stroke={1.5} /> {node.expanded ? "Collapse" : "Expand"}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<AnimatePresence>
{node.expanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ type: "spring", stiffness: 400, damping: 30 }}
className="overflow-hidden"
>
{node.children.map((child) => renderNode(child, depth + 1))}
</motion.div>
)}
</AnimatePresence>
</div>
);
} }
return ( return (
<div <motion.div
className="flex flex-col border-r overflow-hidden select-none" initial={{ opacity: 0 }}
style={{ animate={{ opacity: 1 }}
width: `${width}px`, transition={{ duration: 0.3 }}
minWidth: "180px", role="navigation"
maxWidth: "400px", aria-label="Folder browser"
background: "var(--bg-secondary)", className="flex flex-col border-r bg-sidebar w-full h-full min-h-0"
borderColor: "var(--border)",
}}
> >
<form onSubmit={handlePathSubmit} className="p-2 border-b" style={{ borderColor: "var(--border)" }}> <motion.form
<input initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15, duration: 0.3 }}
onSubmit={handlePathSubmit}
className="px-2 pt-2 pb-2 flex gap-1"
>
<Input
type="text" type="text"
value={pathInput} value={pathInput}
onChange={(e) => setPathInput(e.target.value)} onChange={(e) => setPathInput(e.target.value)}
placeholder="Enter path..." placeholder="Path..."
className="w-full px-2 py-1 text-xs rounded border" aria-label="Folder path"
style={{ className="h-7 text-xs font-mono"
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
/> />
</form>
<div className="flex-1 overflow-y-auto text-xs">
{!currentPath && (
<div className="p-2">
<div className="text-[10px] uppercase tracking-wide mb-1" style={{ color: "var(--text-secondary)" }}>
Drives
</div>
{drives.map((d) => (
<button
key={d}
onClick={() => handleDriveClick(d)}
className="block w-full text-left px-2 py-1 rounded hover:opacity-80"
style={{ color: "var(--text-primary)" }}
>
{d}
</button>
))}
</div>
)}
{currentPath && ( {currentPath && (
<div className="p-2"> <motion.div
{currentPath.includes("\\") || currentPath.includes("/") ? ( initial={{ scale: 0 }}
<button animate={{ scale: 1 }}
onClick={() => { transition={{ type: "spring", stiffness: 500, damping: 25 }}
const parent = currentPath.replace(/\\/g, "/").replace(/\/[^/]+\/?$/, "");
if (parent) scanDirectory(parent.replace(/\//g, "\\") || currentPath.slice(0, 3));
}}
className="block w-full text-left px-2 py-1 rounded mb-1 hover:opacity-80"
style={{ color: "var(--accent)" }}
> >
.. <Tooltip>
</button> <TooltipTrigger asChild>
) : null} <Button type="button" variant="ghost" size="icon-xs" onClick={handleGoUp} aria-label="Go to parent folder">
{folders.map((f) => ( <IconArrowUp size={14} stroke={1.5} />
<button </Button>
key={f} </TooltipTrigger>
onClick={() => handleFolderClick(f)} <TooltipContent side="bottom">Go up</TooltipContent>
className="block w-full text-left px-2 py-1 rounded truncate hover:opacity-80" </Tooltip>
style={{ color: "var(--text-primary)" }} </motion.div>
>
{folderName(f)}
</button>
))}
</div>
)} )}
</motion.form>
<ScrollArea className="flex-1 overflow-hidden">
<div ref={scrollRef} className="px-1.5 pb-2" role="tree" aria-label="Folder tree" onKeyDown={handleTreeKeyDown}>
{tree.length === 0 && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
className="flex flex-col items-center justify-center py-8 gap-2 text-muted-foreground"
>
<motion.div
animate={{ rotate: [0, 5, -5, 0] }}
transition={{ duration: 2, repeat: Infinity, ease: "easeInOut" }}
>
<IconFolderPlus size={24} stroke={1.5} />
</motion.div>
<span className="text-xs">Loading drives...</span>
</motion.div>
)}
{tree.map((node) => renderNode(node, 0))}
</div> </div>
</div> </ScrollArea>
</motion.div>
); );
} }

View File

@@ -1,35 +1,99 @@
import { useFileStore } from "../../stores/fileStore"; import { useFileStore } from "@/stores/fileStore";
import { motion, AnimatePresence } from "framer-motion";
export function StatusBar() { export function StatusBar() {
const files = useFileStore((s) => s.files); const files = useFileStore((s) => s.files);
const selectedFiles = useFileStore((s) => s.selectedFiles); const selectedFiles = useFileStore((s) => s.selectedFiles);
const previewResults = useFileStore((s) => s.previewResults); const previewResults = useFileStore((s) => s.previewResults);
const loading = useFileStore((s) => s.loading); const loading = useFileStore((s) => s.loading);
const currentPath = useFileStore((s) => s.currentPath);
const conflicts = previewResults.filter((r) => r.has_conflict).length; const selectedPreviews = previewResults.filter((r) => selectedFiles.has(r.original_path));
const changes = previewResults.filter( const conflicts = selectedPreviews.filter((r) => r.has_conflict).length;
const changes = selectedPreviews.filter(
(r) => r.original_name !== r.new_name && !r.has_error, (r) => r.original_name !== r.new_name && !r.has_error,
).length; ).length;
const status = loading ? "Scanning..." : changes > 0 ? "Preview ready" : "Ready";
return ( return (
<div <motion.div
className="flex items-center gap-4 px-4 py-1 text-xs border-t select-none" initial={{ opacity: 0, y: 10 }}
style={{ animate={{ opacity: 1, y: 0 }}
background: "var(--bg-secondary)", transition={{ duration: 0.3, delay: 0.2 }}
borderColor: "var(--border)", role="status"
color: "var(--text-secondary)", aria-live="polite"
}} aria-label="Status bar"
className="flex items-center gap-4 px-3 py-1 text-[11px] border-t bg-background text-muted-foreground font-mono"
> >
<span>{files.length} files</span> {/* visually hidden summary for screen readers */}
<span>{selectedFiles.size} selected</span> <span className="sr-only">
{changes > 0 && <span>{changes} to rename</span>} {currentPath ? `${currentPath}. ` : ""}
{conflicts > 0 && ( {files.length} files, {selectedFiles.size} selected
<span style={{ color: "var(--error)" }}>{conflicts} conflicts</span> {changes > 0 ? `, ${changes} to rename` : ""}
{conflicts > 0 ? `, ${conflicts} conflicts` : ""}.
{loading ? " Scanning." : changes > 0 ? " Preview ready." : " Ready."}
</span>
<AnimatePresence mode="wait">
{currentPath && (
<motion.span
key={currentPath}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
transition={{ duration: 0.2 }}
className="truncate max-w-[300px]"
title={currentPath}
>
{currentPath}
</motion.span>
)} )}
</AnimatePresence>
<motion.span
key={`files-${files.length}`}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: "spring", stiffness: 500, damping: 25 }}
>
{files.length} files
</motion.span>
<span>{selectedFiles.size} selected</span>
<AnimatePresence>
{changes > 0 && (
<motion.span
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ type: "spring", stiffness: 500, damping: 25 }}
className="text-primary"
>
{changes} to rename
</motion.span>
)}
</AnimatePresence>
<AnimatePresence>
{conflicts > 0 && (
<motion.span
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ type: "spring", stiffness: 500, damping: 25 }}
className="text-destructive"
>
{conflicts} conflicts
</motion.span>
)}
</AnimatePresence>
<div className="flex-1" /> <div className="flex-1" />
<span>{status}</span> <AnimatePresence mode="wait">
</div> <motion.span
key={loading ? "scanning" : changes > 0 ? "preview" : "ready"}
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
transition={{ duration: 0.15 }}
>
{loading ? "Scanning..." : changes > 0 ? "Preview ready" : "Ready"}
</motion.span>
</AnimatePresence>
</motion.div>
); );
} }

View File

@@ -1,34 +1,107 @@
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { useFileStore } from "../../stores/fileStore"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { useRuleStore } from "../../stores/ruleStore"; import { useFileStore } from "@/stores/fileStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
IconArrowBackUp,
IconLayoutSidebar,
IconSettings,
IconTemplate,
IconMinus,
IconSquare,
IconX,
} from "@tabler/icons-react";
import { SettingsDialog } from "@/components/settings/SettingsDialog";
import { PresetsDialog } from "@/components/presets/PresetsDialog";
import { motion } from "framer-motion";
export function Toolbar() { interface ToolbarProps {
sidebarOpen: boolean;
onToggleSidebar: () => void;
}
export function Toolbar({ sidebarOpen, onToggleSidebar }: ToolbarProps) {
const [settingsOpen, setSettingsOpen] = useState(false);
const [presetsOpen, setPresetsOpen] = useState(false);
const previewResults = useFileStore((s) => s.previewResults); const previewResults = useFileStore((s) => s.previewResults);
const selectedFiles = useFileStore((s) => s.selectedFiles);
const currentPath = useFileStore((s) => s.currentPath); const currentPath = useFileStore((s) => s.currentPath);
const scanDirectory = useFileStore((s) => s.scanDirectory); const scanDirectory = useFileStore((s) => s.scanDirectory);
const requestPreview = useRuleStore((s) => s.requestPreview);
const resetAllRules = useRuleStore((s) => s.resetAllRules); const selectedPreviews = previewResults.filter(
(r) => selectedFiles.has(r.original_path) && !r.has_conflict && !r.has_error && r.original_name !== r.new_name,
);
const renameCount = selectedPreviews.length;
const appWindow = getCurrentWindow();
async function handleRename() { async function handleRename() {
const ops = previewResults.filter( const ops = selectedPreviews;
(r) => !r.has_conflict && !r.has_error && r.original_name !== r.new_name,
);
if (ops.length === 0) return; if (ops.length === 0) return;
const settings = useSettingsStore.getState();
if (settings.confirmBeforeRename) {
const ok = window.confirm(`Rename ${ops.length} file${ops.length !== 1 ? "s" : ""}?`);
if (!ok) return;
}
try { try {
const report = await invoke<{ succeeded: number; failed: string[] }>( const report = await invoke<{ succeeded: number; failed: string[] }>(
"execute_rename", "execute_rename",
{ operations: ops }, {
operations: ops,
createBackup: settings.createBackups,
undoLimit: settings.undoHistoryLimit,
skipReadOnly: settings.skipReadOnly,
conflictStrategy: settings.conflictStrategy,
backupPath: settings.backupLocation === "custom" ? settings.customBackupPath : null,
},
); );
if (report.failed.length > 0) {
console.error("Some renames failed:", report.failed);
}
// refresh
if (currentPath) { if (currentPath) {
await scanDirectory(currentPath); await scanDirectory(currentPath);
} }
// toast notification
if (settings.showToastOnComplete) {
if (report.failed.length > 0) {
toast.warning(`Renamed ${report.succeeded} file${report.succeeded !== 1 ? "s" : ""}`, {
description: `${report.failed.length} failed`,
});
} else {
toast.success(`Renamed ${report.succeeded} file${report.succeeded !== 1 ? "s" : ""}`, {
description: "All operations completed",
});
}
}
// play sound
if (settings.playSoundOnComplete) {
try {
const ctx = new AudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = 800;
osc.type = "sine";
gain.gain.setValueAtTime(0.15, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.2);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + 0.2);
} catch {}
}
// flash taskbar
if (settings.flashTaskbarOnComplete && document.hidden) {
getCurrentWindow().requestUserAttention(2).catch(() => {});
}
} catch (e) { } catch (e) {
console.error("Rename failed:", e); toast.error("Rename failed", { description: String(e) });
} }
} }
@@ -43,56 +116,120 @@ export function Toolbar() {
} }
} }
const hasChanges = previewResults.some(
(r) => r.original_name !== r.new_name && !r.has_conflict && !r.has_error,
);
return ( return (
<div <>
className="flex items-center gap-2 px-4 py-2 border-b select-none" <motion.div
style={{ initial={{ opacity: 0, y: -10 }}
background: "var(--bg-secondary)", animate={{ opacity: 1, y: 0 }}
borderColor: "var(--border)", transition={{ duration: 0.3, ease: "easeOut" }}
}} role="toolbar"
aria-label="Main toolbar"
className="flex items-center gap-1.5 px-2 py-1.5 border-b bg-background rounded-t-[9px]"
data-tauri-drag-region
> >
<span className="font-semibold text-sm mr-4" style={{ color: "var(--accent)" }}> <Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={onToggleSidebar}
aria-label={sidebarOpen ? "Hide sidebar" : "Show sidebar"}
aria-expanded={sidebarOpen}
className={sidebarOpen ? "text-foreground" : "text-muted-foreground"}
>
<motion.div
animate={{ rotateY: sidebarOpen ? 0 : 180 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
>
<IconLayoutSidebar size={18} stroke={1.5} />
</motion.div>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{sidebarOpen ? "Hide sidebar" : "Show sidebar"}</TooltipContent>
</Tooltip>
<div className="w-px h-4 bg-border mx-1" />
<motion.div
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.97 }}
>
<Button
onClick={handleRename}
disabled={renameCount === 0}
size="sm"
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-40 font-medium"
>
{renameCount > 0 ? `Rename ${renameCount} file${renameCount !== 1 ? "s" : ""}` : "No changes"}
</Button>
</motion.div>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon-sm" onClick={handleUndo} aria-label="Undo last rename">
<IconArrowBackUp size={18} stroke={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Undo last rename</TooltipContent>
</Tooltip>
<div className="flex-1 flex items-center justify-center" data-tauri-drag-region>
<span className="text-xs font-semibold text-primary tracking-wide uppercase pointer-events-none" data-tauri-drag-region>
Nomina Nomina
</span> </span>
<button
onClick={handleRename}
disabled={!hasChanges}
className="px-4 py-1.5 rounded text-sm font-medium text-white disabled:opacity-40"
style={{ background: hasChanges ? "var(--accent)" : "var(--border)" }}
>
Rename
</button>
<button
onClick={() => requestPreview(currentPath)}
className="px-3 py-1.5 rounded text-sm border"
style={{ borderColor: "var(--border)", color: "var(--text-primary)" }}
>
Preview
</button>
<button
onClick={handleUndo}
className="px-3 py-1.5 rounded text-sm border"
style={{ borderColor: "var(--border)", color: "var(--text-primary)" }}
>
Undo
</button>
<div className="flex-1" />
<button
onClick={resetAllRules}
className="px-3 py-1.5 rounded text-sm border"
style={{ borderColor: "var(--border)", color: "var(--text-secondary)" }}
>
Clear Rules
</button>
</div> </div>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon-sm" onClick={() => setPresetsOpen(true)} aria-label="Presets">
<IconTemplate size={18} stroke={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Presets</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon-sm" onClick={() => setSettingsOpen(true)} aria-label="Settings">
<motion.div
whileHover={{ rotate: 90 }}
transition={{ duration: 0.4, ease: "easeInOut" }}
>
<IconSettings size={18} stroke={1.5} />
</motion.div>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Settings</TooltipContent>
</Tooltip>
<div className="w-px h-4 bg-border mx-1" />
{/* window controls */}
<button
onClick={() => appWindow.minimize()}
aria-label="Minimize window"
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<IconMinus size={14} stroke={1.5} />
</button>
<button
onClick={() => appWindow.toggleMaximize()}
aria-label="Maximize window"
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<IconSquare size={13} stroke={1.5} />
</button>
<button
onClick={() => appWindow.close()}
aria-label="Close window"
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
>
<IconX size={14} stroke={1.5} />
</button>
</motion.div>
<PresetsDialog open={presetsOpen} onOpenChange={setPresetsOpen} />
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
</>
); );
} }

View File

@@ -0,0 +1,694 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { OverlayScrollbarsComponent, type OverlayScrollbarsComponentRef } from "overlayscrollbars-react";
import { useRuleStore, type PipelineRule } from "@/stores/ruleStore";
import { useFileStore } from "@/stores/fileStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { Button } from "@/components/ui/button";
import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
DropdownMenuLabel,
} from "@/components/ui/dropdown-menu";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { Switch } from "@/components/ui/switch";
import {
IconPlus,
IconReplace,
IconRegex,
IconEraser,
IconTextPlus,
IconLetterCase,
IconNumbers,
IconCalendar,
IconArrowsExchange,
IconFileTypography,
IconTrash,
IconX,
IconEdit,
IconHash,
IconFolder,
IconLanguage,
IconSortAscendingNumbers,
IconCut,
IconDice,
IconArrowsRightLeft,
IconShieldCheck,
IconCopy,
IconChevronLeft,
IconChevronRight,
IconPlayerPlay,
IconPlayerPause,
} from "@tabler/icons-react";
import { cn } from "@/lib/utils";
import { motion, AnimatePresence } from "framer-motion";
import {
DndContext,
closestCenter,
PointerSensor,
KeyboardSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
horizontalListSortingStrategy,
useSortable,
sortableKeyboardCoordinates,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { ReplaceConfig } from "./configs/ReplaceConfig";
import { RegexConfig } from "./configs/RegexConfig";
import { RemoveConfig } from "./configs/RemoveConfig";
import { AddConfig } from "./configs/AddConfig";
import { CaseConfig } from "./configs/CaseConfig";
import { NumberingConfig } from "./configs/NumberingConfig";
import { ExtensionConfig } from "./configs/ExtensionConfig";
import { DateConfig } from "./configs/DateConfig";
import { MovePartsConfig } from "./configs/MovePartsConfig";
import { TextEditorConfig } from "./configs/TextEditorConfig";
import { HashConfig } from "./configs/HashConfig";
import { FolderNameConfig } from "./configs/FolderNameConfig";
import { TransliterateConfig } from "./configs/TransliterateConfig";
import { PaddingConfig } from "./configs/PaddingConfig";
import { TruncateConfig } from "./configs/TruncateConfig";
import { RandomizeConfig } from "./configs/RandomizeConfig";
import { SwapConfig } from "./configs/SwapConfig";
import { SanitizeConfig } from "./configs/SanitizeConfig";
const ruleTypes = [
{ id: "add", label: "Add", icon: IconTextPlus, desc: "Insert text" },
{ id: "case", label: "Case", icon: IconLetterCase, desc: "Change letter case" },
{ id: "date", label: "Date", icon: IconCalendar, desc: "Insert date/time info" },
{ id: "text_editor", label: "Editor", icon: IconEdit, desc: "Edit names as text" },
{ id: "extension", label: "Extension", icon: IconFileTypography, desc: "Change file extension" },
{ id: "folder_name", label: "Folder", icon: IconFolder, desc: "Insert parent folder name" },
{ id: "hash", label: "Hash", icon: IconHash, desc: "Add file content hash" },
{ id: "move_parts", label: "Move", icon: IconArrowsExchange, desc: "Move or swap parts" },
{ id: "numbering", label: "Number", icon: IconNumbers, desc: "Add sequence numbers" },
{ id: "padding", label: "Padding", icon: IconSortAscendingNumbers, desc: "Pad numbers in names" },
{ id: "randomize", label: "Random", icon: IconDice, desc: "Add random characters" },
{ id: "regex", label: "Regex", icon: IconRegex, desc: "Pattern matching" },
{ id: "remove", label: "Remove", icon: IconEraser, desc: "Strip characters" },
{ id: "replace", label: "Replace", icon: IconReplace, desc: "Find and replace text" },
{ id: "sanitize", label: "Sanitize", icon: IconShieldCheck, desc: "Clean up filenames" },
{ id: "swap", label: "Swap", icon: IconArrowsRightLeft, desc: "Swap parts around delimiter" },
{ id: "transliterate", label: "Translit", icon: IconLanguage, desc: "Non-ASCII to ASCII" },
{ id: "truncate", label: "Truncate", icon: IconCut, desc: "Limit filename length" },
] as const;
const ruleIconMap: Record<string, typeof IconReplace> = {
replace: IconReplace,
regex: IconRegex,
remove: IconEraser,
add: IconTextPlus,
case: IconLetterCase,
numbering: IconNumbers,
date: IconCalendar,
move_parts: IconArrowsExchange,
extension: IconFileTypography,
text_editor: IconEdit,
hash: IconHash,
folder_name: IconFolder,
transliterate: IconLanguage,
padding: IconSortAscendingNumbers,
truncate: IconCut,
randomize: IconDice,
swap: IconArrowsRightLeft,
sanitize: IconShieldCheck,
};
function getRuleSummary(rule: Record<string, unknown>): string {
const type = rule.type as string;
switch (type) {
case "replace": {
const s = rule.search as string;
const r = rule.replace_with as string;
if (!s) return "Not configured";
return `"${s}" -> "${r}"`;
}
case "regex": {
const p = rule.pattern as string;
if (!p) return "Not configured";
return `/${p}/`;
}
case "remove": {
const parts: string[] = [];
if ((rule.first_n as number) > 0) parts.push(`First ${rule.first_n}`);
if ((rule.last_n as number) > 0) parts.push(`Last ${rule.last_n}`);
if (rule.crop_before) parts.push("Crop");
if (rule.remove_pattern) parts.push("Pattern");
const trim = rule.trim as Record<string, boolean> | undefined;
if (trim && Object.values(trim).some(Boolean)) parts.push("Trim");
return parts.length > 0 ? parts.join(", ") : "Not configured";
}
case "add": {
const parts: string[] = [];
if (rule.prefix) parts.push(`"${rule.prefix}"+`);
if (rule.suffix) parts.push(`+"${rule.suffix}"`);
return parts.length > 0 ? parts.join(" ") : "Not configured";
}
case "case":
return (rule.mode as string) === "Same" ? "Not configured" : (rule.mode as string);
case "numbering":
return (rule.mode as string) === "None" ? "Not configured" : `${rule.mode} from ${rule.start}`;
case "extension":
return (rule.mode as string) === "Same" ? "Not configured" : (rule.mode as string);
case "date":
return (rule.mode as string) === "None" ? "Not configured" : `${rule.source} ${rule.mode}`;
case "move_parts": {
const len = rule.source_length as number;
if (!len) return "Not configured";
return `${rule.copy_mode ? "Copy" : "Move"} ${len} chars`;
}
case "text_editor": {
const names = rule.names as string[];
if (!names?.length) return "Not configured";
return `${names.length} name${names.length !== 1 ? "s" : ""}`;
}
case "hash":
return (rule.mode as string) === "None" ? "Not configured" : `${rule.algorithm} ${rule.mode}`;
case "folder_name":
return (rule.mode as string) === "None" ? "Not configured" : `Level ${rule.level} ${rule.mode}`;
case "transliterate":
return "ASCII conversion";
case "padding":
return `Width ${rule.width}, pad '${rule.pad_char}'`;
case "truncate":
return `Max ${rule.max_length} chars`;
case "randomize":
return `${rule.format} ${rule.mode}`;
case "swap":
return (rule.delimiter as string) ? `Split on "${rule.delimiter}"` : "Not configured";
case "sanitize": {
const parts: string[] = [];
if (rule.illegal_chars) parts.push("Illegal");
if ((rule.spaces_to as string) !== "None") parts.push("Spaces");
if (rule.strip_diacritics) parts.push("Accents");
if (rule.normalize_unicode) parts.push("Unicode");
if (rule.strip_zero_width) parts.push("ZW");
if (rule.collapse_whitespace) parts.push("Collapse");
if (rule.trim_dots_spaces) parts.push("Trim");
return parts.length > 0 ? parts.join(", ") : "Not configured";
}
default:
return "...";
}
}
function isRuleActive(rule: Record<string, unknown>): boolean {
const type = rule.type as string;
if (!rule.enabled) return false;
switch (type) {
case "replace": return !!(rule.search as string);
case "regex": return !!(rule.pattern as string);
case "remove": {
const trim = rule.trim as Record<string, boolean> | undefined;
const hasTrim = trim && Object.values(trim).some(Boolean);
return (rule.first_n as number) > 0 || (rule.last_n as number) > 0 || (rule.from as number) !== (rule.to as number) || !!(rule.collapse_chars) || !!(rule.remove_pattern) || !!(rule.crop_before) || !!(rule.crop_after) || !!hasTrim;
}
case "add": return !!(rule.prefix as string) || !!(rule.suffix as string) || !!(rule.insert as string) || !!(rule.inserts as unknown[])?.length;
case "case": return (rule.mode as string) !== "Same";
case "numbering": return (rule.mode as string) !== "None";
case "extension": return (rule.mode as string) !== "Same" || !!(rule.mapping as unknown[])?.length;
case "date": return (rule.mode as string) !== "None";
case "move_parts": return (rule.source_length as number) > 0;
case "text_editor": return !!(rule.names as string[])?.length;
case "hash": return (rule.mode as string) !== "None";
case "folder_name": return (rule.mode as string) !== "None";
case "transliterate": return true;
case "padding": return true;
case "truncate": return true;
case "randomize": return true;
case "swap": return !!(rule.delimiter as string);
case "sanitize": return !!(rule.illegal_chars) || (rule.spaces_to as string) !== "None" || !!(rule.strip_diacritics) || !!(rule.normalize_unicode) || !!(rule.strip_zero_width) || !!(rule.collapse_whitespace) || !!(rule.trim_dots_spaces);
default: return false;
}
}
function ConfigContent({ ruleId }: { ruleId: string }) {
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId));
if (!rule) return null;
switch (rule.config.type) {
case "replace": return <ReplaceConfig ruleId={ruleId} />;
case "regex": return <RegexConfig ruleId={ruleId} />;
case "remove": return <RemoveConfig ruleId={ruleId} />;
case "add": return <AddConfig ruleId={ruleId} />;
case "case": return <CaseConfig ruleId={ruleId} />;
case "numbering": return <NumberingConfig ruleId={ruleId} />;
case "extension": return <ExtensionConfig ruleId={ruleId} />;
case "date": return <DateConfig ruleId={ruleId} />;
case "move_parts": return <MovePartsConfig ruleId={ruleId} />;
case "text_editor": return <TextEditorConfig ruleId={ruleId} />;
case "hash": return <HashConfig ruleId={ruleId} />;
case "folder_name": return <FolderNameConfig ruleId={ruleId} />;
case "transliterate": return <TransliterateConfig ruleId={ruleId} />;
case "padding": return <PaddingConfig ruleId={ruleId} />;
case "truncate": return <TruncateConfig ruleId={ruleId} />;
case "randomize": return <RandomizeConfig ruleId={ruleId} />;
case "swap": return <SwapConfig ruleId={ruleId} />;
case "sanitize": return <SanitizeConfig ruleId={ruleId} />;
default: return null;
}
}
function PopoverArrowWithBorder() {
return (
<div className="absolute left-1/2 -translate-x-1/2" style={{ bottom: -14 }}>
<svg width={32} height={14} viewBox="0 0 32 14">
<polygon points="0,0 16,14 32,0" className="fill-border" />
</svg>
<svg width={30} height={13} viewBox="0 0 30 13" className="absolute left-[1px]" style={{ top: 0 }}>
<polygon points="0,0 15,13 30,0" className="fill-popover" />
</svg>
</div>
);
}
function PipelineCard({ rule, index, onScrollToCenter }: { rule: PipelineRule; index: number; onScrollToCenter: (el: HTMLElement) => void }) {
const [open, setOpen] = useState(false);
const [tooltipsReady, setTooltipsReady] = useState(false);
const updateRule = useRuleStore((s) => s.updateRule);
const removeRule = useRuleStore((s) => s.removeRule);
const resetRule = useRuleStore((s) => s.resetRule);
const duplicateRule = useRuleStore((s) => s.duplicateRule);
const reorderPipeline = useRuleStore((s) => s.reorderPipeline);
const pipeline = useRuleStore((s) => s.pipeline);
const cardRef = useRef<HTMLDivElement>(null);
const {
attributes,
listeners,
setNodeRef: setSortableRef,
transform,
transition,
isDragging,
} = useSortable({ id: rule.id });
const setNodeRef = useCallback((node: HTMLElement | null) => {
setSortableRef(node);
(cardRef as React.MutableRefObject<HTMLDivElement | null>).current = node as HTMLDivElement | null;
}, [setSortableRef]);
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 10 : undefined,
opacity: isDragging ? 0.5 : undefined,
};
useEffect(() => {
if (open) {
setTooltipsReady(false);
const timer = setTimeout(() => setTooltipsReady(true), 700);
if (cardRef.current) onScrollToCenter(cardRef.current);
return () => clearTimeout(timer);
} else {
setTooltipsReady(false);
}
}, [open, onScrollToCenter]);
const Icon = ruleIconMap[rule.config.type] || IconReplace;
const active = isRuleActive(rule.config as unknown as Record<string, unknown>);
const summary = getRuleSummary(rule.config as unknown as Record<string, unknown>);
const typeInfo = ruleTypes.find((r) => r.id === rule.config.type);
const pipelineIndex = pipeline.findIndex((r) => r.id === rule.id);
const canMoveLeft = pipelineIndex > 0;
const canMoveRight = pipelineIndex < pipeline.length - 1;
const stateLabel = !rule.config.enabled ? "disabled" : active ? "active" : "inactive";
return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
<ContextMenu>
<ContextMenuTrigger asChild>
<div>
<Popover open={open} onOpenChange={setOpen}>
<PopoverAnchor asChild>
<div>
<PopoverTrigger asChild>
<motion.button
initial={{ opacity: 0, scale: 0.8, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.8, y: 20 }}
whileHover={{ y: -3, transition: { duration: 0.15 } }}
whileTap={{ scale: 0.97 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
aria-label={`${typeInfo?.label || rule.config.type} rule, step ${index + 1}, ${stateLabel}. Press Space to reorder.`}
aria-roledescription="sortable"
className={cn(
"flex items-start gap-3 px-4 py-4 rounded-xl border text-left shrink-0 transition-colors min-w-[200px] max-w-[260px]",
"cursor-grab group relative overflow-hidden",
isDragging && "cursor-grabbing",
open && "ring-2 ring-primary/40 border-primary/50 shadow-lg shadow-primary/5",
active && rule.config.enabled
? "border-primary/25 bg-primary/[0.04]"
: "border-border bg-card hover:bg-muted/50",
!rule.config.enabled && "opacity-45",
)}
>
{/* step number - bottom right */}
<div className="absolute bottom-2.5 right-3 text-[15px] font-mono text-muted-foreground/40">
{index + 1}
</div>
<div className={cn(
"mt-0.5 p-2.5 rounded-lg transition-colors relative",
active ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"
)}>
<Icon size={22} stroke={1.5} />
{/* non-color active indicator - small filled dot */}
{active && rule.config.enabled && (
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-primary border border-card" aria-hidden="true" />
)}
{/* non-color disabled indicator - diagonal line */}
{!rule.config.enabled && (
<span className="absolute inset-0 flex items-center justify-center" aria-hidden="true">
<span className="w-full h-0.5 bg-muted-foreground/60 rotate-[-45deg]" />
</span>
)}
</div>
<div className="flex flex-col gap-1 min-w-0 flex-1">
<span className={cn(
"text-sm font-medium leading-tight",
active ? "text-foreground" : "text-muted-foreground"
)}>
{typeInfo?.label || rule.config.type}
</span>
<span className="text-xs text-muted-foreground leading-tight truncate">
{summary}
</span>
</div>
<Switch
checked={rule.config.enabled}
onCheckedChange={(checked) => updateRule(rule.id, { enabled: checked })}
className="mt-1 scale-75 origin-top-right"
onClick={(e) => e.stopPropagation()}
aria-label={`${rule.config.enabled ? "Disable" : "Enable"} ${typeInfo?.label || rule.config.type} rule`}
/>
</motion.button>
</PopoverTrigger>
</div>
</PopoverAnchor>
<PopoverContent
side="top"
align="center"
sideOffset={24}
onOpenAutoFocus={(e) => {
// focus first interactive element inside the config panel
e.preventDefault();
requestAnimationFrame(() => {
const el = cardRef.current?.closest("[data-slot='popover']")?.querySelector<HTMLElement>(
".p-4 input, .p-4 select, .p-4 button, .p-4 [tabindex='0']"
);
el?.focus();
});
}}
className={cn(
rule.config.type === "text_editor" ? "w-[560px]" : "w-[440px]",
"p-0 shadow-xl shadow-black/10 dark:shadow-black/30 !overflow-visible relative",
)}
>
<div className="flex items-center justify-between px-4 py-3 border-b">
<div className="flex items-center gap-2.5">
<div className="p-1.5 rounded-md bg-primary/10">
<Icon size={18} stroke={1.5} className="text-primary" />
</div>
<span className="text-sm font-medium">
{typeInfo?.label || rule.config.type}
</span>
<span className="text-xs text-muted-foreground">
Step {index + 1}
</span>
</div>
<div className="flex items-center gap-1.5">
{tooltipsReady ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={() => resetRule(rule.id)}
className="text-muted-foreground hover:text-foreground"
aria-label="Reset to defaults"
>
<IconEraser size={20} stroke={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Reset to defaults</TooltipContent>
</Tooltip>
) : (
<Button
variant="ghost"
size="icon-sm"
onClick={() => resetRule(rule.id)}
className="text-muted-foreground hover:text-foreground"
aria-label="Reset to defaults"
>
<IconEraser size={20} stroke={1.5} />
</Button>
)}
{tooltipsReady ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={() => { removeRule(rule.id); setOpen(false); }}
className="text-muted-foreground hover:text-destructive"
aria-label="Remove step"
>
<IconTrash size={20} stroke={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Remove step</TooltipContent>
</Tooltip>
) : (
<Button
variant="ghost"
size="icon-sm"
onClick={() => { removeRule(rule.id); setOpen(false); }}
className="text-muted-foreground hover:text-destructive"
aria-label="Remove step"
>
<IconTrash size={20} stroke={1.5} />
</Button>
)}
{tooltipsReady ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setOpen(false)}
className="text-muted-foreground hover:text-foreground"
aria-label="Close"
>
<IconX size={20} stroke={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Close</TooltipContent>
</Tooltip>
) : (
<Button
variant="ghost"
size="icon-sm"
onClick={() => setOpen(false)}
className="text-muted-foreground hover:text-foreground"
aria-label="Close"
>
<IconX size={20} stroke={1.5} />
</Button>
)}
</div>
</div>
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="p-4"
>
<ConfigContent ruleId={rule.id} />
</motion.div>
<PopoverArrowWithBorder />
</PopoverContent>
</Popover>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-48">
<ContextMenuItem
onClick={() => updateRule(rule.id, { enabled: !rule.config.enabled })}
className="gap-2 text-[13px]"
>
{rule.config.enabled
? <><IconPlayerPause size={15} stroke={1.5} /> Disable</>
: <><IconPlayerPlay size={15} stroke={1.5} /> Enable</>
}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => resetRule(rule.id)} className="gap-2 text-[13px]">
<IconEraser size={15} stroke={1.5} /> Reset to defaults
</ContextMenuItem>
<ContextMenuItem onClick={() => duplicateRule(rule.id)} className="gap-2 text-[13px]">
<IconCopy size={15} stroke={1.5} /> Duplicate
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
onClick={() => canMoveLeft && reorderPipeline(pipelineIndex, pipelineIndex - 1)}
disabled={!canMoveLeft}
className="gap-2 text-[13px]"
>
<IconChevronLeft size={15} stroke={1.5} /> Move left
</ContextMenuItem>
<ContextMenuItem
onClick={() => canMoveRight && reorderPipeline(pipelineIndex, pipelineIndex + 1)}
disabled={!canMoveRight}
className="gap-2 text-[13px]"
>
<IconChevronRight size={15} stroke={1.5} /> Move right
</ContextMenuItem>
<>
<ContextMenuSeparator />
<ContextMenuItem
onClick={() => removeRule(rule.id)}
className="gap-2 text-[13px] text-destructive focus:text-destructive"
>
<IconTrash size={15} stroke={1.5} /> Remove
</ContextMenuItem>
</>
</ContextMenuContent>
</ContextMenu>
</div>
);
}
export function PipelineStrip() {
const pipeline = useRuleStore((s) => s.pipeline);
const addRule = useRuleStore((s) => s.addRule);
const reorderPipeline = useRuleStore((s) => s.reorderPipeline);
const requestPreview = useRuleStore((s) => s.requestPreview);
const currentPath = useFileStore((s) => s.currentPath);
const sortedFilePaths = useFileStore((s) => s.sortedFilePaths);
const showDisabledRules = useSettingsStore((s) => s.showDisabledRules);
const visiblePipeline = showDisabledRules === "hidden"
? pipeline.filter((r) => r.config.enabled)
: pipeline;
const osRef = useRef<OverlayScrollbarsComponentRef>(null);
const scrollToCenter = useCallback((el: HTMLElement) => {
const instance = osRef.current?.osInstance();
if (!instance) return;
const viewport = instance.elements().viewport;
const viewportRect = viewport.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const elCenter = elRect.left + elRect.width / 2;
const viewportCenter = viewportRect.left + viewportRect.width / 2;
const scrollLeft = viewport.scrollLeft + (elCenter - viewportCenter);
viewport.scrollTo({ left: scrollLeft, behavior: "smooth" });
}, []);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = pipeline.findIndex((r) => r.id === active.id);
const newIndex = pipeline.findIndex((r) => r.id === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
reorderPipeline(oldIndex, newIndex);
}
}
}
useEffect(() => {
if (currentPath) {
requestPreview(currentPath);
}
}, [pipeline, currentPath, sortedFilePaths, requestPreview]);
const isEmpty = visiblePipeline.length === 0;
return (
<div className="border-t bg-muted/30 h-[104px]" role="region" aria-label="Rename pipeline">
<OverlayScrollbarsComponent
ref={osRef}
options={{ overflow: { x: isEmpty ? "hidden" : "scroll", y: "hidden" }, scrollbars: { autoHide: "move", autoHideDelay: 600 } }}
className="px-4 py-4 h-full"
>
<div className={cn("flex items-center gap-3 h-full", isEmpty ? "justify-center" : "w-max")}>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={visiblePipeline.map((r) => r.id)}
strategy={horizontalListSortingStrategy}
>
<AnimatePresence mode="popLayout">
{visiblePipeline.map((rule, index) => (
<PipelineCard key={rule.id} rule={rule} index={index} onScrollToCenter={scrollToCenter} />
))}
</AnimatePresence>
</SortableContext>
</DndContext>
<div className="shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="shrink-0 gap-2 text-sm border-dashed hover:border-primary hover:text-primary transition-colors h-auto py-4 px-5"
>
<IconPlus size={18} stroke={1.5} />
Add Rule
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="top" className="w-[420px] p-2">
<DropdownMenuLabel className="px-2">Add a rule to the pipeline</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="grid grid-cols-2 gap-0.5">
{ruleTypes.map((rt) => (
<DropdownMenuItem
key={rt.id}
className="gap-2 py-2 px-2 cursor-pointer rounded-md"
onClick={() => addRule(rt.id)}
>
<div className="p-1 rounded-md bg-muted shrink-0">
<rt.icon size={14} stroke={1.5} />
</div>
<div className="flex flex-col min-w-0">
<span className="text-[13px] font-medium leading-tight">{rt.label}</span>
<span className="text-[11px] text-muted-foreground leading-tight truncate">{rt.desc}</span>
</div>
</DropdownMenuItem>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</OverlayScrollbarsComponent>
</div>
);
}

View File

@@ -0,0 +1,95 @@
import { useRuleStore } from "@/stores/ruleStore";
import { Input } from "@/components/ui/input";
import { NumberInput } from "@/components/ui/number-input";
import { Checkbox } from "@/components/ui/checkbox";
import { IconPlus, IconTrash } from "@tabler/icons-react";
import type { AddConfig as AddConfigType } from "@/types/rules";
export function AddConfig({ ruleId }: { ruleId: string }) {
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as AddConfigType | undefined;
const updateRule = useRuleStore((s) => s.updateRule);
if (!rule) return null;
const update = (changes: Partial<AddConfigType>) => updateRule(ruleId, changes);
const addInsert = () => {
update({ inserts: [...rule.inserts, { position: 0, text: "" }] });
};
const removeInsert = (idx: number) => {
update({ inserts: rule.inserts.filter((_, i) => i !== idx) });
};
const updateInsert = (idx: number, changes: Partial<{ position: number; text: string }>) => {
update({
inserts: rule.inserts.map((ins, i) => (i === idx ? { ...ins, ...changes } : ins)),
});
};
return (
<div className="flex flex-col gap-3 text-sm">
<div className="grid grid-cols-2 gap-2">
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Prefix</span>
<Input
value={rule.prefix}
onChange={(e) => update({ prefix: e.target.value })}
placeholder="Add before..."
className="h-8 text-xs font-mono"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Suffix</span>
<Input
value={rule.suffix}
onChange={(e) => update({ suffix: e.target.value })}
placeholder="Add after..."
className="h-8 text-xs font-mono"
/>
</label>
</div>
<div className="flex gap-2">
<label className="flex flex-col flex-1 gap-1">
<span className="text-xs text-muted-foreground">Insert text</span>
<Input
value={rule.insert}
onChange={(e) => update({ insert: e.target.value })}
placeholder="Text to insert..."
className="h-8 text-xs font-mono"
/>
</label>
<label className="flex flex-col w-20 gap-1">
<span className="text-xs text-muted-foreground">At position</span>
<NumberInput value={rule.insert_at} onChange={(v) => update({ insert_at: v })} min={0} />
</label>
</div>
{rule.inserts.length > 0 && (
<div className="flex flex-col gap-1.5">
<span className="text-xs text-muted-foreground">Extra inserts</span>
{rule.inserts.map((ins, idx) => (
<div key={idx} className="flex gap-2 items-center">
<Input
value={ins.text}
onChange={(e) => updateInsert(idx, { text: e.target.value })}
placeholder="Text..."
className="h-7 text-xs font-mono flex-1"
/>
<NumberInput value={ins.position} onChange={(v) => updateInsert(idx, { position: v })} min={0} />
<button onClick={() => removeInsert(idx)} className="text-muted-foreground hover:text-destructive p-0.5">
<IconTrash size={14} />
</button>
</div>
))}
</div>
)}
<div className="flex items-center gap-3">
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.word_space} onCheckedChange={(c) => update({ word_space: !!c })} />
Add spaces between words
</label>
<button onClick={addInsert} className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground ml-auto">
<IconPlus size={14} /> Insert
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,65 @@
import { useRuleStore } from "@/stores/ruleStore";
import { Input } from "@/components/ui/input";
import { SegmentedControl } from "@/components/ui/segmented-control";
import type { CaseConfig as CaseConfigType } from "@/types/rules";
const basicModes = [
{ value: "Same", label: "Same" },
{ value: "Upper", label: "UPPER" },
{ value: "Lower", label: "lower" },
{ value: "Title", label: "Title" },
{ value: "Sentence", label: "Sent." },
{ value: "SmartTitle", label: "Smart" },
{ value: "Invert", label: "iNVERT" },
{ value: "Random", label: "rAnD" },
] as const;
const devModes = [
{ value: "CamelCase", label: "camelCase" },
{ value: "PascalCase", label: "Pascal" },
{ value: "SnakeCase", label: "snake_case" },
{ value: "KebabCase", label: "kebab-case" },
{ value: "DotCase", label: "dot.case" },
] as const;
export function CaseConfig({ ruleId }: { ruleId: string }) {
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as CaseConfigType | undefined;
const updateRule = useRuleStore((s) => s.updateRule);
if (!rule) return null;
const update = (changes: Partial<CaseConfigType>) => updateRule(ruleId, changes);
const devValues = ["CamelCase", "PascalCase", "SnakeCase", "KebabCase", "DotCase"];
const isDevMode = devValues.includes(rule.mode);
return (
<div className="flex flex-col gap-3 text-sm">
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Text case</span>
<SegmentedControl
value={isDevMode ? ("" as typeof rule.mode) : rule.mode}
onChange={(m) => update({ mode: m })}
options={basicModes}
size="sm"
/>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Developer case</span>
<SegmentedControl
value={isDevMode ? rule.mode : ("" as typeof rule.mode)}
onChange={(m) => update({ mode: m })}
options={devModes}
size="sm"
/>
</div>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Exceptions (comma-separated)</span>
<Input
value={rule.exceptions}
onChange={(e) => update({ exceptions: e.target.value })}
placeholder="e.g. USB, HTML, API"
className="h-8 text-xs font-mono"
/>
</label>
</div>
);
}

View File

@@ -0,0 +1,86 @@
import { useRuleStore } from "@/stores/ruleStore";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { SegmentedControl } from "@/components/ui/segmented-control";
import type { DateConfig as DateConfigType } from "@/types/rules";
const dateModes = [
{ value: "None", label: "None" },
{ value: "Prefix", label: "Prefix" },
{ value: "Suffix", label: "Suffix" },
{ value: "Insert", label: "Insert" },
] as const;
const dateSources = [
{ value: "Created", label: "Created" },
{ value: "Modified", label: "Modified" },
{ value: "Accessed", label: "Accessed" },
{ value: "ExifTaken", label: "EXIF" },
{ value: "Current", label: "Now" },
] as const;
const dateFormats = [
{ value: "YMD", label: "Y-M-D" },
{ value: "DMY", label: "D-M-Y" },
{ value: "MDY", label: "M-D-Y" },
{ value: "YM", label: "Y-M" },
{ value: "MY", label: "M-Y" },
{ value: "Y", label: "Year" },
] as const;
export function DateConfig({ ruleId }: { ruleId: string }) {
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as DateConfigType | undefined;
const updateRule = useRuleStore((s) => s.updateRule);
if (!rule) return null;
const update = (changes: Partial<DateConfigType>) => updateRule(ruleId, changes);
return (
<div className="flex flex-col gap-3 text-sm">
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Position</span>
<SegmentedControl value={rule.mode} onChange={(m) => update({ mode: m })} options={dateModes} />
</div>
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Source</span>
<SegmentedControl value={rule.source} onChange={(s) => update({ source: s })} options={dateSources} size="sm" />
</div>
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Format</span>
<SegmentedControl value={rule.format} onChange={(f) => update({ format: f })} options={dateFormats} size="sm" />
</div>
<div className="grid grid-cols-2 gap-2">
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Date separator</span>
<Input
value={rule.separator}
onChange={(e) => update({ separator: e.target.value })}
placeholder="-"
className="h-8 text-xs font-mono"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Name separator</span>
<Input
value={rule.segment_separator}
onChange={(e) => update({ segment_separator: e.target.value })}
placeholder="_"
className="h-8 text-xs font-mono"
/>
</label>
</div>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Custom format (overrides preset)</span>
<Input
value={rule.custom_format || ""}
onChange={(e) => update({ custom_format: e.target.value || null })}
placeholder="%Y-%m-%d_%H%M%S"
className="h-8 text-xs font-mono"
/>
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.include_time} onCheckedChange={(c) => update({ include_time: !!c })} />
Include time
</label>
</div>
);
}

View File

@@ -0,0 +1,98 @@
import { useRuleStore } from "@/stores/ruleStore";
import { Input } from "@/components/ui/input";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { Checkbox } from "@/components/ui/checkbox";
import { IconPlus, IconTrash } from "@tabler/icons-react";
import type { ExtensionConfig as ExtensionConfigType } from "@/types/rules";
const extModes = [
{ value: "Same", label: "Same" },
{ value: "Lower", label: "lower" },
{ value: "Upper", label: "UPPER" },
{ value: "Title", label: "Title" },
{ value: "Extra", label: "Extra" },
{ value: "Remove", label: "Remove" },
{ value: "Fixed", label: "Fixed" },
] as const;
export function ExtensionConfig({ ruleId }: { ruleId: string }) {
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as ExtensionConfigType | undefined;
const updateRule = useRuleStore((s) => s.updateRule);
if (!rule) return null;
const update = (changes: Partial<ExtensionConfigType>) => updateRule(ruleId, changes);
const mappings = rule.mapping || [];
const addMapping = () => {
update({ mapping: [...mappings, ["", ""]] });
};
const removeMapping = (idx: number) => {
const next = mappings.filter((_, i) => i !== idx);
update({ mapping: next.length > 0 ? next : null });
};
const updateMapping = (idx: number, pos: 0 | 1, val: string) => {
const next = mappings.map((m, i) => {
if (i !== idx) return m;
const copy: [string, string] = [...m];
copy[pos] = val;
return copy;
});
update({ mapping: next });
};
return (
<div className="flex flex-col gap-3 text-sm">
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Mode</span>
<SegmentedControl value={rule.mode} onChange={(m) => update({ mode: m })} options={extModes} size="sm" />
</div>
{rule.mode === "Fixed" && (
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">New extension</span>
<Input
value={rule.fixed_value}
onChange={(e) => update({ fixed_value: e.target.value })}
placeholder="e.g. txt, jpg, png"
className="h-8 text-xs font-mono"
/>
</label>
)}
{mappings.length > 0 && (
<div className="flex flex-col gap-1.5">
<span className="text-xs text-muted-foreground">Extension mappings</span>
{mappings.map((m, idx) => (
<div key={idx} className="flex gap-2 items-center">
<Input
value={m[0]}
onChange={(e) => updateMapping(idx, 0, e.target.value)}
placeholder="From..."
className="h-7 text-xs font-mono flex-1"
/>
<span className="text-xs text-muted-foreground">{"->"}</span>
<Input
value={m[1]}
onChange={(e) => updateMapping(idx, 1, e.target.value)}
placeholder="To..."
className="h-7 text-xs font-mono flex-1"
/>
<button onClick={() => removeMapping(idx)} className="text-muted-foreground hover:text-destructive p-0.5">
<IconTrash size={14} />
</button>
</div>
))}
</div>
)}
<div className="flex items-center gap-3">
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.multi_extension} onCheckedChange={(c) => update({ multi_extension: !!c })} />
Multi-extension (e.g. .tar.gz)
</label>
<button onClick={addMapping} className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground ml-auto">
<IconPlus size={14} /> Mapping
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import { useRuleStore } from "@/stores/ruleStore";
import { Input } from "@/components/ui/input";
import { NumberInput } from "@/components/ui/number-input";
import { SegmentedControl } from "@/components/ui/segmented-control";
import type { FolderNameConfig as FolderNameConfigType } from "@/types/rules";
const folderModes = [
{ value: "None", label: "None" },
{ value: "Prefix", label: "Prefix" },
{ value: "Suffix", label: "Suffix" },
{ value: "Replace", label: "Replace" },
] as const;
export function FolderNameConfig({ ruleId }: { ruleId: string }) {
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as FolderNameConfigType | undefined;
const updateRule = useRuleStore((s) => s.updateRule);
if (!rule) return null;
const update = (changes: Partial<FolderNameConfigType>) => updateRule(ruleId, changes);
return (
<div className="flex flex-col gap-3 text-sm">
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Position</span>
<SegmentedControl value={rule.mode} onChange={(m) => update({ mode: m })} options={folderModes} />
</div>
<div className="grid grid-cols-2 gap-2">
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Folder level (1 = parent)</span>
<NumberInput value={rule.level} onChange={(v) => update({ level: v })} min={1} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Separator</span>
<Input value={rule.separator} onChange={(e) => update({ separator: e.target.value })} className="h-8 text-xs font-mono" />
</label>
</div>
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { useRuleStore } from "@/stores/ruleStore";
import { Input } from "@/components/ui/input";
import { NumberInput } from "@/components/ui/number-input";
import { Checkbox } from "@/components/ui/checkbox";
import { SegmentedControl } from "@/components/ui/segmented-control";
import type { HashConfig as HashConfigType } from "@/types/rules";
const hashModes = [
{ value: "None", label: "None" },
{ value: "Prefix", label: "Prefix" },
{ value: "Suffix", label: "Suffix" },
{ value: "Replace", label: "Replace" },
] as const;
const algorithms = [
{ value: "MD5", label: "MD5" },
{ value: "SHA1", label: "SHA1" },
{ value: "SHA256", label: "SHA256" },
] as const;
export function HashConfig({ ruleId }: { ruleId: string }) {
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as HashConfigType | undefined;
const updateRule = useRuleStore((s) => s.updateRule);
if (!rule) return null;
const update = (changes: Partial<HashConfigType>) => updateRule(ruleId, changes);
return (
<div className="flex flex-col gap-3 text-sm">
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Position</span>
<SegmentedControl value={rule.mode} onChange={(m) => update({ mode: m })} options={hashModes} />
</div>
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Algorithm</span>
<SegmentedControl value={rule.algorithm} onChange={(a) => update({ algorithm: a })} options={algorithms} />
</div>
<div className="grid grid-cols-2 gap-2">
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Hash length (0 = full)</span>
<NumberInput value={rule.length} onChange={(v) => update({ length: v })} min={0} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Separator</span>
<Input value={rule.separator} onChange={(e) => update({ separator: e.target.value })} className="h-8 text-xs font-mono" />
</label>
</div>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.uppercase} onCheckedChange={(c) => update({ uppercase: !!c })} />
Uppercase hex
</label>
</div>
);
}

View File

@@ -0,0 +1,83 @@
import { useRuleStore } from "@/stores/ruleStore";
import { Input } from "@/components/ui/input";
import { NumberInput } from "@/components/ui/number-input";
import { Checkbox } from "@/components/ui/checkbox";
import { SegmentedControl } from "@/components/ui/segmented-control";
import type { MovePartsConfig as MovePartsConfigType } from "@/types/rules";
const selectionModes = [
{ value: "Chars", label: "Chars" },
{ value: "Words", label: "Words" },
{ value: "Regex", label: "Regex" },
] as const;
const targetModes = [
{ value: "None", label: "None" },
{ value: "Start", label: "Start" },
{ value: "End", label: "End" },
] as const;
export function MovePartsConfig({ ruleId }: { ruleId: string }) {
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as MovePartsConfigType | undefined;
const updateRule = useRuleStore((s) => s.updateRule);
if (!rule) return null;
const update = (changes: Partial<MovePartsConfigType>) => updateRule(ruleId, changes);
const targetValue = typeof rule.target === "string" ? rule.target : "Position";
return (
<div className="flex flex-col gap-3 text-sm">
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Selection mode</span>
<SegmentedControl value={rule.selection_mode} onChange={(m) => update({ selection_mode: m })} options={selectionModes} />
</div>
{rule.selection_mode === "Regex" ? (
<div className="grid grid-cols-[1fr_80px] gap-2">
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Pattern</span>
<Input
value={rule.regex_pattern || ""}
onChange={(e) => update({ regex_pattern: e.target.value || null })}
placeholder="Regex..."
className="h-8 text-xs font-mono"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Group</span>
<NumberInput value={rule.regex_group} onChange={(v) => update({ regex_group: v })} min={0} />
</label>
</div>
) : (
<div className="grid grid-cols-2 gap-2">
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">From {rule.selection_mode === "Words" ? "word" : "pos"}</span>
<NumberInput value={rule.source_from} onChange={(v) => update({ source_from: v })} min={0} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Length</span>
<NumberInput value={rule.source_length} onChange={(v) => update({ source_length: v })} min={0} />
</label>
</div>
)}
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Target</span>
<SegmentedControl value={targetValue} onChange={(t) => update({ target: t as "None" | "Start" | "End" })} options={targetModes} />
</div>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Separator</span>
<Input
value={rule.separator}
onChange={(e) => update({ separator: e.target.value })}
placeholder=""
className="h-8 text-xs font-mono"
/>
</label>
<div className="flex items-center gap-4">
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.copy_mode} onCheckedChange={(c) => update({ copy_mode: !!c })} />
Copy (keep original)
</label>
</div>
</div>
);
}

View File

@@ -0,0 +1,80 @@
import { useRuleStore } from "@/stores/ruleStore";
import { Input } from "@/components/ui/input";
import { NumberInput } from "@/components/ui/number-input";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { Checkbox } from "@/components/ui/checkbox";
import type { NumberingConfig as NumberingConfigType } from "@/types/rules";
const numberModes = [
{ value: "None", label: "None" },
{ value: "Prefix", label: "Prefix" },
{ value: "Suffix", label: "Suffix" },
{ value: "Both", label: "Both" },
{ value: "Insert", label: "Insert" },
] as const;
const bases = [
{ value: "Decimal", label: "Dec" },
{ value: "Hex", label: "Hex" },
{ value: "Octal", label: "Oct" },
{ value: "Binary", label: "Bin" },
{ value: "Alpha", label: "Alpha" },
{ value: "Roman", label: "Roman" },
] as const;
export function NumberingConfig({ ruleId }: { ruleId: string }) {
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as NumberingConfigType | undefined;
const updateRule = useRuleStore((s) => s.updateRule);
if (!rule) return null;
const update = (changes: Partial<NumberingConfigType>) => updateRule(ruleId, changes);
return (
<div className="flex flex-col gap-3 text-sm">
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Position</span>
<SegmentedControl value={rule.mode} onChange={(m) => update({ mode: m })} options={numberModes} />
</div>
<div className="grid grid-cols-4 gap-2">
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Start</span>
<NumberInput value={rule.start} onChange={(v) => update({ start: v })} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Step</span>
<NumberInput value={rule.increment} onChange={(v) => update({ increment: v })} min={1} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Padding</span>
<NumberInput value={rule.padding} onChange={(v) => update({ padding: v })} min={1} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Separator</span>
<Input value={rule.separator} onChange={(e) => update({ separator: e.target.value })} className="h-8 text-xs font-mono" />
</label>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Base</span>
<SegmentedControl value={rule.base} onChange={(b) => update({ base: b })} options={bases} size="sm" />
</div>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Custom format (overrides base)</span>
<Input
value={rule.custom_format || ""}
onChange={(e) => update({ custom_format: e.target.value || null })}
placeholder="e.g. {n:03}"
className="h-8 text-xs font-mono"
/>
</label>
<div className="flex items-center gap-4 flex-wrap">
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.per_folder} onCheckedChange={(c) => update({ per_folder: !!c })} />
Per folder
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.reverse} onCheckedChange={(c) => update({ reverse: !!c })} />
Reverse order
</label>
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { useRuleStore } from "@/stores/ruleStore";
import { Input } from "@/components/ui/input";
import { NumberInput } from "@/components/ui/number-input";
import { Checkbox } from "@/components/ui/checkbox";
import type { PaddingConfig as PaddingConfigType } from "@/types/rules";
export function PaddingConfig({ ruleId }: { ruleId: string }) {
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as PaddingConfigType | undefined;
const updateRule = useRuleStore((s) => s.updateRule);
if (!rule) return null;
const update = (changes: Partial<PaddingConfigType>) => updateRule(ruleId, changes);
return (
<div className="flex flex-col gap-3 text-sm">
<div className="grid grid-cols-2 gap-2">
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Width</span>
<NumberInput value={rule.width} onChange={(v) => update({ width: v })} min={1} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Pad character</span>
<Input
value={rule.pad_char}
onChange={(e) => update({ pad_char: e.target.value.slice(0, 1) || "0" })}
maxLength={1}
className="h-8 text-xs font-mono text-center"
/>
</label>
</div>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.pad_all} onCheckedChange={(c) => update({ pad_all: !!c })} />
Pad all numbers (not just first)
</label>
</div>
);
}

View File

@@ -0,0 +1,52 @@
import { useRuleStore } from "@/stores/ruleStore";
import { Input } from "@/components/ui/input";
import { NumberInput } from "@/components/ui/number-input";
import { SegmentedControl } from "@/components/ui/segmented-control";
import type { RandomizeConfig as RandomizeConfigType } from "@/types/rules";
const randomModes = [
{ value: "Replace", label: "Replace" },
{ value: "Prefix", label: "Prefix" },
{ value: "Suffix", label: "Suffix" },
] as const;
const randomFormats = [
{ value: "Hex", label: "Hex" },
{ value: "Alpha", label: "Alpha" },
{ value: "AlphaNum", label: "AlphaNum" },
{ value: "UUID", label: "UUID" },
] as const;
export function RandomizeConfig({ ruleId }: { ruleId: string }) {
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as RandomizeConfigType | undefined;
const updateRule = useRuleStore((s) => s.updateRule);
if (!rule) return null;
const update = (changes: Partial<RandomizeConfigType>) => updateRule(ruleId, changes);
return (
<div className="flex flex-col gap-3 text-sm">
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Position</span>
<SegmentedControl value={rule.mode} onChange={(m) => update({ mode: m })} options={randomModes} />
</div>
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Format</span>
<SegmentedControl value={rule.format} onChange={(f) => update({ format: f })} options={randomFormats} />
</div>
<div className="grid grid-cols-2 gap-2">
{rule.format !== "UUID" && (
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Length</span>
<NumberInput value={rule.length} onChange={(v) => update({ length: v })} min={1} />
</label>
)}
{rule.mode !== "Replace" && (
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Separator</span>
<Input value={rule.separator} onChange={(e) => update({ separator: e.target.value })} className="h-8 text-xs font-mono" />
</label>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import { useRuleStore } from "@/stores/ruleStore";
import { Input } from "@/components/ui/input";
import { NumberInput } from "@/components/ui/number-input";
import { Checkbox } from "@/components/ui/checkbox";
import type { RegexConfig as RegexConfigType } from "@/types/rules";
export function RegexConfig({ ruleId }: { ruleId: string }) {
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as RegexConfigType | undefined;
const updateRule = useRuleStore((s) => s.updateRule);
if (!rule) return null;
const update = (changes: Partial<RegexConfigType>) => updateRule(ruleId, changes);
return (
<div className="flex flex-col gap-3 text-sm">
<div className="flex gap-2">
<label className="flex flex-col flex-1 gap-1">
<span className="text-xs text-muted-foreground">Pattern</span>
<Input
value={rule.pattern}
onChange={(e) => update({ pattern: e.target.value })}
placeholder="Regex pattern..."
className="h-8 text-xs font-mono"
/>
</label>
<label className="flex flex-col flex-1 gap-1">
<span className="text-xs text-muted-foreground">Replace with</span>
<Input
value={rule.replace_with}
onChange={(e) => update({ replace_with: e.target.value })}
placeholder="Replacement..."
className="h-8 text-xs font-mono"
/>
</label>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.case_insensitive} onCheckedChange={(c) => update({ case_insensitive: !!c })} />
Case insensitive
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Match limit</span>
<NumberInput value={rule.match_limit ?? 0} onChange={(v) => update({ match_limit: v || null })} min={0} />
</label>
</div>
</div>
);
}

View File

@@ -0,0 +1,113 @@
import { useRuleStore } from "@/stores/ruleStore";
import { Input } from "@/components/ui/input";
import { NumberInput } from "@/components/ui/number-input";
import { Checkbox } from "@/components/ui/checkbox";
import { SegmentedControl } from "@/components/ui/segmented-control";
import type { RemoveConfig as RemoveConfigType } from "@/types/rules";
const removeModes = [
{ value: "Chars", label: "Chars" },
{ value: "Words", label: "Words" },
] as const;
export function RemoveConfig({ ruleId }: { ruleId: string }) {
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as RemoveConfigType | undefined;
const updateRule = useRuleStore((s) => s.updateRule);
if (!rule) return null;
const update = (changes: Partial<RemoveConfigType>) => updateRule(ruleId, changes);
const label = rule.mode === "Words" ? "words" : "chars";
return (
<div className="flex flex-col gap-3 text-sm">
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Mode</span>
<SegmentedControl value={rule.mode} onChange={(m) => update({ mode: m })} options={removeModes} />
</div>
<div className="grid grid-cols-2 gap-2">
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">First N {label}</span>
<NumberInput value={rule.first_n} onChange={(v) => update({ first_n: v })} min={0} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Last N {label}</span>
<NumberInput value={rule.last_n} onChange={(v) => update({ last_n: v })} min={0} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">From position</span>
<NumberInput value={rule.from} onChange={(v) => update({ from: v })} min={0} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">To position</span>
<NumberInput value={rule.to} onChange={(v) => update({ to: v })} min={0} />
</label>
</div>
<div className="grid grid-cols-2 gap-2">
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Crop before</span>
<Input
value={rule.crop_before || ""}
onChange={(e) => update({ crop_before: e.target.value || null })}
placeholder="Text..."
className="h-8 text-xs font-mono"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Crop after</span>
<Input
value={rule.crop_after || ""}
onChange={(e) => update({ crop_after: e.target.value || null })}
placeholder="Text..."
className="h-8 text-xs font-mono"
/>
</label>
</div>
<div className="flex items-center gap-3 flex-wrap">
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.trim.digits} onCheckedChange={(c) => update({ trim: { ...rule.trim, digits: !!c } })} />
Digits
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.trim.spaces} onCheckedChange={(c) => update({ trim: { ...rule.trim, spaces: !!c } })} />
Spaces
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.trim.symbols} onCheckedChange={(c) => update({ trim: { ...rule.trim, symbols: !!c } })} />
Symbols
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.trim.accents} onCheckedChange={(c) => update({ trim: { ...rule.trim, accents: !!c } })} />
Accents
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.trim.lead_dots} onCheckedChange={(c) => update({ trim: { ...rule.trim, lead_dots: !!c } })} />
Leading dots
</label>
</div>
<div className="grid grid-cols-2 gap-2">
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Collapse chars</span>
<Input
value={rule.collapse_chars || ""}
onChange={(e) => update({ collapse_chars: e.target.value || null })}
placeholder="e.g. -_"
className="h-8 text-xs font-mono"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Remove pattern</span>
<Input
value={rule.remove_pattern || ""}
onChange={(e) => update({ remove_pattern: e.target.value || null })}
placeholder="Regex..."
className="h-8 text-xs font-mono"
/>
</label>
</div>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.allow_empty} onCheckedChange={(c) => update({ allow_empty: !!c })} />
Allow removing everything
</label>
</div>
);
}

View File

@@ -0,0 +1,65 @@
import { useRuleStore } from "@/stores/ruleStore";
import { Input } from "@/components/ui/input";
import { NumberInput } from "@/components/ui/number-input";
import { Checkbox } from "@/components/ui/checkbox";
import type { ReplaceConfig as ReplaceConfigType } from "@/types/rules";
export function ReplaceConfig({ ruleId }: { ruleId: string }) {
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as ReplaceConfigType | undefined;
const updateRule = useRuleStore((s) => s.updateRule);
if (!rule) return null;
const update = (changes: Partial<ReplaceConfigType>) => updateRule(ruleId, changes);
return (
<div className="flex flex-col gap-3 text-sm">
<div className="flex gap-2">
<label className="flex flex-col flex-1 gap-1">
<span className="text-xs text-muted-foreground">Find</span>
<Input
value={rule.search}
onChange={(e) => update({ search: e.target.value })}
placeholder="Text to find..."
className="h-8 text-xs font-mono"
/>
</label>
<label className="flex flex-col flex-1 gap-1">
<span className="text-xs text-muted-foreground">Replace with</span>
<Input
value={rule.replace_with}
onChange={(e) => update({ replace_with: e.target.value })}
placeholder="Replacement..."
className="h-8 text-xs font-mono"
/>
</label>
</div>
<div className="flex items-center gap-4 flex-wrap">
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.match_case} onCheckedChange={(c) => update({ match_case: !!c })} />
Match case
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.first_only} onCheckedChange={(c) => update({ first_only: !!c })} />
First only
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.use_regex} onCheckedChange={(c) => update({ use_regex: !!c })} />
Regex
</label>
</div>
<div className="grid grid-cols-3 gap-2">
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Scope start</span>
<NumberInput value={rule.scope_start ?? 0} onChange={(v) => update({ scope_start: v || null })} min={0} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Scope end</span>
<NumberInput value={rule.scope_end ?? 0} onChange={(v) => update({ scope_end: v || null })} min={0} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Occurrence</span>
<NumberInput value={rule.occurrence ?? 0} onChange={(v) => update({ occurrence: v || null })} min={0} />
</label>
</div>
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { useRuleStore } from "@/stores/ruleStore";
import { Checkbox } from "@/components/ui/checkbox";
import { SegmentedControl } from "@/components/ui/segmented-control";
import type { SanitizeConfig as SanitizeConfigType } from "@/types/rules";
const spaceModes = [
{ value: "None", label: "Keep" },
{ value: "Underscores", label: "_" },
{ value: "Dashes", label: "-" },
{ value: "Dots", label: "." },
] as const;
export function SanitizeConfig({ ruleId }: { ruleId: string }) {
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as SanitizeConfigType | undefined;
const updateRule = useRuleStore((s) => s.updateRule);
if (!rule) return null;
const update = (changes: Partial<SanitizeConfigType>) => updateRule(ruleId, changes);
return (
<div className="flex flex-col gap-3 text-sm">
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Spaces</span>
<SegmentedControl value={rule.spaces_to} onChange={(s) => update({ spaces_to: s })} options={spaceModes} />
</div>
<div className="flex flex-col gap-2">
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.illegal_chars} onCheckedChange={(c) => update({ illegal_chars: !!c })} />
Remove illegal characters
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.trim_dots_spaces} onCheckedChange={(c) => update({ trim_dots_spaces: !!c })} />
Trim leading/trailing dots and spaces
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.collapse_whitespace} onCheckedChange={(c) => update({ collapse_whitespace: !!c })} />
Collapse repeated separators
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.strip_diacritics} onCheckedChange={(c) => update({ strip_diacritics: !!c })} />
Strip diacritics/accents
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.strip_zero_width} onCheckedChange={(c) => update({ strip_zero_width: !!c })} />
Strip zero-width characters
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.normalize_unicode} onCheckedChange={(c) => update({ normalize_unicode: !!c })} />
Normalize unicode (NFC)
</label>
</div>
</div>
);
}

View File

@@ -0,0 +1,45 @@
import { useRuleStore } from "@/stores/ruleStore";
import { Input } from "@/components/ui/input";
import { SegmentedControl } from "@/components/ui/segmented-control";
import type { SwapConfig as SwapConfigType } from "@/types/rules";
const swapOccurrences = [
{ value: "First", label: "First" },
{ value: "Last", label: "Last" },
] as const;
export function SwapConfig({ ruleId }: { ruleId: string }) {
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as SwapConfigType | undefined;
const updateRule = useRuleStore((s) => s.updateRule);
if (!rule) return null;
const update = (changes: Partial<SwapConfigType>) => updateRule(ruleId, changes);
return (
<div className="flex flex-col gap-3 text-sm">
<div className="grid grid-cols-2 gap-2">
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Split on</span>
<Input
value={rule.delimiter}
onChange={(e) => update({ delimiter: e.target.value })}
placeholder="e.g. , or -"
className="h-8 text-xs font-mono"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Rejoin with (blank = same)</span>
<Input
value={rule.new_delimiter ?? ""}
onChange={(e) => update({ new_delimiter: e.target.value || null })}
placeholder="Same as split"
className="h-8 text-xs font-mono"
/>
</label>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Split at occurrence</span>
<SegmentedControl value={rule.occurrence} onChange={(o) => update({ occurrence: o })} options={swapOccurrences} />
</div>
</div>
);
}

View File

@@ -0,0 +1,85 @@
import { useEffect, useState } from "react";
import { useRuleStore } from "@/stores/ruleStore";
import { useFileStore } from "@/stores/fileStore";
import { Button } from "@/components/ui/button";
import type { TextEditorConfig as TextEditorConfigType } from "@/types/rules";
export function TextEditorConfig({ ruleId }: { ruleId: string }) {
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as TextEditorConfigType | undefined;
const updateRule = useRuleStore((s) => s.updateRule);
const sortedFilePaths = useFileStore((s) => s.sortedFilePaths);
const selectedFiles = useFileStore((s) => s.selectedFiles);
const files = useFileStore((s) => s.files);
const [text, setText] = useState("");
const [initialized, setInitialized] = useState(false);
// get selected file stems in display order
const fileMap = new Map(files.map((f) => [f.path, f]));
const selectedStems = sortedFilePaths
.filter((p) => selectedFiles.has(p))
.map((p) => fileMap.get(p)?.stem ?? "");
const lineCount = text ? text.split("\n").length : 0;
const fileCount = selectedStems.length;
const mismatch = lineCount !== fileCount && text.length > 0;
// populate textarea with current stems when first opened or when selection changes and user hasn't edited
useEffect(() => {
if (!rule) return;
if (rule.names.length === 0 && !initialized) {
const joined = selectedStems.join("\n");
setText(joined);
setInitialized(true);
}
}, [rule, selectedStems.length, initialized]);
// sync rule names when first loaded with existing data
useEffect(() => {
if (!rule) return;
if (rule.names.length > 0 && !initialized) {
setText(rule.names.join("\n"));
setInitialized(true);
}
}, [rule, initialized]);
if (!rule) return null;
const handleChange = (val: string) => {
setText(val);
const names = val.split("\n");
updateRule(ruleId, { names });
};
const handleLoad = () => {
const joined = selectedStems.join("\n");
setText(joined);
updateRule(ruleId, { names: selectedStems });
};
return (
<div className="flex flex-col gap-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
{lineCount} line{lineCount !== 1 ? "s" : ""} / {fileCount} file{fileCount !== 1 ? "s" : ""}
</span>
<Button variant="ghost" size="sm" onClick={handleLoad} className="h-6 text-xs px-2">
Load current names
</Button>
</div>
{mismatch && (
<div className="text-xs text-amber-500 bg-amber-500/10 rounded px-2 py-1">
Line count ({lineCount}) doesn't match selected files ({fileCount}).
Extra lines will be ignored, missing lines will keep original names.
</div>
)}
<textarea
value={text}
onChange={(e) => handleChange(e.target.value)}
spellCheck={false}
className="w-full min-h-[360px] max-h-[600px] rounded-lg border border-input bg-transparent p-3 text-xs font-mono leading-relaxed resize-y outline-none focus:border-ring focus:ring-3 focus:ring-ring/50 dark:bg-input/30"
placeholder={"One filename per line...\nLine 1 = first selected file\nLine 2 = second selected file\n..."}
/>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import type {} from "@/types/rules";
export function TransliterateConfig({ ruleId: _ }: { ruleId: string }) {
return (
<div className="text-sm text-muted-foreground">
Converts non-ASCII characters to their closest ASCII equivalents.
No configuration needed - just enable the rule.
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { useRuleStore } from "@/stores/ruleStore";
import { Input } from "@/components/ui/input";
import { NumberInput } from "@/components/ui/number-input";
import { SegmentedControl } from "@/components/ui/segmented-control";
import type { TruncateConfig as TruncateConfigType } from "@/types/rules";
const truncateFrom = [
{ value: "End", label: "From end" },
{ value: "Start", label: "From start" },
] as const;
export function TruncateConfig({ ruleId }: { ruleId: string }) {
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as TruncateConfigType | undefined;
const updateRule = useRuleStore((s) => s.updateRule);
if (!rule) return null;
const update = (changes: Partial<TruncateConfigType>) => updateRule(ruleId, changes);
return (
<div className="flex flex-col gap-3 text-sm">
<div className="grid grid-cols-2 gap-2">
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Max length</span>
<NumberInput value={rule.max_length} onChange={(v) => update({ max_length: v })} min={1} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Truncation marker</span>
<Input
value={rule.suffix}
onChange={(e) => update({ suffix: e.target.value })}
placeholder="e.g. ..."
className="h-8 text-xs font-mono"
/>
</label>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Truncate from</span>
<SegmentedControl value={rule.from} onChange={(f) => update({ from: f })} options={truncateFrom} />
</div>
</div>
);
}

View File

@@ -0,0 +1,306 @@
import { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { save, open as dialogOpen } from "@tauri-apps/plugin-dialog";
import { useRuleStore } from "@/stores/ruleStore";
import { useFileStore } from "@/stores/fileStore";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
IconTrash,
IconPlayerPlay,
IconPlus,
IconFileExport,
IconFileImport,
} from "@tabler/icons-react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import type { NominaPreset, PresetInfo } from "@/types/presets";
interface PresetsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function PresetsDialog({ open, onOpenChange }: PresetsDialogProps) {
const [presets, setPresets] = useState<PresetInfo[]>([]);
const [saving, setSaving] = useState(false);
const [name, setName] = useState("");
const [desc, setDesc] = useState("");
const [tooltipsReady, setTooltipsReady] = useState(false);
const pipeline = useRuleStore((s) => s.pipeline);
const loadPipeline = useRuleStore((s) => s.loadPipeline);
const filters = useFileStore((s) => s.filters);
useEffect(() => {
if (open) {
setTooltipsReady(false);
const timer = setTimeout(() => setTooltipsReady(true), 700);
refresh();
return () => clearTimeout(timer);
} else {
setTooltipsReady(false);
}
}, [open]);
async function refresh() {
try {
const list = await invoke<PresetInfo[]>("list_presets");
setPresets(list);
} catch (e) {
console.error("Failed to list presets:", e);
}
}
async function handleSave() {
if (!name.trim()) return;
const preset: NominaPreset = {
version: 1,
name: name.trim(),
description: desc.trim(),
created: new Date().toISOString(),
rules: pipeline.map((r) => r.config),
filters,
};
try {
await invoke("save_preset", { preset });
setName("");
setDesc("");
setSaving(false);
await refresh();
} catch (e) {
console.error("Failed to save preset:", e);
}
}
async function handleLoad(path: string) {
try {
const preset = await invoke<NominaPreset>("load_preset", { path });
loadPipeline(preset.rules);
if (preset.filters) {
useFileStore.getState().setFilters(preset.filters);
}
onOpenChange(false);
} catch (e) {
console.error("Failed to load preset:", e);
}
}
async function handleDelete(path: string) {
try {
await invoke("delete_preset", { path });
await refresh();
} catch (e) {
await refresh();
}
}
async function handleExport(preset: PresetInfo) {
try {
const dest = await save({
title: "Export preset",
defaultPath: `${preset.name}.nomina`,
filters: [{ name: "Nomina Preset", extensions: ["nomina"] }],
});
if (!dest) return;
await invoke("export_preset", { sourcePath: preset.path, destPath: dest });
toast.success(`Exported "${preset.name}"`);
} catch (e) {
toast.error(`Export failed: ${e}`);
}
}
async function handleImport() {
try {
const selected = await dialogOpen({
title: "Import preset",
multiple: false,
filters: [
{ name: "All supported presets", extensions: ["nomina", "bru"] },
{ name: "Nomina Preset", extensions: ["nomina"] },
{ name: "Bulk Rename Utility", extensions: ["bru"] },
],
});
if (!selected) return;
const path = typeof selected === "string" ? selected : selected;
const preset = await invoke<NominaPreset>("import_preset", { path });
loadPipeline(preset.rules);
if (preset.filters) {
useFileStore.getState().setFilters(preset.filters);
}
toast.success(`Imported "${preset.name}" (${preset.rules.length} rules)`);
onOpenChange(false);
} catch (e) {
toast.error(`Import failed: ${e}`);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md" showCloseButton={false}>
<DialogHeader>
<DialogTitle>Presets</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[280px]">
{presets.length === 0 && !saving && (
<p className="text-xs text-muted-foreground py-6 text-center">
No saved presets yet
</p>
)}
<div className="flex flex-col gap-1">
{presets.map((p) => (
<div
key={p.path}
className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-muted group"
>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{p.name}</div>
{p.description && (
<div className="text-xs text-muted-foreground truncate">{p.description}</div>
)}
</div>
{tooltipsReady ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleLoad(p.path)}
className="opacity-0 group-hover:opacity-100"
aria-label={`Apply preset ${p.name}`}
>
<IconPlayerPlay size={14} stroke={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Apply</TooltipContent>
</Tooltip>
) : (
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleLoad(p.path)}
className="opacity-0 group-hover:opacity-100"
aria-label={`Apply preset ${p.name}`}
>
<IconPlayerPlay size={14} stroke={1.5} />
</Button>
)}
{tooltipsReady ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleExport(p)}
className="opacity-0 group-hover:opacity-100"
aria-label={`Export preset ${p.name}`}
>
<IconFileExport size={14} stroke={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Export</TooltipContent>
</Tooltip>
) : (
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleExport(p)}
className="opacity-0 group-hover:opacity-100"
aria-label={`Export preset ${p.name}`}
>
<IconFileExport size={14} stroke={1.5} />
</Button>
)}
{tooltipsReady ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleDelete(p.path)}
className="opacity-0 group-hover:opacity-100 text-destructive"
aria-label={`Delete preset ${p.name}`}
>
<IconTrash size={14} stroke={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Delete</TooltipContent>
</Tooltip>
) : (
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleDelete(p.path)}
className="opacity-0 group-hover:opacity-100 text-destructive"
aria-label={`Delete preset ${p.name}`}
>
<IconTrash size={14} stroke={1.5} />
</Button>
)}
</div>
))}
</div>
</ScrollArea>
{saving ? (
<div className="flex flex-col gap-2">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Preset name"
className="h-8 text-xs"
autoFocus
onKeyDown={(e) => e.key === "Enter" && handleSave()}
/>
<Input
value={desc}
onChange={(e) => setDesc(e.target.value)}
placeholder="Description (optional)"
className="h-8 text-xs"
onKeyDown={(e) => e.key === "Enter" && handleSave()}
/>
</div>
) : null}
<DialogFooter className="sm:justify-between">
<Button variant="outline" size="sm" onClick={handleImport}>
<IconFileImport size={14} stroke={1.5} className="mr-1" />
Import
</Button>
<div className="flex gap-2">
{saving ? (
<>
<Button variant="outline" size="sm" onClick={() => setSaving(false)}>
Cancel
</Button>
<Button size="sm" onClick={handleSave} disabled={!name.trim()}>
Save
</Button>
</>
) : (
<Button
size="sm"
onClick={() => setSaving(true)}
className={cn(pipeline.length === 0 && "opacity-50")}
disabled={pipeline.length === 0}
>
<IconPlus size={14} stroke={1.5} className="mr-1" />
Save current pipeline
</Button>
)}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,102 +0,0 @@
import { useRuleStore } from "../../stores/ruleStore";
import type { AddConfig, StepMode } from "../../types/rules";
export function AddTab() {
const rule = useRuleStore((s) => s.rules.add) as AddConfig;
const updateRule = useRuleStore((s) => s.updateRule);
const update = (changes: Partial<AddConfig>) => updateRule("add", changes);
return (
<div className="flex flex-col gap-3 text-xs">
<div className="flex gap-3">
<label className="flex flex-col flex-1 gap-1">
<span style={{ color: "var(--text-secondary)" }}>Prefix</span>
<input
type="text"
value={rule.prefix}
onChange={(e) => update({ prefix: e.target.value })}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
/>
</label>
<label className="flex flex-col flex-1 gap-1">
<span style={{ color: "var(--text-secondary)" }}>Suffix</span>
<input
type="text"
value={rule.suffix}
onChange={(e) => update({ suffix: e.target.value })}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
/>
</label>
</div>
<div className="flex gap-3">
<label className="flex flex-col flex-1 gap-1">
<span style={{ color: "var(--text-secondary)" }}>Insert</span>
<input
type="text"
value={rule.insert}
onChange={(e) => update({ insert: e.target.value })}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
/>
</label>
<label className="flex flex-col gap-1 w-24">
<span style={{ color: "var(--text-secondary)" }}>At position</span>
<input
type="number"
value={rule.insert_at}
onChange={(e) => update({ insert_at: parseInt(e.target.value) || 0 })}
min={0}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
/>
</label>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={rule.word_space}
onChange={(e) => update({ word_space: e.target.checked })}
/>
<span>Word space</span>
</label>
<div className="flex-1" />
<span style={{ color: "var(--text-secondary)" }}>Mode:</span>
{(["Simultaneous", "Sequential"] as StepMode[]).map((mode) => (
<label key={mode} className="flex items-center gap-1 cursor-pointer">
<input
type="radio"
name="add-mode"
checked={rule.step_mode === mode}
onChange={() => update({ step_mode: mode })}
/>
<span>{mode}</span>
</label>
))}
</div>
</div>
);
}

View File

@@ -1,69 +0,0 @@
import { useRuleStore } from "../../stores/ruleStore";
import type { CaseConfig, StepMode } from "../../types/rules";
const caseModes = ["Same", "Upper", "Lower", "Title", "Sentence", "Invert", "Random"] as const;
export function CaseTab() {
const rule = useRuleStore((s) => s.rules.case) as CaseConfig;
const updateRule = useRuleStore((s) => s.updateRule);
const update = (changes: Partial<CaseConfig>) => updateRule("case", changes);
return (
<div className="flex flex-col gap-3 text-xs">
<div className="flex gap-3 items-end">
<label className="flex flex-col gap-1 w-40">
<span style={{ color: "var(--text-secondary)" }}>Case mode</span>
<select
value={rule.mode}
onChange={(e) => update({ mode: e.target.value as CaseConfig["mode"] })}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
>
{caseModes.map((m) => (
<option key={m} value={m}>{m}</option>
))}
</select>
</label>
{rule.mode === "Title" && (
<label className="flex flex-col flex-1 gap-1">
<span style={{ color: "var(--text-secondary)" }}>Exceptions (comma-separated)</span>
<input
type="text"
value={rule.exceptions}
onChange={(e) => update({ exceptions: e.target.value })}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
placeholder="the, a, an, of..."
/>
</label>
)}
</div>
<div className="flex items-center gap-2">
<div className="flex-1" />
<span style={{ color: "var(--text-secondary)" }}>Mode:</span>
{(["Simultaneous", "Sequential"] as StepMode[]).map((mode) => (
<label key={mode} className="flex items-center gap-1 cursor-pointer">
<input
type="radio"
name="case-mode"
checked={rule.step_mode === mode}
onChange={() => update({ step_mode: mode })}
/>
<span>{mode}</span>
</label>
))}
</div>
</div>
);
}

View File

@@ -1,71 +0,0 @@
import { useRuleStore } from "../../stores/ruleStore";
import type { ExtensionConfig, StepMode } from "../../types/rules";
const extModes = ["Same", "Lower", "Upper", "Title", "Extra", "Remove", "Fixed"] as const;
export function ExtensionTab() {
const rule = useRuleStore((s) => s.rules.extension) as ExtensionConfig;
const updateRule = useRuleStore((s) => s.updateRule);
const update = (changes: Partial<ExtensionConfig>) => updateRule("extension", changes);
return (
<div className="flex flex-col gap-3 text-xs">
<div className="flex gap-3 items-end">
<label className="flex flex-col gap-1 w-40">
<span style={{ color: "var(--text-secondary)" }}>Extension mode</span>
<select
value={rule.mode}
onChange={(e) => update({ mode: e.target.value as ExtensionConfig["mode"] })}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
>
{extModes.map((m) => (
<option key={m} value={m}>{m}</option>
))}
</select>
</label>
{(rule.mode === "Fixed" || rule.mode === "Extra") && (
<label className="flex flex-col flex-1 gap-1">
<span style={{ color: "var(--text-secondary)" }}>
{rule.mode === "Extra" ? "Extra extension" : "New extension"}
</span>
<input
type="text"
value={rule.fixed_value}
onChange={(e) => update({ fixed_value: e.target.value })}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
placeholder="e.g. bak, txt..."
/>
</label>
)}
</div>
<div className="flex items-center gap-2">
<div className="flex-1" />
<span style={{ color: "var(--text-secondary)" }}>Mode:</span>
{(["Simultaneous", "Sequential"] as StepMode[]).map((mode) => (
<label key={mode} className="flex items-center gap-1 cursor-pointer">
<input
type="radio"
name="ext-mode"
checked={rule.step_mode === mode}
onChange={() => update({ step_mode: mode })}
/>
<span>{mode}</span>
</label>
))}
</div>
</div>
);
}

View File

@@ -1,136 +0,0 @@
import { useRuleStore } from "../../stores/ruleStore";
import type { NumberingConfig, StepMode } from "../../types/rules";
const numberModes = ["None", "Prefix", "Suffix", "Both", "Insert"] as const;
const bases = ["Decimal", "Hex", "Octal", "Binary", "Alpha"] as const;
export function NumberingTab() {
const rule = useRuleStore((s) => s.rules.numbering) as NumberingConfig;
const updateRule = useRuleStore((s) => s.updateRule);
const update = (changes: Partial<NumberingConfig>) => updateRule("numbering", changes);
return (
<div className="flex flex-col gap-3 text-xs">
<div className="flex gap-3">
<label className="flex flex-col gap-1 w-28">
<span style={{ color: "var(--text-secondary)" }}>Position</span>
<select
value={rule.mode}
onChange={(e) => update({ mode: e.target.value as NumberingConfig["mode"] })}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
>
{numberModes.map((m) => (
<option key={m} value={m}>{m}</option>
))}
</select>
</label>
<label className="flex flex-col gap-1 w-20">
<span style={{ color: "var(--text-secondary)" }}>Start</span>
<input
type="number"
value={rule.start}
onChange={(e) => update({ start: parseInt(e.target.value) || 0 })}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
/>
</label>
<label className="flex flex-col gap-1 w-20">
<span style={{ color: "var(--text-secondary)" }}>Step</span>
<input
type="number"
value={rule.increment}
onChange={(e) => update({ increment: parseInt(e.target.value) || 1 })}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
/>
</label>
<label className="flex flex-col gap-1 w-20">
<span style={{ color: "var(--text-secondary)" }}>Padding</span>
<input
type="number"
value={rule.padding}
onChange={(e) => update({ padding: parseInt(e.target.value) || 1 })}
min={1}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
/>
</label>
<label className="flex flex-col gap-1 w-20">
<span style={{ color: "var(--text-secondary)" }}>Separator</span>
<input
type="text"
value={rule.separator}
onChange={(e) => update({ separator: e.target.value })}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
/>
</label>
<label className="flex flex-col gap-1 w-24">
<span style={{ color: "var(--text-secondary)" }}>Base</span>
<select
value={rule.base}
onChange={(e) => update({ base: e.target.value as NumberingConfig["base"] })}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
>
{bases.map((b) => (
<option key={b} value={b}>{b}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={rule.per_folder}
onChange={(e) => update({ per_folder: e.target.checked })}
/>
<span>Reset per folder</span>
</label>
<div className="flex-1" />
<span style={{ color: "var(--text-secondary)" }}>Mode:</span>
{(["Simultaneous", "Sequential"] as StepMode[]).map((mode) => (
<label key={mode} className="flex items-center gap-1 cursor-pointer">
<input
type="radio"
name="numbering-mode"
checked={rule.step_mode === mode}
onChange={() => update({ step_mode: mode })}
/>
<span>{mode}</span>
</label>
))}
</div>
</div>
);
}

View File

@@ -1,74 +0,0 @@
import { useRuleStore } from "../../stores/ruleStore";
import type { RegexConfig, StepMode } from "../../types/rules";
export function RegexTab() {
const rule = useRuleStore((s) => s.rules.regex) as RegexConfig;
const updateRule = useRuleStore((s) => s.updateRule);
const update = (changes: Partial<RegexConfig>) => updateRule("regex", changes);
return (
<div className="flex flex-col gap-3 text-xs">
<div className="flex gap-3">
<label className="flex flex-col flex-1 gap-1">
<span style={{ color: "var(--text-secondary)" }}>Pattern</span>
<input
type="text"
value={rule.pattern}
onChange={(e) => update({ pattern: e.target.value })}
className="px-2 py-1.5 rounded border font-mono"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
placeholder="Regex pattern..."
/>
</label>
<label className="flex flex-col flex-1 gap-1">
<span style={{ color: "var(--text-secondary)" }}>Replace with</span>
<input
type="text"
value={rule.replace_with}
onChange={(e) => update({ replace_with: e.target.value })}
className="px-2 py-1.5 rounded border font-mono"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
placeholder="$1, $2 for capture groups..."
/>
</label>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={rule.case_insensitive}
onChange={(e) => update({ case_insensitive: e.target.checked })}
/>
<span>Case insensitive</span>
</label>
<div className="flex-1" />
<div className="flex items-center gap-2">
<span style={{ color: "var(--text-secondary)" }}>Mode:</span>
{(["Simultaneous", "Sequential"] as StepMode[]).map((mode) => (
<label key={mode} className="flex items-center gap-1 cursor-pointer">
<input
type="radio"
name="regex-mode"
checked={rule.step_mode === mode}
onChange={() => update({ step_mode: mode })}
/>
<span>{mode}</span>
</label>
))}
</div>
</div>
</div>
);
}

View File

@@ -1,123 +0,0 @@
import { useRuleStore } from "../../stores/ruleStore";
import type { RemoveConfig, StepMode } from "../../types/rules";
export function RemoveTab() {
const rule = useRuleStore((s) => s.rules.remove) as RemoveConfig;
const updateRule = useRuleStore((s) => s.updateRule);
const update = (changes: Partial<RemoveConfig>) => updateRule("remove", changes);
return (
<div className="flex flex-col gap-3 text-xs">
<div className="flex gap-3">
<label className="flex flex-col gap-1 w-24">
<span style={{ color: "var(--text-secondary)" }}>First N</span>
<input
type="number"
value={rule.first_n}
onChange={(e) => update({ first_n: parseInt(e.target.value) || 0 })}
min={0}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
/>
</label>
<label className="flex flex-col gap-1 w-24">
<span style={{ color: "var(--text-secondary)" }}>Last N</span>
<input
type="number"
value={rule.last_n}
onChange={(e) => update({ last_n: parseInt(e.target.value) || 0 })}
min={0}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
/>
</label>
<label className="flex flex-col gap-1 w-24">
<span style={{ color: "var(--text-secondary)" }}>From</span>
<input
type="number"
value={rule.from}
onChange={(e) => update({ from: parseInt(e.target.value) || 0 })}
min={0}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
/>
</label>
<label className="flex flex-col gap-1 w-24">
<span style={{ color: "var(--text-secondary)" }}>To</span>
<input
type="number"
value={rule.to}
onChange={(e) => update({ to: parseInt(e.target.value) || 0 })}
min={0}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
/>
</label>
</div>
<div className="flex gap-3">
<label className="flex flex-col flex-1 gap-1">
<span style={{ color: "var(--text-secondary)" }}>Crop before</span>
<input
type="text"
value={rule.crop_before || ""}
onChange={(e) => update({ crop_before: e.target.value || null })}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
/>
</label>
<label className="flex flex-col flex-1 gap-1">
<span style={{ color: "var(--text-secondary)" }}>Crop after</span>
<input
type="text"
value={rule.crop_after || ""}
onChange={(e) => update({ crop_after: e.target.value || null })}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
/>
</label>
</div>
<div className="flex items-center gap-2">
<div className="flex-1" />
<span style={{ color: "var(--text-secondary)" }}>Mode:</span>
{(["Simultaneous", "Sequential"] as StepMode[]).map((mode) => (
<label key={mode} className="flex items-center gap-1 cursor-pointer">
<input
type="radio"
name="remove-mode"
checked={rule.step_mode === mode}
onChange={() => update({ step_mode: mode })}
/>
<span>{mode}</span>
</label>
))}
</div>
</div>
);
}

View File

@@ -1,82 +0,0 @@
import { useRuleStore } from "../../stores/ruleStore";
import type { ReplaceConfig, StepMode } from "../../types/rules";
export function ReplaceTab() {
const rule = useRuleStore((s) => s.rules.replace) as ReplaceConfig;
const updateRule = useRuleStore((s) => s.updateRule);
const update = (changes: Partial<ReplaceConfig>) => updateRule("replace", changes);
return (
<div className="flex flex-col gap-3 text-xs">
<div className="flex gap-3">
<label className="flex flex-col flex-1 gap-1">
<span style={{ color: "var(--text-secondary)" }}>Find</span>
<input
type="text"
value={rule.search}
onChange={(e) => update({ search: e.target.value })}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
placeholder="Text to find..."
/>
</label>
<label className="flex flex-col flex-1 gap-1">
<span style={{ color: "var(--text-secondary)" }}>Replace with</span>
<input
type="text"
value={rule.replace_with}
onChange={(e) => update({ replace_with: e.target.value })}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
placeholder="Replacement text..."
/>
</label>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={rule.match_case}
onChange={(e) => update({ match_case: e.target.checked })}
/>
<span>Match case</span>
</label>
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={rule.first_only}
onChange={(e) => update({ first_only: e.target.checked })}
/>
<span>First only</span>
</label>
<div className="flex-1" />
<div className="flex items-center gap-2">
<span style={{ color: "var(--text-secondary)" }}>Mode:</span>
{(["Simultaneous", "Sequential"] as StepMode[]).map((mode) => (
<label key={mode} className="flex items-center gap-1 cursor-pointer">
<input
type="radio"
name="replace-mode"
checked={rule.step_mode === mode}
onChange={() => update({ step_mode: mode })}
/>
<span>{mode}</span>
</label>
))}
</div>
</div>
</div>
);
}

View File

@@ -1,113 +0,0 @@
import { useEffect } from "react";
import { useRuleStore } from "../../stores/ruleStore";
import { useFileStore } from "../../stores/fileStore";
import { ReplaceTab } from "./ReplaceTab";
import { RegexTab } from "./RegexTab";
import { RemoveTab } from "./RemoveTab";
import { AddTab } from "./AddTab";
import { CaseTab } from "./CaseTab";
import { NumberingTab } from "./NumberingTab";
import { ExtensionTab } from "./ExtensionTab";
const tabs = [
{ id: "replace", label: "Replace" },
{ id: "regex", label: "Regex" },
{ id: "remove", label: "Remove" },
{ id: "add", label: "Add" },
{ id: "case", label: "Case" },
{ id: "numbering", label: "Number" },
{ id: "extension", label: "Extension" },
];
export function RulePanel() {
const activeTab = useRuleStore((s) => s.activeTab);
const setActiveTab = useRuleStore((s) => s.setActiveTab);
const rules = useRuleStore((s) => s.rules);
const requestPreview = useRuleStore((s) => s.requestPreview);
const currentPath = useFileStore((s) => s.currentPath);
// auto-preview when rules change
useEffect(() => {
if (currentPath) {
requestPreview(currentPath);
}
}, [rules, currentPath, requestPreview]);
function isTabActive(id: string): boolean {
const rule = rules[id];
if (!rule || !rule.enabled) return false;
// check if rule has any non-default values set
switch (id) {
case "replace":
return !!(rule as any).search;
case "regex":
return !!(rule as any).pattern;
case "remove":
return (rule as any).first_n > 0 || (rule as any).last_n > 0 || (rule as any).from !== (rule as any).to;
case "add":
return !!(rule as any).prefix || !!(rule as any).suffix || !!(rule as any).insert;
case "case":
return (rule as any).mode !== "Same";
case "numbering":
return (rule as any).mode !== "None";
case "extension":
return (rule as any).mode !== "Same";
default:
return false;
}
}
return (
<div
className="border-t flex flex-col"
style={{
borderColor: "var(--border)",
background: "var(--bg-secondary)",
height: "240px",
minHeight: "160px",
}}
>
{/* tab bar */}
<div
className="flex border-b shrink-0"
style={{ borderColor: "var(--border)" }}
>
{tabs.map((tab) => {
const active = activeTab === tab.id;
const hasContent = isTabActive(tab.id);
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className="px-4 py-2 text-xs font-medium relative"
style={{
color: active ? "var(--accent)" : "var(--text-secondary)",
background: active ? "var(--bg-primary)" : "transparent",
borderBottom: active ? "2px solid var(--accent)" : "2px solid transparent",
}}
>
{tab.label}
{hasContent && (
<span
className="absolute top-1 right-1 w-1.5 h-1.5 rounded-full"
style={{ background: "var(--accent)" }}
/>
)}
</button>
);
})}
</div>
{/* tab content */}
<div className="flex-1 overflow-auto p-3">
{activeTab === "replace" && <ReplaceTab />}
{activeTab === "regex" && <RegexTab />}
{activeTab === "remove" && <RemoveTab />}
{activeTab === "add" && <AddTab />}
{activeTab === "case" && <CaseTab />}
{activeTab === "numbering" && <NumberingTab />}
{activeTab === "extension" && <ExtensionTab />}
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,67 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,30 @@
import * as React from "react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { IconCheck } from "@tabler/icons-react"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<IconCheck stroke={1.5} />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -0,0 +1,266 @@
import * as React from "react"
import { ContextMenu as ContextMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { usePortalContainer } from "@/lib/portal"
import { IconChevronRight, IconCheck } from "@tabler/icons-react"
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuTrigger({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger
data-slot="context-menu-trigger"
className={cn("select-none", className)}
{...props}
/>
)
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
}
function ContextMenuContent({
className,
collisionPadding = 8,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
const container = usePortalContainer();
return (
<ContextMenuPrimitive.Portal container={container ?? undefined}>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
collisionBoundary={container ?? undefined}
collisionPadding={collisionPadding}
className={cn("z-50 max-h-(--radix-context-menu-content-available-height) min-w-36 origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto overscroll-contain rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/context-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<IconChevronRight className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn("z-50 min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-lg border bg-popover p-1 text-popover-foreground shadow-lg duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute right-2">
<ContextMenuPrimitive.ItemIndicator>
<IconCheck
/>
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2">
<ContextMenuPrimitive.ItemIndicator>
<IconCheck
/>
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/context-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}

View File

@@ -0,0 +1,166 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { usePortalContainer } from "@/lib/portal"
import { Button } from "@/components/ui/button"
import { IconX } from "@tabler/icons-react"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
const container = usePortalContainer();
return <DialogPrimitive.Portal data-slot="dialog-portal" container={container ?? undefined} {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 text-sm ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
>
<IconX stroke={1.5} />
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-base leading-none font-medium", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,272 @@
"use client"
import * as React from "react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { usePortalContainer } from "@/lib/portal"
import { IconCheck, IconChevronRight } from "@tabler/icons-react"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
align = "start",
sideOffset = 4,
collisionPadding = 8,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
const container = usePortalContainer();
return (
<DropdownMenuPrimitive.Portal container={container ?? undefined}>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
align={align}
collisionBoundary={container ?? undefined}
collisionPadding={collisionPadding}
className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto overscroll-contain rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<IconCheck stroke={1.5} />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<IconCheck stroke={1.5} />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<IconChevronRight className="ml-auto" stroke={1.5} />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn("z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,196 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { IconMinus, IconPlus } from "@tabler/icons-react";
import { cn } from "@/lib/utils";
import { useSettingsStore } from "@/stores/settingsStore";
interface NumberInputProps {
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
sensitivity?: number;
className?: string;
"aria-label"?: string;
}
export function NumberInput({
value,
onChange,
min = -Infinity,
max = Infinity,
step = 1,
sensitivity = 0.5,
className,
"aria-label": ariaLabel,
}: NumberInputProps) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState("");
const [dragging, setDragging] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const pressed = useRef(false);
const dragState = useRef({ startX: 0, startVal: 0, moved: false, target: "" });
const repeatTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const repeatInterval = useRef<ReturnType<typeof setInterval> | null>(null);
const repeatCount = useRef(0);
const valueRef = useRef(value);
valueRef.current = value;
const clamp = useCallback(
(v: number) => Math.max(min, Math.min(max, v)),
[min, max],
);
const stopRepeat = useCallback(() => {
if (repeatTimer.current) { clearTimeout(repeatTimer.current); repeatTimer.current = null; }
if (repeatInterval.current) { clearInterval(repeatInterval.current); repeatInterval.current = null; }
repeatCount.current = 0;
}, []);
useEffect(() => stopRepeat, [stopRepeat]);
const startRepeat = useCallback((direction: number) => {
stopRepeat();
repeatTimer.current = setTimeout(() => {
const tick = () => {
repeatCount.current++;
// accelerate: after 10 ticks use step*2, after 30 use step*5
let mult = 1;
if (repeatCount.current > 30) mult = 5;
else if (repeatCount.current > 10) mult = 2;
onChange(clamp(valueRef.current + direction * step * mult));
};
tick();
repeatInterval.current = setInterval(tick, 60);
}, 400);
}, [stopRepeat, step, clamp, onChange]);
const startEdit = () => {
setEditValue(String(value));
setEditing(true);
requestAnimationFrame(() => inputRef.current?.select());
};
const commitEdit = () => {
setEditing(false);
const parsed = parseInt(editValue);
if (!isNaN(parsed)) onChange(clamp(parsed));
};
const onPointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (editing) return;
e.preventDefault();
const actionEl = (e.target as HTMLElement).closest("[data-action]") as HTMLElement | null;
const target = actionEl?.dataset.action || "value";
dragState.current = { startX: e.clientX, startVal: value, moved: false, target };
pressed.current = true;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
if (target === "dec") startRepeat(-1);
else if (target === "inc") startRepeat(1);
},
[editing, value, startRepeat],
);
const onPointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!pressed.current) return;
const zoom = useSettingsStore.getState().zoom;
const dx = (e.clientX - dragState.current.startX) / zoom;
if (Math.abs(dx) > 4) {
dragState.current.moved = true;
stopRepeat();
if (!dragging) setDragging(true);
}
if (dragState.current.moved) {
const delta = Math.round(dx * sensitivity) * step;
onChange(clamp(dragState.current.startVal + delta));
}
},
[dragging, sensitivity, step, clamp, onChange, stopRepeat],
);
const onPointerUp = useCallback(() => {
if (!pressed.current) return;
pressed.current = false;
const wasDragging = dragging;
const wasRepeating = repeatCount.current > 0;
setDragging(false);
stopRepeat();
if (dragState.current.moved) return;
if (wasDragging) return;
if (wasRepeating) return;
const { target } = dragState.current;
if (target === "dec") {
onChange(clamp(dragState.current.startVal - step));
} else if (target === "inc") {
onChange(clamp(dragState.current.startVal + step));
} else {
startEdit();
}
}, [dragging, step, clamp, onChange, stopRepeat]);
return (
<div
className={cn(
"flex items-center h-8 rounded-lg border border-input bg-transparent transition-colors",
"focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50",
!editing && !dragging && "cursor-ew-resize",
dragging && "cursor-ew-resize border-primary/50",
"dark:bg-input/30",
className,
)}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
role="spinbutton"
aria-valuenow={value}
aria-valuemin={min === -Infinity ? undefined : min}
aria-valuemax={max === Infinity ? undefined : max}
aria-label={ariaLabel}
>
<div
data-action="dec"
className={cn(
"shrink-0 flex items-center justify-center w-6 h-full text-muted-foreground rounded-l-lg transition-colors",
dragging ? "cursor-ew-resize" : "cursor-pointer",
!dragging && "hover:text-foreground hover:bg-muted/50",
value <= min && "opacity-30 pointer-events-none",
)}
>
<IconMinus size={12} stroke={2} />
</div>
{editing ? (
<input
ref={inputRef}
type="text"
inputMode="numeric"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={commitEdit}
onKeyDown={(e) => {
if (e.key === "Enter") commitEdit();
if (e.key === "Escape") setEditing(false);
}}
className="flex-1 min-w-0 bg-transparent text-center text-xs font-mono outline-none px-0.5"
/>
) : (
<span className="flex-1 min-w-0 text-center text-xs font-mono select-none tabular-nums">
{value}
</span>
)}
<div
data-action="inc"
className={cn(
"shrink-0 flex items-center justify-center w-6 h-full text-muted-foreground rounded-r-lg transition-colors",
dragging ? "cursor-ew-resize" : "cursor-pointer",
!dragging && "hover:text-foreground hover:bg-muted/50",
value >= max && "opacity-30 pointer-events-none",
)}
>
<IconPlus size={12} stroke={2} />
</div>
</div>
);
}

View File

@@ -0,0 +1,106 @@
import * as React from "react"
import { Popover as PopoverPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { usePortalContainer } from "@/lib/portal"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
collisionPadding = 8,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
const container = usePortalContainer();
return (
<PopoverPrimitive.Portal container={container ?? undefined}>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
collisionBoundary={container ?? undefined}
collisionPadding={collisionPadding}
className={cn(
"z-50 flex w-72 origin-(--radix-popover-content-transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden overscroll-contain duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-0.5 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
return (
<div
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: React.ComponentProps<"p">) {
return (
<p
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
function PopoverArrow({
className,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Arrow>) {
return (
<PopoverPrimitive.Arrow
data-slot="popover-arrow"
className={cn("fill-popover", className)}
{...props}
/>
)
}
export {
Popover,
PopoverAnchor,
PopoverArrow,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
}

View File

@@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,71 @@
import { useRef, useState, useEffect, useCallback } from "react";
import { cn } from "@/lib/utils";
import { motion } from "framer-motion";
interface SegmentedControlProps<T extends string> {
value: T;
onChange: (value: T) => void;
options: readonly { value: T; label: string }[];
size?: "sm" | "md";
className?: string;
}
export function SegmentedControl<T extends string>({
value,
onChange,
options,
size = "md",
className,
}: SegmentedControlProps<T>) {
const buttonsRef = useRef<Map<string, HTMLButtonElement>>(new Map());
const [highlight, setHighlight] = useState<{ left: number; width: number } | null>(null);
const hasAnimated = useRef(false);
const measure = useCallback(() => {
const btn = buttonsRef.current.get(value);
if (btn) {
setHighlight({ left: btn.offsetLeft, width: btn.offsetWidth });
}
}, [value]);
useEffect(() => {
measure();
}, [measure]);
return (
<div
className={cn(
"relative flex items-center rounded-lg border border-input bg-muted/50 p-0.5 shrink-0",
className,
)}
>
{highlight && (
<motion.div
className="absolute rounded-md bg-background shadow-sm ring-1 ring-primary/20"
initial={{ left: highlight.left, width: highlight.width }}
animate={{ left: highlight.left, width: highlight.width }}
transition={hasAnimated.current ? { type: "tween", duration: 0.3, ease: "easeInOut" } : { duration: 0 }}
onAnimationComplete={() => { hasAnimated.current = true; }}
style={{ top: 2, bottom: 2 }}
/>
)}
{options.map((opt) => (
<button
key={opt.value}
ref={(el) => {
if (el) buttonsRef.current.set(opt.value, el);
else buttonsRef.current.delete(opt.value);
}}
onClick={() => onChange(opt.value)}
className={cn(
"relative z-10 flex-1 rounded-md font-medium whitespace-nowrap",
size === "sm" ? "px-1.5 py-0.5 text-[10px]" : "px-2.5 py-1 text-xs",
value === opt.value ? "text-primary" : "text-muted-foreground hover:text-foreground",
)}
>
{opt.label}
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,20 @@
import { Toaster as Sonner } from "sonner";
export function Toaster() {
return (
<Sonner
position="bottom-right"
toastOptions={{
classNames: {
toast: "bg-card text-card-foreground border-border shadow-lg font-sans text-[13px]",
title: "font-medium",
description: "text-muted-foreground text-[12px]",
success: "border-success/30",
error: "border-destructive/30",
},
}}
offset={8}
gap={6}
/>
);
}

View File

@@ -0,0 +1,31 @@
import * as React from "react"
import { Switch as SwitchPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View File

@@ -0,0 +1,57 @@
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { usePortalContainer } from "@/lib/portal"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
const container = usePortalContainer();
return (
<TooltipPrimitive.Portal container={container ?? undefined}>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }

View File

@@ -0,0 +1,19 @@
import { create } from "zustand";
interface AnnounceStore {
message: string;
announce: (text: string) => void;
}
export const useAnnounceStore = create<AnnounceStore>((set) => ({
message: "",
announce: (text: string) => {
// clear first so repeated identical messages still trigger
set({ message: "" });
requestAnimationFrame(() => set({ message: text }));
},
}));
export function announce(text: string) {
useAnnounceStore.getState().announce(text);
}

View File

@@ -1,12 +0,0 @@
import { useEffect, useState } from "react";
export function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}

View File

@@ -1,16 +1,13 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useFileStore } from "../stores/fileStore"; import { useFileStore } from "@/stores/fileStore";
import { useRuleStore } from "../stores/ruleStore"; import { useRuleStore } from "@/stores/ruleStore";
export function useKeyboardShortcuts() { export function useKeyboardShortcuts() {
const selectAll = useFileStore((s) => s.selectAll); const selectAll = useFileStore((s) => s.selectAll);
const deselectAll = useFileStore((s) => s.deselectAll); const deselectAll = useFileStore((s) => s.deselectAll);
const resetAllRules = useRuleStore((s) => s.resetAllRules); const resetAllRules = useRuleStore((s) => s.resetAllRules);
const setActiveTab = useRuleStore((s) => s.setActiveTab);
useEffect(() => { useEffect(() => {
const tabs = ["replace", "regex", "remove", "add", "case", "numbering", "extension"];
function handler(e: KeyboardEvent) { function handler(e: KeyboardEvent) {
if (e.ctrlKey && e.key === "a" && !e.shiftKey) { if (e.ctrlKey && e.key === "a" && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
@@ -23,14 +20,9 @@ export function useKeyboardShortcuts() {
if (e.key === "Escape") { if (e.key === "Escape") {
resetAllRules(); resetAllRules();
} }
if (e.ctrlKey && e.key >= "1" && e.key <= "7") {
e.preventDefault();
const idx = parseInt(e.key) - 1;
if (idx < tabs.length) setActiveTab(tabs[idx]);
}
} }
window.addEventListener("keydown", handler); window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler);
}, [selectAll, deselectAll, resetAllRules, setActiveTab]); }, [selectAll, deselectAll, resetAllRules]);
} }

109
ui/src/hooks/useTheme.ts Normal file
View File

@@ -0,0 +1,109 @@
import { useEffect } from "react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useSettingsStore } from "@/stores/settingsStore";
export function useTheme() {
const theme = useSettingsStore((s) => s.theme);
const accentHue = useSettingsStore((s) => s.accentHue);
const alwaysOnTop = useSettingsStore((s) => s.alwaysOnTop);
const compactMode = useSettingsStore((s) => s.compactMode);
const animationsEnabled = useSettingsStore((s) => s.animationsEnabled);
const enableDebugLogging = useSettingsStore((s) => s.enableDebugLogging);
// accessibility settings
const highContrast = useSettingsStore((s) => s.highContrast);
const largerTouchTargets = useSettingsStore((s) => s.largerTouchTargets);
const reduceTransparency = useSettingsStore((s) => s.reduceTransparency);
const focusIndicators = useSettingsStore((s) => s.focusIndicators);
const screenReaderOptimized = useSettingsStore((s) => s.screenReaderOptimized);
const motionSensitivity = useSettingsStore((s) => s.motionSensitivity);
const fontScaling = useSettingsStore((s) => s.fontScaling);
const colorBlindMode = useSettingsStore((s) => s.colorBlindMode);
const keyboardNavigationMode = useSettingsStore((s) => s.keyboardNavigationMode);
const minimumContrastRatio = useSettingsStore((s) => s.minimumContrastRatio);
useEffect(() => {
document.documentElement.style.setProperty("--accent-hue", String(accentHue));
}, [accentHue]);
useEffect(() => {
const root = document.documentElement;
function apply(dark: boolean) {
root.classList.toggle("dark", dark);
}
if (theme === "dark") { apply(true); return; }
if (theme === "light") { apply(false); return; }
const mq = window.matchMedia("(prefers-color-scheme: dark)");
apply(mq.matches);
const handler = (e: MediaQueryListEvent) => apply(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, [theme]);
useEffect(() => {
getCurrentWindow().setAlwaysOnTop(alwaysOnTop).catch(() => {});
}, [alwaysOnTop]);
useEffect(() => {
document.documentElement.classList.toggle("compact", compactMode);
}, [compactMode]);
useEffect(() => {
document.documentElement.classList.toggle("no-animations", !animationsEnabled);
}, [animationsEnabled]);
useEffect(() => {
(window as any).__NOMINA_DEBUG = enableDebugLogging;
if (enableDebugLogging) console.info("[nomina] debug logging enabled");
}, [enableDebugLogging]);
// accessibility wiring
useEffect(() => {
document.documentElement.classList.toggle("high-contrast", highContrast);
}, [highContrast]);
useEffect(() => {
document.documentElement.classList.toggle("larger-targets", largerTouchTargets);
}, [largerTouchTargets]);
useEffect(() => {
document.documentElement.classList.toggle("reduce-transparency", reduceTransparency);
}, [reduceTransparency]);
useEffect(() => {
document.documentElement.classList.toggle("enhanced-focus", focusIndicators === "enhanced");
}, [focusIndicators]);
useEffect(() => {
document.documentElement.classList.toggle("sr-optimized", screenReaderOptimized);
}, [screenReaderOptimized]);
useEffect(() => {
const root = document.documentElement;
root.classList.toggle("motion-reduced", motionSensitivity === "reduced");
root.classList.toggle("motion-none", motionSensitivity === "none");
if (motionSensitivity === "none") {
root.classList.add("no-animations");
}
}, [motionSensitivity]);
useEffect(() => {
document.documentElement.style.setProperty("--font-scale", String(fontScaling));
}, [fontScaling]);
useEffect(() => {
const root = document.documentElement;
root.removeAttribute("data-color-blind");
if (colorBlindMode !== "none") {
root.setAttribute("data-color-blind", colorBlindMode);
}
}, [colorBlindMode]);
useEffect(() => {
document.documentElement.classList.toggle("keyboard-nav", keyboardNavigationMode);
}, [keyboardNavigationMode]);
useEffect(() => {
document.documentElement.classList.toggle("contrast-aaa", minimumContrastRatio === "aaa");
}, [minimumContrastRatio]);
}

View File

@@ -0,0 +1,123 @@
import { useEffect } from "react";
import { getCurrentWindow, availableMonitors } from "@tauri-apps/api/window";
import { PhysicalPosition, PhysicalSize } from "@tauri-apps/api/dpi";
const KEY = "nomina-window-state";
const MIN_WIDTH = 400;
const MIN_HEIGHT = 300;
const MAX_WIDTH = 7680;
const MAX_HEIGHT = 4320;
interface WindowState {
x: number;
y: number;
width: number;
height: number;
maximized: boolean;
}
function applyMaximizedClass(maximized: boolean) {
document.documentElement.classList.toggle("maximized", maximized);
}
async function isPositionVisible(x: number, y: number, w: number, h: number): Promise<boolean> {
try {
const monitors = await availableMonitors();
if (monitors.length === 0) return true; // can't check, assume ok
// at least 100px of the window must be visible on some monitor
for (const m of monitors) {
const mx = m.position.x;
const my = m.position.y;
const mw = m.size.width;
const mh = m.size.height;
const overlapX = Math.max(0, Math.min(x + w, mx + mw) - Math.max(x, mx));
const overlapY = Math.max(0, Math.min(y + h, my + mh) - Math.max(y, my));
if (overlapX >= 100 && overlapY >= 50) return true;
}
return false;
} catch {
return true;
}
}
function isSizeSane(w: number, h: number): boolean {
return w >= MIN_WIDTH && w <= MAX_WIDTH && h >= MIN_HEIGHT && h <= MAX_HEIGHT
&& Number.isFinite(w) && Number.isFinite(h);
}
export function useWindowState() {
useEffect(() => {
const win = getCurrentWindow();
// restore
(async () => {
try {
const raw = localStorage.getItem(KEY);
if (raw) {
const s: WindowState = JSON.parse(raw);
if (s.maximized) {
win.maximize();
applyMaximizedClass(true);
} else {
const sizeOk = isSizeSane(s.width, s.height);
const width = sizeOk ? s.width : 1200;
const height = sizeOk ? s.height : 800;
const posOk = Number.isFinite(s.x) && Number.isFinite(s.y)
&& await isPositionVisible(s.x, s.y, width, height);
await win.setSize(new PhysicalSize(width, height));
if (posOk) {
await win.setPosition(new PhysicalPosition(s.x, s.y));
} else {
await win.center();
}
applyMaximizedClass(false);
}
}
} catch {}
})();
// track maximize/unmaximize to toggle padding/border-radius
const unlistenResize = win.onResized(async () => {
try {
const maximized = await win.isMaximized();
applyMaximizedClass(maximized);
} catch {}
});
// save state periodically - no onCloseRequested needed
async function saveState() {
try {
const maximized = await win.isMaximized();
if (maximized) {
const prev = localStorage.getItem(KEY);
if (prev) {
const s: WindowState = JSON.parse(prev);
s.maximized = true;
localStorage.setItem(KEY, JSON.stringify(s));
} else {
localStorage.setItem(KEY, JSON.stringify({ x: 100, y: 100, width: 1200, height: 800, maximized: true }));
}
return;
}
const pos = await win.outerPosition();
const size = await win.outerSize();
const state: WindowState = {
x: pos.x,
y: pos.y,
width: size.width,
height: size.height,
maximized: false,
};
localStorage.setItem(KEY, JSON.stringify(state));
} catch {}
}
const timer = setInterval(saveState, 2000);
return () => {
clearInterval(timer);
unlistenResize.then((fn) => fn());
};
}, []);
}

View File

@@ -1,78 +1,431 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/geist";
@import "overlayscrollbars/overlayscrollbars.css";
@custom-variant dark (&:is(.dark *));
:root { :root {
--bg-primary: #ffffff; --accent-hue: 160;
--bg-secondary: #f8f9fa; --background: oklch(0.985 0 0);
--bg-tertiary: #e9ecef; --foreground: oklch(0.145 0 0);
--text-primary: #212529; --card: oklch(1 0 0);
--text-secondary: #6c757d; --card-foreground: oklch(0.145 0 0);
--border: #dee2e6; --popover: oklch(1 0 0);
--accent: #4f46e5; --popover-foreground: oklch(0.145 0 0);
--accent-hover: #4338ca; --primary: oklch(0.546 0.155 var(--accent-hue));
--success: #16a34a; --primary-foreground: oklch(0.985 0 0);
--warning: #d97706; --secondary: oklch(0.965 0 0);
--error: #dc2626; --secondary-foreground: oklch(0.205 0 0);
--row-even: #ffffff; --muted: oklch(0.965 0 0);
--row-odd: #f8f9fb; --muted-foreground: oklch(0.385 0 0);
--accent: oklch(0.965 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--success: oklch(0.648 0.2 145);
--warning: oklch(0.7 0.17 75);
--border: oklch(0.912 0 0);
--input: oklch(0.912 0 0);
--ring: oklch(0.546 0.155 var(--accent-hue));
--radius: 0.5rem;
--sidebar: oklch(0.975 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.546 0.155 var(--accent-hue));
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.965 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.912 0 0);
--sidebar-ring: oklch(0.546 0.155 var(--accent-hue));
--window-border: oklch(0.75 0 0);
} }
@media (prefers-color-scheme: dark) { .dark {
:root { --background: oklch(0.178 0 0);
--bg-primary: #1a1a2e; --foreground: oklch(0.935 0 0);
--bg-secondary: #16213e; --card: oklch(0.215 0 0);
--bg-tertiary: #0f3460; --card-foreground: oklch(0.935 0 0);
--text-primary: #e2e8f0; --popover: oklch(0.215 0 0);
--text-secondary: #94a3b8; --popover-foreground: oklch(0.935 0 0);
--border: #334155; --primary: oklch(0.696 0.17 var(--accent-hue));
--accent: #6366f1; --primary-foreground: oklch(0.145 0.03 var(--accent-hue));
--accent-hover: #818cf8; --secondary: oklch(0.255 0 0);
--success: #22c55e; --secondary-foreground: oklch(0.935 0 0);
--warning: #f59e0b; --muted: oklch(0.255 0 0);
--error: #ef4444; --muted-foreground: oklch(0.72 0 0);
--row-even: #1a1a2e; --accent: oklch(0.255 0 0);
--row-odd: #1e2240; --accent-foreground: oklch(0.935 0 0);
--destructive: oklch(0.704 0.191 22.216);
--success: oklch(0.72 0.19 150);
--warning: oklch(0.75 0.17 75);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 12%);
--ring: oklch(0.696 0.17 var(--accent-hue));
--sidebar: oklch(0.195 0 0);
--sidebar-foreground: oklch(0.935 0 0);
--sidebar-primary: oklch(0.696 0.17 var(--accent-hue));
--sidebar-primary-foreground: oklch(0.145 0.03 var(--accent-hue));
--sidebar-accent: oklch(0.255 0 0);
--sidebar-accent-foreground: oklch(0.935 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.696 0.17 var(--accent-hue));
--window-border: oklch(0.32 0 0);
}
@theme inline {
--font-sans: 'Geist Variable', ui-sans-serif, system-ui, sans-serif;
--font-mono: 'Geist Mono Variable', 'Geist Mono', ui-monospace, monospace;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-success: var(--success);
--color-warning: var(--warning);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
@layer base {
* {
@apply border-border outline-ring/50;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
*::-webkit-scrollbar {
width: 6px;
height: 6px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
*::-webkit-scrollbar-thumb:hover {
background: var(--muted-foreground);
}
/* hide native scrollbar on elements managed by OverlayScrollbars */
[data-overlayscrollbars] {
scrollbar-width: none;
}
[data-overlayscrollbars]::-webkit-scrollbar {
display: none;
}
textarea {
resize: vertical;
}
html, body {
overflow: hidden;
height: 100vh;
width: 100vw;
margin: 0;
padding: 0;
background: transparent;
}
body {
@apply text-foreground font-sans antialiased;
background: transparent;
}
html {
@apply font-sans;
}
#root {
height: 100vh;
width: 100vw;
padding: 20px;
box-sizing: border-box;
pointer-events: none;
}
html.maximized #root {
padding: 0;
}
html.maximized .window-frame {
border-radius: 0;
box-shadow: none;
border-color: transparent;
} }
} }
* { @layer components {
margin: 0; .window-frame {
padding: 0; position: relative;
box-sizing: border-box; border: 1px solid var(--window-border);
} border-radius: 8px;
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
overflow: hidden; overflow: hidden;
height: 100vh;
}
#root {
height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: var(--background);
pointer-events: auto;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.28), 0 0 2px rgba(0, 0, 0, 0.12);
}
.marquee-selection {
position: fixed;
border: 2.5px dashed var(--primary);
border-radius: 12px;
background: oklch(0.55 0.15 var(--accent-hue) / 12%);
pointer-events: none;
z-index: 50;
}
} }
input, select, button { /* disable CSS transitions/animations when toggled off */
font-family: inherit; html.no-animations *,
font-size: inherit; html.no-animations *::before,
html.no-animations *::after {
transition-duration: 0s !important;
animation-duration: 0s !important;
} }
::-webkit-scrollbar { /* compact mode - tighter spacing throughout */
width: 8px; html.compact {
height: 8px; --radius: 0.3rem;
}
html.compact .window-frame {
font-size: 11px;
}
html.compact [data-slot="switch"] {
scale: 0.8;
}
html.compact [data-slot="dialog-content"] {
gap: 0.25rem;
padding: 0.5rem;
}
html.compact [data-slot="dialog-header"] {
gap: 0.25rem;
}
/* tighter sidebar tree nodes */
html.compact .bg-sidebar {
font-size: 11px;
}
html.compact .bg-sidebar form {
padding: 0.25rem 0.375rem;
gap: 0.125rem;
}
/* tighter buttons and inputs */
html.compact button,
html.compact [data-slot="button"] {
padding-top: 0.125rem;
padding-bottom: 0.125rem;
}
/* pipeline strip */
html.compact [data-overlayscrollbars] {
padding: 0.5rem 0.75rem;
}
/* tighter general spacing */
html.compact .gap-3 { gap: 0.375rem; }
html.compact .gap-2 { gap: 0.25rem; }
html.compact .gap-1\.5 { gap: 0.125rem; }
html.compact .py-1\.5 { padding-top: 0.125rem; padding-bottom: 0.125rem; }
html.compact .py-2 { padding-top: 0.25rem; padding-bottom: 0.25rem; }
html.compact .px-4 { padding-left: 0.5rem; padding-right: 0.5rem; }
html.compact .py-4 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
html.compact .py-3 { padding-top: 0.375rem; padding-bottom: 0.375rem; }
html.compact .px-2 { padding-left: 0.25rem; padding-right: 0.25rem; }
/* === ACCESSIBILITY === */
/* font scaling */
:root {
--font-scale: 1;
}
html body {
font-size: calc(14px * var(--font-scale));
} }
::-webkit-scrollbar-track { /* high contrast - boost muted colors to meet 7:1 AAA */
background: var(--bg-secondary); html.high-contrast {
--muted-foreground: oklch(0.35 0 0);
--border: oklch(0.78 0 0);
}
html.high-contrast.dark {
--muted-foreground: oklch(0.75 0 0);
--border: oklch(1 0 0 / 25%);
} }
::-webkit-scrollbar-thumb { /* AAA contrast mode - same adjustments */
background: var(--border); html.contrast-aaa {
border-radius: 4px; --muted-foreground: oklch(0.35 0 0);
}
html.contrast-aaa.dark {
--muted-foreground: oklch(0.75 0 0);
} }
::-webkit-scrollbar-thumb:hover { /* larger touch targets - min 44x44px on all interactive elements */
background: var(--text-secondary); html.larger-targets button,
html.larger-targets [data-slot="button"],
html.larger-targets [data-slot="switch"],
html.larger-targets [role="checkbox"],
html.larger-targets [role="option"],
html.larger-targets [role="menuitem"],
html.larger-targets [role="treeitem"] {
min-height: 44px;
min-width: 44px;
}
html.larger-targets [data-slot="switch"] {
scale: 1.2;
}
/* reduce transparency - replace all backdrop-filter and opacity overlays */
html.reduce-transparency .window-frame {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
}
html.reduce-transparency [class*="backdrop-blur"],
html.reduce-transparency [class*="bg-opacity"] {
backdrop-filter: none !important;
}
html.reduce-transparency .bg-primary\/\[0\.03\],
html.reduce-transparency .bg-primary\/\[0\.04\],
html.reduce-transparency .bg-primary\/\[0\.06\] {
background: var(--muted);
}
/* enhanced focus indicators - thicker, high-contrast rings */
html.enhanced-focus *:focus-visible {
outline: 3px solid var(--ring) !important;
outline-offset: 2px !important;
box-shadow: 0 0 0 5px oklch(0.5 0.15 var(--accent-hue) / 25%) !important;
}
/* keyboard navigation mode - show focus only on keyboard, with visible outlines */
html.keyboard-nav *:focus-visible {
outline: 2px solid var(--ring) !important;
outline-offset: 2px !important;
}
html.keyboard-nav *:focus:not(:focus-visible) {
outline: none !important;
}
/* motion reduced - slower, simpler transitions */
html.motion-reduced *,
html.motion-reduced *::before,
html.motion-reduced *::after {
transition-duration: 0.01s !important;
animation-duration: 0.01s !important;
}
/* motion none - no transitions at all */
html.motion-none *,
html.motion-none *::before,
html.motion-none *::after {
transition-duration: 0s !important;
animation-duration: 0s !important;
animation-iteration-count: 1 !important;
}
/* color blind filters */
html[data-color-blind="deuteranopia"] .window-frame {
filter: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'><filter id='d'><feColorMatrix type='matrix' values='0.625 0.375 0 0 0 0.7 0.3 0 0 0 0 0.3 0.7 0 0 0 0 0 1 0'/></filter></svg>#d");
}
html[data-color-blind="protanopia"] .window-frame {
filter: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'><filter id='p'><feColorMatrix type='matrix' values='0.567 0.433 0 0 0 0.558 0.442 0 0 0 0 0.242 0.758 0 0 0 0 0 1 0'/></filter></svg>#p");
}
html[data-color-blind="tritanopia"] .window-frame {
filter: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'><filter id='t'><feColorMatrix type='matrix' values='0.95 0.05 0 0 0 0 0.433 0.567 0 0 0 0.475 0.525 0 0 0 0 0 1 0'/></filter></svg>#t");
}
/* screen reader optimized - visually hidden elements for extra context */
html.sr-optimized .sr-extra {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* skip navigation link */
.skip-nav {
position: absolute;
top: -100px;
left: 0;
z-index: 9999;
padding: 8px 16px;
background: var(--primary);
color: var(--primary-foreground);
font-size: 14px;
font-weight: 600;
text-decoration: none;
border-radius: 0 0 8px 0;
}
.skip-nav:focus {
top: 0;
}
/* AAA target sizes - ensure 44x44px minimum hit area on all interactive elements */
/* uses ::after pseudo-element to expand clickable area without changing visual size */
button:not([data-no-target-expand]),
[data-slot="switch"],
[role="checkbox"],
[role="option"],
[role="treeitem"] > [role="button"],
[data-action="dec"],
[data-action="inc"] {
position: relative;
}
button:not([data-no-target-expand])::after,
[data-slot="switch"]::after,
[role="checkbox"]::after,
[data-action="dec"]::after,
[data-action="inc"]::after {
content: "";
position: absolute;
min-width: 44px;
min-height: 44px;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
/* prevent scroll leaking from overlay menus to the app */
[data-slot="context-menu-content"],
[data-slot="dropdown-menu-content"],
[data-slot="popover-content"] {
overscroll-behavior: contain;
}
/* OverlayScrollbars theme */
.os-scrollbar {
--os-size: 8px;
--os-padding-perpendicular: 1px;
--os-padding-axis: 2px;
--os-track-border-radius: 4px;
--os-handle-border-radius: 4px;
--os-handle-bg: var(--border);
--os-handle-bg-hover: var(--muted-foreground);
--os-handle-bg-active: var(--muted-foreground);
--os-track-bg: transparent;
--os-track-bg-hover: transparent;
--os-handle-min-size: 30px;
transition: opacity 0.15s ease;
} }

6
ui/src/lib/portal.ts Normal file
View File

@@ -0,0 +1,6 @@
import { createContext, useContext } from "react";
const PortalContainerContext = createContext<HTMLElement | null>(null);
export const PortalContainerProvider = PortalContainerContext.Provider;
export const usePortalContainer = () => useContext(PortalContainerContext);

6
ui/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -1,12 +1,14 @@
import { create } from "zustand"; import { create } from "zustand";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import type { FileEntry, FilterConfig, PreviewResult } from "../types/files"; import type { FileEntry, FilterConfig, PreviewResult } from "@/types/files";
import { useSettingsStore } from "./settingsStore";
interface FileState { interface FileState {
currentPath: string; currentPath: string;
files: FileEntry[]; files: FileEntry[];
previewResults: PreviewResult[]; previewResults: PreviewResult[];
selectedFiles: Set<string>; selectedFiles: Set<string>;
sortedFilePaths: string[];
loading: boolean; loading: boolean;
error: string | null; error: string | null;
filters: FilterConfig; filters: FilterConfig;
@@ -18,6 +20,7 @@ interface FileState {
selectAll: () => void; selectAll: () => void;
deselectAll: () => void; deselectAll: () => void;
setFilters: (filters: Partial<FilterConfig>) => void; setFilters: (filters: Partial<FilterConfig>) => void;
setSortedFilePaths: (paths: string[]) => void;
} }
export const useFileStore = create<FileState>((set, get) => ({ export const useFileStore = create<FileState>((set, get) => ({
@@ -25,6 +28,7 @@ export const useFileStore = create<FileState>((set, get) => ({
files: [], files: [],
previewResults: [], previewResults: [],
selectedFiles: new Set(), selectedFiles: new Set(),
sortedFilePaths: [],
loading: false, loading: false,
error: null, error: null,
filters: { filters: {
@@ -33,7 +37,7 @@ export const useFileStore = create<FileState>((set, get) => ({
min_size: null, min_size: null,
max_size: null, max_size: null,
include_files: true, include_files: true,
include_folders: false, include_folders: true,
include_hidden: false, include_hidden: false,
subfolder_depth: 0, subfolder_depth: 0,
}, },
@@ -43,17 +47,23 @@ export const useFileStore = create<FileState>((set, get) => ({
scanDirectory: async (path) => { scanDirectory: async (path) => {
set({ loading: true, error: null }); set({ loading: true, error: null });
try { try {
const showHidden = useSettingsStore.getState().showHiddenFiles;
const filters = { ...get().filters, include_hidden: showHidden };
const files = await invoke<FileEntry[]>("scan_directory", { const files = await invoke<FileEntry[]>("scan_directory", {
path, path,
filters: get().filters, filters,
}); });
const allPaths = files.map((f) => f.path);
const autoSelect = useSettingsStore.getState().autoSelectAll;
set({ set({
files, files,
currentPath: path, currentPath: path,
loading: false, loading: false,
selectedFiles: new Set(files.map((f) => f.path)), selectedFiles: autoSelect ? new Set(allPaths) : new Set(),
sortedFilePaths: allPaths,
previewResults: [], previewResults: [],
}); });
useSettingsStore.getState().setLastFolder(path);
} catch (e) { } catch (e) {
set({ error: String(e), loading: false }); set({ error: String(e), loading: false });
} }
@@ -82,4 +92,6 @@ export const useFileStore = create<FileState>((set, get) => ({
setFilters: (filters) => { setFilters: (filters) => {
set({ filters: { ...get().filters, ...filters } }); set({ filters: { ...get().filters, ...filters } });
}, },
setSortedFilePaths: (paths) => set({ sortedFilePaths: paths }),
})); }));

Some files were not shown because too many files have changed in this diff Show More