diff --git a/.gitignore b/.gitignore
index ea8c4bf..6af1d2c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,3 @@
/target
+src/assets/icons
+config-gen/node_modules
diff --git a/Cargo.lock b/Cargo.lock
index 7f37fc3..e4acad5 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -26,6 +26,12 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
[[package]]
name = "anstream"
version = "0.6.19"
@@ -82,6 +88,26 @@ version = "1.0.98"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
+[[package]]
+name = "arboard"
+version = "3.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
+dependencies = [
+ "clipboard-win",
+ "image",
+ "log",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-foundation",
+ "parking_lot",
+ "percent-encoding",
+ "windows-sys 0.60.2",
+ "x11rb",
+]
+
[[package]]
name = "assert_fs"
version = "1.1.3"
@@ -158,6 +184,15 @@ version = "2.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967"
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
[[package]]
name = "bstr"
version = "1.12.0"
@@ -174,12 +209,39 @@ version = "3.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
+[[package]]
+name = "bytemuck"
+version = "1.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4"
+
+[[package]]
+name = "byteorder-lite"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
+
[[package]]
name = "bytes"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
+[[package]]
+name = "cassowary"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
+
+[[package]]
+name = "castaway"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
+dependencies = [
+ "rustversion",
+]
+
[[package]]
name = "cc"
version = "1.2.27"
@@ -235,12 +297,62 @@ version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675"
+[[package]]
+name = "clipboard-win"
+version = "5.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
+dependencies = [
+ "error-code",
+]
+
+[[package]]
+name = "color-eyre"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d"
+dependencies = [
+ "backtrace",
+ "color-spantrace",
+ "eyre",
+ "indenter",
+ "once_cell",
+ "owo-colors",
+ "tracing-error",
+]
+
+[[package]]
+name = "color-spantrace"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427"
+dependencies = [
+ "once_cell",
+ "owo-colors",
+ "tracing-core",
+ "tracing-error",
+]
+
[[package]]
name = "colorchoice"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
+[[package]]
+name = "compact_str"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
+dependencies = [
+ "castaway",
+ "cfg-if",
+ "itoa",
+ "rustversion",
+ "ryu",
+ "static_assertions",
+]
+
[[package]]
name = "convert_case"
version = "0.7.1"
@@ -266,6 +378,24 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
@@ -291,6 +421,22 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+[[package]]
+name = "crossterm"
+version = "0.28.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
+dependencies = [
+ "bitflags",
+ "crossterm_winapi",
+ "mio",
+ "parking_lot",
+ "rustix 0.38.44",
+ "signal-hook",
+ "signal-hook-mio",
+ "winapi",
+]
+
[[package]]
name = "crossterm"
version = "0.29.0"
@@ -303,7 +449,7 @@ dependencies = [
"document-features",
"mio",
"parking_lot",
- "rustix",
+ "rustix 1.0.7",
"signal-hook",
"signal-hook-mio",
"winapi",
@@ -318,6 +464,57 @@ dependencies = [
"winapi",
]
+[[package]]
+name = "crunchy"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "darling"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
+dependencies = [
+ "darling_core",
+ "darling_macro",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
+dependencies = [
+ "fnv",
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
+dependencies = [
+ "darling_core",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "derive_more"
version = "2.0.1"
@@ -345,6 +542,47 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[package]]
+name = "dirs"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
+dependencies = [
+ "dirs-sys",
+]
+
+[[package]]
+name = "dirs-sys"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
+dependencies = [
+ "libc",
+ "option-ext",
+ "redox_users",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "dispatch2"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
+dependencies = [
+ "bitflags",
+ "objc2",
+]
+
[[package]]
name = "displaydoc"
version = "0.2.5"
@@ -377,6 +615,12 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
+[[package]]
+name = "either"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
+
[[package]]
name = "encoding_rs"
version = "0.8.35"
@@ -402,12 +646,67 @@ dependencies = [
"windows-sys 0.60.2",
]
+[[package]]
+name = "error-code"
+version = "3.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
+
+[[package]]
+name = "eyre"
+version = "0.6.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec"
+dependencies = [
+ "indenter",
+ "once_cell",
+]
+
[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
+[[package]]
+name = "fax"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab"
+dependencies = [
+ "fax_derive",
+]
+
+[[package]]
+name = "fax_derive"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "fdeflate"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
+dependencies = [
+ "simd-adler32",
+]
+
+[[package]]
+name = "flate2"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc5a4e564e38c699f2880d3fda590bedc2e69f3f84cd48b457bd892ce61d0aa9"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
[[package]]
name = "float-cmp"
version = "0.10.0"
@@ -423,6 +722,12 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
[[package]]
name = "foreign-types"
version = "0.3.2"
@@ -495,6 +800,26 @@ dependencies = [
"thread_local",
]
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "gethostname"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
+dependencies = [
+ "rustix 1.0.7",
+ "windows-link 0.2.1",
+]
+
[[package]]
name = "getrandom"
version = "0.2.16"
@@ -567,11 +892,27 @@ dependencies = [
"tracing",
]
+[[package]]
+name = "half"
+version = "2.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
+dependencies = [
+ "cfg-if",
+ "crunchy",
+ "zerocopy",
+]
+
[[package]]
name = "hashbrown"
version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash",
+]
[[package]]
name = "heck"
@@ -702,15 +1043,26 @@ name = "iconmate"
version = "1.0.1"
dependencies = [
"anyhow",
+ "arboard",
"assert_fs",
"clap",
+ "color-eyre",
+ "crossterm 0.29.0",
+ "dirs",
"inquire",
+ "json5",
+ "nucleo-matcher",
"predicates",
+ "ratatui",
"regex",
"reqwest",
+ "serde",
+ "serde_json",
+ "shlex",
"tempfile",
"tokio",
"tokio-test",
+ "tui-textarea",
]
[[package]]
@@ -799,6 +1151,12 @@ dependencies = [
"zerovec",
]
+[[package]]
+name = "ident_case"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+
[[package]]
name = "idna"
version = "1.0.3"
@@ -836,6 +1194,26 @@ dependencies = [
"winapi-util",
]
+[[package]]
+name = "image"
+version = "0.25.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7"
+dependencies = [
+ "bytemuck",
+ "byteorder-lite",
+ "moxcms",
+ "num-traits",
+ "png",
+ "tiff",
+]
+
+[[package]]
+name = "indenter"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5"
+
[[package]]
name = "indexmap"
version = "2.10.0"
@@ -846,6 +1224,12 @@ dependencies = [
"hashbrown",
]
+[[package]]
+name = "indoc"
+version = "2.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd"
+
[[package]]
name = "inquire"
version = "0.9.1"
@@ -853,11 +1237,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2628910d0114e9139056161d8644a2026be7b117f8498943f9437748b04c9e0a"
dependencies = [
"bitflags",
- "crossterm",
+ "crossterm 0.29.0",
"dyn-clone",
"fuzzy-matcher",
"unicode-segmentation",
- "unicode-width",
+ "unicode-width 0.2.0",
+]
+
+[[package]]
+name = "instability"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "435d80800b936787d62688c927b6490e887c7ef5ff9ce922c6c6050fca75eb9a"
+dependencies = [
+ "darling",
+ "indoc",
+ "proc-macro2",
+ "quote",
+ "syn",
]
[[package]]
@@ -882,6 +1279,15 @@ version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
+[[package]]
+name = "itertools"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
+dependencies = [
+ "either",
+]
+
[[package]]
name = "itoa"
version = "1.0.15"
@@ -898,12 +1304,45 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "json5"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1"
+dependencies = [
+ "pest",
+ "pest_derive",
+ "serde",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
[[package]]
name = "libc"
version = "0.2.174"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776"
+[[package]]
+name = "libredox"
+version = "0.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
+dependencies = [
+ "bitflags",
+ "libc",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.4.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
+
[[package]]
name = "linux-raw-sys"
version = "0.9.4"
@@ -938,6 +1377,15 @@ version = "0.4.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
+[[package]]
+name = "lru"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
+dependencies = [
+ "hashbrown",
+]
+
[[package]]
name = "memchr"
version = "2.7.5"
@@ -951,56 +1399,150 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
-name = "miniz_oxide"
-version = "0.8.9"
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "mio"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c"
+dependencies = [
+ "libc",
+ "log",
+ "wasi 0.11.1+wasi-snapshot-preview1",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "moxcms"
+version = "0.7.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c588e11a3082784af229e23e8e4ecf5bcc6fbe4f69101e0421ce8d79da7f0b40"
+dependencies = [
+ "num-traits",
+ "pxfm",
+]
+
+[[package]]
+name = "native-tls"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
+dependencies = [
+ "libc",
+ "log",
+ "openssl",
+ "openssl-probe",
+ "openssl-sys",
+ "schannel",
+ "security-framework",
+ "security-framework-sys",
+ "tempfile",
+]
+
+[[package]]
+name = "normalize-line-endings"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be"
+
+[[package]]
+name = "nucleo-matcher"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85"
+dependencies = [
+ "memchr",
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "objc2"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05"
+dependencies = [
+ "objc2-encode",
+]
+
+[[package]]
+name = "objc2-app-kit"
+version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
- "adler2",
+ "bitflags",
+ "objc2",
+ "objc2-core-graphics",
+ "objc2-foundation",
]
[[package]]
-name = "mio"
-version = "1.0.4"
+name = "objc2-core-foundation"
+version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c"
+checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
- "libc",
- "log",
- "wasi 0.11.1+wasi-snapshot-preview1",
- "windows-sys 0.59.0",
+ "bitflags",
+ "dispatch2",
+ "objc2",
]
[[package]]
-name = "native-tls"
-version = "0.2.14"
+name = "objc2-core-graphics"
+version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
+checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
- "libc",
- "log",
- "openssl",
- "openssl-probe",
- "openssl-sys",
- "schannel",
- "security-framework",
- "security-framework-sys",
- "tempfile",
+ "bitflags",
+ "dispatch2",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-io-surface",
]
[[package]]
-name = "normalize-line-endings"
-version = "0.3.0"
+name = "objc2-encode"
+version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be"
+checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
[[package]]
-name = "num-traits"
-version = "0.2.19"
+name = "objc2-foundation"
+version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
- "autocfg",
+ "bitflags",
+ "objc2",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "objc2-io-surface"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
+dependencies = [
+ "bitflags",
+ "objc2",
+ "objc2-core-foundation",
]
[[package]]
@@ -1068,6 +1610,18 @@ dependencies = [
"vcpkg",
]
+[[package]]
+name = "option-ext"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+
+[[package]]
+name = "owo-colors"
+version = "4.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52"
+
[[package]]
name = "parking_lot"
version = "0.12.4"
@@ -1091,12 +1645,61 @@ dependencies = [
"windows-targets 0.52.6",
]
+[[package]]
+name = "paste"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
+
[[package]]
name = "percent-encoding"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
+[[package]]
+name = "pest"
+version = "2.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662"
+dependencies = [
+ "memchr",
+ "ucd-trie",
+]
+
+[[package]]
+name = "pest_derive"
+version = "2.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77"
+dependencies = [
+ "pest",
+ "pest_generator",
+]
+
+[[package]]
+name = "pest_generator"
+version = "2.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f"
+dependencies = [
+ "pest",
+ "pest_meta",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "pest_meta"
+version = "2.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220"
+dependencies = [
+ "pest",
+ "sha2",
+]
+
[[package]]
name = "pin-project-lite"
version = "0.2.16"
@@ -1115,6 +1718,19 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
+[[package]]
+name = "png"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0"
+dependencies = [
+ "bitflags",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "miniz_oxide",
+]
+
[[package]]
name = "potential_utf"
version = "0.1.2"
@@ -1163,6 +1779,21 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "pxfm"
+version = "0.1.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3cbdf373972bf78df4d3b518d07003938e2c7d1fb5891e55f9cb6df57009d84"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "quick-error"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
+
[[package]]
name = "quote"
version = "1.0.40"
@@ -1178,6 +1809,27 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+[[package]]
+name = "ratatui"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
+dependencies = [
+ "bitflags",
+ "cassowary",
+ "compact_str",
+ "crossterm 0.28.1",
+ "indoc",
+ "instability",
+ "itertools",
+ "lru",
+ "paste",
+ "strum",
+ "unicode-segmentation",
+ "unicode-truncate",
+ "unicode-width 0.2.0",
+]
+
[[package]]
name = "redox_syscall"
version = "0.5.13"
@@ -1187,6 +1839,17 @@ dependencies = [
"bitflags",
]
+[[package]]
+name = "redox_users"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
+dependencies = [
+ "getrandom 0.2.16",
+ "libredox",
+ "thiserror",
+]
+
[[package]]
name = "regex"
version = "1.11.3"
@@ -1276,6 +1939,19 @@ version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f"
+[[package]]
+name = "rustix"
+version = "0.38.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys 0.4.15",
+ "windows-sys 0.59.0",
+]
+
[[package]]
name = "rustix"
version = "1.0.7"
@@ -1285,7 +1961,7 @@ dependencies = [
"bitflags",
"errno",
"libc",
- "linux-raw-sys",
+ "linux-raw-sys 0.9.4",
"windows-sys 0.59.0",
]
@@ -1425,6 +2101,26 @@ dependencies = [
"serde",
]
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
[[package]]
name = "shlex"
version = "1.3.0"
@@ -1461,6 +2157,12 @@ dependencies = [
"libc",
]
+[[package]]
+name = "simd-adler32"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe"
+
[[package]]
name = "slab"
version = "0.4.10"
@@ -1489,12 +2191,40 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
+[[package]]
+name = "static_assertions"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+[[package]]
+name = "strum"
+version = "0.26.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
+dependencies = [
+ "strum_macros",
+]
+
+[[package]]
+name = "strum_macros"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "rustversion",
+ "syn",
+]
+
[[package]]
name = "subtle"
version = "2.6.1"
@@ -1562,7 +2292,7 @@ dependencies = [
"fastrand",
"getrandom 0.3.3",
"once_cell",
- "rustix",
+ "rustix 1.0.7",
"windows-sys 0.59.0",
]
@@ -1572,6 +2302,26 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
+[[package]]
+name = "thiserror"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "thread_local"
version = "1.1.9"
@@ -1581,6 +2331,20 @@ dependencies = [
"cfg-if",
]
+[[package]]
+name = "tiff"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f"
+dependencies = [
+ "fax",
+ "flate2",
+ "half",
+ "quick-error",
+ "weezl",
+ "zune-jpeg",
+]
+
[[package]]
name = "tinystr"
version = "0.8.1"
@@ -1739,6 +2503,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678"
dependencies = [
"once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-error"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db"
+dependencies = [
+ "tracing",
+ "tracing-subscriber",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5"
+dependencies = [
+ "sharded-slab",
+ "thread_local",
+ "tracing-core",
]
[[package]]
@@ -1747,6 +2533,29 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+[[package]]
+name = "tui-textarea"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae"
+dependencies = [
+ "crossterm 0.28.1",
+ "ratatui",
+ "unicode-width 0.2.0",
+]
+
+[[package]]
+name = "typenum"
+version = "1.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
+
+[[package]]
+name = "ucd-trie"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
+
[[package]]
name = "unicode-ident"
version = "1.0.18"
@@ -1759,11 +2568,28 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
+[[package]]
+name = "unicode-truncate"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
+dependencies = [
+ "itertools",
+ "unicode-segmentation",
+ "unicode-width 0.1.14",
+]
+
[[package]]
name = "unicode-width"
-version = "0.2.1"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
+
+[[package]]
+name = "unicode-width"
+version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c"
+checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
[[package]]
name = "untrusted"
@@ -1794,12 +2620,24 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+[[package]]
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
[[package]]
name = "walkdir"
version = "2.5.0"
@@ -1915,6 +2753,12 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "weezl"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3"
+
[[package]]
name = "winapi"
version = "0.3.9"
@@ -1952,13 +2796,19 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
[[package]]
name = "windows-registry"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
dependencies = [
- "windows-link",
+ "windows-link 0.1.3",
"windows-result",
"windows-strings",
]
@@ -1969,7 +2819,7 @@ version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
dependencies = [
- "windows-link",
+ "windows-link 0.1.3",
]
[[package]]
@@ -1978,7 +2828,7 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
dependencies = [
- "windows-link",
+ "windows-link 0.1.3",
]
[[package]]
@@ -2151,6 +3001,23 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb"
+[[package]]
+name = "x11rb"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
+dependencies = [
+ "gethostname",
+ "rustix 1.0.7",
+ "x11rb-protocol",
+]
+
+[[package]]
+name = "x11rb-protocol"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
+
[[package]]
name = "yoke"
version = "0.8.0"
@@ -2175,6 +3042,26 @@ dependencies = [
"synstructure",
]
+[[package]]
+name = "zerocopy"
+version = "0.8.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "zerofrom"
version = "0.1.6"
@@ -2234,3 +3121,18 @@ dependencies = [
"quote",
"syn",
]
+
+[[package]]
+name = "zune-core"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
+
+[[package]]
+name = "zune-jpeg"
+version = "0.4.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
+dependencies = [
+ "zune-core",
+]
diff --git a/Cargo.toml b/Cargo.toml
index 53cc1a0..62e1cf5 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -11,8 +11,19 @@ clap = { version = "4.5", features = ["derive"] }
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1.37", features = ["full"] }
anyhow = "1.0"
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
inquire = "0.9.1"
regex = "1.11.3"
+ratatui = "0.29.0"
+crossterm = "0.29.0"
+color-eyre = "0.6.5"
+tui-textarea = "0.7.0"
+arboard = "3.6.1"
+dirs = "6.0.0"
+json5 = "0.4.1"
+shlex = "1.3.0"
+nucleo-matcher = "0.3"
[dev-dependencies]
tempfile = "3.8"
diff --git a/Justfile b/Justfile
index a0187d0..d5d620f 100644
--- a/Justfile
+++ b/Justfile
@@ -1,9 +1,14 @@
-set dotenv-load
+set dotenv-load := true
default:
just --list
+# Usage: just config-schema
+config-schema:
+ cd config-gen && bun install && bun run generate
+
# Tag and release a new version - custom script by carlo.
+
# Usage: just tag_and_release
tag:
sh tag_and_release.sh
diff --git a/README.md b/README.md
index 6c1df90..941aa59 100644
--- a/README.md
+++ b/README.md
@@ -1,19 +1,43 @@
-# iconmate ๐
-
-Your new favorite way to manage icons for your Vite, NextJS projects without icon libraries!
-
-Based on my blog post on [Why you might not need an icon library](https://carlotaleon.net/blog/why-you-dont-need-an-icon-library). Built with ๐ฆ Rust, โก๏ธ designed for speed, ๐ฆ
made for developers who hate icon library bloat.
-
-Stop installing bloated icon libraries. All you need is [icones.js.org](https://icones.js.org) or your designer's Figma icon pack and paste them into your project with surgical precision.
-
-**What Makes It Special โจ**
-
-- **Zero Dependencies** ๐ฆ: Just a CLI, No icon libraries to bundle
-- **Framework Native** ๐งฉ: Works with React, Vue, Svelte, Solid - generates components automatically
-- **Interactive Mode** ๐ฎ: Just run `iconmate` and let it guide you
-- **URL Support** ๐: Fetch from any SVG URL, not just iconify
-- **Raw SVG** ๐: Copy-paste SVG code directly
-- **Empty SVG** ๐๏ธ: Create placeholder icons for rapid prototyping
+
+
+
+
+iconmate
+
+Add SVG icons to your JS apps without icon libraries.
+
+
+
+
+
+
+ Quick Start
+ ยท
+ Install
+ ยท
+ Presets
+ ยท
+ CLI Commands
+ ยท
+ AI Ready
+ ยท
+ Configuration
+
+
+Built from my blog post on [Why you might not need an icon library](https://carlotaleon.net/blog/why-you-dont-need-an-icon-library), `iconmate` is a Rust-powered CLI for developers who want the speed and control of plain files without icon-package bloat.
+
+Use [icones.js.org](https://icones.js.org), a direct SVG URL, or raw SVG markup. `iconmate` drops the icon straight into your project and keeps your exports tidy.
+
+## Why iconmate?
+
+- **Made with Rust๐ฆ**: Fast TUI that you can use on any IDE (powered by ratatui, nucleo).
+- **AI-ready automation** ๐ค: Let your coding agents get icons and add it to your project! A CLI is basically an MCP, just let AI use `iconmate --help` and it should be able to get everything running!
+- **Zero dependencies** ๐ฆ: No icon library runtime added to your app
+- **Framework-native output** ๐งฉ: Generate files for React, Vue, Svelte, Solid, or plain SVG
+- **Interactive by default** ๐ฎ: Run `iconmate` and have a pleasant interactive TUI
+- **Multiple sources** ๐: Pull icons from Iconify names, URLs, or even raw SVG (which means it works with your private icon packs i.e. Anron)
+- **Fast workflow** โก: Generate file + export line in one step
+- **Prototype-friendly** ๐๏ธ: Create empty SVG placeholders when needed
## Quick Start
@@ -21,16 +45,11 @@ Stop installing bloated icon libraries. All you need is [icones.js.org](https://
# Install
npm install -g iconmate
-# Run inside your project ๐
+# Run the TUI in your project ๐
iconmate
-
-> ๐ Folder (src/assets/icons/) # Enter
-> โจ Preset # Choose react
-> ๐ Icon # heroicons:heart
-> ๐ Name # Heart
```
-โจ That's it. The interactive CLI guides you through adding icon to your project!
+โจ That's it. The interactive TUI guides you through adding icons to your project.
```tsx
// ๐ Then, you can just easily use any icon on your project like this!
@@ -42,49 +61,105 @@ function MyApp() {
}
```
-You can also add sensible defaults by passing flags:
+## Configuration
+
+You can also add sensible defaults by passing flags as configs:
```bash
iconmate --folder src/components/Icons/ --folder src/components/icons
iconmate --folder src/components/Icons/ --icon heroicons:heart --name Heart
```
+Iconmate now includes config schemas + TS type definitions in the repo:
+
+- Local config schema (repo): [`iconmatelocal.schema.json`](https://github.com/Blankeos/iconmate/blob/main/iconmatelocal.schema.json)
+- Global config schema (repo): [`iconmateglobal.schema.json`](https://github.com/Blankeos/iconmate/blob/main/iconmateglobal.schema.json)
+- Local config schema (raw): `https://raw.githubusercontent.com/Blankeos/iconmate/main/iconmatelocal.schema.json`
+- Global config schema (raw): `https://raw.githubusercontent.com/Blankeos/iconmate/main/iconmateglobal.schema.json`
+- Schema source: `config-gen/src/schema.ts`
+- TS type definitions: `config-gen/src/types.ts`
+
+Regenerate schemas from project root:
+
+```bash
+just config-schema
+```
+
+`just config-schema` installs `config-gen` deps and generates both schema files.
+
+### Local Config (`iconmate.config.json`)
+
+```json
+{
+ "$schema": "https://raw.githubusercontent.com/Blankeos/iconmate/main/iconmatelocal.schema.json",
+ "folder": "src/assets/icons",
+ "preset": "normal",
+ "output_line_template": "export { default as Icon%name% } from './%icon%%ext%';",
+ "svg_view_cmd": "zed %filename%"
+}
+```
+
+Use the raw URL for `$schema` so editors can fetch JSON directly.
+
+Local config keys:
+
+- `folder` (default: `src/assets/icons`)
+- `preset` (default: `normal`, meaning plain `.svg` mode)
+- `output_line_template` (default: `export { default as Icon%name% } from './%icon%%ext%';`)
+- `svg_view_cmd` (supports `%filename%` token)
+
+Allowed `preset` values:
+
+- `normal` (plain SVG mode)
+- `react`
+- `svelte`
+- `solid`
+- `vue`
+- `emptysvg`
+
+### Global Config (user-level)
+
+Global config is for user-wide defaults and currently documents `svg_view_cmd`.
+
+Suggested paths:
+
+- macOS: `~/Library/Application Support/iconmate/config.json`
+- Linux: `~/.config/iconmate/config.json`
+- Windows: `%APPDATA%\\iconmate\\config.json`
+
+Example global config:
+
+```json
+{
+ "$schema": "https://raw.githubusercontent.com/Blankeos/iconmate/main/iconmatelocal.schema.json",
+ "svg_view_cmd": "code %filename%"
+}
+```
+
+> [!NOTE]
+> This release adds config schemas and generated docs/types. Runtime loading/precedence wiring in the CLI/TUI is tracked in `folder-system-plan.md`.
+
## Installation
### NPM ๐ฆ
```bash
+# Install globally with either:
npm install -g iconmate
-# or
pnpm add -g iconmate
-# or
bun add -g iconmate
-```
-
-For one-off usage:
-
-```bash
+# Use without installing with either:
npx iconmate
-# or
pnpm dlx iconmate
-# or
bunx iconmate
```
-> [!NOTE]
-> **Note for Bun users:** Bun doesn't run `postinstall` scripts [by default](https://bun.com/guides/install/trusted) which is needed to install the iconmate binary. Add `"trustedDependencies": ["iconmate"]` to your `package.json` to do it! But you're only limited to running it with a package.json.
->
-> Recommended: Just use pnpx for quick one-off usage. If in a project, either install globally or configure trustDependencies.
-
### Install from Cargo ๐ฆ
```bash
cargo install iconmate
-```
-Or clone and install from source:
-
-```bash
+# Or clone and install from source:
git clone https://github.com/blankeos/iconmate.git
cd iconmate
cargo install --path .
@@ -104,33 +179,24 @@ Determines the output filetype and the contents inside that file type.
| `emptysvg` | `.svg` | Placeholder |
> [!IMPORTANT]
-> If you want to use `.svg` file types, make sure to setup [svgr](https://github.com/gregberge/svgr). I covered how to do this in:
+> If you want to use `.svg` file types, make sure to setup [svgr](https://github.com/gregberge/svgr) for your js apps. I covered how to do this in:
>
> - [SolidJS (Vite)](https://carlotaleon.net/blog/why-you-dont-need-an-icon-library#use-svg-only-with-solidjs)
> - [React (Vite)](https://carlotaleon.net/blog/why-you-dont-need-an-icon-library#use-svg-only-with-react-vite)
> - [React (NextJS)](https://carlotaleon.net/blog/why-you-dont-need-an-icon-library#use-svg-only-with-react-nextjs)
> - Vue - contribution welcome!
-> - Svelte - couldn't find an svgr integration :(
+> - Svelte - couldn't find an svgr integration. Just use the svg preset.
## Command Line
-### Interactive Mode (Recommended)
+### Interactive TUI Mode (Recommended)
```bash
iconmate
-
-# Description of each prompt:
-> ๐ Folder (src/assets/icons/) # where your icons will be saved.
-
-> โจ Preset # i.e. How will it be saved? An `.svg` or `.tsx` file in react, solid, etc.
-
-> ๐ Icon # Source of your icon. i.e. 'heroicons:heart' from https://icones.js.org, full URL, or any SVG. Can be empty except for 'emptysvg' preset.
-
-> ๐ Filename # The filename without the extension. i.e. heroicons:heart. Will only be prompted if you used an SVG, or an URL on icon.
-
-> ๐ Name # The "Heart" in
```
+This section is helpful for AI:
+
### Add Specific Icon
```bash
@@ -149,6 +215,12 @@ iconmate add --folder src/assets/icons --icon https://api.iconify.design/mdi:hea
iconmate add --folder src/assets/icons --icon '... ' --name Heart
```
+You can also pull raw SVG directly from the Iconify API:
+
+```bash
+iconmate add --folder src/assets/icons --icon "$(curl -fsSL https://api.iconify.design/mdi:heart.svg)" --name Heart
+```
+
### Custom Export Template
```bash
@@ -161,6 +233,51 @@ iconmate add --folder src/assets/icons --icon heroicons:heart --name Heart --out
iconmate delete --folder src/assets/icons
```
+### Iconify API Commands
+
+```bash
+# Search by keyword (text: one prefix:icon per line)
+iconmate iconify search heart
+
+# Search with pagination and JSON output
+iconmate iconify search heart --limit 20 --start 0 --format json
+
+# Include collection metadata in JSON search output
+iconmate iconify search heart --format json --include-collections
+
+# List all available collections
+iconmate iconify collections
+
+# List icons from one collection prefix
+iconmate iconify collection mdi
+
+# Get one icon as raw SVG (default)
+iconmate iconify get mdi:heart
+
+# Get one icon as raw Iconify JSON
+iconmate iconify get mdi:heart --format json
+```
+
+`iconmate iconify get --format json` uses Iconify's JSON endpoint format,
+for example `https://api.iconify.design/mdi.json?icons=heart`.
+
+### AI-Ready Workflows
+
+`iconmate` is designed to be easy for AI agents and scripts to drive end-to-end.
+
+```bash
+# 1) Search in machine-readable JSON
+iconmate iconify search heart --format json --limit 20 --include-collections
+
+# 2) Add an icon non-interactively from prefix:name
+iconmate add --folder src/assets/icons --icon mdi:heart --name Heart
+
+# 3) Or fetch raw SVG from Iconify API and add directly
+iconmate add --folder src/assets/icons --icon "$(curl -fsSL https://api.iconify.design/mdi:heart.svg)" --name Heart
+```
+
+This means an AI can search, choose, and add icons without opening a browser.
+
### Package.json Scripts
Best practice: Add sensible defaults to your script runner.
@@ -169,12 +286,6 @@ Best practice: Add sensible defaults to your script runner.
"scripts": {
// Usage: npm run iconmate (usually this is the only command you need)!
"iconmate": "iconmate --folder src/assets/icons/"
-
- // Usage: npm run iconmate-react
- "iconmate-react": "iconmate --folder ./src/assets/icons/ --preset react",
-
- // Usage: npm run iconmate-empty
- "iconmate-empty": "iconmate --folder ./src/assets/icons/ --preset emptysvg",
}
```
@@ -186,9 +297,9 @@ Best practice: Add sensible defaults to your script runner.
## How It Works
-1. **Find your icon**: Visit https://icones.js.org.
-2. **Copy the name**: Like `heroicons:heart`.
-3. **Run iconmate**: `iconmate`
+1. **Find your icon**: Use https://icones.js.org _or_ `iconmate iconify search `.
+2. **Pick the icon id**: For example `heroicons:heart`.
+3. **Add with iconmate**: Interactive (`iconmate`) or direct (`iconmate add ...`).

@@ -215,16 +326,23 @@ Contributions are welcomeโpull requests for bug fixes, new framework presets,
- โ
Custom export templates
- โ
Zero-config installation
-### Original Future Plans
+### Roadmap & Out-of-scoped
- [x] An empty command. Creates an .svg, adds it to the index.ts with a name you can specify.
- [x] Paste an actual svg instead of an icon `name`.
-- [x] Just a `--preset=svg,react,solid,svelte,vue` - which basically overrides templates. Default is `svg`.
+- [x] Presets (`normal`, `react`, `solid`, `svelte`, `vue`, `emptysvg`) override output templates and file types.
- [x] Prompt Mode via `iconmate` - Interactive mode so you won't need to pass arguments.
- [x] Delete an icon using `iconmate delete`
+- [x] An interactive TUI instead of prompt-mode.
+ - [x] Rename in the TUI (but recommended for you to just use the LSP to do it)
+ - [x] A lot of TUI functionalities wokr
+- [x] `iconmate iconify --help` commands for AI to easily look for icons itself.
+- [x] Search and add Iconify icons directly inside the TUI (no need to open https://icones.js.org).
- [ ] Other frameworks i.e. --preset=flutter or Go/Rust GUI apps? (Not sure how they work yet though).
- [ ] ~Zed or VSCode Extension~ (seems unnecessary now, it's just a CLI)
+### Near-Term Roadmap
+
---
Made with Rust ๐ฆ | Based on [my blog post][my_blog]
diff --git a/TODOS.md b/TODOS.md
new file mode 100644
index 0000000..175352c
--- /dev/null
+++ b/TODOS.md
@@ -0,0 +1,68 @@
+# Iconmate TODO Roadmap
+
+## Done in this pass
+
+- [x] Enter submits add popup (when focused on Name).
+- [x] Paste shortcut support across add popup fields (Cmd/Ctrl+V behavior).
+- [x] Default name + filename inference from Iconify names and known URLs.
+- [x] Parse Iconify URL patterns and icones.js.org URL patterns.
+- [x] Add popup visual cleanup (reduced borders/lines, removed debug block).
+- [x] Delete popup visual cleanup (simpler `y` / `n` flow, less instruction noise).
+
+## Existing completed
+
+- [x] Help popup: status instead of defaults.
+- [x] Homepage: Up/Down during search while keeping focus.
+- [x] `iconmate.jsonc` generation.
+
+## Next high-priority slice
+
+- [x] Rename flow UX
+ - [x] Add a lightweight rename action that only renames the file path export target.
+ - [x] Keep icon component alias unchanged and show a warning for alias rename in-editor.
+ - [x] Add explicit messaging to recommend IDE rename refactor for symbol-level rename.
+ - [x] Acceptance: rename updates file + `index.ts` export path safely.
+
+- [x] Global system prefs (`~/.config/iconmate.jsonc` or `~/iconmate.jsonc`)
+ - [x] Confirm final search paths and precedence for macOS/Linux/Windows.
+ - [x] Implement robust loader with unknown-key warnings (non-fatal).
+ - [x] Add schema validation and friendly error output with file path context.
+ - [x] Acceptance: startup logs/source reflect applied global values.
+
+- [x] `o` key to open selected icon
+ - [x] Add keybind in main list and shared opener support for Iconify search list integration.
+ - [x] Resolve viewer command from local -> global -> OS default.
+ - [x] Add `%filename%` token substitution and command escaping behavior.
+ - [x] Acceptance: selected icon opens in configured app/editor.
+
+- [x] `svg_viewer_cmd` defaults and fallback behavior
+ - [x] macOS: Quick Look/open behavior.
+ - [x] Linux/Windows: browser/system default fallback.
+ - [x] Fallback to web preview when local command fails.
+ - [x] Acceptance: command works cross-platform with graceful fallback.
+
+## Add-flow product direction
+
+- [x] ~During add: allow icon discovery using Iconify API inside TUI~
+ - Prototype inline query input + paginated result list.
+ - Validate whether this beats copy/paste from icones.js.org.
+ - Decide final UX based on keystroke count and speed.
+
+- [x] Dedicated icon search interface (TUI)
+ - [x] Launch from Home (`i`) instead of starting inside Add popup.
+ - [x] Build collection picker + icon view with `Tab` switching and Up/Down navigation.
+ - [x] Use `/collections` for collections and local client-side collection filtering.
+ - [x] Run debounced icon `/search` only while the Icons tab is active.
+ - [x] `Enter` on collection clears search input and opens collection-scoped icon list.
+ - [x] `Enter` on icon opens Add popup with icon/name/filename prefilled (no auto-submit).
+ - [x] `Ctrl+o` in Icons downloads to temp and opens preview with loading/status feedback.
+
+- [x] Search as standalone CLI for AI/tooling
+ - Add machine-friendly command (JSON first).
+ - Keep deterministic outputs and stable field names.
+ - Acceptance: scriptable icon search with no interactive prompts.
+
+## Notes
+
+- Keep TUI minimal-first: less chrome, more keyboard speed.
+- Prefer defaults inferred from icon source whenever possible.
diff --git a/_docs/iconmate_banner.jpg b/_docs/iconmate_banner.jpg
new file mode 100644
index 0000000..7d14bda
Binary files /dev/null and b/_docs/iconmate_banner.jpg differ
diff --git a/_docs/iconmate_logo.png b/_docs/iconmate_logo.png
new file mode 100644
index 0000000..733434a
Binary files /dev/null and b/_docs/iconmate_logo.png differ
diff --git a/config-gen/bun.lock b/config-gen/bun.lock
new file mode 100644
index 0000000..30f53ae
--- /dev/null
+++ b/config-gen/bun.lock
@@ -0,0 +1,15 @@
+{
+ "lockfileVersion": 1,
+ "configVersion": 1,
+ "workspaces": {
+ "": {
+ "name": "@iconmate/config-gen",
+ "dependencies": {
+ "zod": "^4.1.11",
+ },
+ },
+ },
+ "packages": {
+ "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
+ }
+}
diff --git a/config-gen/package.json b/config-gen/package.json
new file mode 100644
index 0000000..5c6efdb
--- /dev/null
+++ b/config-gen/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "@iconmate/config-gen",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "generate": "bun src/generate.ts"
+ },
+ "dependencies": {
+ "zod": "^4.1.11"
+ }
+}
diff --git a/config-gen/src/generate.ts b/config-gen/src/generate.ts
new file mode 100644
index 0000000..3aa5d89
--- /dev/null
+++ b/config-gen/src/generate.ts
@@ -0,0 +1,44 @@
+import { writeFileSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import * as z from "zod";
+
+import { GlobalConfigSchema, LocalConfigSchema } from "./schema.ts";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const root = resolve(here, "../..");
+const localOutputPath = resolve(root, "iconmatelocal.schema.json");
+const globalOutputPath = resolve(root, "iconmateglobal.schema.json");
+const schemaDraft = "https://json-schema.org/draft/2020-12/schema";
+
+function writeJson(filePath: string, json: object) {
+ writeFileSync(filePath, `${JSON.stringify(json, null, 2)}\n`, "utf8");
+}
+
+const localSchema = z.toJSONSchema(LocalConfigSchema, {
+ io: "input",
+ target: "draft-2020-12"
+});
+
+const globalSchema = z.toJSONSchema(GlobalConfigSchema, {
+ io: "input",
+ target: "draft-2020-12"
+});
+
+const localStandaloneSchema = {
+ $schema: schemaDraft,
+ $id: "https://raw.githubusercontent.com/Blankeos/iconmate/main/iconmatelocal.schema.json",
+ ...localSchema
+};
+
+const globalStandaloneSchema = {
+ $schema: schemaDraft,
+ $id: "https://raw.githubusercontent.com/Blankeos/iconmate/main/iconmateglobal.schema.json",
+ ...globalSchema
+};
+
+writeJson(localOutputPath, localStandaloneSchema);
+writeJson(globalOutputPath, globalStandaloneSchema);
+
+console.log(`Generated ${localOutputPath}`);
+console.log(`Generated ${globalOutputPath}`);
diff --git a/config-gen/src/schema.ts b/config-gen/src/schema.ts
new file mode 100644
index 0000000..3e8c6fc
--- /dev/null
+++ b/config-gen/src/schema.ts
@@ -0,0 +1,61 @@
+import * as z from "zod";
+
+export const DEFAULT_FOLDER = "src/assets/icons";
+export const DEFAULT_PRESET = "normal";
+export const DEFAULT_OUTPUT_LINE_TEMPLATE =
+ "export { default as Icon%name% } from './%icon%%ext%';";
+
+export const PRESET_VALUES = ["normal", "react", "svelte", "solid", "vue", "emptysvg"] as const;
+
+export const PresetSchema = z.enum(PRESET_VALUES).meta({
+ title: "Preset",
+ description:
+ "Icon output preset. 'normal' means plain SVG mode. Other values are framework or emptysvg presets.",
+ default: DEFAULT_PRESET,
+ examples: ["normal", "react", "solid", "emptysvg"]
+});
+
+export const SvgViewCommandSchema = z.string().min(1).meta({
+ title: "SVG View Command",
+ description:
+ "Command used to open an SVG. Supports `%filename%` placeholder, for example: `zed %filename%`.",
+ examples: ["zed %filename%", "code %filename%", "open %filename%"]
+});
+
+export const OutputLineTemplateSchema = z.string().min(1).meta({
+ title: "Output Line Template",
+ description:
+ "Template for each export line. Supported placeholders: `%name%`, `%icon%`, `%ext%`.",
+ default: DEFAULT_OUTPUT_LINE_TEMPLATE,
+ examples: [DEFAULT_OUTPUT_LINE_TEMPLATE]
+});
+
+export const LocalConfigSchema = z
+ .object({
+ folder: z.string().min(1).optional().meta({
+ description: "Folder where icons are written.",
+ default: DEFAULT_FOLDER,
+ examples: [DEFAULT_FOLDER, "src/components/icons"]
+ }),
+ preset: PresetSchema.optional(),
+ output_line_template: OutputLineTemplateSchema.optional(),
+ svg_view_cmd: SvgViewCommandSchema.optional()
+ })
+ .meta({
+ id: "IconmateLocalConfig",
+ title: "Iconmate Local Config",
+ description: "Schema for project-level iconmate config (`iconmate.config.json`)."
+ });
+
+export const GlobalConfigSchema = z
+ .object({
+ svg_view_cmd: SvgViewCommandSchema.optional()
+ })
+ .meta({
+ id: "IconmateGlobalConfig",
+ title: "Iconmate Global Config",
+ description: "Schema for user-level iconmate config at the OS-specific config directory."
+ });
+
+export type LocalConfig = z.input;
+export type GlobalConfig = z.input;
diff --git a/config-gen/src/types.ts b/config-gen/src/types.ts
new file mode 100644
index 0000000..9e0b941
--- /dev/null
+++ b/config-gen/src/types.ts
@@ -0,0 +1,41 @@
+export type IconmatePreset = "normal" | "react" | "svelte" | "solid" | "vue" | "emptysvg";
+
+/**
+ * Project-level Iconmate config loaded from `iconmate.config.json`.
+ */
+export interface IconmateLocalConfig {
+ /**
+ * Folder where icons are written.
+ * Default: `src/assets/icons`.
+ */
+ folder?: string;
+
+ /**
+ * Output preset. `normal` means plain `.svg` mode.
+ * Default: `"normal"`.
+ */
+ preset?: IconmatePreset;
+
+ /**
+ * Template used for each export line in `index.ts`.
+ * Supported variables: `%name%`, `%icon%`, `%ext%`.
+ */
+ output_line_template?: string;
+
+ /**
+ * Command used to open SVG files from the TUI.
+ * Use `%filename%` as the SVG file path placeholder.
+ */
+ svg_view_cmd?: string;
+}
+
+/**
+ * User-level Iconmate config loaded from the OS-specific config path.
+ */
+export interface IconmateGlobalConfig {
+ /**
+ * Command used to open SVG files from the TUI.
+ * Use `%filename%` as the SVG file path placeholder.
+ */
+ svg_view_cmd?: string;
+}
diff --git a/config-gen/tsconfig.json b/config-gen/tsconfig.json
new file mode 100644
index 0000000..bf4fec0
--- /dev/null
+++ b/config-gen/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "strict": true,
+ "skipLibCheck": true,
+ "noEmit": true
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/folder-system-plan.md b/folder-system-plan.md
new file mode 100644
index 0000000..7031e19
--- /dev/null
+++ b/folder-system-plan.md
@@ -0,0 +1,129 @@
+# Folder + Config System Plan
+
+Goal: add a clean configuration system for both project-level defaults and user-level defaults, plus a generated JSON schema and typed TS definitions for editor tooling.
+
+## Scope
+
+- Add a local config file for per-project behavior.
+- Add a global config file for user-wide behavior.
+- Generate root JSON schema files from a `config-gen` package using Zod v4.
+- Wire `svg_view_cmd` into TUI open behavior (`o`) for both local icons and Iconify search results.
+
+## Config Surfaces
+
+### Local Config (project-level)
+
+- Filename: `iconmate.config.json` (in project root).
+- Intended usage: checked into repo, shared with the team.
+- Keys:
+ - `folder` (local only)
+ - `preset` (local only)
+ - `output_line_template` (local only)
+ - `svg_view_cmd` (local + global)
+
+### Global Config (user-level)
+
+- Intended usage: personal machine defaults.
+- Initial key support:
+ - `svg_view_cmd`
+- Suggested paths (using OS conventions):
+ - macOS: `~/Library/Application Support/iconmate/config.json`
+ - Linux: `~/.config/iconmate/config.json`
+ - Windows: `%APPDATA%\\iconmate\\config.json`
+
+## Config Keys and Defaults
+
+- `folder`
+ - Scope: local only
+ - Type: `string`
+ - Default: `src/assets/icons`
+- `preset`
+ - Scope: local only
+ - Type: `string`
+ - Default: empty (`""`) which means plain SVG mode
+- `svg_view_cmd`
+ - Scope: local + global
+ - Type: `string`
+ - Default behavior:
+ - macOS: open with Quick Look
+ - Linux: open with system browser
+ - Windows: open with default browser
+ - Custom value examples:
+ - `zed %filename%`
+ - `code %filename%`
+ - Token support:
+ - `%filename%` = full path to the SVG file to open
+- `output_line_template`
+ - Scope: local only
+ - Type: `string`
+ - Default: `export { default as Icon%name% } from './%icon%%ext%';`
+ - Variables:
+ - `%name%`
+ - `%icon%`
+ - `%ext%`
+
+## Precedence Rules
+
+- For `folder`, `preset`, and `output_line_template`: CLI flag > local config > built-in default.
+- For `svg_view_cmd`: CLI flag (if added later) > local config > global config > OS default.
+- Unknown keys should be ignored with a warning (non-fatal).
+
+## TUI Open Behavior (`o`)
+
+- Local icon list:
+ - pressing `o` opens the existing file via resolved `svg_view_cmd`.
+- Iconify search results:
+ - pressing `o` downloads the SVG to a cache file, then opens it via resolved `svg_view_cmd`.
+
+## Cache Plan for Iconify Preview
+
+- Use OS data directory with an `iconmate` namespace.
+- Suggested cache root:
+ - macOS: `~/Library/Application Support/iconmate/cache/iconify`
+ - Linux: `~/.local/share/iconmate/cache/iconify`
+ - Windows: `%LOCALAPPDATA%\\iconmate\\cache\\iconify`
+- Cache filename strategy: `__.svg` (sanitized for filesystem safety).
+
+## `config-gen` Package Plan
+
+- Create folder: `config-gen/`.
+- Add `config-gen/package.json`.
+- Use Zod v4 as source of truth for config schemas and descriptions.
+- Define schemas in TypeScript and generate:
+ - local/global config TS types
+ - `iconmatelocal.schema.json` at repo root
+ - `iconmateglobal.schema.json` at repo root
+
+Suggested layout:
+
+- `config-gen/src/schema.ts` (zod definitions + docs metadata)
+- `config-gen/src/generate.ts` (writes root schema files)
+- `iconmatelocal.schema.json` (generated, committed)
+- `iconmateglobal.schema.json` (generated, committed)
+
+## Documentation Requirements
+
+- README should include:
+ - local config filename and key meanings
+ - global config purpose and location conventions
+ - `svg_view_cmd` token usage (`%filename%`)
+ - defaults for macOS/Linux/Windows behavior
+ - note that `o` opens local and Iconify preview SVGs
+
+## Milestones
+
+1. Add `config-gen` scaffolding with Zod v4.
+2. Define local/global schemas and TS types.
+3. Generate and commit root schema files.
+4. Load local/global config at app startup and apply precedence.
+5. Implement `o` open flow for local and Iconify search entries.
+6. Add docs and tests.
+
+## Acceptance Criteria
+
+- Local config supports `folder`, `preset`, `output_line_template`, and `svg_view_cmd`.
+- Global config supports `svg_view_cmd`.
+- Root schema files are generated from Zod schemas.
+- `o` works for local icons and Iconify search results.
+- Custom viewer command supports `%filename%` substitution.
+- README clearly documents behavior and defaults.
diff --git a/iconify-cli-plan.md b/iconify-cli-plan.md
new file mode 100644
index 0000000..fc8795c
--- /dev/null
+++ b/iconify-cli-plan.md
@@ -0,0 +1,152 @@
+# Iconify CLI + API Plan
+
+Goal: ship first-class Iconify commands in `iconmate` with a reusable API client and AI-friendly output defaults.
+
+## Scope
+
+- Keep command group: `iconmate iconify ...`.
+- Keep shared API module: `src/iconify.rs`.
+- Keep existing Iconify SVG fetches routed through shared client.
+- Keep API models and errors presentation-agnostic (no CLI formatting in API layer).
+- Update endpoint contracts to match real Iconify responses.
+
+## Reality Check from API Probes
+
+- Base URL: `https://api.iconify.design`
+- `GET /collections` returns a top-level object keyed by prefix (not wrapped in `collections`).
+- `GET /collections?prefix=mdi` works as an exact-prefix filter.
+- `GET /collections?prefix=m` returns empty (no fuzzy prefix matching).
+- `GET /collection?prefix=mdi` does not return an `icons` field; it returns `uncategorized`, `categories`, `aliases`, and `hidden`.
+- `GET /{prefix}:{icon}.svg` returns SVG payload.
+- `GET /{prefix}.json?icons={icon}` returns icon JSON payload.
+
+## CLI Surface
+
+### Commands
+
+- `iconmate iconify search `
+- `iconmate iconify collections`
+- `iconmate iconify collection `
+- `iconmate iconify get `
+
+### Flags
+
+- `search`
+ - `--limit `
+ - `--start `
+ - `--format ` (default: `text`)
+ - `--include-collections` (JSON mode only)
+- `collections`
+ - `--format ` (default: `text`)
+ - `--prefix ` (optional exact match only)
+- `collection`
+ - `--format ` (default: `text`)
+ - `--raw` (optional: return unmodified API shape in JSON mode)
+- `get`
+ - `--format ` (default: `svg`)
+
+## Architecture
+
+- Keep `IconifyClient` with configurable base URL.
+- Keep typed methods:
+ - `collections(prefix: Option<&str>)`
+ - `collection(prefix)`
+ - `search(query, limit, start, include_collections)`
+ - `svg(prefix_icon)`
+ - `icon_json(prefix, icon)`
+- Add collection normalization helper in API layer:
+ - flatten `uncategorized` + category icon lists into one deduped icon list
+ - optionally expose raw categories/aliases/hidden in raw mode
+
+## Data Model Draft
+
+- `IconifySearchResponse`
+ - `icons: Vec`
+ - `total: u32`
+ - `limit: u32`
+ - `start: u32`
+ - `collections: Option>`
+- `IconifyCollectionsResponse`
+ - `HashMap` (top-level map)
+- `IconifyCollectionResponseRaw`
+ - `prefix: String`
+ - `total: Option`
+ - `title: Option`
+ - `uncategorized: Option>`
+ - `categories: Option>>`
+ - `aliases: Option>`
+ - `hidden: Option>`
+- `IconifyCollectionResponseNormalized`
+ - `prefix: String`
+ - `icons: Vec` (deduped)
+ - `total: Option`
+
+## Output Contract (AI-Friendly)
+
+### `search`
+
+- Text: one `prefix:icon` per line.
+- JSON: `{ icons, total, limit, start }`.
+- Include `collections` only when `--include-collections` is set.
+
+### `collections`
+
+- Text: `prefixnametotal`.
+- JSON: `[ { prefix, name, total } ]`.
+- Optional `--prefix` is exact only.
+
+### `collection`
+
+- Text: one `prefix:icon` per line from normalized deduped icon list.
+- JSON default: `{ prefix, icons, total }` (normalized).
+- JSON with `--raw`: full API object for advanced agents.
+
+### `get`
+
+- Default: raw SVG to stdout.
+- JSON mode: call `GET /{prefix}.json?icons={icon}` and return API payload.
+
+## Integration with Existing Icon Fetch Path
+
+- Keep `IconSourceType::IconifyName` using `IconifyClient::svg()`.
+- Keep one shared Iconify transport path for CLI and TUI.
+
+## Error Handling
+
+- Keep structured API errors (network, decode, http status, invalid icon name).
+- Include HTTP status and endpoint in errors.
+- CLI maps API errors to concise `anyhow` messages and non-zero exit codes.
+
+## Testing Strategy
+
+- Unit tests in `src/iconify.rs` for:
+ - deserializing real `/collections` top-level map payload
+ - deserializing real `/collection` payload with categories and aliases
+ - normalization and dedupe behavior for collection icon lists
+ - optional field handling and 404/HTTP error mapping
+- CLI tests for text/JSON output for all iconify subcommands.
+- Integration test confirming `IconSourceType::IconifyName` uses shared client path.
+
+## Docs
+
+- Update `README.md` with:
+ - command usage and examples
+ - exact behavior of `collections --prefix`
+ - normalized vs raw output for `collection`
+ - raw SVG piping examples for `get`
+
+## Milestones
+
+1. Align `src/iconify.rs` models with actual endpoint shapes.
+2. Add collection normalization and optional raw mode.
+3. Wire CLI formatting and keep existing shared fetch integration.
+4. Add tests and refresh docs.
+
+## Acceptance Criteria
+
+- All four iconify commands work for happy path and common errors.
+- `collections` no longer returns empty due model mismatch.
+- `collection` returns meaningful icon lists via normalization.
+- `get` defaults to raw SVG and supports JSON mode.
+- API layer is reusable and CLI-independent.
+- Tests and docs cover real endpoint behavior.
diff --git a/iconmate.jsonc b/iconmate.jsonc
new file mode 100644
index 0000000..79e341a
--- /dev/null
+++ b/iconmate.jsonc
@@ -0,0 +1,4 @@
+{
+ "$schema": "./iconmatelocal.schema.json",
+ "svg_view_cmd": "zed %filename%",
+}
diff --git a/iconmateglobal.schema.json b/iconmateglobal.schema.json
new file mode 100644
index 0000000..cd6b8ce
--- /dev/null
+++ b/iconmateglobal.schema.json
@@ -0,0 +1,21 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://raw.githubusercontent.com/Blankeos/iconmate/main/iconmateglobal.schema.json",
+ "type": "object",
+ "properties": {
+ "svg_view_cmd": {
+ "type": "string",
+ "minLength": 1,
+ "title": "SVG View Command",
+ "description": "Command used to open an SVG. Supports `%filename%` placeholder, for example: `zed %filename%`.",
+ "examples": [
+ "zed %filename%",
+ "code %filename%",
+ "open %filename%"
+ ]
+ }
+ },
+ "id": "IconmateGlobalConfig",
+ "title": "Iconmate Global Config",
+ "description": "Schema for user-level iconmate config at the OS-specific config directory."
+}
diff --git a/iconmatelocal.schema.json b/iconmatelocal.schema.json
new file mode 100644
index 0000000..261786c
--- /dev/null
+++ b/iconmatelocal.schema.json
@@ -0,0 +1,61 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://raw.githubusercontent.com/Blankeos/iconmate/main/iconmatelocal.schema.json",
+ "type": "object",
+ "properties": {
+ "folder": {
+ "description": "Folder where icons are written.",
+ "default": "src/assets/icons",
+ "examples": [
+ "src/assets/icons",
+ "src/components/icons"
+ ],
+ "type": "string",
+ "minLength": 1
+ },
+ "preset": {
+ "type": "string",
+ "enum": [
+ "normal",
+ "react",
+ "svelte",
+ "solid",
+ "vue",
+ "emptysvg"
+ ],
+ "title": "Preset",
+ "description": "Icon output preset. 'normal' means plain SVG mode. Other values are framework or emptysvg presets.",
+ "default": "normal",
+ "examples": [
+ "normal",
+ "react",
+ "solid",
+ "emptysvg"
+ ]
+ },
+ "output_line_template": {
+ "type": "string",
+ "minLength": 1,
+ "title": "Output Line Template",
+ "description": "Template for each export line. Supported placeholders: `%name%`, `%icon%`, `%ext%`.",
+ "default": "export { default as Icon%name% } from './%icon%%ext%';",
+ "examples": [
+ "export { default as Icon%name% } from './%icon%%ext%';"
+ ]
+ },
+ "svg_view_cmd": {
+ "type": "string",
+ "minLength": 1,
+ "title": "SVG View Command",
+ "description": "Command used to open an SVG. Supports `%filename%` placeholder, for example: `zed %filename%`.",
+ "examples": [
+ "zed %filename%",
+ "code %filename%",
+ "open %filename%"
+ ]
+ }
+ },
+ "id": "IconmateLocalConfig",
+ "title": "Iconmate Local Config",
+ "description": "Schema for project-level iconmate config (`iconmate.config.json`)."
+}
diff --git a/npm/README.md b/npm/README.md
index 6c1df90..24d875b 100644
--- a/npm/README.md
+++ b/npm/README.md
@@ -1,19 +1,43 @@
-# iconmate ๐
-
-Your new favorite way to manage icons for your Vite, NextJS projects without icon libraries!
-
-Based on my blog post on [Why you might not need an icon library](https://carlotaleon.net/blog/why-you-dont-need-an-icon-library). Built with ๐ฆ Rust, โก๏ธ designed for speed, ๐ฆ
made for developers who hate icon library bloat.
-
-Stop installing bloated icon libraries. All you need is [icones.js.org](https://icones.js.org) or your designer's Figma icon pack and paste them into your project with surgical precision.
-
-**What Makes It Special โจ**
-
-- **Zero Dependencies** ๐ฆ: Just a CLI, No icon libraries to bundle
-- **Framework Native** ๐งฉ: Works with React, Vue, Svelte, Solid - generates components automatically
-- **Interactive Mode** ๐ฎ: Just run `iconmate` and let it guide you
-- **URL Support** ๐: Fetch from any SVG URL, not just iconify
-- **Raw SVG** ๐: Copy-paste SVG code directly
-- **Empty SVG** ๐๏ธ: Create placeholder icons for rapid prototyping
+
+
+
+
+iconmate
+
+Add SVG icons to your JS apps without icon libraries.
+
+
+
+
+
+
+ Quick Start
+ ยท
+ Install
+ ยท
+ Presets
+ ยท
+ CLI Commands
+ ยท
+ AI Ready
+ ยท
+ Configuration
+
+
+Built from my blog post on [Why you might not need an icon library](https://carlotaleon.net/blog/why-you-dont-need-an-icon-library), `iconmate` is a Rust-powered CLI for developers who want the speed and control of plain files without icon-package bloat.
+
+Use [icones.js.org](https://icones.js.org), a direct SVG URL, or raw SVG markup. `iconmate` drops the icon straight into your project and keeps your exports tidy.
+
+## Why iconmate?
+
+- **Made with Rust๐ฆ**: Fast TUI that you can use on any IDE (powered by ratatui, nucleo).
+- **AI-ready automation** ๐ค: Let your coding agents get icons and add it to your project! A CLI is basically an MCP, just let AI use `iconmate --help` and it should be able to get everything running!
+- **Zero dependencies** ๐ฆ: No icon library runtime added to your app
+- **Framework-native output** ๐งฉ: Generate files for React, Vue, Svelte, Solid, or plain SVG
+- **Interactive by default** ๐ฎ: Run `iconmate` and have a pleasant interactive TUI
+- **Multiple sources** ๐: Pull icons from Iconify names, URLs, or even raw SVG (which means it works with your private icon packs i.e. Anron)
+- **Fast workflow** โก: Generate file + export line in one step
+- **Prototype-friendly** ๐๏ธ: Create empty SVG placeholders when needed
## Quick Start
@@ -21,16 +45,11 @@ Stop installing bloated icon libraries. All you need is [icones.js.org](https://
# Install
npm install -g iconmate
-# Run inside your project ๐
+# Run the TUI in your project ๐
iconmate
-
-> ๐ Folder (src/assets/icons/) # Enter
-> โจ Preset # Choose react
-> ๐ Icon # heroicons:heart
-> ๐ Name # Heart
```
-โจ That's it. The interactive CLI guides you through adding icon to your project!
+โจ That's it. The interactive TUI guides you through adding icons to your project.
```tsx
// ๐ Then, you can just easily use any icon on your project like this!
@@ -42,49 +61,105 @@ function MyApp() {
}
```
-You can also add sensible defaults by passing flags:
+## Configuration
+
+You can also add sensible defaults by passing flags as configs:
```bash
iconmate --folder src/components/Icons/ --folder src/components/icons
iconmate --folder src/components/Icons/ --icon heroicons:heart --name Heart
```
+Iconmate now includes config schemas + TS type definitions in the repo:
+
+- Local config schema (repo): [`iconmatelocal.schema.json`](https://github.com/Blankeos/iconmate/blob/main/iconmatelocal.schema.json)
+- Global config schema (repo): [`iconmateglobal.schema.json`](https://github.com/Blankeos/iconmate/blob/main/iconmateglobal.schema.json)
+- Local config schema (raw): `https://raw.githubusercontent.com/Blankeos/iconmate/main/iconmatelocal.schema.json`
+- Global config schema (raw): `https://raw.githubusercontent.com/Blankeos/iconmate/main/iconmateglobal.schema.json`
+- Schema source: `config-gen/src/schema.ts`
+- TS type definitions: `config-gen/src/types.ts`
+
+Regenerate schemas from project root:
+
+```bash
+just config-schema
+```
+
+`just config-schema` installs `config-gen` deps and generates both schema files.
+
+### Local Config (`iconmate.config.json`)
+
+```json
+{
+ "$schema": "https://raw.githubusercontent.com/Blankeos/iconmate/main/iconmatelocal.schema.json",
+ "folder": "src/assets/icons",
+ "preset": "normal",
+ "output_line_template": "export { default as Icon%name% } from './%icon%%ext%';",
+ "svg_view_cmd": "zed %filename%"
+}
+```
+
+Use the raw URL for `$schema` so editors can fetch JSON directly.
+
+Local config keys:
+
+- `folder` (default: `src/assets/icons`)
+- `preset` (default: `normal`, meaning plain `.svg` mode)
+- `output_line_template` (default: `export { default as Icon%name% } from './%icon%%ext%';`)
+- `svg_view_cmd` (supports `%filename%` token)
+
+Allowed `preset` values:
+
+- `normal` (plain SVG mode)
+- `react`
+- `svelte`
+- `solid`
+- `vue`
+- `emptysvg`
+
+### Global Config (user-level)
+
+Global config is for user-wide defaults and currently documents `svg_view_cmd`.
+
+Suggested paths:
+
+- macOS: `~/Library/Application Support/iconmate/config.json`
+- Linux: `~/.config/iconmate/config.json`
+- Windows: `%APPDATA%\\iconmate\\config.json`
+
+Example global config:
+
+```json
+{
+ "$schema": "https://raw.githubusercontent.com/Blankeos/iconmate/main/iconmatelocal.schema.json",
+ "svg_view_cmd": "code %filename%"
+}
+```
+
+> [!NOTE]
+> This release adds config schemas and generated docs/types. Runtime loading/precedence wiring in the CLI/TUI is tracked in `folder-system-plan.md`.
+
## Installation
### NPM ๐ฆ
```bash
+# Install globally with either:
npm install -g iconmate
-# or
pnpm add -g iconmate
-# or
bun add -g iconmate
-```
-
-For one-off usage:
-
-```bash
+# Use without installing with either:
npx iconmate
-# or
pnpm dlx iconmate
-# or
bunx iconmate
```
-> [!NOTE]
-> **Note for Bun users:** Bun doesn't run `postinstall` scripts [by default](https://bun.com/guides/install/trusted) which is needed to install the iconmate binary. Add `"trustedDependencies": ["iconmate"]` to your `package.json` to do it! But you're only limited to running it with a package.json.
->
-> Recommended: Just use pnpx for quick one-off usage. If in a project, either install globally or configure trustDependencies.
-
### Install from Cargo ๐ฆ
```bash
cargo install iconmate
-```
-Or clone and install from source:
-
-```bash
+# Or clone and install from source:
git clone https://github.com/blankeos/iconmate.git
cd iconmate
cargo install --path .
@@ -104,33 +179,24 @@ Determines the output filetype and the contents inside that file type.
| `emptysvg` | `.svg` | Placeholder |
> [!IMPORTANT]
-> If you want to use `.svg` file types, make sure to setup [svgr](https://github.com/gregberge/svgr). I covered how to do this in:
+> If you want to use `.svg` file types, make sure to setup [svgr](https://github.com/gregberge/svgr) for your js apps. I covered how to do this in:
>
> - [SolidJS (Vite)](https://carlotaleon.net/blog/why-you-dont-need-an-icon-library#use-svg-only-with-solidjs)
> - [React (Vite)](https://carlotaleon.net/blog/why-you-dont-need-an-icon-library#use-svg-only-with-react-vite)
> - [React (NextJS)](https://carlotaleon.net/blog/why-you-dont-need-an-icon-library#use-svg-only-with-react-nextjs)
> - Vue - contribution welcome!
-> - Svelte - couldn't find an svgr integration :(
+> - Svelte - couldn't find an svgr integration. Just use the svg preset.
## Command Line
-### Interactive Mode (Recommended)
+### Interactive TUI Mode (Recommended)
```bash
iconmate
-
-# Description of each prompt:
-> ๐ Folder (src/assets/icons/) # where your icons will be saved.
-
-> โจ Preset # i.e. How will it be saved? An `.svg` or `.tsx` file in react, solid, etc.
-
-> ๐ Icon # Source of your icon. i.e. 'heroicons:heart' from https://icones.js.org, full URL, or any SVG. Can be empty except for 'emptysvg' preset.
-
-> ๐ Filename # The filename without the extension. i.e. heroicons:heart. Will only be prompted if you used an SVG, or an URL on icon.
-
-> ๐ Name # The "Heart" in
```
+This section is helpful for AI:
+
### Add Specific Icon
```bash
@@ -149,6 +215,12 @@ iconmate add --folder src/assets/icons --icon https://api.iconify.design/mdi:hea
iconmate add --folder src/assets/icons --icon '... ' --name Heart
```
+You can also pull raw SVG directly from the Iconify API:
+
+```bash
+iconmate add --folder src/assets/icons --icon "$(curl -fsSL https://api.iconify.design/mdi:heart.svg)" --name Heart
+```
+
### Custom Export Template
```bash
@@ -161,6 +233,51 @@ iconmate add --folder src/assets/icons --icon heroicons:heart --name Heart --out
iconmate delete --folder src/assets/icons
```
+### Iconify API Commands
+
+```bash
+# Search by keyword (text: one prefix:icon per line)
+iconmate iconify search heart
+
+# Search with pagination and JSON output
+iconmate iconify search heart --limit 20 --start 0 --format json
+
+# Include collection metadata in JSON search output
+iconmate iconify search heart --format json --include-collections
+
+# List all available collections
+iconmate iconify collections
+
+# List icons from one collection prefix
+iconmate iconify collection mdi
+
+# Get one icon as raw SVG (default)
+iconmate iconify get mdi:heart
+
+# Get one icon as raw Iconify JSON
+iconmate iconify get mdi:heart --format json
+```
+
+`iconmate iconify get --format json` uses Iconify's JSON endpoint format,
+for example `https://api.iconify.design/mdi.json?icons=heart`.
+
+### AI-Ready Workflows
+
+`iconmate` is designed to be easy for AI agents and scripts to drive end-to-end.
+
+```bash
+# 1) Search in machine-readable JSON
+iconmate iconify search heart --format json --limit 20 --include-collections
+
+# 2) Add an icon non-interactively from prefix:name
+iconmate add --folder src/assets/icons --icon mdi:heart --name Heart
+
+# 3) Or fetch raw SVG from Iconify API and add directly
+iconmate add --folder src/assets/icons --icon "$(curl -fsSL https://api.iconify.design/mdi:heart.svg)" --name Heart
+```
+
+This means an AI can search, choose, and add icons without opening a browser.
+
### Package.json Scripts
Best practice: Add sensible defaults to your script runner.
@@ -169,12 +286,6 @@ Best practice: Add sensible defaults to your script runner.
"scripts": {
// Usage: npm run iconmate (usually this is the only command you need)!
"iconmate": "iconmate --folder src/assets/icons/"
-
- // Usage: npm run iconmate-react
- "iconmate-react": "iconmate --folder ./src/assets/icons/ --preset react",
-
- // Usage: npm run iconmate-empty
- "iconmate-empty": "iconmate --folder ./src/assets/icons/ --preset emptysvg",
}
```
@@ -186,9 +297,9 @@ Best practice: Add sensible defaults to your script runner.
## How It Works
-1. **Find your icon**: Visit https://icones.js.org.
-2. **Copy the name**: Like `heroicons:heart`.
-3. **Run iconmate**: `iconmate`
+1. **Find your icon**: Use https://icones.js.org _or_ `iconmate iconify search `.
+2. **Pick the icon id**: For example `heroicons:heart`.
+3. **Add with iconmate**: Interactive (`iconmate`) or direct (`iconmate add ...`).

@@ -215,16 +326,23 @@ Contributions are welcomeโpull requests for bug fixes, new framework presets,
- โ
Custom export templates
- โ
Zero-config installation
-### Original Future Plans
+### Roadmap & Out-of-scoped
- [x] An empty command. Creates an .svg, adds it to the index.ts with a name you can specify.
- [x] Paste an actual svg instead of an icon `name`.
-- [x] Just a `--preset=svg,react,solid,svelte,vue` - which basically overrides templates. Default is `svg`.
+- [x] Presets (`normal`, `react`, `solid`, `svelte`, `vue`, `emptysvg`) override output templates and file types.
- [x] Prompt Mode via `iconmate` - Interactive mode so you won't need to pass arguments.
- [x] Delete an icon using `iconmate delete`
+- [x] An interactive TUI instead of prompt-mode.
+ - [x] Rename in the TUI (but recommended for you to just use the LSP to do it)
+ - [x] A lot of TUI functionalities wokr
+- [x] `iconmate iconify --help` commands for AI to easily look for icons itself.
+- [x] Search and add Iconify icons directly inside the TUI (no need to open https://icones.js.org).
- [ ] Other frameworks i.e. --preset=flutter or Go/Rust GUI apps? (Not sure how they work yet though).
- [ ] ~Zed or VSCode Extension~ (seems unnecessary now, it's just a CLI)
+### Near-Term Roadmap
+
---
Made with Rust ๐ฆ | Based on [my blog post][my_blog]
diff --git a/npm/bin.js b/npm/bin.js
index 01c2893..f38d59d 100755
--- a/npm/bin.js
+++ b/npm/bin.js
@@ -3,23 +3,43 @@
const { spawn } = require("child_process");
const path = require("path");
const fs = require("fs");
+const { install } = require("./install");
const binaryName = process.platform === "win32" ? "iconmate.exe" : "iconmate";
const binaryPath = path.join(__dirname, "bin", binaryName);
-if (!fs.existsSync(binaryPath)) {
- console.error("โ iconmate binary not found. Please reinstall:");
- console.error("npm uninstall -g iconmate && npm install -g iconmate");
- process.exit(1);
+async function ensureBinary() {
+ if (fs.existsSync(binaryPath)) {
+ return;
+ }
+
+ console.error("iconmate binary not found. Attempting download...");
+
+ try {
+ await install();
+ } catch (error) {
+ process.exit(1);
+ }
+
+ if (!fs.existsSync(binaryPath)) {
+ console.error("โ iconmate binary still missing after download.");
+ process.exit(1);
+ }
}
-const child = spawn(binaryPath, process.argv.slice(2), { stdio: "inherit" });
+async function run() {
+ await ensureBinary();
-child.on("error", (err) => {
- console.error("โ Failed to start iconmate:", err.message);
- process.exit(1);
-});
+ const child = spawn(binaryPath, process.argv.slice(2), { stdio: "inherit" });
+
+ child.on("error", (err) => {
+ console.error("โ Failed to start iconmate:", err.message);
+ process.exit(1);
+ });
+
+ child.on("exit", (code, signal) => {
+ process.exit(signal ? 1 : code || 0);
+ });
+}
-child.on("exit", (code, signal) => {
- process.exit(signal ? 1 : code || 0);
-});
+run();
diff --git a/npm/install.js b/npm/install.js
index d3a9a47..207cf3b 100644
--- a/npm/install.js
+++ b/npm/install.js
@@ -4,8 +4,6 @@ const { execSync } = require("child_process");
const fs = require("fs");
const path = require("path");
const https = require("https");
-const { pipeline } = require("stream");
-const { promisify } = require("util");
// Version should match your Rust crate version
const VERSION = require("./package.json").version;
@@ -91,13 +89,22 @@ function extractArchive(archivePath, extractDir, platformInfo) {
const cmd =
platformInfo.extension === ".zip"
- ? `unzip -o "${archivePath}" -d "${extractDir}" 2>/dev/null || powershell -command \"Expand-Archive -Path '${archivePath}' -DestinationPath '${extractDir}' -Force\"`
+ ? `unzip -o "${archivePath}" -d "${extractDir}" 2>/dev/null || powershell -command "Expand-Archive -Path '${archivePath}' -DestinationPath '${extractDir}' -Force"`
: `tar -xf "${archivePath}" -C "${extractDir}"`;
execSync(cmd, { stdio: "inherit" });
}
-async function install() {
+function logInstallFailure(error) {
+ const message = error instanceof Error ? error.message : String(error);
+ console.error("โ Installation failed:", message);
+ console.error("\nYou can install iconmate directly using:");
+ console.error(
+ 'curl --proto "=https" --tlsv1.2 -LsSf https://github.com/Blankeos/iconmate/releases/latest/download/iconmate-installer.sh | sh',
+ );
+}
+
+async function install({ exitOnComplete = false } = {}) {
try {
const platformInfo = getPlatformInfo();
const binDir = path.join(__dirname, "bin");
@@ -132,20 +139,28 @@ async function install() {
fs.unlinkSync(archivePath);
console.log(`โ
iconmate v${VERSION} installed successfully!`);
- process.exit(0);
+
+ if (exitOnComplete) {
+ process.exit(0);
+ return binaryPath;
+ }
+
+ return binaryPath;
} catch (error) {
- console.error("โ Installation failed:", error.message);
- console.error("\\nYou can install iconmate directly using:");
- console.error(
- 'curl --proto \"=https\" --tlsv1.2 -LsSf https://github.com/Blankeos/iconmate/releases/latest/download/iconmate-installer.sh | sh',
- );
- process.exit(1);
+ logInstallFailure(error);
+
+ if (exitOnComplete) {
+ process.exit(1);
+ return;
+ }
+
+ throw error;
}
}
// Only run install if this script is executed directly
if (require.main === module) {
- install();
+ install({ exitOnComplete: true });
}
-module.exports = { getPlatformInfo };
+module.exports = { getPlatformInfo, install };
diff --git a/src/app_state.rs b/src/app_state.rs
new file mode 100644
index 0000000..f01b18b
--- /dev/null
+++ b/src/app_state.rs
@@ -0,0 +1,135 @@
+use crate::{utils::IconEntry, views::main::MainState};
+use std::sync::mpsc::{Receiver, Sender};
+use tui_textarea::Input;
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum AppFocus {
+ Main,
+ AddPopup,
+ DeletePopup,
+ RenamePopup,
+ HelpPopup,
+ IconifySearchPopup,
+}
+
+#[derive(Debug, Clone)]
+pub struct IconifyCollectionListItem {
+ pub prefix: String,
+ pub name: String,
+ pub total: Option,
+}
+
+#[derive(Debug, Clone)]
+pub struct IconifySearchPayload {
+ pub icons: Vec,
+}
+
+#[derive(Debug)]
+pub enum AppEvent {
+ IconifyCollectionsLoaded {
+ request_id: u64,
+ result: Result, String>,
+ },
+ IconifySearchLoaded {
+ request_id: u64,
+ query: String,
+ result: Result,
+ },
+ IconifyCollectionIconsLoaded {
+ request_id: u64,
+ prefix: String,
+ result: Result, String>,
+ },
+}
+
+#[derive(Debug, Clone)]
+pub struct AppConfig {
+ pub folder: String,
+ pub preset: String,
+ pub template: Option,
+ pub svg_viewer_cmd: Option,
+ pub svg_viewer_cmd_source: String,
+ pub global_config_loaded: bool,
+ pub project_config_loaded: bool,
+}
+
+pub struct App {
+ pub config: AppConfig,
+
+ // App state
+ pub tx: Sender,
+ pub rx: Receiver,
+
+ pub should_quit: bool,
+
+ pub selected_index: usize,
+
+ pub items: Vec,
+
+ pub filtered_items: Vec,
+
+ pub app_focus: AppFocus,
+
+ // Deeper states
+ pub main_state: crate::views::main::MainState,
+ pub add_popup_state: Option,
+ pub delete_popup_state: Option,
+ pub rename_popup_state: Option,
+ pub iconify_search_popup_state:
+ Option,
+
+ pub next_async_request_id: u64,
+}
+
+impl App {
+ pub fn new(config: AppConfig) -> Self {
+ let (tx, rx) = std::sync::mpsc::channel();
+ let mut app = Self {
+ config,
+
+ should_quit: false,
+ tx,
+ rx,
+
+ selected_index: 0,
+ filtered_items: Vec::new(),
+ items: Vec::new(),
+
+ app_focus: AppFocus::Main,
+ add_popup_state: None,
+ delete_popup_state: None,
+ rename_popup_state: None,
+ iconify_search_popup_state: None,
+ next_async_request_id: 0,
+ main_state: MainState::new(),
+ };
+
+ app.init_icons();
+ app
+ }
+
+ pub fn init_icons(&mut self) {
+ // Try to read the current project's export file
+ self.items = crate::utils::get_existing_icons(&self.config.folder).unwrap_or_default();
+ self.filtered_items = self.items.clone();
+ }
+
+ pub fn update(&mut self) {
+ while let Ok(event) = self.rx.try_recv() {
+ self.handle_app_event(event);
+ }
+
+ self.tick_iconify_search_popup();
+ }
+
+ pub fn handlekeys(&mut self, key: Input) {
+ match self.app_focus {
+ AppFocus::Main => self.handlekeys_main(key),
+ AppFocus::AddPopup => self.handlekeys_add_popup(key),
+ AppFocus::DeletePopup => self.handlekeys_delete_popup(key),
+ AppFocus::RenamePopup => self.handlekeys_rename_popup(key),
+ AppFocus::HelpPopup => self.handlekeys_help_popup(key),
+ AppFocus::IconifySearchPopup => self.handlekeys_iconify_search_popup(key),
+ }
+ }
+}
diff --git a/src/config.rs b/src/config.rs
new file mode 100644
index 0000000..0e2f390
--- /dev/null
+++ b/src/config.rs
@@ -0,0 +1,454 @@
+use anyhow::Context;
+use serde_json::{Map, Value};
+use std::path::{Path, PathBuf};
+
+use crate::utils::{PRESETS_OPTIONS, Preset};
+
+pub const DEFAULT_FOLDER: &str = "src/assets/icons";
+pub const DEFAULT_OUTPUT_LINE_TEMPLATE: &str =
+ "export { default as Icon%name% } from './%icon%%ext%';";
+
+#[derive(Debug, Clone, Default)]
+struct LocalConfigFile {
+ folder: Option,
+ preset: Option,
+ output_line_template: Option,
+ svg_viewer_cmd: Option,
+}
+
+#[derive(Debug, Clone, Default)]
+struct GlobalConfigFile {
+ svg_viewer_cmd: Option,
+}
+
+#[derive(Debug, Clone)]
+struct LoadedConfigFile {
+ path: PathBuf,
+ value: T,
+}
+
+#[derive(Debug, Clone)]
+pub struct ResolvedTuiConfig {
+ pub folder: String,
+ pub preset: String,
+ pub output_line_template: String,
+ pub svg_viewer_cmd: Option,
+ pub svg_viewer_cmd_source: String,
+ pub global_config_loaded: bool,
+ pub project_config_loaded: bool,
+ pub warnings: Vec,
+ pub info: Vec,
+}
+
+pub fn resolve_tui_config(
+ cli_folder: Option<&PathBuf>,
+ cli_preset: Option<&Preset>,
+ cli_output_line_template: Option<&String>,
+) -> anyhow::Result {
+ let mut warnings = Vec::new();
+ let mut info = Vec::new();
+
+ let local = load_local_config(&mut warnings)?;
+ let global = load_global_config(&mut warnings)?;
+
+ if let Some(config) = &local {
+ info.push(format!(
+ "Loaded local config from {}",
+ config.path.display()
+ ));
+ }
+ if let Some(config) = &global {
+ info.push(format!(
+ "Loaded global config from {}",
+ config.path.display()
+ ));
+ }
+
+ let folder = cli_folder
+ .map(|path| path.display().to_string())
+ .or_else(|| {
+ local
+ .as_ref()
+ .and_then(|config| config.value.folder.clone())
+ })
+ .unwrap_or_else(|| DEFAULT_FOLDER.to_string());
+
+ let preset = cli_preset
+ .map(|preset| preset.to_str().to_string())
+ .or_else(|| {
+ local
+ .as_ref()
+ .and_then(|config| config.value.preset.clone())
+ })
+ .unwrap_or_else(|| "normal".to_string());
+
+ let output_line_template = cli_output_line_template
+ .cloned()
+ .or_else(|| {
+ local
+ .as_ref()
+ .and_then(|config| config.value.output_line_template.clone())
+ })
+ .unwrap_or_else(|| DEFAULT_OUTPUT_LINE_TEMPLATE.to_string());
+
+ let (svg_viewer_cmd, svg_viewer_cmd_source) = if let Some(config) = &local {
+ if let Some(command) = config.value.svg_viewer_cmd.clone() {
+ (
+ Some(command),
+ format!("local config ({})", config.path.display()),
+ )
+ } else if let Some(global_config) = &global {
+ if let Some(command) = global_config.value.svg_viewer_cmd.clone() {
+ (
+ Some(command),
+ format!("global config ({})", global_config.path.display()),
+ )
+ } else {
+ (None, "OS default".to_string())
+ }
+ } else {
+ (None, "OS default".to_string())
+ }
+ } else if let Some(global_config) = &global {
+ if let Some(command) = global_config.value.svg_viewer_cmd.clone() {
+ (
+ Some(command),
+ format!("global config ({})", global_config.path.display()),
+ )
+ } else {
+ (None, "OS default".to_string())
+ }
+ } else {
+ (None, "OS default".to_string())
+ };
+
+ info.push(format!(
+ "Resolved svg_viewer_cmd source: {}",
+ svg_viewer_cmd_source
+ ));
+
+ Ok(ResolvedTuiConfig {
+ folder,
+ preset,
+ output_line_template,
+ svg_viewer_cmd,
+ svg_viewer_cmd_source,
+ global_config_loaded: global.is_some(),
+ project_config_loaded: local.is_some(),
+ warnings,
+ info,
+ })
+}
+
+fn load_local_config(
+ warnings: &mut Vec,
+) -> anyhow::Result>> {
+ let current_dir =
+ std::env::current_dir().context("Failed to resolve current working directory")?;
+ let candidates = [
+ current_dir.join("iconmate.config.jsonc"),
+ current_dir.join("iconmate.config.json"),
+ current_dir.join("iconmate.jsonc"),
+ current_dir.join("iconmate.json"),
+ ];
+
+ let Some(path) = candidates.into_iter().find(|candidate| candidate.exists()) else {
+ return Ok(None);
+ };
+
+ let value = parse_jsonc_file(&path)?;
+ let parsed = parse_local_value(value, &path, warnings)?;
+ Ok(Some(LoadedConfigFile {
+ path,
+ value: parsed,
+ }))
+}
+
+fn load_global_config(
+ warnings: &mut Vec,
+) -> anyhow::Result>> {
+ let mut candidates = Vec::::new();
+
+ if let Some(config_dir) = dirs::config_dir() {
+ candidates.push(config_dir.join("iconmate.jsonc"));
+ candidates.push(config_dir.join("iconmate.json"));
+ candidates.push(config_dir.join("iconmate").join("config.jsonc"));
+ candidates.push(config_dir.join("iconmate").join("config.json"));
+ }
+
+ if let Some(home_dir) = dirs::home_dir() {
+ candidates.push(home_dir.join("iconmate.jsonc"));
+ candidates.push(home_dir.join("iconmate.json"));
+ }
+
+ candidates.dedup();
+ let Some(path) = candidates.into_iter().find(|candidate| candidate.exists()) else {
+ return Ok(None);
+ };
+
+ let value = parse_jsonc_file(&path)?;
+ let parsed = parse_global_value(value, &path, warnings)?;
+ Ok(Some(LoadedConfigFile {
+ path,
+ value: parsed,
+ }))
+}
+
+fn parse_jsonc_file(path: &Path) -> anyhow::Result {
+ let raw = std::fs::read_to_string(path)
+ .with_context(|| format!("Failed to read config file {}", path.display()))?;
+ json5::from_str::(&raw).with_context(|| {
+ format!(
+ "Invalid config format in {}. Expected JSON/JSONC-compatible object.",
+ path.display()
+ )
+ })
+}
+
+fn parse_local_value(
+ value: Value,
+ path: &Path,
+ warnings: &mut Vec,
+) -> anyhow::Result {
+ let object = as_object(value, path)?;
+ warn_unknown_keys(
+ &object,
+ &[
+ "$schema",
+ "folder",
+ "preset",
+ "output_line_template",
+ "svg_view_cmd",
+ "svg_viewer_cmd",
+ ],
+ path,
+ warnings,
+ );
+
+ let folder = read_string_field(&object, path, "folder", false)?;
+
+ let mut preset = read_string_field(&object, path, "preset", true)?;
+ if matches!(preset.as_deref(), Some("")) {
+ warnings.push(format!(
+ "Config key 'preset' in {} uses deprecated empty value; use 'normal' instead.",
+ path.display()
+ ));
+ preset = Some("normal".to_string());
+ }
+
+ if let Some(value) = preset.as_deref() {
+ let valid_presets = PRESETS_OPTIONS
+ .iter()
+ .map(|option| option.preset.to_str())
+ .collect::>();
+
+ if !valid_presets.contains(&value) {
+ anyhow::bail!(
+ "Invalid config at {}: key 'preset' must be one of [{}], got '{}'.",
+ path.display(),
+ valid_presets.join(", "),
+ value
+ );
+ }
+ }
+
+ let output_line_template = read_string_field(&object, path, "output_line_template", false)?;
+ let svg_viewer_cmd = read_svg_viewer_cmd(&object, path, warnings)?;
+
+ Ok(LocalConfigFile {
+ folder,
+ preset,
+ output_line_template,
+ svg_viewer_cmd,
+ })
+}
+
+fn parse_global_value(
+ value: Value,
+ path: &Path,
+ warnings: &mut Vec,
+) -> anyhow::Result {
+ let object = as_object(value, path)?;
+ warn_unknown_keys(
+ &object,
+ &["$schema", "svg_view_cmd", "svg_viewer_cmd"],
+ path,
+ warnings,
+ );
+
+ let svg_viewer_cmd = read_svg_viewer_cmd(&object, path, warnings)?;
+ Ok(GlobalConfigFile { svg_viewer_cmd })
+}
+
+fn as_object(value: Value, path: &Path) -> anyhow::Result> {
+ value.as_object().cloned().ok_or_else(|| {
+ anyhow::anyhow!(
+ "Invalid config at {}: expected top-level object.",
+ path.display()
+ )
+ })
+}
+
+fn warn_unknown_keys(
+ object: &Map,
+ allowed_keys: &[&str],
+ path: &Path,
+ warnings: &mut Vec,
+) {
+ for key in object.keys() {
+ if !allowed_keys.iter().any(|allowed| *allowed == key) {
+ warnings.push(format!(
+ "Ignoring unknown key '{}' in {}",
+ key,
+ path.display()
+ ));
+ }
+ }
+}
+
+fn read_string_field(
+ object: &Map,
+ path: &Path,
+ key: &str,
+ allow_empty: bool,
+) -> anyhow::Result> {
+ let Some(value) = object.get(key) else {
+ return Ok(None);
+ };
+
+ let Some(value) = value.as_str() else {
+ anyhow::bail!(
+ "Invalid config at {}: key '{}' must be a string.",
+ path.display(),
+ key
+ );
+ };
+
+ if !allow_empty && value.trim().is_empty() {
+ anyhow::bail!(
+ "Invalid config at {}: key '{}' cannot be empty.",
+ path.display(),
+ key
+ );
+ }
+
+ Ok(Some(value.to_string()))
+}
+
+fn read_svg_viewer_cmd(
+ object: &Map,
+ path: &Path,
+ warnings: &mut Vec,
+) -> anyhow::Result> {
+ let legacy = read_string_field(object, path, "svg_view_cmd", false)?;
+ let modern = read_string_field(object, path, "svg_viewer_cmd", false)?;
+
+ match (legacy, modern) {
+ (Some(old_value), Some(new_value)) => {
+ if old_value != new_value {
+ warnings.push(format!(
+ "Both 'svg_view_cmd' and 'svg_viewer_cmd' are set in {}; using 'svg_viewer_cmd'.",
+ path.display()
+ ));
+ }
+ Ok(Some(new_value))
+ }
+ (Some(value), None) => Ok(Some(value)),
+ (None, Some(value)) => Ok(Some(value)),
+ (None, None) => Ok(None),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_global_svg_viewer_cmd_alias() {
+ let value: Value = serde_json::json!({
+ "svg_viewer_cmd": "code %filename%"
+ });
+ let mut warnings = Vec::new();
+ let parsed =
+ parse_global_value(value, Path::new("/tmp/iconmate.jsonc"), &mut warnings).unwrap();
+ assert_eq!(parsed.svg_viewer_cmd, Some("code %filename%".to_string()));
+ assert!(warnings.is_empty());
+ }
+
+ #[test]
+ fn warns_on_unknown_global_key() {
+ let value: Value = serde_json::json!({
+ "svg_view_cmd": "open %filename%",
+ "extra": true
+ });
+ let mut warnings = Vec::new();
+ let _ = parse_global_value(value, Path::new("/tmp/iconmate.jsonc"), &mut warnings).unwrap();
+ assert_eq!(warnings.len(), 1);
+ assert!(warnings[0].contains("Ignoring unknown key 'extra'"));
+ }
+
+ #[test]
+ fn allows_schema_key_without_warning() {
+ let value: Value = serde_json::json!({
+ "$schema": "https://example.com/iconmate.schema.json",
+ "svg_view_cmd": "open %filename%"
+ });
+ let mut warnings = Vec::new();
+ let parsed =
+ parse_global_value(value, Path::new("/tmp/iconmate.jsonc"), &mut warnings).unwrap();
+
+ assert_eq!(parsed.svg_viewer_cmd, Some("open %filename%".to_string()));
+ assert!(warnings.is_empty());
+ }
+
+ #[test]
+ fn allows_schema_key_in_local_config_without_warning() {
+ let value: Value = serde_json::json!({
+ "$schema": "https://example.com/iconmate-local.schema.json",
+ "preset": "normal"
+ });
+ let mut warnings = Vec::new();
+ let parsed = parse_local_value(
+ value,
+ Path::new("/tmp/iconmate.config.jsonc"),
+ &mut warnings,
+ )
+ .expect("local config with $schema should parse");
+
+ assert_eq!(parsed.preset.as_deref(), Some("normal"));
+ assert!(warnings.is_empty());
+ }
+
+ #[test]
+ fn validates_local_preset_values() {
+ let value: Value = serde_json::json!({
+ "preset": "invalid"
+ });
+ let mut warnings = Vec::new();
+ let error = parse_local_value(
+ value,
+ Path::new("/tmp/iconmate.config.jsonc"),
+ &mut warnings,
+ )
+ .expect_err("invalid preset should fail validation");
+ assert!(error.to_string().contains("key 'preset' must be one of"));
+ }
+
+ #[test]
+ fn normalizes_empty_local_preset_to_normal_with_warning() {
+ let value: Value = serde_json::json!({
+ "preset": ""
+ });
+ let mut warnings = Vec::new();
+ let parsed = parse_local_value(
+ value,
+ Path::new("/tmp/iconmate.config.jsonc"),
+ &mut warnings,
+ )
+ .expect("empty preset should be normalized");
+
+ assert_eq!(parsed.preset.as_deref(), Some("normal"));
+ assert_eq!(warnings.len(), 1);
+ assert!(warnings[0].contains("deprecated empty value"));
+ }
+}
diff --git a/src/iconify.rs b/src/iconify.rs
new file mode 100644
index 0000000..c7218ea
--- /dev/null
+++ b/src/iconify.rs
@@ -0,0 +1,487 @@
+use reqwest::{Client, StatusCode, Url};
+use serde::{Deserialize, Serialize, de::DeserializeOwned};
+use std::collections::HashMap;
+
+const DEFAULT_ICONIFY_BASE_URL: &str = "https://api.iconify.design";
+pub const ICONIFY_BASE_URL_ENV: &str = "ICONMATE_ICONIFY_BASE_URL";
+
+#[derive(Debug, Clone)]
+pub struct IconifyClient {
+ client: Client,
+ base_url: Url,
+}
+
+impl IconifyClient {
+ pub fn new() -> Result {
+ Self::from_base_url(DEFAULT_ICONIFY_BASE_URL)
+ }
+
+ pub fn from_env() -> Result {
+ let base = std::env::var(ICONIFY_BASE_URL_ENV)
+ .unwrap_or_else(|_| DEFAULT_ICONIFY_BASE_URL.to_string());
+ Self::from_base_url(&base)
+ }
+
+ pub fn from_base_url(base_url: &str) -> Result {
+ let normalized = if base_url.ends_with('/') {
+ base_url.to_string()
+ } else {
+ format!("{base_url}/")
+ };
+
+ let base_url = Url::parse(&normalized).map_err(|source| IconifyError::InvalidBaseUrl {
+ base_url: base_url.to_string(),
+ source: source.to_string(),
+ })?;
+
+ Ok(Self {
+ client: Client::new(),
+ base_url,
+ })
+ }
+
+ pub async fn collections(&self) -> Result {
+ let collections: HashMap =
+ self.get_json("collections", &[]).await?;
+ Ok(IconifyCollectionsResponse { collections })
+ }
+
+ pub async fn collection(
+ &self,
+ prefix: &str,
+ ) -> Result {
+ let response: IconifyCollectionApiResponse = self
+ .get_json("collection", &[("prefix".to_string(), prefix.to_string())])
+ .await?;
+
+ let icons = merge_collection_icons(
+ response.icons,
+ response.uncategorized.as_ref(),
+ response.categories.as_ref(),
+ );
+
+ Ok(IconifyCollectionResponse {
+ prefix: response.prefix,
+ icons,
+ uncategorized: response.uncategorized,
+ })
+ }
+
+ pub async fn search(
+ &self,
+ query: &str,
+ limit: Option,
+ start: Option,
+ include_collections: bool,
+ ) -> Result {
+ let mut params = vec![("query".to_string(), query.to_string())];
+
+ if let Some(limit) = limit {
+ params.push(("limit".to_string(), limit.to_string()));
+ }
+
+ if let Some(start) = start {
+ params.push(("start".to_string(), start.to_string()));
+ }
+
+ let mut response: IconifySearchResponse = self.get_json("search", ¶ms).await?;
+
+ if !include_collections {
+ response.collections = None;
+ }
+
+ Ok(response)
+ }
+
+ pub async fn svg(&self, prefix_icon: &str) -> Result {
+ let path = format!("{prefix_icon}.svg");
+ self.get_text(&path, &[]).await
+ }
+
+ pub async fn icon_json(
+ &self,
+ prefix: &str,
+ icon: &str,
+ ) -> Result {
+ let path = format!("{prefix}.json");
+ self.get_json(&path, &[("icons".to_string(), icon.to_string())])
+ .await
+ }
+
+ pub async fn icon_json_by_name(
+ &self,
+ prefix_icon: &str,
+ ) -> Result {
+ let (prefix, icon) = prefix_icon
+ .split_once(':')
+ .ok_or_else(|| IconifyError::InvalidIconName(prefix_icon.to_string()))?;
+ self.icon_json(prefix, icon).await
+ }
+
+ async fn get_json(
+ &self,
+ path: &str,
+ query: &[(String, String)],
+ ) -> Result {
+ let url = self.build_url(path, query)?;
+ let endpoint = url.to_string();
+
+ let response = self
+ .client
+ .get(url)
+ .send()
+ .await
+ .map_err(IconifyError::Network)?;
+ let status = response.status();
+ let body = response.text().await.map_err(IconifyError::Network)?;
+
+ if !status.is_success() {
+ return Err(IconifyError::HttpStatus {
+ status,
+ endpoint,
+ body,
+ });
+ }
+
+ serde_json::from_str(&body).map_err(|source| IconifyError::JsonDecode { endpoint, source })
+ }
+
+ async fn get_text(
+ &self,
+ path: &str,
+ query: &[(String, String)],
+ ) -> Result {
+ let url = self.build_url(path, query)?;
+ let endpoint = url.to_string();
+
+ let response = self
+ .client
+ .get(url)
+ .send()
+ .await
+ .map_err(IconifyError::Network)?;
+ let status = response.status();
+ let body = response.text().await.map_err(IconifyError::Network)?;
+
+ if !status.is_success() {
+ return Err(IconifyError::HttpStatus {
+ status,
+ endpoint,
+ body,
+ });
+ }
+
+ Ok(body)
+ }
+
+ fn build_url(&self, path: &str, query: &[(String, String)]) -> Result {
+ let relative_path = if path.starts_with('/') {
+ format!("./{}", path.trim_start_matches('/'))
+ } else {
+ format!("./{path}")
+ };
+
+ let mut url =
+ self.base_url
+ .join(&relative_path)
+ .map_err(|source| IconifyError::InvalidEndpoint {
+ path: path.to_string(),
+ source: source.to_string(),
+ })?;
+
+ if !query.is_empty() {
+ let mut pairs = url.query_pairs_mut();
+ for (key, value) in query {
+ pairs.append_pair(key, value);
+ }
+ }
+
+ Ok(url)
+ }
+}
+
+fn merge_collection_icons(
+ icons: Vec,
+ uncategorized: Option<&Vec>,
+ categories: Option<&HashMap>>,
+) -> Vec {
+ let mut merged = icons;
+
+ if let Some(uncategorized) = uncategorized {
+ merged.extend(uncategorized.iter().cloned());
+ }
+
+ if let Some(categories) = categories {
+ for category_icons in categories.values() {
+ merged.extend(category_icons.iter().cloned());
+ }
+ }
+
+ let mut deduped = Vec::new();
+ let mut seen = std::collections::HashSet::new();
+
+ for icon in merged {
+ if seen.insert(icon.clone()) {
+ deduped.push(icon);
+ }
+ }
+
+ deduped
+}
+
+#[derive(Debug)]
+pub enum IconifyError {
+ InvalidBaseUrl {
+ base_url: String,
+ source: String,
+ },
+ InvalidEndpoint {
+ path: String,
+ source: String,
+ },
+ InvalidIconName(String),
+ Network(reqwest::Error),
+ HttpStatus {
+ status: StatusCode,
+ endpoint: String,
+ body: String,
+ },
+ JsonDecode {
+ endpoint: String,
+ source: serde_json::Error,
+ },
+}
+
+impl std::fmt::Display for IconifyError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ IconifyError::InvalidBaseUrl { base_url, .. } => {
+ write!(f, "invalid Iconify base URL: {base_url}")
+ }
+ IconifyError::InvalidEndpoint { path, .. } => {
+ write!(f, "invalid Iconify endpoint path: {path}")
+ }
+ IconifyError::InvalidIconName(name) => {
+ write!(
+ f,
+ "invalid Iconify icon name (expected ): {name}"
+ )
+ }
+ IconifyError::Network(source) => write!(f, "Iconify network error: {source}"),
+ IconifyError::HttpStatus {
+ status, endpoint, ..
+ } => {
+ write!(f, "Iconify request failed ({status}) for {endpoint}")
+ }
+ IconifyError::JsonDecode { endpoint, source } => {
+ write!(
+ f,
+ "failed to parse Iconify response from {endpoint}: {source}"
+ )
+ }
+ }
+ }
+}
+
+impl std::error::Error for IconifyError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ IconifyError::InvalidBaseUrl { .. } => None,
+ IconifyError::InvalidEndpoint { .. } => None,
+ IconifyError::InvalidIconName(_) => None,
+ IconifyError::Network(source) => Some(source),
+ IconifyError::JsonDecode { source, .. } => Some(source),
+ IconifyError::HttpStatus { .. } => None,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct IconifySearchResponse {
+ #[serde(default)]
+ pub icons: Vec,
+ #[serde(default)]
+ pub total: u32,
+ #[serde(default)]
+ pub limit: u32,
+ #[serde(default)]
+ pub start: u32,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub collections: Option>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct IconifyCollectionsResponse {
+ pub collections: HashMap,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, Default)]
+pub struct IconifyCollectionMeta {
+ #[serde(default)]
+ pub name: Option,
+ #[serde(default)]
+ pub title: Option,
+ #[serde(default)]
+ pub total: Option,
+ #[serde(flatten, default)]
+ pub extra: HashMap,
+}
+
+impl IconifyCollectionMeta {
+ pub fn display_name(&self, fallback: &str) -> String {
+ if let Some(name) = &self.name {
+ return name.clone();
+ }
+
+ if let Some(title) = &self.title {
+ return title.clone();
+ }
+
+ fallback.to_string()
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct IconifyCollectionResponse {
+ pub prefix: String,
+ #[serde(default)]
+ pub icons: Vec,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub uncategorized: Option>,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+struct IconifyCollectionApiResponse {
+ pub prefix: String,
+ #[serde(default)]
+ pub icons: Vec,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub uncategorized: Option>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub categories: Option>>,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parse_search_response_without_collections() {
+ let fixture = r#"
+ {
+ "icons": ["mdi:home", "mdi:heart"],
+ "total": 2,
+ "limit": 20,
+ "start": 0
+ }
+ "#;
+
+ let response: IconifySearchResponse =
+ serde_json::from_str(fixture).expect("fixture should deserialize");
+
+ assert_eq!(response.icons.len(), 2);
+ assert_eq!(response.total, 2);
+ assert!(response.collections.is_none());
+ }
+
+ #[test]
+ fn parse_search_response_with_collections() {
+ let fixture = r#"
+ {
+ "icons": ["mdi:home"],
+ "total": 1,
+ "limit": 1,
+ "start": 0,
+ "collections": {
+ "mdi": {"name": "Material Design Icons", "total": 7000}
+ }
+ }
+ "#;
+
+ let response: IconifySearchResponse =
+ serde_json::from_str(fixture).expect("fixture should deserialize");
+
+ assert_eq!(response.icons, vec!["mdi:home"]);
+ assert!(response.collections.is_some());
+ assert!(
+ response
+ .collections
+ .as_ref()
+ .expect("collections should exist")
+ .contains_key("mdi")
+ );
+ }
+
+ #[test]
+ fn parse_collections_response() {
+ let fixture = r#"
+ {
+ "mdi": {
+ "name": "Material Design Icons",
+ "total": 7447
+ },
+ "heroicons": {
+ "title": "Heroicons",
+ "total": 292
+ }
+ }
+ "#;
+
+ let response: HashMap =
+ serde_json::from_str(fixture).expect("fixture should deserialize");
+
+ let mdi = response.get("mdi").expect("mdi should exist");
+ assert_eq!(mdi.display_name("mdi"), "Material Design Icons");
+ assert_eq!(mdi.total, Some(7447));
+
+ let heroicons = response.get("heroicons").expect("heroicons should exist");
+ assert_eq!(heroicons.display_name("heroicons"), "Heroicons");
+ }
+
+ #[test]
+ fn parse_collection_response_with_optional_uncategorized() {
+ let fixture = r#"
+ {
+ "prefix": "mdi",
+ "icons": ["home", "heart"],
+ "uncategorized": ["orphan"]
+ }
+ "#;
+
+ let response: IconifyCollectionResponse =
+ serde_json::from_str(fixture).expect("fixture should deserialize");
+
+ assert_eq!(response.prefix, "mdi");
+ assert_eq!(response.icons, vec!["home", "heart"]);
+ assert_eq!(response.uncategorized, Some(vec!["orphan".to_string()]));
+ }
+
+ #[test]
+ fn merge_icons_from_uncategorized_and_categories() {
+ let categories = HashMap::from([
+ (
+ "Actions".to_string(),
+ vec!["home".to_string(), "heart".to_string()],
+ ),
+ (
+ "Shapes".to_string(),
+ vec!["star".to_string(), "home".to_string()],
+ ),
+ ]);
+
+ let merged = merge_collection_icons(
+ Vec::new(),
+ Some(&vec!["orphan".to_string(), "home".to_string()]),
+ Some(&categories),
+ );
+
+ assert!(merged.contains(&"orphan".to_string()));
+ assert!(merged.contains(&"home".to_string()));
+ assert!(merged.contains(&"heart".to_string()));
+ assert!(merged.contains(&"star".to_string()));
+ assert_eq!(
+ merged.iter().filter(|icon| icon.as_str() == "home").count(),
+ 1
+ );
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index 572c43e..259ab7f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,32 +1,22 @@
+mod app_state;
+mod config;
+mod iconify;
+mod tui;
+mod utils;
+mod viewer;
+mod views;
+
+use crate::iconify::{IconifyClient, IconifyCollectionResponse, IconifySearchResponse};
+use crate::utils::{
+ _determine_icon_source_type, _icon_source_to_svg, _make_svg_filename, IconEntry,
+ IconSourceType, PRESETS_OPTIONS, Preset, default_name_and_filename_from_icon_source,
+};
use clap::{Parser, Subcommand, ValueEnum};
-use reqwest::Url;
+use serde::Serialize;
use std::fs;
use std::io::Write;
use std::path::PathBuf;
-#[derive(ValueEnum, Clone, Debug, PartialEq)]
-enum Preset {
- /// Use a blank SVG.
- #[value(name = "emptysvg")]
- EmptySvg,
-
- /// React Component .tsx
- #[value(name = "react")]
- React,
-
- /// Svelte Component .svelte
- #[value(name = "svelte")]
- Svelte,
-
- /// Solid Component .tsx
- #[value(name = "solid")]
- Solid,
-
- /// Vue
- #[value(name = "vue")]
- Vue,
-}
-
/// A CLI tool to fetch icons and save them into your Vite, NextJS, or similar project.
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
@@ -35,34 +25,30 @@ struct CliArgs {
command: Option,
/// Pathname of the folder where the icon will be saved and index.ts updated.
- #[arg(long, global = true)]
+ #[arg(long)]
folder: Option,
/// Optional preset to use instead of fetching an icon.
- #[arg(long, global = true)]
+ #[arg(long)]
preset: Option,
/// The alias for the SVG, used in the index.ts export (e.g., "Chevron").
- #[arg(long, global = true)]
+ #[arg(long)]
name: Option,
/// The name of the icon (e.g., "stash:chevron") or a full URL to the icon (e.g., "https://api.iconify.design/stash:chevron.svg") or an SVG.
- #[arg(long, global = true)]
+ #[arg(long)]
icon: Option,
/// Optional custom filename for the SVG file (without extension). Defaults to the icon name.
- #[arg(long, global = true)]
+ #[arg(long)]
filename: Option,
/// Custom template for the export line. Use %name% for the icon alias and %icon% for the filename stem.
/// Variables: %icon%, %name%
/// Normally for complex usecases where for example you might need url suffixes for imports i.e. `?react`.
- #[arg(
- long,
- global = true,
- default_value = "export { default as Icon%name% } from './%icon%%ext%';"
- )]
- output_line_template: String,
+ #[arg(long)]
+ output_line_template: Option,
}
#[derive(Debug, Subcommand)]
@@ -94,20 +80,90 @@ enum Commands {
/// Normally for complex usecases where for example you might need url suffixes for imports i.e. `?react`.
#[arg(
long,
- default_value = "export { default as Icon%name% } from './%icon%.%ext%';"
+ default_value = "export { default as Icon%name% } from './%icon%%ext%';"
)]
output_line_template: String,
},
/// Start an interactive prompt to add icons.
- Prompt {},
+ Tui {},
/// Delete an icon from your collection of icons
Delete {
/// Pathname of the folder where all the icons are saved.
- #[arg(long, global = true)]
+ #[arg(long)]
folder: Option,
},
+
+ /// Query Iconify collections, search results, and raw SVGs.
+ Iconify {
+ #[command(subcommand)]
+ command: IconifyCommands,
+ },
+}
+
+#[derive(Clone, Debug, ValueEnum, PartialEq)]
+enum OutputFormat {
+ Text,
+ Json,
+}
+
+#[derive(Clone, Debug, ValueEnum)]
+enum GetFormat {
+ Svg,
+ Json,
+}
+
+#[derive(Debug, Subcommand)]
+enum IconifyCommands {
+ /// Search Iconify by keyword.
+ Search {
+ /// Search query, such as "heart".
+ query: String,
+
+ /// Maximum number of records.
+ #[arg(long)]
+ limit: Option,
+
+ /// Start offset for pagination.
+ #[arg(long)]
+ start: Option,
+
+ /// Output format.
+ #[arg(long, value_enum, default_value = "text")]
+ format: OutputFormat,
+
+ /// Include collection metadata (JSON mode only).
+ #[arg(long)]
+ include_collections: bool,
+ },
+
+ /// List available Iconify collections.
+ Collections {
+ /// Output format.
+ #[arg(long, value_enum, default_value = "text")]
+ format: OutputFormat,
+ },
+
+ /// List all icons in a collection prefix.
+ Collection {
+ /// Collection prefix, such as "mdi".
+ prefix: String,
+
+ /// Output format.
+ #[arg(long, value_enum, default_value = "text")]
+ format: OutputFormat,
+ },
+
+ /// Fetch one icon by Iconify name ().
+ Get {
+ /// Iconify icon name, such as "mdi:heart".
+ icon: String,
+
+ /// Output format.
+ #[arg(long, value_enum, default_value = "svg")]
+ format: GetFormat,
+ },
}
/// Configuration for the icon fetching and saving logic.
@@ -120,127 +176,178 @@ struct AppConfig {
preset: Option,
}
-/// Enum representing the type of icon source
-#[derive(Debug, PartialEq)]
-enum IconSourceType {
- /// A plain iconify name (e.g., "stash:chevron")
- IconifyName,
- /// A full HTTP/HTTPS URL
- Url,
- /// Raw SVG content
- SvgContent,
- /// None provided
- None,
+#[derive(Serialize)]
+struct SearchJsonOutput {
+ icons: Vec,
+ total: u32,
+ limit: u32,
+ start: u32,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ collections: Option>,
}
-/// Util: Determines the type of icon source
-fn _determine_icon_source_type(icon_source: Option<&String>) -> IconSourceType {
- match icon_source {
- Some(icon) => {
- if icon.trim_start().starts_with(" IconSourceType::None,
- }
+#[derive(Serialize)]
+struct CollectionsJsonOutput {
+ prefix: String,
+ name: String,
+ total: u32,
}
-/// Util: Converts any icon_source into an SVG
-async fn _icon_source_to_svg(
- icon_source: &Option,
- append_attribute: Option<&'static str>,
- remove_comments: bool,
-) -> anyhow::Result {
- // If icon_source is missing, return a minimal SVG (Note: rust skill issue idk how else to just reuse the last clause in the match below)
- let Some(icon_source) = icon_source else {
- return Ok(r#" "#.to_string());
- };
+#[derive(Serialize)]
+struct CollectionJsonOutput {
+ prefix: String,
+ icons: Vec,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ uncategorized: Option>,
+}
- let mut content = match _determine_icon_source_type(Some(icon_source)) {
- IconSourceType::SvgContent => {
- // Already an SVG document
- icon_source.clone()
- }
- IconSourceType::IconifyName => {
- // Construct the full URL for the icon
- let icon_url = Url::parse(&format!("https://api.iconify.design/{}.svg", icon_source))?;
- println!("Fetching icon from: {}", icon_url);
-
- // Fetch the SVG content
- let client = reqwest::Client::new();
- let response = client.get(icon_url).send().await?.error_for_status()?;
- response.text().await?
- }
- IconSourceType::Url => {
- // Already a full URL
- let icon_url = Url::parse(icon_source)?;
- println!("Fetching icon from: {}", icon_url);
-
- // Fetch the SVG content
- let client = reqwest::Client::new();
- let response = client.get(icon_url).send().await?.error_for_status()?;
- response.text().await?
- }
- IconSourceType::None => {
- return Ok(r#" "#.to_string());
- }
- };
+fn print_json(value: &T) -> anyhow::Result<()> {
+ println!("{}", serde_json::to_string_pretty(value)?);
+ Ok(())
+}
+
+fn iconify_error_to_anyhow(error: crate::iconify::IconifyError) -> anyhow::Error {
+ match error {
+ crate::iconify::IconifyError::HttpStatus {
+ status,
+ endpoint,
+ body,
+ } => {
+ if status == reqwest::StatusCode::NOT_FOUND && endpoint.contains("/collection?prefix=")
+ {
+ return anyhow::anyhow!(
+ "Iconify collection not found. Use a collection prefix like 'mdi' (not 'mdi:home')."
+ );
+ }
- // -- Transformations if applicable ---
-
- // 1. Append attribute (i.e. for jsx,svelte,vue)
- if let Some(attr) = append_attribute {
- // Find the first occurrence of ""
- if let Some(svg_start) = content.find("') {
- let insert_pos = svg_start + svg_tag_end;
- let before = &content[..insert_pos];
- let after = &content[insert_pos..];
- content = format!("{} {}{}", before, attr, after);
+ let body = body.trim();
+ if body.is_empty() {
+ anyhow::anyhow!("Iconify request failed ({status}) for {endpoint}")
+ } else {
+ anyhow::anyhow!(
+ "Iconify request failed ({status}) for {endpoint}. Response: {body}"
+ )
}
}
+ other => anyhow::Error::new(other),
}
+}
- // 2. Remove Comments
- if remove_comments {
- // Remove HTML comments from SVG content
- // This regex matches including any content in between
- let re = regex::Regex::new(r"").unwrap();
- content = re.replace_all(&content, "").to_string();
+fn into_collection_output(response: IconifyCollectionResponse) -> CollectionJsonOutput {
+ CollectionJsonOutput {
+ prefix: response.prefix,
+ icons: response.icons,
+ uncategorized: response.uncategorized,
}
-
- Ok(content)
}
-/// Util: Reused in all cases, for appending the filename of svg, i.e. add .tsx or .svg or .svelte.
-/// Returns a file_stem and an ext
-fn _make_svg_filename(
- stem_from_cli: Option<&String>,
- ext: &'static str,
- icon_source: Option<&String>,
- name_from_cli: &str,
-) -> (String, &'static str) {
- let stem = if let Some(stem) = stem_from_cli {
- stem.clone()
- } else if let Some(icon) = icon_source {
- // Only use icon_source if it's a plain iconify name (no http/https, no icon.clone(),
- _ => name_from_cli.to_string().to_lowercase(),
- }
- } else {
- name_from_cli.to_string().to_lowercase()
- };
+async fn run_iconify_command(command: IconifyCommands) -> anyhow::Result<()> {
+ let client = IconifyClient::from_env().map_err(iconify_error_to_anyhow)?;
+
+ match command {
+ IconifyCommands::Search {
+ query,
+ limit,
+ start,
+ format,
+ include_collections,
+ } => {
+ if include_collections && format != OutputFormat::Json {
+ anyhow::bail!("--include-collections can only be used with --format json");
+ }
- if stem.ends_with(ext) {
- (stem.replace(ext, ""), ext)
- } else {
- (stem, ext)
+ let response: IconifySearchResponse = client
+ .search(&query, limit, start, include_collections)
+ .await
+ .map_err(iconify_error_to_anyhow)?;
+
+ match format {
+ OutputFormat::Text => {
+ for icon in response.icons {
+ println!("{icon}");
+ }
+ }
+ OutputFormat::Json => {
+ let payload = SearchJsonOutput {
+ icons: response.icons,
+ total: response.total,
+ limit: response.limit,
+ start: response.start,
+ collections: response.collections,
+ };
+ print_json(&payload)?;
+ }
+ }
+ }
+ IconifyCommands::Collections { format } => {
+ let response = client
+ .collections()
+ .await
+ .map_err(iconify_error_to_anyhow)?;
+
+ let mut rows: Vec = response
+ .collections
+ .into_iter()
+ .map(|(prefix, meta)| CollectionsJsonOutput {
+ name: meta.display_name(&prefix),
+ total: meta.total.unwrap_or(0),
+ prefix,
+ })
+ .collect();
+
+ rows.sort_by(|a, b| a.prefix.cmp(&b.prefix));
+
+ match format {
+ OutputFormat::Text => {
+ for row in rows {
+ println!("{}\t{}\t{}", row.prefix, row.name, row.total);
+ }
+ }
+ OutputFormat::Json => {
+ print_json(&rows)?;
+ }
+ }
+ }
+ IconifyCommands::Collection { prefix, format } => {
+ let prefix = prefix
+ .split_once(':')
+ .map(|(collection_prefix, _)| collection_prefix)
+ .unwrap_or(&prefix)
+ .to_string();
+
+ let response = client
+ .collection(&prefix)
+ .await
+ .map_err(iconify_error_to_anyhow)?;
+
+ match format {
+ OutputFormat::Text => {
+ for icon in &response.icons {
+ println!("{}:{icon}", response.prefix);
+ }
+ }
+ OutputFormat::Json => {
+ let payload = into_collection_output(response);
+ print_json(&payload)?;
+ }
+ }
+ }
+ IconifyCommands::Get { icon, format } => match format {
+ GetFormat::Svg => {
+ let svg = client.svg(&icon).await.map_err(iconify_error_to_anyhow)?;
+ println!("{svg}");
+ }
+ GetFormat::Json => {
+ let payload = client
+ .icon_json_by_name(&icon)
+ .await
+ .map_err(iconify_error_to_anyhow)?;
+ print_json(&payload)?;
+ }
+ },
}
+
+ Ok(())
}
/// The main logic of the application.
@@ -262,20 +369,22 @@ async fn run_app(config: AppConfig) -> anyhow::Result<()> {
// eprintln!(" preset: {:?}", config.preset);
// eprintln!("}}");
+ let effective_preset = config.preset.clone().unwrap_or(Preset::Normal);
+
// Determine SVG content and filename stem based on a valid combination of arguments.
let (svg_content, file_stem_str, ext, output_line_template) = match (
&config.icon,
- &config.preset,
+ effective_preset,
) {
// Case 1: Icon is provided AND the preset is EmptySvg. This is the only mutual exclusivity.
- (Some(_), Some(Preset::EmptySvg)) => {
+ (Some(_), Preset::EmptySvg) => {
anyhow::bail!(
"The --icon argument cannot be used with the --preset emptysvg. Please provide only one or the other."
);
}
// Case 2: Only a preset is provided.
- (None, Some(Preset::EmptySvg)) => {
+ (None, Preset::EmptySvg) => {
let content = r#" "#.to_string();
let (file_stem, ext) = _make_svg_filename(
config.filename.as_ref(),
@@ -292,7 +401,7 @@ async fn run_app(config: AppConfig) -> anyhow::Result<()> {
}
// Case 3: React
- (icon_source, Some(Preset::React)) => {
+ (icon_source, Preset::React) => {
let content = _icon_source_to_svg(icon_source, Some("{...props}"), true).await?;
// Wrap the SVG in a React component template
@@ -316,7 +425,7 @@ async fn run_app(config: AppConfig) -> anyhow::Result<()> {
}
// Case 4: Svelte
- (icon_source, Some(Preset::Svelte)) => {
+ (icon_source, Preset::Svelte) => {
let content = _icon_source_to_svg(icon_source, Some("{...props}"), false).await?;
// Wrap the SVG in a Svelte component template
@@ -340,7 +449,7 @@ async fn run_app(config: AppConfig) -> anyhow::Result<()> {
}
// Case 5: Solid
- (icon_source, Some(Preset::Solid)) => {
+ (icon_source, Preset::Solid) => {
let content = _icon_source_to_svg(icon_source, Some("{...props}"), true).await?;
// Wrap the SVG in a Solid component template
@@ -364,7 +473,7 @@ async fn run_app(config: AppConfig) -> anyhow::Result<()> {
}
// Case 6: Vue
- (icon_source, Some(Preset::Vue)) => {
+ (icon_source, Preset::Vue) => {
let content = _icon_source_to_svg(icon_source, Some("v-bind=\"$props\""), true).await?;
// Wrap the SVG in a Vue component template
@@ -387,8 +496,8 @@ async fn run_app(config: AppConfig) -> anyhow::Result<()> {
))
}
- // Case 7: Only an icon is provided.
- (Some(icon_source), None) => {
+ // Case 7: Only an icon is provided in `normal` mode.
+ (Some(icon_source), Preset::Normal) => {
let content = _icon_source_to_svg(&Some(icon_source.clone()), None, false).await?;
let (file_stem, ext) = _make_svg_filename(
@@ -400,9 +509,9 @@ async fn run_app(config: AppConfig) -> anyhow::Result<()> {
Ok((content, file_stem, ext, config.output_line_template.clone()))
}
- // Case 8: Neither icon nor preset is provided.
- (None, None) => {
- anyhow::bail!("Either an --icon or a --preset must be provided.");
+ // Case 8: Normal mode still requires an icon source.
+ (None, Preset::Normal) => {
+ anyhow::bail!("The --icon argument is required when --preset is normal.");
}
}?;
@@ -426,7 +535,15 @@ async fn run_app(config: AppConfig) -> anyhow::Result<()> {
if index_ts_path.exists() {
let mut contents = fs::read_to_string(&index_ts_path)?;
- if !contents.contains(&export_line) {
+ let export_line_trimmed = export_line.trim_end();
+ let export_already_exists = contents
+ .lines()
+ .any(|line| line.trim_end() == export_line_trimmed);
+
+ if !export_already_exists {
+ if !contents.is_empty() && !contents.ends_with('\n') {
+ contents.push('\n');
+ }
contents.push_str(&export_line);
fs::write(&index_ts_path, contents)?;
println!("Added export to: {}", index_ts_path.display());
@@ -466,61 +583,14 @@ async fn run_prompt_mode(cli: &CliArgs) -> anyhow::Result<()> {
let preset = match &cli.preset {
Some(p) => {
- println!("> โฆ Preset: emptysvg");
+ println!("> โฆ Preset: {}", p.to_str());
Some(p.clone())
}
None => {
- #[derive(Debug, Copy, Clone)]
- struct PresetOpt {
- key: &'static str,
- desc: &'static str,
- }
-
- impl std::fmt::Display for PresetOpt {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- write!(f, "{} โ {}", self.key, self.desc)
- }
- }
-
- let preset_opts = vec![
- PresetOpt {
- key: "normal",
- desc: "Plain svg (.svg)",
- },
- PresetOpt {
- key: "emptysvg",
- desc: "A blank SVG file (.svg)",
- },
- PresetOpt {
- key: "react",
- desc: "React Component (.tsx)",
- },
- PresetOpt {
- key: "svelte",
- desc: "Svelte Component (.svelte)",
- },
- PresetOpt {
- key: "solid",
- desc: "Solid Component (.tsx)",
- },
- PresetOpt {
- key: "vue",
- desc: "Vue Component (.vue)",
- },
- ];
- let preset_raw = Select::new("โฆ Preset", preset_opts)
+ let preset_opt = Select::new("โฆ Preset", PRESETS_OPTIONS.to_vec())
.with_render_config(render_config.clone())
.prompt()?;
-
- // My rust skill issue doesn't know how to return this as just 1 item.
- match preset_raw.key {
- "emptysvg" => Some(Preset::EmptySvg),
- "react" => Some(Preset::React),
- "svelte" => Some(Preset::Svelte),
- "solid" => Some(Preset::Solid),
- "vue" => Some(Preset::Vue),
- _ => None,
- }
+ Some(preset_opt.preset)
}
};
@@ -569,17 +639,29 @@ async fn run_prompt_mode(cli: &CliArgs) -> anyhow::Result<()> {
},
};
+ let inferred_name = icon
+ .as_ref()
+ .and_then(|icon_source| default_name_and_filename_from_icon_source(icon_source))
+ .map(|(name, _)| name);
+
let name = match &cli.name {
Some(n) => {
println!("> โง Name: {}", n);
n.clone()
}
- None => Text::new("โง Name (required, e.g., Heart)")
- .with_render_config(render_config)
- .with_validator(inquire::validator::ValueRequiredValidator::new(
- "Name is required.",
- ))
- .prompt()?,
+ None => {
+ let mut prompt = Text::new("โง Name (required, e.g., Heart)")
+ .with_render_config(render_config)
+ .with_validator(inquire::validator::ValueRequiredValidator::new(
+ "Name is required.",
+ ));
+
+ if let Some(default_name) = inferred_name.as_deref() {
+ prompt = prompt.with_default(default_name);
+ }
+
+ prompt.prompt()?
+ }
};
let config = AppConfig {
@@ -587,72 +669,64 @@ async fn run_prompt_mode(cli: &CliArgs) -> anyhow::Result<()> {
name,
icon,
filename,
- output_line_template: cli.output_line_template.clone(),
+ output_line_template: cli
+ .output_line_template
+ .clone()
+ .unwrap_or_else(|| config::DEFAULT_OUTPUT_LINE_TEMPLATE.to_string()),
preset,
};
run_app(config).await
}
-/// Struct to hold icon information for deletion
-#[derive(Debug)]
-struct IconEntry {
- name: String,
- file_path: String,
-}
-
impl std::fmt::Display for IconEntry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} โ {}", self.name, self.file_path)
}
}
-/// Parses an export line to extract icon name and file path
-fn _parse_export_line(line: &str) -> Option {
- // Example line: "export { default as IconWallet } from './ic:baseline-account-balance-wallet.svg';"
- // or: "export { default as IconHeart } from './heroicons:heart.svg?react';"
- // or: "export { default as IconHeart } from './heroicons:heart.tsx';"
- // or: "export { default as IconHeart } from './heroicons:heart.svg';"
-
- // Trim whitespace and check for export pattern
- let line = line.trim();
- if let Some(export_start) = line.find("export { default as Icon") {
- // Find the name between "Icon" and "}"
- let after_icon = &line[export_start + 24..];
- if let Some(name_end) = after_icon.find('}') {
- let name = after_icon[..name_end].trim();
-
- // Extract file path from the 'from' part
- if let Some(from_start) = line.find("from '") {
- let path_start = from_start + 6;
- if let Some(path_end) = line[path_start..].find('\'') {
- let raw_path = &line[path_start..path_start + path_end];
- // Remove leading ./ if present
- let cleaned_path = raw_path.trim_start_matches("./");
- // Remove query parameters like "?raw" or "?react"
- let cleaned_path = if let Some(query_pos) = cleaned_path.find('?') {
- &cleaned_path[..query_pos]
- } else {
- cleaned_path
- };
- return Some(IconEntry {
- name: name.to_string(),
- file_path: cleaned_path.to_string(),
- });
- }
- }
- }
+fn remove_selected_exports_from_index(contents: &str, selected_icons: &[IconEntry]) -> String {
+ use std::collections::HashSet;
+
+ let selected = selected_icons
+ .iter()
+ .map(|icon| (icon.name.clone(), icon.file_path.clone()))
+ .collect::>();
+
+ let kept_lines = contents
+ .lines()
+ .filter(|line| {
+ !crate::utils::parse_export_line_ts(line)
+ .map(|entry| selected.contains(&(entry.name, entry.file_path)))
+ .unwrap_or(false)
+ })
+ .collect::>();
+
+ let mut updated = kept_lines.join("\n");
+ if contents.ends_with('\n') {
+ updated.push('\n');
}
- None
+
+ updated
}
/// Interactive mode: deleting an icon from a select list of icons.
-async fn run_delete_prompt_mode(cli: &CliArgs) -> anyhow::Result<()> {
+fn resolve_delete_folder<'a>(
+ cli: &'a CliArgs,
+ command_folder: Option<&'a PathBuf>,
+) -> Option<&'a PathBuf> {
+ command_folder.or(cli.folder.as_ref())
+}
+
+async fn run_delete_prompt_mode(
+ cli: &CliArgs,
+ command_folder: Option<&PathBuf>,
+) -> anyhow::Result<()> {
use inquire::{Confirm, MultiSelect, Text, ui::RenderConfig};
let render_config = RenderConfig::default().with_prompt_prefix(inquire::ui::Styled::new("โ"));
// Step 1: Get the folder path
- let folder_raw = match &cli.folder {
+ let folder_raw = match resolve_delete_folder(cli, command_folder) {
Some(f) => {
println!("> ๏ Folder: {}", f.display());
f.display().to_string()
@@ -677,7 +751,7 @@ async fn run_delete_prompt_mode(cli: &CliArgs) -> anyhow::Result<()> {
let mut icons = Vec::new();
for line in contents.lines() {
- if let Some(icon_entry) = _parse_export_line(line) {
+ if let Some(icon_entry) = crate::utils::parse_export_line_ts(line) {
icons.push(icon_entry);
}
}
@@ -712,8 +786,6 @@ async fn run_delete_prompt_mode(cli: &CliArgs) -> anyhow::Result<()> {
}
// Step 7: Delete the icons
- let mut updated_index_content = contents.clone();
-
for icon_to_delete in &selected_icons {
let full_path = folder.join(&icon_to_delete.file_path);
@@ -722,46 +794,17 @@ async fn run_delete_prompt_mode(cli: &CliArgs) -> anyhow::Result<()> {
if let Err(e) = fs::remove_file(&full_path) {
eprintln!("Failed to delete {}: {}", full_path.display(), e);
} else {
- println!("Deleted: {}", full_path.display());
+ eprintln!("Deleted: {}", full_path.display());
}
} else {
- println!("File not found: {}", full_path.display());
- }
-
- // Remove the export line from index.ts content
- if let Some(line_to_remove) = _parse_export_line(&contents) {
- if line_to_remove.name == icon_to_delete.name
- && line_to_remove.file_path == icon_to_delete.file_path
- {
- if let Some(line_start) = contents.find(&line_to_remove.name) {
- // Find the start of the line
- let mut start = line_start;
- while start > 0 && contents.chars().nth(start - 1) != Some('\n') {
- start -= 1;
- }
-
- // Find the end of the line
- let mut end = start;
- let chars: Vec = contents.chars().collect();
- while end < chars.len() && chars[end] != '\n' {
- end += 1;
- }
- if end < chars.len() {
- end += 1; // Include the newline
- }
-
- // Remove the line
- let before = &updated_index_content[..start];
- let after = &updated_index_content[end..];
- updated_index_content = format!("{}{}", before, after);
- }
- }
+ eprintln!("File not found: {}", full_path.display());
}
}
+ let updated_index_content = remove_selected_exports_from_index(&contents, &selected_icons);
+
// Write the updated index.ts
fs::write(&index_ts_path, updated_index_content)?;
- println!("Updated index.ts");
Ok(())
}
@@ -789,8 +832,95 @@ async fn main() -> anyhow::Result<()> {
};
run_app(config).await
}
- Some(Commands::Prompt {}) => run_prompt_mode(&args).await,
- Some(Commands::Delete { folder: _ }) => run_delete_prompt_mode(&args).await,
- None => run_prompt_mode(&args).await,
+ Some(Commands::Tui {}) => run_prompt_mode(&args).await,
+ Some(Commands::Delete { ref folder }) => {
+ run_delete_prompt_mode(&args, folder.as_ref()).await
+ }
+ Some(Commands::Iconify { command }) => run_iconify_command(command).await,
+ None => {
+ let resolved = config::resolve_tui_config(
+ args.folder.as_ref(),
+ args.preset.as_ref(),
+ args.output_line_template.as_ref(),
+ )?;
+
+ for warning in &resolved.warnings {
+ eprintln!("Warning: {warning}");
+ }
+ for info in &resolved.info {
+ eprintln!("{info}");
+ }
+
+ let config = app_state::AppConfig {
+ folder: resolved.folder,
+ preset: resolved.preset,
+ template: Some(resolved.output_line_template),
+ svg_viewer_cmd: resolved.svg_viewer_cmd,
+ svg_viewer_cmd_source: resolved.svg_viewer_cmd_source,
+ global_config_loaded: resolved.global_config_loaded,
+ project_config_loaded: resolved.project_config_loaded,
+ };
+ tui::run(config).await
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn remove_selected_exports_removes_each_selected_line() {
+ let contents = "export { default as IconOne } from './one.svg';\nexport { default as IconTwo } from './two.svg?react';\nexport { default as IconThree } from './three.svg';\n";
+
+ let selected_icons = vec![
+ crate::utils::parse_export_line_ts("export { default as IconOne } from './one.svg';")
+ .expect("line should parse"),
+ crate::utils::parse_export_line_ts(
+ "export { default as IconTwo } from './two.svg?react';",
+ )
+ .expect("line should parse"),
+ ];
+
+ let updated = remove_selected_exports_from_index(contents, &selected_icons);
+
+ assert!(!updated.contains("IconOne"));
+ assert!(!updated.contains("IconTwo"));
+ assert!(updated.contains("IconThree"));
+ }
+
+ #[test]
+ fn resolve_delete_folder_prefers_subcommand_folder() {
+ let cli_folder = PathBuf::from("src/assets/icons");
+ let command_folder = PathBuf::from("icons/from/delete");
+ let cli = CliArgs {
+ command: None,
+ folder: Some(cli_folder),
+ preset: None,
+ name: None,
+ icon: None,
+ filename: None,
+ output_line_template: None,
+ };
+
+ let resolved = resolve_delete_folder(&cli, Some(&command_folder));
+ assert_eq!(resolved, Some(&command_folder));
+ }
+
+ #[test]
+ fn resolve_delete_folder_falls_back_to_global_folder() {
+ let cli_folder = PathBuf::from("src/assets/icons");
+ let cli = CliArgs {
+ command: None,
+ folder: Some(cli_folder.clone()),
+ preset: None,
+ name: None,
+ icon: None,
+ filename: None,
+ output_line_template: None,
+ };
+
+ let resolved = resolve_delete_folder(&cli, None);
+ assert_eq!(resolved, Some(&cli_folder));
}
}
diff --git a/src/tui.rs b/src/tui.rs
new file mode 100644
index 0000000..0cbfb10
--- /dev/null
+++ b/src/tui.rs
@@ -0,0 +1,77 @@
+use crate::{
+ app_state::{App, AppConfig, AppFocus},
+ views::main::render_main_view,
+};
+use crossterm::{
+ event::{DisableMouseCapture, EnableMouseCapture},
+ execute,
+ terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
+};
+use ratatui::{Frame, Terminal, backend::CrosstermBackend, layout::Constraint};
+use std::{io, time::Duration};
+use tui_textarea::{Input, Key};
+
+pub async fn run(config: AppConfig) -> Result<(), anyhow::Error> {
+ enable_raw_mode()?;
+ let mut stdout = io::stdout();
+ execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
+ let backend = CrosstermBackend::new(stdout);
+ let mut terminal = Terminal::new(backend)?;
+
+ let mut app = App::new(config);
+
+ loop {
+ terminal.draw(|f| ui(f, &mut app))?;
+
+ if ratatui::crossterm::event::poll(Duration::from_millis(16))? {
+ match ratatui::crossterm::event::read()?.into() {
+ Input {
+ key: Key::Char('c'),
+ ctrl: true,
+ ..
+ } => break,
+ input => app.handlekeys(input),
+ }
+ }
+
+ app.update();
+ if app.should_quit {
+ break;
+ }
+ }
+
+ disable_raw_mode()?;
+ execute!(
+ terminal.backend_mut(),
+ LeaveAlternateScreen,
+ DisableMouseCapture
+ )?;
+ terminal.show_cursor()?;
+
+ Ok(())
+}
+
+fn ui(f: &mut Frame, app: &mut App) {
+ let area = f.area();
+ let layout = ratatui::layout::Layout::default()
+ .direction(ratatui::layout::Direction::Vertical)
+ .margin(1)
+ .constraints([Constraint::Min(0)])
+ .split(area);
+
+ // Pages
+ render_main_view(f, layout[0], app);
+
+ // Modals
+
+ match app.app_focus {
+ AppFocus::AddPopup => crate::views::add_popup::render_add_popup(f, app),
+ AppFocus::DeletePopup => crate::views::delete_popup::render_delete_popup(f, app),
+ AppFocus::RenamePopup => crate::views::rename_popup::render_rename_popup(f, app),
+ AppFocus::HelpPopup => crate::views::help_popup::render_help_popup(f, app),
+ AppFocus::IconifySearchPopup => {
+ crate::views::iconify_search_popup::render_iconify_search_popup(f, app)
+ }
+ _ => {}
+ }
+}
diff --git a/src/utils.rs b/src/utils.rs
new file mode 100644
index 0000000..667ae27
--- /dev/null
+++ b/src/utils.rs
@@ -0,0 +1,1049 @@
+use clap::ValueEnum;
+use ratatui::layout::Rect;
+use reqwest::Url;
+
+use crate::iconify::IconifyClient;
+
+#[derive(ValueEnum, Clone, Debug, PartialEq, Hash)]
+pub enum Preset {
+ /// Use the icon source as a regular SVG.
+ #[value(name = "normal")]
+ Normal,
+
+ /// Use a blank SVG.
+ #[value(name = "emptysvg")]
+ EmptySvg,
+
+ /// React Component .tsx
+ #[value(name = "react")]
+ React,
+
+ /// Svelte Component .svelte
+ #[value(name = "svelte")]
+ Svelte,
+
+ /// Solid Component .tsx
+ #[value(name = "solid")]
+ Solid,
+
+ /// Vue
+ #[value(name = "vue")]
+ Vue,
+}
+
+impl Preset {
+ pub fn to_str(&self) -> &'static str {
+ match self {
+ Preset::Normal => "normal",
+ Preset::EmptySvg => "emptysvg",
+ Preset::React => "react",
+ Preset::Svelte => "svelte",
+ Preset::Solid => "solid",
+ Preset::Vue => "vue",
+ }
+ }
+}
+
+/// A helper struct that pairs a preset with its human-readable description
+#[derive(Debug, Clone)]
+pub struct PresetOption {
+ pub preset: Preset,
+ pub description: &'static str,
+}
+
+impl std::fmt::Display for PresetOption {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{} - {}", self.preset.to_str(), self.description)
+ }
+}
+
+pub const PRESETS_OPTIONS: &[PresetOption] = &[
+ PresetOption {
+ preset: Preset::Normal,
+ description: "Outputs an svg from an icon source (.svg)",
+ },
+ PresetOption {
+ preset: Preset::EmptySvg,
+ description: "Outputs a blank svg placeholder (.svg)",
+ },
+ PresetOption {
+ preset: Preset::React,
+ description: "Outputs a React component (.tsx)",
+ },
+ PresetOption {
+ preset: Preset::Svelte,
+ description: "Outputs a Svelte component (.svelte)",
+ },
+ PresetOption {
+ preset: Preset::Solid,
+ description: "Outputs a SolidJS component (.tsx)",
+ },
+ PresetOption {
+ preset: Preset::Vue,
+ description: "Outputs a Vue component (.vue)",
+ },
+];
+
+/// helper function to create a centered rect using up certain maximum dimensions `r`
+pub fn popup_area(area: Rect, max_width: u16, max_height: u16) -> Rect {
+ let width = max_width.min(area.width);
+ let height = max_height.min(area.height);
+ let horizontal_margin = area.width.saturating_sub(width) / 2;
+ let vertical_margin = area.height.saturating_sub(height) / 2;
+ Rect {
+ x: area.x + horizontal_margin,
+ y: area.y + vertical_margin,
+ width,
+ height,
+ }
+}
+
+/// Struct to hold icon information for deletion
+#[derive(Debug, Clone)]
+pub struct IconEntry {
+ pub name: String,
+ pub file_path: String,
+}
+
+/// Enum representing the type of icon source
+#[derive(Debug, PartialEq)]
+pub enum IconSourceType {
+ /// A plain iconify name (e.g., "stash:chevron")
+ IconifyName,
+ /// A full HTTP/HTTPS URL
+ Url,
+ /// Raw SVG content
+ SvgContent,
+ /// None provided
+ None,
+}
+
+fn decode_icon_candidate(value: &str) -> String {
+ value
+ .replace("%3A", ":")
+ .replace("%3a", ":")
+ .replace("%2F", "/")
+ .replace("%2f", "/")
+}
+
+fn is_iconify_name(value: &str) -> bool {
+ let Some((prefix, icon)) = value.split_once(':') else {
+ return false;
+ };
+
+ if prefix.trim().is_empty() || icon.trim().is_empty() {
+ return false;
+ }
+
+ !value.chars().any(char::is_whitespace)
+}
+
+fn to_pascal_case(input: &str) -> String {
+ input
+ .split(|c: char| !c.is_ascii_alphanumeric())
+ .filter(|part| !part.is_empty())
+ .map(|part| {
+ let mut chars = part.chars();
+ match chars.next() {
+ Some(first) => {
+ let mut built = String::new();
+ built.extend(first.to_uppercase());
+ built.push_str(&chars.as_str().to_ascii_lowercase());
+ built
+ }
+ None => String::new(),
+ }
+ })
+ .collect::()
+}
+
+pub fn iconify_name_from_icon_source(icon_source: &str) -> Option {
+ let trimmed = icon_source.trim();
+ if trimmed.is_empty() || trimmed.trim_start().starts_with(" = url.path_segments()?.collect();
+ if segments.len() >= 4 && segments[0] == "collection" && segments[2] == "icon" {
+ let candidate = format!(
+ "{}:{}",
+ decode_icon_candidate(segments[1]),
+ decode_icon_candidate(segments[3])
+ );
+ if is_iconify_name(&candidate) {
+ return Some(candidate);
+ }
+ }
+
+ if segments.len() >= 3 && segments[0] == "icon" {
+ let candidate = format!(
+ "{}:{}",
+ decode_icon_candidate(segments[1]),
+ decode_icon_candidate(segments[2])
+ );
+ if is_iconify_name(&candidate) {
+ return Some(candidate);
+ }
+ }
+ }
+
+ if host.contains("api.iconify.design") {
+ let path = url.path().trim_start_matches('/').trim_end_matches('/');
+
+ if path.ends_with(".json") {
+ let prefix = path.trim_end_matches(".json");
+ for (key, value) in url.query_pairs() {
+ if key == "icons" {
+ let icon = value.split(',').next().unwrap_or("").trim();
+ let candidate = format!(
+ "{}:{}",
+ decode_icon_candidate(prefix),
+ decode_icon_candidate(icon)
+ );
+ if is_iconify_name(&candidate) {
+ return Some(candidate);
+ }
+ }
+ }
+ }
+
+ let without_ext = path.trim_end_matches(".svg");
+ let decoded = decode_icon_candidate(without_ext);
+ if is_iconify_name(&decoded) {
+ return Some(decoded);
+ }
+
+ if let Some((prefix, icon)) = decoded.split_once('/') {
+ let candidate = format!("{}:{}", prefix, icon);
+ if is_iconify_name(&candidate) {
+ return Some(candidate);
+ }
+ }
+ }
+
+ None
+}
+
+pub fn default_name_and_filename_from_icon_source(icon_source: &str) -> Option<(String, String)> {
+ let iconify_name = iconify_name_from_icon_source(icon_source)?;
+ let icon_name = iconify_name
+ .split_once(':')
+ .map(|(_, icon)| icon)
+ .unwrap_or(iconify_name.as_str());
+ let component_name = to_pascal_case(icon_name);
+
+ if component_name.is_empty() {
+ return None;
+ }
+
+ Some((component_name, iconify_name))
+}
+
+/// Util: Determines the type of icon source
+pub fn _determine_icon_source_type(icon_source: Option<&String>) -> IconSourceType {
+ match icon_source {
+ Some(icon) => {
+ if icon.trim_start().starts_with(" IconSourceType::None,
+ }
+}
+
+/// Util: Converts any icon_source into an SVG
+pub async fn _icon_source_to_svg(
+ icon_source: &Option,
+ append_attribute: Option<&'static str>,
+ remove_comments: bool,
+) -> anyhow::Result {
+ // If icon_source is missing, return a minimal SVG (Note: rust skill issue idk how else to just reuse the last clause in the match below)
+ let Some(icon_source) = icon_source else {
+ return Ok(r#" "#.to_string());
+ };
+
+ let mut content = match _determine_icon_source_type(Some(icon_source)) {
+ IconSourceType::SvgContent => {
+ // Already an SVG document
+ icon_source.clone()
+ }
+ IconSourceType::IconifyName => {
+ let client = IconifyClient::from_env()?;
+ client.svg(icon_source).await?
+ }
+ IconSourceType::Url => {
+ if let Some(iconify_name) = iconify_name_from_icon_source(icon_source) {
+ let client = IconifyClient::from_env()?;
+ client.svg(&iconify_name).await?
+ } else {
+ // Already a full URL
+ let icon_url = Url::parse(icon_source)?;
+ println!("Fetching icon from: {}", icon_url);
+
+ // Fetch the SVG content
+ let client = reqwest::Client::new();
+ let response = client.get(icon_url).send().await?.error_for_status()?;
+ response.text().await?
+ }
+ }
+ IconSourceType::None => {
+ return Ok(r#" "#.to_string());
+ }
+ };
+
+ // -- Transformations if applicable ---
+
+ // 1. Append attribute (i.e. for jsx,svelte,vue)
+ if let Some(attr) = append_attribute {
+ // Find the first occurrence of ""
+ if let Some(svg_start) = content.find("') {
+ let insert_pos = svg_start + svg_tag_end;
+ let before = &content[..insert_pos];
+ let after = &content[insert_pos..];
+ content = format!("{} {}{}", before, attr, after);
+ }
+ }
+ }
+
+ // 2. Remove Comments
+ if remove_comments {
+ // Remove HTML comments from SVG content
+ // This regex matches including any content in between
+ let re = regex::Regex::new(r"").unwrap();
+ content = re.replace_all(&content, "").to_string();
+ }
+
+ Ok(content)
+}
+
+/// Util: Reused in all cases, for appending the filename of svg, i.e. add .tsx or .svg or .svelte.
+/// Returns a file_stem and an ext
+pub fn _make_svg_filename(
+ stem_from_cli: Option<&String>,
+ ext: &'static str,
+ icon_source: Option<&String>,
+ name_from_cli: &str,
+) -> (String, &'static str) {
+ let stem = if let Some(stem) = stem_from_cli {
+ stem.clone()
+ } else if let Some(icon) = icon_source {
+ // Only use icon_source if it's a plain iconify name (no http/https, no {
+ iconify_name_from_icon_source(icon).unwrap_or(icon.clone())
+ }
+ IconSourceType::Url => iconify_name_from_icon_source(icon)
+ .unwrap_or_else(|| name_from_cli.to_string().to_lowercase()),
+ _ => name_from_cli.to_string().to_lowercase(),
+ }
+ } else {
+ name_from_cli.to_string().to_lowercase()
+ };
+
+ if stem.ends_with(ext) {
+ (stem.replace(ext, ""), ext)
+ } else {
+ (stem, ext)
+ }
+}
+
+// Util for tui view in add.
+pub fn filename_from_preset(file_name: Option, preset: Option) -> String {
+ if let Some(preset) = preset {
+ let ext = match preset {
+ Preset::Normal => "svg",
+ Preset::EmptySvg => "svg",
+ Preset::React => "tsx",
+ Preset::Svelte => "svelte",
+ Preset::Solid => "tsx",
+ Preset::Vue => "vue",
+ };
+
+ if let Some(name) = file_name {
+ if name.contains('.') {
+ return name;
+ } else {
+ return format!("{}.{}", name, ext);
+ }
+ } else {
+ return format!("component.{}", ext);
+ }
+ }
+
+ if let Some(name) = file_name {
+ return name;
+ }
+
+ "".to_string()
+}
+
+/// Util: Reads a file line-by-line and extracts every icon entry that matches
+/// the template used by the current project.
+/// Returns a vector of `IconEntry` with the export alias and import file path.
+pub fn get_existing_icons(folder_path: &str) -> anyhow::Result> {
+ use std::fs::File;
+ use std::io::{BufRead, BufReader};
+ use std::path::Path;
+
+ let index_path = Path::new(folder_path).join("index.ts"); // FUTURE: for flutter suport, make sure to configure this + the parsing of it.
+
+ let file = File::open(&index_path)?;
+
+ let reader = BufReader::new(file);
+
+ let mut icons = Vec::new();
+
+ for line in reader.lines() {
+ let line = line?;
+
+ // Skip empty lines and comments
+ if line.trim().is_empty() || line.trim_start().starts_with("//") {
+ continue;
+ }
+
+ // Parse one or more exports per physical line.
+ // This keeps discovery resilient if exports were accidentally concatenated.
+ for statement in line.split(';') {
+ let statement = statement.trim();
+ if statement.is_empty() || statement.starts_with("//") {
+ continue;
+ }
+
+ if let Some(icon_entry) = parse_export_line_ts(statement) {
+ icons.push(icon_entry);
+ }
+ }
+ }
+
+ Ok(icons)
+}
+
+/// For parsing a single export line in typescript.
+pub fn parse_export_line_ts(line: &str) -> Option {
+ let line = line.trim();
+
+ // Skip empty lines and comments
+ if line.is_empty() || line.trim_start().starts_with("//") {
+ return None;
+ }
+
+ // Example lines:
+ // export { default as IconGitHub } from "./devicon:github.svg";
+ // export { default as IconGitHub } from './devicon:github.svg';
+ if !line.starts_with("export") {
+ return None;
+ }
+
+ let open_brace_idx = line.find('{')?;
+ let close_brace_idx = line[open_brace_idx + 1..].find('}')? + open_brace_idx + 1;
+
+ let inside_braces = line[open_brace_idx + 1..close_brace_idx].trim();
+ let mut tokens = inside_braces.split_whitespace();
+ if tokens.next()? != "default" || tokens.next()? != "as" {
+ return None;
+ }
+
+ let name = tokens.next()?.trim_end_matches(',');
+ if name.is_empty() {
+ return None;
+ }
+
+ let after_brace = line[close_brace_idx + 1..].trim_start();
+ let after_from = after_brace.strip_prefix("from")?.trim_start();
+ let quote_char = after_from.chars().next()?;
+ if quote_char != '"' && quote_char != '\'' {
+ return None;
+ }
+
+ let path_start = quote_char.len_utf8();
+ let path_end = after_from[path_start..].find(quote_char)?;
+ let relative_path = &after_from[path_start..path_start + path_end];
+ let mut import_path_end = relative_path.len();
+ if let Some(index) = relative_path.find('?') {
+ import_path_end = import_path_end.min(index);
+ }
+ if let Some(index) = relative_path.find('#') {
+ import_path_end = import_path_end.min(index);
+ }
+ let import_path = relative_path[..import_path_end].trim();
+ if import_path.is_empty() {
+ return None;
+ }
+
+ Some(IconEntry {
+ name: name.to_string(),
+ file_path: import_path.to_string(),
+ })
+}
+
+// FUTURE:
+// pub fn _parse_export_line_dart(line: &str) -> Option {}
+
+/// Deletes an IconEntry based on its file path
+pub fn delete_icon_entry(file_path: &str) -> anyhow::Result<()> {
+ use std::fs;
+ use std::path::Path;
+
+ let path = Path::new(file_path);
+
+ // Delete the icon file when present. We still continue to clean index.ts
+ // if the file is already missing (stale export entry).
+ if path.exists() {
+ fs::remove_file(path)?;
+ }
+
+ // Find the parent folder and index.ts
+ if let Some(parent) = path.parent() {
+ let index_path = parent.join("index.ts");
+
+ if index_path.exists() {
+ // Read the current index.ts
+ let contents = fs::read_to_string(&index_path)?;
+
+ // Generate the file path relative to the parent folder
+ let relative_path = path
+ .strip_prefix(parent)
+ .ok()
+ .and_then(|value| value.to_str())
+ .unwrap_or(file_path);
+
+ // Create the normalized relative path for comparison
+ let normalized_relative_path = normalize_icon_relative_path(relative_path);
+ // Remove all lines that export this file
+ let mut lines_to_keep = Vec::new();
+ let mut found_export = false;
+
+ for line in contents.lines() {
+ let should_remove = parse_export_line_ts(line)
+ .map(|entry| {
+ normalize_icon_relative_path(&entry.file_path) == normalized_relative_path
+ })
+ .unwrap_or(false);
+
+ if should_remove {
+ found_export = true;
+ continue; // Skip this line (remove it)
+ }
+ lines_to_keep.push(line);
+ }
+
+ if found_export {
+ // Write the updated content back
+ let mut updated_content = lines_to_keep.join("\n");
+ if contents.ends_with('\n') {
+ updated_content.push('\n');
+ }
+ fs::write(&index_path, updated_content)?;
+ // println!("Updated index.ts");
+ }
+ }
+ }
+
+ Ok(())
+}
+
+fn normalize_icon_relative_path(value: &str) -> String {
+ value
+ .trim()
+ .trim_matches('"')
+ .trim_matches('\'')
+ .replace('\\', "/")
+ .trim_start_matches("./")
+ .to_string()
+}
+
+fn split_import_path_suffix(value: &str) -> (&str, &str) {
+ let query_index = value.find('?');
+ let hash_index = value.find('#');
+
+ let split_index = match (query_index, hash_index) {
+ (Some(query), Some(hash)) => Some(query.min(hash)),
+ (Some(index), None) | (None, Some(index)) => Some(index),
+ (None, None) => None,
+ };
+
+ match split_index {
+ Some(index) => (&value[..index], &value[index..]),
+ None => (value, ""),
+ }
+}
+
+pub fn rename_icon_entry(
+ folder_path: &str,
+ current_file_path: &str,
+ new_file_path_input: &str,
+) -> anyhow::Result<()> {
+ use std::fs;
+ use std::path::{Component, Path};
+
+ let current_relative_path = normalize_icon_relative_path(current_file_path);
+ if current_relative_path.is_empty() {
+ anyhow::bail!("Current icon path is empty");
+ }
+
+ let mut new_relative_path = normalize_icon_relative_path(new_file_path_input);
+ if new_relative_path.is_empty() {
+ anyhow::bail!("New filename cannot be empty");
+ }
+
+ let new_path = Path::new(&new_relative_path);
+ if new_path.is_absolute() {
+ anyhow::bail!("Please provide a relative filename");
+ }
+ if new_path
+ .components()
+ .any(|component| matches!(component, Component::ParentDir))
+ {
+ anyhow::bail!("Parent directory traversals are not allowed");
+ }
+
+ if new_path.extension().is_none() {
+ if let Some(extension) = Path::new(¤t_relative_path)
+ .extension()
+ .and_then(|ext| ext.to_str())
+ {
+ new_relative_path = format!("{}.{}", new_relative_path, extension);
+ }
+ }
+
+ if new_relative_path == current_relative_path {
+ anyhow::bail!("Filename is unchanged");
+ }
+
+ let folder = Path::new(folder_path);
+ let current_abs_path = folder.join(¤t_relative_path);
+ if !current_abs_path.exists() {
+ anyhow::bail!("Icon file not found: {}", current_abs_path.display());
+ }
+
+ let new_abs_path = folder.join(&new_relative_path);
+ if new_abs_path.exists() {
+ anyhow::bail!("Target file already exists: {}", new_abs_path.display());
+ }
+
+ let index_path = folder.join("index.ts");
+ if !index_path.exists() {
+ anyhow::bail!("No index.ts found in folder: {}", folder.display());
+ }
+
+ let index_contents = fs::read_to_string(&index_path)?;
+ let mut replaced_count = 0usize;
+ let mut updated_lines = Vec::new();
+ for line in index_contents.lines() {
+ if let Some(from_pos) = line.find("from ") {
+ let path_start_search = from_pos + "from ".len();
+ let line_after_from = &line[path_start_search..];
+ if let Some(first_quote_offset) = line_after_from.find(|c| c == '"' || c == '\'') {
+ let first_quote_idx = path_start_search + first_quote_offset;
+ let quote_char = line.as_bytes()[first_quote_idx] as char;
+ let path_start_idx = first_quote_idx + 1;
+ if let Some(second_quote_offset) = line[path_start_idx..].find(quote_char) {
+ let path_end_idx = path_start_idx + second_quote_offset;
+ let matched_path = &line[path_start_idx..path_end_idx];
+ let (matched_base_path, matched_suffix) =
+ split_import_path_suffix(matched_path);
+
+ if normalize_icon_relative_path(matched_base_path) == current_relative_path {
+ let with_dot_prefix = matched_base_path.starts_with("./");
+ let replacement_path = if with_dot_prefix {
+ format!("./{}", new_relative_path)
+ } else {
+ new_relative_path.clone()
+ };
+ let updated_line = format!(
+ "{}{}{}{}{}",
+ &line[..path_start_idx],
+ replacement_path,
+ matched_suffix,
+ quote_char,
+ &line[path_end_idx + 1..]
+ );
+ updated_lines.push(updated_line);
+ replaced_count += 1;
+ continue;
+ }
+ }
+ }
+ }
+
+ updated_lines.push(line.to_string());
+ }
+
+ if replaced_count == 0 {
+ anyhow::bail!(
+ "Could not find an export path for '{}' in index.ts",
+ current_file_path
+ );
+ }
+
+ if let Some(parent) = new_abs_path.parent() {
+ fs::create_dir_all(parent)?;
+ }
+
+ fs::rename(¤t_abs_path, &new_abs_path)?;
+
+ let mut updated_index = updated_lines.join("\n");
+ if index_contents.ends_with('\n') {
+ updated_index.push('\n');
+ }
+ if let Err(write_error) = fs::write(&index_path, updated_index) {
+ let _ = fs::rename(&new_abs_path, ¤t_abs_path);
+ anyhow::bail!(
+ "Failed to update index.ts after rename: {}. Rolled back file rename.",
+ write_error
+ );
+ }
+
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use tempfile::TempDir;
+
+ #[test]
+ fn parses_iconify_name_from_plain_value() {
+ assert_eq!(
+ iconify_name_from_icon_source("mdi:heart-outline"),
+ Some("mdi:heart-outline".to_string())
+ );
+ }
+
+ #[test]
+ fn parses_iconify_name_from_iconify_api_url() {
+ assert_eq!(
+ iconify_name_from_icon_source("https://api.iconify.design/mdi%3Aheart.svg"),
+ Some("mdi:heart".to_string())
+ );
+ }
+
+ #[test]
+ fn parses_iconify_name_from_icones_collection_url() {
+ assert_eq!(
+ iconify_name_from_icon_source("https://icones.js.org/collection/lucide/icon/heart"),
+ Some("lucide:heart".to_string())
+ );
+ }
+
+ #[test]
+ fn parses_iconify_name_from_icones_query_url() {
+ assert_eq!(
+ iconify_name_from_icon_source("https://icones.js.org/?icon=tabler:home"),
+ Some("tabler:home".to_string())
+ );
+ }
+
+ #[test]
+ fn derives_name_and_filename_defaults() {
+ assert_eq!(
+ default_name_and_filename_from_icon_source(
+ "https://api.iconify.design/mdi:arrow-left.svg"
+ ),
+ Some(("ArrowLeft".to_string(), "mdi:arrow-left".to_string()))
+ );
+ }
+
+ #[test]
+ fn parses_typescript_export_with_double_quotes() {
+ let parsed =
+ parse_export_line_ts("export { default as IconGithub } from \"./mdi:github.svg\";")
+ .expect("export with double quotes should parse");
+
+ assert_eq!(parsed.name, "IconGithub");
+ assert_eq!(parsed.file_path, "./mdi:github.svg");
+ }
+
+ #[test]
+ fn parses_typescript_export_with_single_quotes() {
+ let parsed =
+ parse_export_line_ts("export { default as IconGithub } from './mdi:github.svg';")
+ .expect("export with single quotes should parse");
+
+ assert_eq!(parsed.name, "IconGithub");
+ assert_eq!(parsed.file_path, "./mdi:github.svg");
+ }
+
+ #[test]
+ fn parses_typescript_export_with_extra_spacing() {
+ let parsed = parse_export_line_ts(
+ "export { default as IconGithub } from './mdi:github.svg';",
+ )
+ .expect("export with variable spacing should parse");
+
+ assert_eq!(parsed.name, "IconGithub");
+ assert_eq!(parsed.file_path, "./mdi:github.svg");
+ }
+
+ #[test]
+ fn parses_typescript_export_and_strips_import_query() {
+ let parsed = parse_export_line_ts(
+ "export { default as IconGithub } from './mdi:github.svg?react#hash';",
+ )
+ .expect("export with query suffix should parse");
+
+ assert_eq!(parsed.name, "IconGithub");
+ assert_eq!(parsed.file_path, "./mdi:github.svg");
+ }
+
+ #[test]
+ fn renames_file_and_updates_index_path() {
+ let temp_dir = TempDir::new().expect("temp dir should be created");
+ let icons_folder = temp_dir.path().join("icons");
+ std::fs::create_dir_all(&icons_folder).expect("icons folder should be created");
+
+ let old_file = icons_folder.join("old-name.svg");
+ std::fs::write(&old_file, " ").expect("old file should be created");
+
+ let index_path = icons_folder.join("index.ts");
+ std::fs::write(
+ &index_path,
+ "export { default as IconHeart } from \"./old-name.svg\";\n",
+ )
+ .expect("index.ts should be created");
+
+ rename_icon_entry(
+ icons_folder.to_string_lossy().as_ref(),
+ "./old-name.svg",
+ "new-name",
+ )
+ .expect("rename should succeed");
+
+ assert!(!old_file.exists(), "old file should be removed");
+ assert!(
+ icons_folder.join("new-name.svg").exists(),
+ "new file should exist"
+ );
+
+ let index_contents =
+ std::fs::read_to_string(&index_path).expect("index.ts should be readable");
+ assert!(
+ index_contents.contains("export { default as IconHeart } from \"./new-name.svg\";"),
+ "index.ts should point to the renamed file"
+ );
+ }
+
+ #[test]
+ fn renames_path_without_touching_alias() {
+ let temp_dir = TempDir::new().expect("temp dir should be created");
+ let icons_folder = temp_dir.path().join("icons");
+ std::fs::create_dir_all(&icons_folder).expect("icons folder should be created");
+
+ let old_file = icons_folder.join("foo.svg");
+ std::fs::write(&old_file, " ").expect("old file should be created");
+
+ let index_path = icons_folder.join("index.ts");
+ std::fs::write(
+ &index_path,
+ "export { default as IconAliasStays } from './foo.svg';\n",
+ )
+ .expect("index.ts should be created");
+
+ rename_icon_entry(
+ icons_folder.to_string_lossy().as_ref(),
+ "./foo.svg",
+ "bar.svg",
+ )
+ .expect("rename should succeed");
+
+ let index_contents =
+ std::fs::read_to_string(&index_path).expect("index.ts should be readable");
+ assert!(
+ index_contents.contains("export { default as IconAliasStays } from './bar.svg';"),
+ "index.ts should update only the path"
+ );
+ assert!(
+ !index_contents.contains("Iconbar") && index_contents.contains("IconAliasStays"),
+ "icon alias should stay unchanged"
+ );
+ }
+
+ #[test]
+ fn renames_file_and_preserves_import_suffix() {
+ let temp_dir = TempDir::new().expect("temp dir should be created");
+ let icons_folder = temp_dir.path().join("icons");
+ std::fs::create_dir_all(&icons_folder).expect("icons folder should be created");
+
+ let old_file = icons_folder.join("foo.svg");
+ std::fs::write(&old_file, " ").expect("old file should be created");
+
+ let index_path = icons_folder.join("index.ts");
+ std::fs::write(
+ &index_path,
+ "export { default as IconAliasStays } from './foo.svg?react#hash';\n",
+ )
+ .expect("index.ts should be created");
+
+ rename_icon_entry(
+ icons_folder.to_string_lossy().as_ref(),
+ "./foo.svg",
+ "bar.svg",
+ )
+ .expect("rename should succeed");
+
+ let index_contents =
+ std::fs::read_to_string(&index_path).expect("index.ts should be readable");
+ assert!(
+ index_contents
+ .contains("export { default as IconAliasStays } from './bar.svg?react#hash';"),
+ "index.ts should preserve import suffixes"
+ );
+ }
+
+ #[test]
+ fn get_existing_icons_reads_multiple_exports_on_same_line() {
+ let temp_dir = TempDir::new().expect("temp dir should be created");
+ let icons_folder = temp_dir.path().join("icons");
+ std::fs::create_dir_all(&icons_folder).expect("icons folder should be created");
+
+ let index_path = icons_folder.join("index.ts");
+ std::fs::write(
+ &index_path,
+ "export { default as IconGithub } from './mdi:github.svg';export { default as IconDarkTheme24Filled } from './fluent:dark-theme-24-filled.svg';\n",
+ )
+ .expect("index.ts should be created");
+
+ let icons = get_existing_icons(icons_folder.to_string_lossy().as_ref())
+ .expect("icons should be discovered");
+
+ assert_eq!(icons.len(), 2, "both exports should be discovered");
+ assert!(icons.iter().any(|icon| icon.name == "IconGithub"));
+ assert!(
+ icons
+ .iter()
+ .any(|icon| icon.name == "IconDarkTheme24Filled")
+ );
+ }
+
+ #[test]
+ fn delete_icon_entry_preserves_trailing_newline() {
+ let temp_dir = TempDir::new().expect("temp dir should be created");
+ let icons_folder = temp_dir.path().join("icons");
+ std::fs::create_dir_all(&icons_folder).expect("icons folder should be created");
+
+ let keep_file = icons_folder.join("keep.svg");
+ let remove_file = icons_folder.join("remove.svg");
+ std::fs::write(&keep_file, " ").expect("keep icon should be created");
+ std::fs::write(&remove_file, " ").expect("remove icon should be created");
+
+ let index_path = icons_folder.join("index.ts");
+ std::fs::write(
+ &index_path,
+ "export { default as IconKeep } from './keep.svg';\nexport { default as IconRemove } from './remove.svg';\n",
+ )
+ .expect("index.ts should be created");
+
+ delete_icon_entry(remove_file.to_string_lossy().as_ref())
+ .expect("delete should remove icon entry");
+
+ let updated_index =
+ std::fs::read_to_string(&index_path).expect("index.ts should be readable");
+ assert!(
+ updated_index.ends_with('\n'),
+ "index.ts should retain trailing newline"
+ );
+ assert!(updated_index.contains("IconKeep"));
+ assert!(!updated_index.contains("IconRemove"));
+ }
+
+ #[test]
+ fn delete_icon_entry_removes_only_exact_export_path() {
+ let temp_dir = TempDir::new().expect("temp dir should be created");
+ let icons_folder = temp_dir.path().join("icons");
+ std::fs::create_dir_all(&icons_folder).expect("icons folder should be created");
+
+ let remove_file = icons_folder.join("remove.svg");
+ let keep_file = icons_folder.join("remove-filled.svg");
+ std::fs::write(&remove_file, " ").expect("remove icon should be created");
+ std::fs::write(&keep_file, " ").expect("keep icon should be created");
+
+ let index_path = icons_folder.join("index.ts");
+ std::fs::write(
+ &index_path,
+ "export { default as IconRemove } from './remove.svg';\nexport { default as IconRemoveFilled } from './remove-filled.svg';\n",
+ )
+ .expect("index.ts should be created");
+
+ delete_icon_entry(remove_file.to_string_lossy().as_ref())
+ .expect("delete should remove only the exact icon entry");
+
+ let updated_index =
+ std::fs::read_to_string(&index_path).expect("index.ts should be readable");
+ assert!(updated_index.contains("IconRemoveFilled"));
+ assert!(!updated_index.contains("IconRemove }"));
+ }
+
+ #[test]
+ fn delete_icon_entry_handles_absolute_dot_segment_path() {
+ let temp_dir = TempDir::new().expect("temp dir should be created");
+ let icons_folder = temp_dir.path().join("icons");
+ std::fs::create_dir_all(&icons_folder).expect("icons folder should be created");
+
+ let remove_file = icons_folder.join("remove.svg");
+ std::fs::write(&remove_file, " ").expect("remove icon should be created");
+
+ let index_path = icons_folder.join("index.ts");
+ std::fs::write(
+ &index_path,
+ "export { default as IconRemove } from './remove.svg';\n",
+ )
+ .expect("index.ts should be created");
+
+ let absolute_with_dot = icons_folder.join("./remove.svg");
+ delete_icon_entry(absolute_with_dot.to_string_lossy().as_ref())
+ .expect("delete should accept absolute paths with dot segments");
+
+ let updated_index =
+ std::fs::read_to_string(&index_path).expect("index.ts should be readable");
+ assert!(!updated_index.contains("IconRemove"));
+ }
+
+ #[test]
+ fn delete_icon_entry_updates_index_even_if_file_is_missing() {
+ let temp_dir = TempDir::new().expect("temp dir should be created");
+ let icons_folder = temp_dir.path().join("icons");
+ std::fs::create_dir_all(&icons_folder).expect("icons folder should be created");
+
+ let missing_file = icons_folder.join("fluent:dark-theme-24-regular.svg");
+
+ let index_path = icons_folder.join("index.ts");
+ std::fs::write(
+ &index_path,
+ "export { default as IconDarkTheme24Regular } from './fluent:dark-theme-24-regular.svg';\nexport { default as IconKeep } from './keep.svg';\n",
+ )
+ .expect("index.ts should be created");
+
+ delete_icon_entry(missing_file.to_string_lossy().as_ref())
+ .expect("delete should remove stale export when file is missing");
+
+ let updated_index =
+ std::fs::read_to_string(&index_path).expect("index.ts should be readable");
+ assert!(!updated_index.contains("IconDarkTheme24Regular"));
+ assert!(updated_index.contains("IconKeep"));
+ }
+}
diff --git a/src/viewer.rs b/src/viewer.rs
new file mode 100644
index 0000000..87e32dd
--- /dev/null
+++ b/src/viewer.rs
@@ -0,0 +1,200 @@
+use anyhow::Context;
+use std::path::Path;
+use std::process::{Command, Stdio};
+
+#[derive(Debug, Clone)]
+pub enum OpenSvgOutcome {
+ OpenedWithCustomCommand,
+ OpenedWithOsDefault,
+ OpenedWithOsDefaultAfterCustomFailure,
+ OpenedWithWebPreview(String),
+}
+
+pub fn open_svg_with_fallback(
+ svg_path: &Path,
+ svg_viewer_cmd: Option<&str>,
+) -> anyhow::Result {
+ if !svg_path.exists() {
+ anyhow::bail!("Icon file not found: {}", svg_path.display());
+ }
+
+ let mut errors = Vec::::new();
+
+ if let Some(command_template) = svg_viewer_cmd {
+ match open_with_custom_command(command_template, svg_path) {
+ Ok(()) => return Ok(OpenSvgOutcome::OpenedWithCustomCommand),
+ Err(error) => errors.push(format!("custom svg_viewer_cmd failed: {error}")),
+ }
+ }
+
+ match open_with_os_default(svg_path) {
+ Ok(()) => {
+ if svg_viewer_cmd.is_some() {
+ return Ok(OpenSvgOutcome::OpenedWithOsDefaultAfterCustomFailure);
+ }
+ return Ok(OpenSvgOutcome::OpenedWithOsDefault);
+ }
+ Err(error) => errors.push(format!("OS default open failed: {error}")),
+ }
+
+ if let Some(web_preview_url) = iconify_web_preview_url(svg_path) {
+ open_url_in_browser(&web_preview_url).with_context(|| {
+ format!(
+ "Failed to open web preview URL after local open failures: {}",
+ web_preview_url
+ )
+ })?;
+ return Ok(OpenSvgOutcome::OpenedWithWebPreview(web_preview_url));
+ }
+
+ if errors.is_empty() {
+ anyhow::bail!("Failed to open icon file {}", svg_path.display());
+ }
+
+ anyhow::bail!(
+ "Failed to open icon file {}. {}",
+ svg_path.display(),
+ errors.join(" | ")
+ )
+}
+
+fn open_with_custom_command(command_template: &str, svg_path: &Path) -> anyhow::Result<()> {
+ let mut parts = shlex::split(command_template).ok_or_else(|| {
+ anyhow::anyhow!(
+ "Could not parse svg_viewer_cmd. Check quoting in '{}'.",
+ command_template
+ )
+ })?;
+
+ if parts.is_empty() {
+ anyhow::bail!("svg_viewer_cmd is empty");
+ }
+
+ let file_name = svg_path.to_string_lossy().to_string();
+ let mut used_placeholder = false;
+ for part in &mut parts {
+ if part.contains("%filename%") {
+ *part = part.replace("%filename%", &file_name);
+ used_placeholder = true;
+ }
+ }
+
+ if !used_placeholder {
+ parts.push(file_name);
+ }
+
+ let executable = parts.remove(0);
+ spawn_background(&executable, &parts)
+ .with_context(|| format!("Failed to run svg_viewer_cmd '{}'.", command_template))
+}
+
+fn spawn_background(executable: &str, args: &[String]) -> anyhow::Result<()> {
+ Command::new(executable)
+ .args(args)
+ .stdout(Stdio::null())
+ .stderr(Stdio::null())
+ .stdin(Stdio::null())
+ .spawn()
+ .with_context(|| {
+ if args.is_empty() {
+ format!("Failed to spawn '{}'", executable)
+ } else {
+ format!("Failed to spawn '{}' with args {:?}", executable, args)
+ }
+ })?;
+ Ok(())
+}
+
+fn open_with_os_default(svg_path: &Path) -> anyhow::Result<()> {
+ let file_name = svg_path.to_string_lossy().to_string();
+
+ #[cfg(target_os = "macos")]
+ {
+ let ql_args = vec!["-p".to_string(), file_name.clone()];
+ if spawn_background("qlmanage", &ql_args).is_ok() {
+ return Ok(());
+ }
+
+ let open_args = vec![file_name];
+ spawn_background("open", &open_args)
+ .context("Failed to open icon via macOS Quick Look/open")
+ }
+
+ #[cfg(target_os = "linux")]
+ {
+ let args = vec![file_name];
+ return spawn_background("xdg-open", &args).context("Failed to open icon via xdg-open");
+ }
+
+ #[cfg(target_os = "windows")]
+ {
+ let args = vec![
+ "/C".to_string(),
+ "start".to_string(),
+ "".to_string(),
+ file_name,
+ ];
+ return spawn_background("cmd", &args).context("Failed to open icon via cmd start");
+ }
+
+ #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
+ {
+ anyhow::bail!("Unsupported OS for default icon opener")
+ }
+}
+
+fn iconify_web_preview_url(svg_path: &Path) -> Option {
+ let stem = svg_path.file_stem()?.to_string_lossy();
+ let icon_name = crate::utils::iconify_name_from_icon_source(stem.as_ref())?;
+ let encoded = icon_name.replace(':', "%3A");
+ Some(format!("https://api.iconify.design/{encoded}.svg"))
+}
+
+pub fn open_url_in_browser(url: &str) -> anyhow::Result<()> {
+ #[cfg(target_os = "macos")]
+ {
+ spawn_background("open", &[url.to_string()]).context("Failed to open URL via open")
+ }
+
+ #[cfg(target_os = "linux")]
+ {
+ return spawn_background("xdg-open", &[url.to_string()])
+ .context("Failed to open URL via xdg-open");
+ }
+
+ #[cfg(target_os = "windows")]
+ {
+ let args = vec![
+ "/C".to_string(),
+ "start".to_string(),
+ "".to_string(),
+ url.to_string(),
+ ];
+ return spawn_background("cmd", &args).context("Failed to open URL via cmd start");
+ }
+
+ #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
+ {
+ anyhow::bail!("Unsupported OS for URL opener")
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn builds_iconify_web_preview_url_from_iconify_stem() {
+ let path = Path::new("/tmp/mdi:heart.svg");
+ assert_eq!(
+ iconify_web_preview_url(path),
+ Some("https://api.iconify.design/mdi%3Aheart.svg".to_string())
+ );
+ }
+
+ #[test]
+ fn returns_none_for_non_iconify_stem() {
+ let path = Path::new("/tmp/logo.svg");
+ assert_eq!(iconify_web_preview_url(path), None);
+ }
+}
diff --git a/src/views/add_popup.rs b/src/views/add_popup.rs
new file mode 100644
index 0000000..5ad7d2c
--- /dev/null
+++ b/src/views/add_popup.rs
@@ -0,0 +1,660 @@
+use std::process::Command;
+
+use crate::app_state::{App, AppFocus};
+use crate::utils::{PRESETS_OPTIONS, Preset, PresetOption, popup_area};
+use ratatui::Frame;
+use ratatui::layout::{Alignment, Constraint};
+use ratatui::style::Style;
+use ratatui::text::Line;
+use ratatui::widgets::{Block, ListItem, Paragraph};
+use tui_textarea::{Input, Key, TextArea};
+
+// Constants
+// const FOLDER_FIELD_IDX: usize = 0;
+const PRESET_FIELD_IDX: usize = 0;
+const ICON_FIELD_IDX: usize = 1;
+const FILENAME_FIELD_IDX: usize = 2;
+const NAME_FIELD_IDX: usize = 3;
+const DEFAULT_OUTPUT_LINE_TEMPLATE: &str = "export { default as Icon%name% } from './%icon%%ext%';";
+
+#[derive(Debug)]
+pub struct AddPopupState {
+ // Saved values
+ // folder: Option,
+ preset: Option,
+ icon: Option,
+ filename: Option,
+ name: Option,
+
+ // Form States
+ pub current_input: usize,
+ pub inputs: Vec