From 403d7086d404f4efb6cbbb41a83a0f8fdbb107d9 Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Mon, 6 Jul 2026 01:06:43 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A5=D1=83=D0=B9=D0=BD=D1=8F=20=D0=BD=D0=B5?= =?UTF-8?q?=20=D0=BD=D0=B0=20tauri?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 249 +++- src/Cargo.toml | 2 + src/i18n/mod.rs | 159 +++ src/i18n/strings/common.rs | 240 ++++ src/i18n/strings/dialogs.rs | 233 ++++ src/i18n/strings/menu.rs | 3 + src/i18n/strings/mod.rs | 23 + src/i18n/strings/panels.rs | 538 ++++++++ src/i18n/strings/settings.rs | 831 +++++++++++ src/i18n/strings/toolbar.rs | 251 ++++ src/i18n/strings/welcome.rs | 3 + src/lib.rs | 15 +- src/protocols/ftp.rs | 259 ---- src/protocols/ftp/mod.rs | 57 + src/protocols/ftp/ops.rs | 126 ++ src/protocols/ftp/transfer.rs | 101 ++ src/protocols/sftp.rs | 343 ----- src/protocols/sftp/hostkey.rs | 66 + src/protocols/sftp/mod.rs | 93 ++ src/protocols/sftp/ops.rs | 169 +++ src/protocols/sftp/transfer.rs | 100 ++ src/storage/db.rs | 240 ---- src/storage/db/bookmarks.rs | 25 + src/storage/db/history.rs | 98 ++ src/storage/db/mod.rs | 98 ++ src/storage/db/settings.rs | 43 + src/storage/db/sites.rs | 57 + src/transfer/worker.rs | 269 ++-- src/ui/app.rs | 1215 ----------------- src/ui/app/dialogs.rs | 164 +++ src/ui/app/mod.rs | 187 +++ src/ui/app/ops.rs | 152 +++ src/ui/app/poll.rs | 135 ++ src/ui/app/results.rs | 143 ++ src/ui/app/screens.rs | 106 ++ src/ui/app/settings/about.rs | 36 + src/ui/app/settings/connections.rs | 192 +++ src/ui/app/settings/general.rs | 157 +++ src/ui/app/settings/mod.rs | 219 +++ src/ui/app/settings/nav.rs | 107 ++ src/ui/app/settings/prefs.rs | 67 + src/ui/app/settings/transfers.rs | 59 + src/ui/app/visuals.rs | 101 ++ src/ui/app/welcome.rs | 126 ++ src/ui/dialogs/connection.rs | 426 ------ src/ui/dialogs/connection/actions.rs | 159 +++ src/ui/dialogs/connection/form.rs | 173 +++ src/ui/dialogs/connection/history.rs | 82 ++ src/ui/dialogs/connection/mod.rs | 9 + src/ui/dialogs/connection/view.rs | 137 ++ src/ui/dock.rs | 102 ++ src/ui/icons.rs | 1 + src/ui/icons/svg/add-circle-linear.svg | 2 +- src/ui/icons/svg/add-square-linear.svg | 2 +- src/ui/icons/svg/alt-arrow-down-linear.svg | 2 +- src/ui/icons/svg/alt-arrow-up-linear.svg | 2 +- src/ui/icons/svg/clock-circle-linear.svg | 2 +- src/ui/icons/svg/close-circle-linear.svg | 2 +- src/ui/icons/svg/database-linear.svg | 2 +- src/ui/icons/svg/download-linear.svg | 2 +- src/ui/icons/svg/folder-with-files-linear.svg | 2 +- src/ui/icons/svg/history-linear.svg | 2 +- src/ui/icons/svg/pause-circle-linear.svg | 2 +- src/ui/icons/svg/pen-linear.svg | 2 +- src/ui/icons/svg/play-circle-linear.svg | 2 +- src/ui/icons/svg/server-square-linear.svg | 2 +- src/ui/icons/svg/ssd-square-linear.svg | 2 +- .../svg/trash-bin-minimalistic-linear.svg | 2 +- src/ui/icons/svg/upload-linear.svg | 2 +- src/ui/menu.rs | 48 + src/ui/mod.rs | 2 + src/ui/panels/file_pane.rs | 398 ------ src/ui/panels/file_pane/mod.rs | 93 ++ src/ui/panels/file_pane/row.rs | 120 ++ src/ui/panels/file_pane/sort.rs | 81 ++ src/ui/panels/file_pane/table.rs | 150 ++ src/ui/panels/local_pane.rs | 414 ------ src/ui/panels/local_pane/actions.rs | 199 +++ src/ui/panels/local_pane/bars.rs | 137 ++ src/ui/panels/local_pane/mod.rs | 131 ++ src/ui/panels/mod.rs | 1 - src/ui/panels/queue.rs | 64 +- src/ui/panels/remote_pane.rs | 496 ------- src/ui/panels/remote_pane/actions.rs | 189 +++ src/ui/panels/remote_pane/bars.rs | 170 +++ src/ui/panels/remote_pane/mod.rs | 183 +++ src/ui/panels/sidebar.rs | 104 +- src/ui/panels/status_bar.rs | 6 +- src/ui/panels/tabs.rs | 112 -- src/ui/panels/toolbar.rs | 355 ----- src/ui/panels/toolbar/actions.rs | 52 + src/ui/panels/toolbar/buttons.rs | 72 + src/ui/panels/toolbar/history.rs | 101 ++ src/ui/panels/toolbar/mod.rs | 189 +++ src/ui/state.rs | 252 ---- src/ui/state/defaults.rs | 88 ++ src/ui/state/mod.rs | 134 ++ src/ui/state/types.rs | 74 + src/ui/widgets/button.rs | 80 ++ src/ui/widgets/context_menu.rs | 47 + src/ui/widgets/mod.rs | 8 + src/ui/widgets/modal.rs | 67 + src/ui/widgets/overlay.rs | 57 + src/ui/widgets/row.rs | 59 + 104 files changed, 8941 insertions(+), 4772 deletions(-) create mode 100644 src/i18n/mod.rs create mode 100644 src/i18n/strings/common.rs create mode 100644 src/i18n/strings/dialogs.rs create mode 100644 src/i18n/strings/menu.rs create mode 100644 src/i18n/strings/mod.rs create mode 100644 src/i18n/strings/panels.rs create mode 100644 src/i18n/strings/settings.rs create mode 100644 src/i18n/strings/toolbar.rs create mode 100644 src/i18n/strings/welcome.rs delete mode 100644 src/protocols/ftp.rs create mode 100644 src/protocols/ftp/mod.rs create mode 100644 src/protocols/ftp/ops.rs create mode 100644 src/protocols/ftp/transfer.rs delete mode 100644 src/protocols/sftp.rs create mode 100644 src/protocols/sftp/hostkey.rs create mode 100644 src/protocols/sftp/mod.rs create mode 100644 src/protocols/sftp/ops.rs create mode 100644 src/protocols/sftp/transfer.rs delete mode 100644 src/storage/db.rs create mode 100644 src/storage/db/bookmarks.rs create mode 100644 src/storage/db/history.rs create mode 100644 src/storage/db/mod.rs create mode 100644 src/storage/db/settings.rs create mode 100644 src/storage/db/sites.rs delete mode 100644 src/ui/app.rs create mode 100644 src/ui/app/dialogs.rs create mode 100644 src/ui/app/mod.rs create mode 100644 src/ui/app/ops.rs create mode 100644 src/ui/app/poll.rs create mode 100644 src/ui/app/results.rs create mode 100644 src/ui/app/screens.rs create mode 100644 src/ui/app/settings/about.rs create mode 100644 src/ui/app/settings/connections.rs create mode 100644 src/ui/app/settings/general.rs create mode 100644 src/ui/app/settings/mod.rs create mode 100644 src/ui/app/settings/nav.rs create mode 100644 src/ui/app/settings/prefs.rs create mode 100644 src/ui/app/settings/transfers.rs create mode 100644 src/ui/app/visuals.rs create mode 100644 src/ui/app/welcome.rs delete mode 100644 src/ui/dialogs/connection.rs create mode 100644 src/ui/dialogs/connection/actions.rs create mode 100644 src/ui/dialogs/connection/form.rs create mode 100644 src/ui/dialogs/connection/history.rs create mode 100644 src/ui/dialogs/connection/mod.rs create mode 100644 src/ui/dialogs/connection/view.rs create mode 100644 src/ui/dock.rs delete mode 100644 src/ui/panels/file_pane.rs create mode 100644 src/ui/panels/file_pane/mod.rs create mode 100644 src/ui/panels/file_pane/row.rs create mode 100644 src/ui/panels/file_pane/sort.rs create mode 100644 src/ui/panels/file_pane/table.rs delete mode 100644 src/ui/panels/local_pane.rs create mode 100644 src/ui/panels/local_pane/actions.rs create mode 100644 src/ui/panels/local_pane/bars.rs create mode 100644 src/ui/panels/local_pane/mod.rs delete mode 100644 src/ui/panels/remote_pane.rs create mode 100644 src/ui/panels/remote_pane/actions.rs create mode 100644 src/ui/panels/remote_pane/bars.rs create mode 100644 src/ui/panels/remote_pane/mod.rs delete mode 100644 src/ui/panels/tabs.rs delete mode 100644 src/ui/panels/toolbar.rs create mode 100644 src/ui/panels/toolbar/actions.rs create mode 100644 src/ui/panels/toolbar/buttons.rs create mode 100644 src/ui/panels/toolbar/history.rs create mode 100644 src/ui/panels/toolbar/mod.rs delete mode 100644 src/ui/state.rs create mode 100644 src/ui/state/defaults.rs create mode 100644 src/ui/state/mod.rs create mode 100644 src/ui/state/types.rs create mode 100644 src/ui/widgets/button.rs create mode 100644 src/ui/widgets/context_menu.rs create mode 100644 src/ui/widgets/mod.rs create mode 100644 src/ui/widgets/modal.rs create mode 100644 src/ui/widgets/overlay.rs create mode 100644 src/ui/widgets/row.rs diff --git a/Cargo.lock b/Cargo.lock index 0843b7d..195303b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -275,6 +275,28 @@ dependencies = [ "libloading", ] +[[package]] +name = "ashpd" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" +dependencies = [ + "async-fs 2.2.0", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.4", + "raw-window-handle", + "serde", + "serde_repr", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus 5.16.0", +] + [[package]] name = "async-broadcast" version = "0.5.1" @@ -442,6 +464,17 @@ dependencies = [ "url", ] +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io 2.6.0", + "blocking", + "futures-lite 2.6.1", +] + [[package]] name = "async-process" version = "1.8.1" @@ -762,6 +795,15 @@ dependencies = [ "objc2 0.5.2", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + [[package]] name = "blocking" version = "1.6.2" @@ -1243,6 +1285,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.13.0", + "block2 0.6.2", + "libc", "objc2 0.6.4", ] @@ -1287,6 +1331,17 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +[[package]] +name = "duplicate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e92f10a49176cbffacaedabfaa11d51db1ea0f80a83c26e1873b43cd1742c24" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "proc-macro2-diagnostics", +] + [[package]] name = "ecolor" version = "0.29.1" @@ -1383,6 +1438,17 @@ dependencies = [ "winit", ] +[[package]] +name = "egui_dock" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fe4e3414481dea5ed59fdfa88f2e00b6ea60fbe758ca7a8636d0dbed93ab9cf" +dependencies = [ + "duplicate", + "egui", + "paste", +] + [[package]] name = "egui_extras" version = "0.29.1" @@ -2959,11 +3025,13 @@ dependencies = [ "dirs", "eframe", "egui", + "egui_dock", "egui_extras", "futures-lite 2.6.1", "image", "keyring", "muda", + "rfd", "rusqlite", "serde", "serde_json", @@ -3425,7 +3493,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ "bitflags 2.13.0", - "block2", + "block2 0.5.1", "libc", "objc2 0.5.2", "objc2-core-data", @@ -3441,6 +3509,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.0", + "block2 0.6.2", "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", @@ -3454,7 +3523,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ "bitflags 2.13.0", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", "objc2-foundation 0.2.2", @@ -3466,7 +3535,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -3478,7 +3547,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ "bitflags 2.13.0", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -3513,7 +3582,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", "objc2-metal", @@ -3525,7 +3594,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-contacts", "objc2-foundation 0.2.2", @@ -3544,7 +3613,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ "bitflags 2.13.0", - "block2", + "block2 0.5.1", "dispatch", "libc", "objc2 0.5.2", @@ -3578,7 +3647,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -3591,7 +3660,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ "bitflags 2.13.0", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -3603,7 +3672,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ "bitflags 2.13.0", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", "objc2-metal", @@ -3626,7 +3695,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ "bitflags 2.13.0", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-cloud-kit", "objc2-core-data", @@ -3646,7 +3715,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -3658,7 +3727,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ "bitflags 2.13.0", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", "objc2-foundation 0.2.2", @@ -3981,6 +4050,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + [[package]] name = "potential_utf" version = "0.1.5" @@ -4077,6 +4152,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "version_check", +] + [[package]] name = "profiling" version = "1.0.18" @@ -4385,6 +4472,30 @@ dependencies = [ "usvg", ] +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "ashpd", + "block2 0.6.2", + "dispatch2", + "js-sys", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "pollster", + "raw-window-handle", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + [[package]] name = "rgb" version = "0.8.53" @@ -5341,8 +5452,15 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "usvg" version = "0.37.0" @@ -5401,6 +5519,7 @@ checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ "getrandom 0.4.2", "js-sys", + "serde_core", "wasm-bindgen", ] @@ -6272,7 +6391,7 @@ dependencies = [ "android-activity", "atomic-waker", "bitflags 2.13.0", - "block2", + "block2 0.5.1", "bytemuck", "calloop 0.13.0", "cfg_aliases 0.2.1", @@ -6629,6 +6748,41 @@ dependencies = [ "zvariant 4.2.0", ] +[[package]] +name = "zbus" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" +dependencies = [ + "async-broadcast 0.7.2", + "async-executor", + "async-io 2.6.0", + "async-lock 3.4.2", + "async-process 2.5.0", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener 5.4.1", + "futures-core", + "futures-lite 2.6.1", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.3", + "zbus_macros 5.16.0", + "zbus_names 4.3.2", + "zvariant 5.12.0", +] + [[package]] name = "zbus-lockstep" version = "0.4.4" @@ -6680,6 +6834,21 @@ dependencies = [ "zvariant_utils 2.1.0", ] +[[package]] +name = "zbus_macros" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zbus_names 4.3.2", + "zvariant 5.12.0", + "zvariant_utils 3.4.0", +] + [[package]] name = "zbus_names" version = "2.6.1" @@ -6702,6 +6871,17 @@ dependencies = [ "zvariant 4.2.0", ] +[[package]] +name = "zbus_names" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +dependencies = [ + "serde", + "winnow 1.0.3", + "zvariant 5.12.0", +] + [[package]] name = "zbus_xml" version = "4.0.0" @@ -6846,6 +7026,21 @@ dependencies = [ "zvariant_derive 4.2.0", ] +[[package]] +name = "zvariant" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" +dependencies = [ + "endi", + "enumflags2", + "serde", + "url", + "winnow 1.0.3", + "zvariant_derive 5.12.0", + "zvariant_utils 3.4.0", +] + [[package]] name = "zvariant_derive" version = "3.15.2" @@ -6872,6 +7067,19 @@ dependencies = [ "zvariant_utils 2.1.0", ] +[[package]] +name = "zvariant_derive" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils 3.4.0", +] + [[package]] name = "zvariant_utils" version = "1.0.1" @@ -6893,3 +7101,16 @@ dependencies = [ "quote", "syn 2.0.117", ] + +[[package]] +name = "zvariant_utils" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", + "winnow 1.0.3", +] diff --git a/src/Cargo.toml b/src/Cargo.toml index 16a14be..cda3b95 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -35,4 +35,6 @@ chrono = "0.4" futures-lite = "2" image = "0.25" muda = "0.19.3" +egui_dock = "0.14" +rfd = "0.15" diff --git a/src/i18n/mod.rs b/src/i18n/mod.rs new file mode 100644 index 0000000..6d23bc1 --- /dev/null +++ b/src/i18n/mod.rs @@ -0,0 +1,159 @@ +//! Локализация интерфейса. Иммедиэйт-мод UI перерисовывается целиком каждый +//! кадр на главном потоке, поэтому вместо протаскивания языка через сигнатуры +//! всех функций рендера — один глобальный атомик с текущим языком плюс +//! функция [`t`] для поиска перевода по ключу. Переводы лежат в `strings/*`, +//! по одному файлу на область интерфейса, и собираются в одну таблицу лениво. +mod strings; + +use std::collections::HashMap; +use std::sync::OnceLock; +use std::sync::atomic::{AtomicU8, Ordering}; + +/// Порядок вариантов — он же порядок колонок в каждом `[&str; N]` из `strings/*`. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Lang { + En, + Ru, + Es, + Fr, + De, + It, + Pt, + Pl, + Zh, + Ja, + Ko, + Tr, +} + +pub const COUNT: usize = 12; + +impl Lang { + pub const ALL: [Lang; COUNT] = [ + Lang::En, + Lang::Ru, + Lang::Es, + Lang::Fr, + Lang::De, + Lang::It, + Lang::Pt, + Lang::Pl, + Lang::Zh, + Lang::Ja, + Lang::Ko, + Lang::Tr, + ]; + + pub fn index(self) -> usize { + self as usize + } + + fn from_index(i: u8) -> Lang { + Self::ALL.get(i as usize).copied().unwrap_or(Lang::En) + } + + /// Код языка — используется для хранения выбора в БД (Settings) и для + /// определения стартового языка по локали ОС. + pub fn code(self) -> &'static str { + match self { + Lang::En => "en", + Lang::Ru => "ru", + Lang::Es => "es", + Lang::Fr => "fr", + Lang::De => "de", + Lang::It => "it", + Lang::Pt => "pt", + Lang::Pl => "pl", + Lang::Zh => "zh", + Lang::Ja => "ja", + Lang::Ko => "ko", + Lang::Tr => "tr", + } + } + + pub fn from_code(code: &str) -> Option { + Self::ALL.into_iter().find(|l| l.code() == code) + } + + /// Название языка на нём самом — так его узнают в списке выбора, даже не + /// зная текущего языка интерфейса. + pub fn native_name(self) -> &'static str { + match self { + Lang::En => "English", + Lang::Ru => "Русский", + Lang::Es => "Español", + Lang::Fr => "Français", + Lang::De => "Deutsch", + Lang::It => "Italiano", + Lang::Pt => "Português", + Lang::Pl => "Polski", + Lang::Zh => "简体中文", + Lang::Ja => "日本語", + Lang::Ko => "한국어", + Lang::Tr => "Türkçe", + } + } + + /// Есть ли у языка глифы вне Latin/Cyrillic, для которых системный SF Pro + /// не подходит — тогда `visuals::system_fonts` подмешивает CJK-шрифт. + pub fn needs_cjk_font(self) -> bool { + matches!(self, Lang::Zh | Lang::Ja | Lang::Ko) + } +} + +static CURRENT: AtomicU8 = AtomicU8::new(0); // Lang::En + +pub fn current() -> Lang { + Lang::from_index(CURRENT.load(Ordering::Relaxed)) +} + +pub fn set_lang(lang: Lang) { + CURRENT.store(lang.index() as u8, Ordering::Relaxed); +} + +/// Язык ОС на старте (до первой загрузки из БД) — грубое сопоставление по +/// первым двум буквам `LANG`/локали; неизвестное всегда откатывается на English. +pub fn detect_system_lang() -> Lang { + let raw = std::env::var("LANG").unwrap_or_default(); + let code = raw.split(['_', '.', '-']).next().unwrap_or(""); + Lang::from_code(code).unwrap_or(Lang::En) +} + +type Table = HashMap<&'static str, [&'static str; COUNT]>; +static TABLE: OnceLock = OnceLock::new(); + +fn table() -> &'static Table { + TABLE.get_or_init(|| { + let mut m = HashMap::with_capacity(1024); + for &(key, values) in strings::all_entries() { + if m.insert(key, values).is_some() { + debug_assert!(false, "duplicate i18n key: {key}"); + } + } + m + }) +} + +/// Перевод по ключу на текущий язык. Отсутствующий ключ возвращает сам ключ — +/// это специально заметно в UI (и в debug-сборке паникует), чтобы пропущенный +/// перевод не потерялся молча. +pub fn t(key: &'static str) -> &'static str { + match table().get(key) { + Some(values) => values[current().index()], + None => { + debug_assert!(false, "missing i18n key: {key}"); + key + } + } +} + +/// [`t`] с подстановкой значений вместо плейсхолдеров вида `{name}` — для +/// строк с динамическим содержимым (имя файла, хост и т.п.), которое не может +/// быть частью статической таблицы переводов. +pub fn tf(key: &'static str, pairs: &[(&str, &str)]) -> String { + let mut s = t(key).to_string(); + for (placeholder, value) in pairs { + s = s.replace(placeholder, value); + } + s +} diff --git a/src/i18n/strings/common.rs b/src/i18n/strings/common.rs new file mode 100644 index 0000000..f1129eb --- /dev/null +++ b/src/i18n/strings/common.rs @@ -0,0 +1,240 @@ +//! Общие термины, повторяющиеся в разных экранах (кнопки, поля форм) — +//! вынесены отдельно от контекста, чтобы не переводить одно и то же слово +//! по три раза с риском разойтись в формулировках. +use super::Entry; + +pub const ENTRIES: &[Entry] = &[ + ( + "common.cancel", + [ + "Cancel", "Отмена", "Cancelar", "Annuler", "Abbrechen", "Annulla", "Cancelar", + "Anuluj", "取消", "キャンセル", "취소", "İptal", + ], + ), + ( + "common.ok", + [ + "OK", "ОК", "Aceptar", "OK", "OK", "OK", "OK", "OK", "确定", "OK", "확인", "Tamam", + ], + ), + ( + "common.delete", + [ + "Delete", "Удалить", "Eliminar", "Supprimer", "Löschen", "Elimina", "Excluir", + "Usuń", "删除", "削除", "삭제", "Sil", + ], + ), + ( + "common.rename", + [ + "Rename", + "Переименовать", + "Renombrar", + "Renommer", + "Umbenennen", + "Rinomina", + "Renomear", + "Zmień nazwę", + "重命名", + "名前を変更", + "이름 바꾸기", + "Yeniden Adlandır", + ], + ), + ( + "common.edit", + [ + "Edit", "Изменить", "Editar", "Modifier", "Bearbeiten", "Modifica", "Editar", + "Edytuj", "编辑", "編集", "편집", "Düzenle", + ], + ), + ( + "common.save", + [ + "Save", "Сохранить", "Guardar", "Enregistrer", "Speichern", "Salva", "Salvar", + "Zapisz", "保存", "保存", "저장", "Kaydet", + ], + ), + ( + "common.open", + [ + "Open", "Открыть", "Abrir", "Ouvrir", "Öffnen", "Apri", "Abrir", "Otwórz", "打开", + "開く", "열기", "Aç", + ], + ), + ( + "common.close", + [ + "Close", "Закрыть", "Cerrar", "Fermer", "Schließen", "Chiudi", "Fechar", "Zamknij", + "关闭", "閉じる", "닫기", "Kapat", + ], + ), + ( + "common.copy_path", + [ + "Copy Path", + "Скопировать путь", + "Copiar ruta", + "Copier le chemin", + "Pfad kopieren", + "Copia percorso", + "Copiar caminho", + "Kopiuj ścieżkę", + "复制路径", + "パスをコピー", + "경로 복사", + "Yolu Kopyala", + ], + ), + ( + "common.new_folder", + [ + "New Folder", + "Новая папка", + "Nueva carpeta", + "Nouveau dossier", + "Neuer Ordner", + "Nuova cartella", + "Nova pasta", + "Nowy folder", + "新建文件夹", + "新規フォルダ", + "새 폴더", + "Yeni Klasör", + ], + ), + ( + "common.connect", + [ + "Connect", "Подключить", "Conectar", "Connecter", "Verbinden", "Connetti", + "Conectar", "Połącz", "连接", "接続", "연결", "Bağlan", + ], + ), + ( + "common.connecting", + [ + "Connecting…", + "Подключение…", + "Conectando…", + "Connexion…", + "Verbinde…", + "Connessione…", + "Conectando…", + "Łączenie…", + "正在连接…", + "接続中…", + "연결 중…", + "Bağlanıyor…", + ], + ), + ( + "common.host", + [ + "Host", "Хост", "Host", "Hôte", "Host", "Host", "Host", "Host", "主机", "ホスト", + "호스트", "Sunucu", + ], + ), + ( + "common.username", + [ + "Username", + "Имя пользователя", + "Usuario", + "Nom d'utilisateur", + "Benutzername", + "Nome utente", + "Usuário", + "Nazwa użytkownika", + "用户名", + "ユーザー名", + "사용자 이름", + "Kullanıcı Adı", + ], + ), + ( + "common.password", + [ + "Password", "Пароль", "Contraseña", "Mot de passe", "Passwort", "Password", "Senha", + "Hasło", "密码", "パスワード", "비밀번호", "Parola", + ], + ), + ( + "common.port", + [ + "Port", "Порт", "Puerto", "Port", "Port", "Porta", "Porta", "Port", "端口", "ポート", + "포트", "Port", + ], + ), + ( + "common.label", + [ + "Label", "Метка", "Etiqueta", "Étiquette", "Bezeichnung", "Etichetta", "Rótulo", + "Etykieta", "标签", "ラベル", "라벨", "Etiket", + ], + ), + ( + "common.refresh", + [ + "Refresh", "Обновить", "Actualizar", "Actualiser", "Aktualisieren", "Aggiorna", + "Atualizar", "Odśwież", "刷新", "更新", "새로고침", "Yenile", + ], + ), + ( + "common.upload", + [ + "Upload", "Загрузить", "Subir", "Envoyer", "Hochladen", "Carica", "Enviar", + "Prześlij", "上传", "アップロード", "업로드", "Yükle", + ], + ), + ( + "common.download", + [ + "Download", "Скачать", "Descargar", "Télécharger", "Herunterladen", "Scarica", + "Baixar", "Pobierz", "下载", "ダウンロード", "다운로드", "İndir", + ], + ), + ( + "common.settings", + [ + "Settings", + "Настройки", + "Configuración", + "Paramètres", + "Einstellungen", + "Impostazioni", + "Configurações", + "Ustawienia", + "设置", + "設定", + "설정", + "Ayarlar", + ], + ), + ( + "common.history", + [ + "History", "История", "Historial", "Historique", "Verlauf", "Cronologia", + "Histórico", "Historia", "历史记录", "履歴", "기록", "Geçmiş", + ], + ), + ( + "common.search", + [ + "Search", "Поиск", "Buscar", "Rechercher", "Suchen", "Cerca", "Pesquisar", "Szukaj", + "搜索", "検索", "검색", "Ara", + ], + ), + ( + "common.yes", + [ + "Yes", "Да", "Sí", "Oui", "Ja", "Sì", "Sim", "Tak", "是", "はい", "예", "Evet", + ], + ), + ( + "common.no", + [ + "No", "Нет", "No", "Non", "Nein", "No", "Não", "Nie", "否", "いいえ", "아니요", + "Hayır", + ], + ), +]; diff --git a/src/i18n/strings/dialogs.rs b/src/i18n/strings/dialogs.rs new file mode 100644 index 0000000..3ccd1af --- /dev/null +++ b/src/i18n/strings/dialogs.rs @@ -0,0 +1,233 @@ +//! Диалог New Connection, модалки New Folder/Delete/Rename, ошибки формы. +use super::Entry; + +pub const ENTRIES: &[Entry] = &[ + ( + "dialogs.ssh_key_hint", + [ + "Leave empty to sign in with an SSH key (agent or ~/.ssh).", + "Оставьте пустым для входа по SSH-ключу (агент или ~/.ssh).", + "Déjalo vacío para iniciar sesión con una clave SSH (agente o ~/.ssh).", + "Laissez vide pour vous connecter avec une clé SSH (agent ou ~/.ssh).", + "Leer lassen, um sich mit einem SSH-Schlüssel anzumelden (Agent oder ~/.ssh).", + "Lascia vuoto per accedere con una chiave SSH (agente o ~/.ssh).", + "Deixe em branco para entrar com uma chave SSH (agente ou ~/.ssh).", + "Pozostaw puste, aby zalogować się kluczem SSH (agent lub ~/.ssh).", + "留空以使用 SSH 密钥登录(代理或 ~/.ssh)。", + "SSH 鍵でサインインする場合は空欄のままにしてください(エージェントまたは ~/.ssh)。", + "SSH 키로 로그인하려면 비워 두세요(에이전트 또는 ~/.ssh).", + "SSH anahtarıyla oturum açmak için boş bırakın (aracı veya ~/.ssh).", + ], + ), + ( + "dialogs.key_file_label", + [ + "Key file (optional)", + "Файл ключа (необязательно)", + "Archivo de clave (opcional)", + "Fichier de clé (facultatif)", + "Schlüsseldatei (optional)", + "File chiave (opzionale)", + "Arquivo de chave (opcional)", + "Plik klucza (opcjonalnie)", + "密钥文件(可选)", + "鍵ファイル(任意)", + "키 파일(선택 사항)", + "Anahtar dosyası (isteğe bağlı)", + ], + ), + ( + "dialogs.browse", + [ + "Browse…", "Обзор…", "Examinar…", "Parcourir…", "Durchsuchen…", "Sfoglia…", + "Procurar…", "Przeglądaj…", "浏览…", "参照…", "찾아보기…", "Gözat…", + ], + ), + ( + "dialogs.choose_ssh_key_title", + [ + "Choose SSH key", + "Выбор SSH-ключа", + "Elegir clave SSH", + "Choisir la clé SSH", + "SSH-Schlüssel auswählen", + "Scegli chiave SSH", + "Escolher chave SSH", + "Wybierz klucz SSH", + "选择 SSH 密钥", + "SSH 鍵を選択", + "SSH 키 선택", + "SSH Anahtarı Seç", + ], + ), + ( + "dialogs.label_optional", + [ + "Label (optional)", + "Метка (необязательно)", + "Etiqueta (opcional)", + "Étiquette (facultatif)", + "Bezeichnung (optional)", + "Etichetta (opzionale)", + "Rótulo (opcional)", + "Etykieta (opcjonalnie)", + "标签(可选)", + "ラベル(任意)", + "라벨(선택 사항)", + "Etiket (isteğe bağlı)", + ], + ), + ( + "dialogs.confirm_delete_title", + [ + "Delete \"{name}\"?", + "Удалить «{name}»?", + "¿Eliminar \"{name}\"?", + "Supprimer « {name} » ?", + "„{name}\" löschen?", + "Eliminare \"{name}\"?", + "Excluir \"{name}\"?", + "Usunąć „{name}\"?", + "删除\u{201c}{name}\u{201d}?", + "「{name}」を削除しますか?", + "\"{name}\"을(를) 삭제하시겠습니까?", + "\"{name}\" silinsin mi?", + ], + ), + ( + "dialogs.cannot_undo", + [ + "This action cannot be undone.", + "Это действие необратимо.", + "Esta acción no se puede deshacer.", + "Cette action est irréversible.", + "Diese Aktion kann nicht rückgängig gemacht werden.", + "Questa azione non può essere annullata.", + "Esta ação não pode ser desfeita.", + "Tej operacji nie można cofnąć.", + "此操作无法撤销。", + "この操作は元に戻せません。", + "이 작업은 되돌릴 수 없습니다.", + "Bu işlem geri alınamaz.", + ], + ), + ( + "errors.host_required", + [ + "Host is required.", + "Укажите хост.", + "El host es obligatorio.", + "L'hôte est requis.", + "Host ist erforderlich.", + "L'host è obbligatorio.", + "O host é obrigatório.", + "Host jest wymagany.", + "请输入主机地址。", + "ホストを入力してください。", + "호스트를 입력해야 합니다.", + "Sunucu adresi gerekli.", + ], + ), + ( + "errors.username_required", + [ + "Username is required.", + "Укажите имя пользователя.", + "El usuario es obligatorio.", + "Le nom d'utilisateur est requis.", + "Benutzername ist erforderlich.", + "Il nome utente è obbligatorio.", + "O nome de usuário é obrigatório.", + "Nazwa użytkownika jest wymagana.", + "请输入用户名。", + "ユーザー名を入力してください。", + "사용자 이름을 입력해야 합니다.", + "Kullanıcı adı gerekli.", + ], + ), + ( + "errors.port_invalid", + [ + "Port must be a number between 1 and 65535.", + "Порт должен быть числом от 1 до 65535.", + "El puerto debe ser un número entre 1 y 65535.", + "Le port doit être un nombre compris entre 1 et 65535.", + "Der Port muss eine Zahl zwischen 1 und 65535 sein.", + "La porta deve essere un numero compreso tra 1 e 65535.", + "A porta deve ser um número entre 1 e 65535.", + "Port musi być liczbą od 1 do 65535.", + "端口必须是 1 到 65535 之间的数字。", + "ポートは 1〜65535 の数値で指定してください。", + "포트는 1에서 65535 사이의 숫자여야 합니다.", + "Port 1 ile 65535 arasında bir sayı olmalıdır.", + ], + ), + ( + "errors.ftps_not_implemented", + [ + "FTPS not yet implemented", + "FTPS пока не реализован", + "FTPS aún no está implementado", + "FTPS pas encore pris en charge", + "FTPS ist noch nicht implementiert", + "FTPS non ancora implementato", + "FTPS ainda não implementado", + "FTPS nie jest jeszcze obsługiwany", + "尚未实现 FTPS", + "FTPS はまだ実装されていません", + "FTPS는 아직 구현되지 않았습니다", + "FTPS henüz uygulanmadı", + ], + ), + ( + "errors.ftp_password_required", + [ + "Password is required for FTP connections.", + "Для подключения по FTP требуется пароль.", + "Se requiere una contraseña para las conexiones FTP.", + "Un mot de passe est requis pour les connexions FTP.", + "Für FTP-Verbindungen ist ein Passwort erforderlich.", + "È richiesta una password per le connessioni FTP.", + "É necessária uma senha para conexões FTP.", + "Połączenia FTP wymagają hasła.", + "FTP 连接需要密码。", + "FTP 接続にはパスワードが必要です。", + "FTP 연결에는 비밀번호가 필요합니다.", + "FTP bağlantıları için parola gereklidir.", + ], + ), + ( + "welcome.tagline", + [ + "FTP/SFTP client", + "FTP/SFTP-клиент", + "Cliente FTP/SFTP", + "Client FTP/SFTP", + "FTP/SFTP-Client", + "Client FTP/SFTP", + "Cliente FTP/SFTP", + "Klient FTP/SFTP", + "FTP/SFTP 客户端", + "FTP/SFTP クライアント", + "FTP/SFTP 클라이언트", + "FTP/SFTP İstemcisi", + ], + ), + ( + "welcome.recent_connections", + [ + "RECENT CONNECTIONS", + "НЕДАВНИЕ ПОДКЛЮЧЕНИЯ", + "CONEXIONES RECIENTES", + "CONNEXIONS RÉCENTES", + "LETZTE VERBINDUNGEN", + "CONNESSIONI RECENTI", + "CONEXÕES RECENTES", + "OSTATNIE POŁĄCZENIA", + "最近的连接", + "最近の接続", + "최근 연결", + "SON BAĞLANTILAR", + ], + ), +]; diff --git a/src/i18n/strings/menu.rs b/src/i18n/strings/menu.rs new file mode 100644 index 0000000..288400e --- /dev/null +++ b/src/i18n/strings/menu.rs @@ -0,0 +1,3 @@ +use super::Entry; + +pub const ENTRIES: &[Entry] = &[]; diff --git a/src/i18n/strings/mod.rs b/src/i18n/strings/mod.rs new file mode 100644 index 0000000..4baceea --- /dev/null +++ b/src/i18n/strings/mod.rs @@ -0,0 +1,23 @@ +//! Таблицы переводов, разбитые по областям интерфейса — так проще найти и +//! проверить перевод конкретного экрана, чем в одном файле на тысячу строк. +//! Порядок колонок в каждом `[&str; 12]`: en, ru, es, fr, de, it, pt, pl, zh, ja, ko, tr. +mod common; +mod dialogs; +mod menu; +mod panels; +mod settings; +mod toolbar; +mod welcome; + +type Entry = (&'static str, [&'static str; super::COUNT]); + +pub fn all_entries() -> impl Iterator { + common::ENTRIES + .iter() + .chain(toolbar::ENTRIES.iter()) + .chain(panels::ENTRIES.iter()) + .chain(settings::ENTRIES.iter()) + .chain(dialogs::ENTRIES.iter()) + .chain(welcome::ENTRIES.iter()) + .chain(menu::ENTRIES.iter()) +} diff --git a/src/i18n/strings/panels.rs b/src/i18n/strings/panels.rs new file mode 100644 index 0000000..682df62 --- /dev/null +++ b/src/i18n/strings/panels.rs @@ -0,0 +1,538 @@ +//! Сайдбар, локальная/удалённая панели, таблица файлов. +use super::Entry; + +pub const ENTRIES: &[Entry] = &[ + ( + "panels.local_label", + [ + "LOCAL", "ЛОКАЛЬНО", "LOCAL", "LOCAL", "LOKAL", "LOCALE", "LOCAL", "LOKALNE", "本地", + "ローカル", "로컬", "YEREL", + ], + ), + ( + "panels.remote_label", + [ + "REMOTE · {host}", + "УДАЛЁННО · {host}", + "REMOTO · {host}", + "DISTANT · {host}", + "REMOTE · {host}", + "REMOTO · {host}", + "REMOTO · {host}", + "ZDALNE · {host}", + "远程 · {host}", + "リモート · {host}", + "원격 · {host}", + "UZAK · {host}", + ], + ), + ( + "panels.loading", + [ + "Loading…", + "Загрузка…", + "Cargando…", + "Chargement…", + "Wird geladen…", + "Caricamento…", + "Carregando…", + "Ładowanie…", + "加载中…", + "読み込み中…", + "로딩 중…", + "Yükleniyor…", + ], + ), + ( + "panels.count_items", + [ + "{n} items", + "{n} объектов", + "{n} elementos", + "{n} éléments", + "{n} Elemente", + "{n} elementi", + "{n} itens", + "{n} elementów", + "{n} 项", + "{n} 件", + "{n}개 항목", + "{n} öğe", + ], + ), + ( + "panels.col_name", + [ + "Name", "Имя", "Nombre", "Nom", "Name", "Nome", "Nome", "Nazwa", "名称", "名前", + "이름", "Ad", + ], + ), + ( + "panels.col_modified", + [ + "Modified", + "Изменён", + "Modificado", + "Modifié", + "Geändert", + "Modificato", + "Modificado", + "Zmodyfikowano", + "修改时间", + "更新日時", + "수정됨", + "Değiştirilme", + ], + ), + ( + "panels.col_type", + [ + "Type", "Тип", "Tipo", "Type", "Typ", "Tipo", "Tipo", "Typ", "类型", "種類", "유형", + "Tür", + ], + ), + ( + "panels.col_size", + [ + "Size", "Размер", "Tamaño", "Taille", "Größe", "Dimensione", "Tamanho", "Rozmiar", + "大小", "サイズ", "크기", "Boyut", + ], + ), + ( + "panels.type_dir", + [ + "Dir", "Папка", "Carpeta", "Dossier", "Ordner", "Cartella", "Pasta", "Folder", + "文件夹", "フォルダ", "폴더", "Klasör", + ], + ), + ( + "panels.type_file", + [ + "File", "Файл", "Archivo", "Fichier", "Datei", "File", "Arquivo", "Plik", "文件", + "ファイル", "파일", "Dosya", + ], + ), + ( + "panels.type_link", + [ + "Link", "Ссылка", "Enlace", "Lien", "Link", "Collegamento", "Link", "Link", "链接", + "リンク", "링크", "Bağlantı", + ], + ), + ( + "panels.path_copied", + [ + "Path copied", + "Путь скопирован", + "Ruta copiada", + "Chemin copié", + "Pfad kopiert", + "Percorso copiato", + "Caminho copiado", + "Skopiowano ścieżkę", + "路径已复制", + "パスをコピーしました", + "경로가 복사되었습니다", + "Yol kopyalandı", + ], + ), + ( + "panels.sidebar.drives", + [ + "DRIVES", "ДИСКИ", "UNIDADES", "DISQUES", "LAUFWERKE", "UNITÀ", "UNIDADES", "DYSKI", + "磁盘", "ドライブ", "드라이브", "SÜRÜCÜLER", + ], + ), + ( + "panels.sidebar.root_label", + [ + "/ (root)", + "/ (корень)", + "/ (raíz)", + "/ (racine)", + "/ (Wurzel)", + "/ (radice)", + "/ (raiz)", + "/ (główny)", + "/ (根目录)", + "/ (ルート)", + "/ (루트)", + "/ (kök)", + ], + ), + ( + "panels.sidebar.bookmarks", + [ + "BOOKMARKS", + "ЗАКЛАДКИ", + "MARCADORES", + "FAVORIS", + "LESEZEICHEN", + "PREFERITI", + "FAVORITOS", + "ZAKŁADKI", + "书签", + "ブックマーク", + "북마크", + "YER İMLERİ", + ], + ), + ( + "panels.sidebar.remove_bookmark", + [ + "Remove bookmark", + "Удалить закладку", + "Quitar marcador", + "Supprimer le favori", + "Lesezeichen entfernen", + "Rimuovi preferito", + "Remover favorito", + "Usuń zakładkę", + "移除书签", + "ブックマークを削除", + "북마크 제거", + "Yer imini kaldır", + ], + ), + ( + "panels.sidebar.qa_home", + [ + "Home", "Домой", "Inicio", "Accueil", "Start", "Home", "Início", "Dom", "主目录", + "ホーム", "홈", "Ana Sayfa", + ], + ), + ( + "panels.sidebar.qa_desktop", + [ + "Desktop", + "Рабочий стол", + "Escritorio", + "Bureau", + "Schreibtisch", + "Scrivania", + "Área de Trabalho", + "Pulpit", + "桌面", + "デスクトップ", + "바탕화면", + "Masaüstü", + ], + ), + ( + "panels.sidebar.qa_documents", + [ + "Documents", "Документы", "Documentos", "Documents", "Dokumente", "Documenti", + "Documentos", "Dokumenty", "文稿", "書類", "문서", "Belgeler", + ], + ), + ( + "panels.sidebar.qa_downloads", + [ + "Downloads", + "Загрузки", + "Descargas", + "Téléchargements", + "Downloads", + "Download", + "Downloads", + "Pobrane", + "下载", + "ダウンロード", + "다운로드", + "İndirilenler", + ], + ), + ( + "panels.sidebar.qa_pictures", + [ + "Pictures", "Изображения", "Imágenes", "Images", "Bilder", "Immagini", "Imagens", + "Obrazy", "图片", "ピクチャ", "사진", "Resimler", + ], + ), + ( + "panels.sidebar.qa_music", + [ + "Music", "Музыка", "Música", "Musique", "Musik", "Musica", "Música", "Muzyka", + "音乐", "ミュージック", "음악", "Müzik", + ], + ), + ( + "panels.sidebar.qa_videos", + [ + "Videos", "Видео", "Vídeos", "Vidéos", "Videos", "Video", "Vídeos", "Filmy", "影片", + "ムービー", "동영상", "Videolar", + ], + ), + ( + "panels.sidebar.bookmark_this_folder", + [ + "Bookmark this folder", + "Добавить папку в закладки", + "Marcar esta carpeta", + "Ajouter ce dossier aux favoris", + "Diesen Ordner als Lesezeichen speichern", + "Aggiungi cartella ai preferiti", + "Adicionar pasta aos favoritos", + "Dodaj folder do zakładek", + "将此文件夹加入书签", + "このフォルダをブックマーク", + "이 폴더 북마크", + "Bu klasörü yer imlerine ekle", + ], + ), + ( + "panels.local.error", + [ + "Local error: {err}", + "Локальная ошибка: {err}", + "Error local: {err}", + "Erreur locale : {err}", + "Lokaler Fehler: {err}", + "Errore locale: {err}", + "Erro local: {err}", + "Błąd lokalny: {err}", + "本地错误:{err}", + "ローカルエラー: {err}", + "로컬 오류: {err}", + "Yerel hata: {err}", + ], + ), + ( + "panels.local.add_to_bookmarks", + [ + "Add to Bookmarks", + "Добавить в закладки", + "Añadir a marcadores", + "Ajouter aux favoris", + "Zu Lesezeichen hinzufügen", + "Aggiungi ai preferiti", + "Adicionar aos favoritos", + "Dodaj do zakładek", + "添加到书签", + "ブックマークに追加", + "북마크에 추가", + "Yer İmlerine Ekle", + ], + ), + ( + "panels.local.upload_to_server", + [ + "Upload to Server", + "Загрузить на сервер", + "Subir al servidor", + "Envoyer vers le serveur", + "Auf Server hochladen", + "Carica sul server", + "Enviar para o servidor", + "Wyślij na serwer", + "上传到服务器", + "サーバーへアップロード", + "서버에 업로드", + "Sunucuya Yükle", + ], + ), + ( + "panels.local.conn_inactive", + [ + "Connection no longer active", + "Подключение больше не активно", + "La conexión ya no está activa", + "La connexion n'est plus active", + "Verbindung ist nicht mehr aktiv", + "La connessione non è più attiva", + "A conexão não está mais ativa", + "Połączenie już nieaktywne", + "连接已失效", + "接続は無効になりました", + "연결이 더 이상 활성 상태가 아닙니다", + "Bağlantı artık etkin değil", + ], + ), + ( + "panels.local.moved", + [ + "Moved {name}", + "Перемещено: {name}", + "Movido: {name}", + "Déplacé : {name}", + "Verschoben: {name}", + "Spostato: {name}", + "Movido: {name}", + "Przeniesiono: {name}", + "已移动:{name}", + "移動しました: {name}", + "이동됨: {name}", + "Taşındı: {name}", + ], + ), + ( + "panels.local.move_failed", + [ + "Move failed: {err}", + "Не удалось переместить: {err}", + "Error al mover: {err}", + "Échec du déplacement : {err}", + "Verschieben fehlgeschlagen: {err}", + "Spostamento non riuscito: {err}", + "Falha ao mover: {err}", + "Nie udało się przenieść: {err}", + "移动失败:{err}", + "移動に失敗しました: {err}", + "이동 실패: {err}", + "Taşıma başarısız: {err}", + ], + ), + ( + "panels.local.opening", + [ + "Opening {name}", + "Открывается {name}", + "Abriendo {name}", + "Ouverture de {name}", + "{name} wird geöffnet", + "Apertura di {name}", + "Abrindo {name}", + "Otwieranie {name}", + "正在打开 {name}", + "{name} を開いています", + "{name} 여는 중", + "{name} açılıyor", + ], + ), + ( + "panels.local.open_failed", + [ + "Open failed: {err}", + "Не удалось открыть: {err}", + "Error al abrir: {err}", + "Échec de l'ouverture : {err}", + "Öffnen fehlgeschlagen: {err}", + "Apertura non riuscita: {err}", + "Falha ao abrir: {err}", + "Nie udało się otworzyć: {err}", + "打开失败:{err}", + "開くのに失敗しました: {err}", + "열기 실패: {err}", + "Açma başarısız: {err}", + ], + ), + ( + "panels.local.already_bookmarked", + [ + "Already bookmarked", + "Уже в закладках", + "Ya está en marcadores", + "Déjà dans les favoris", + "Bereits als Lesezeichen gespeichert", + "Già nei preferiti", + "Já está nos favoritos", + "Już w zakładkach", + "已在书签中", + "既にブックマーク済みです", + "이미 북마크됨", + "Zaten yer imlerinde", + ], + ), + ( + "panels.local.bookmark_added", + [ + "Bookmark added", + "Закладка добавлена", + "Marcador añadido", + "Favori ajouté", + "Lesezeichen hinzugefügt", + "Preferito aggiunto", + "Favorito adicionado", + "Dodano zakładkę", + "已添加书签", + "ブックマークを追加しました", + "북마크 추가됨", + "Yer imi eklendi", + ], + ), + ( + "panels.local.bookmark_failed", + [ + "Bookmark failed: {err}", + "Не удалось добавить закладку: {err}", + "Error al añadir marcador: {err}", + "Échec de l'ajout du favori : {err}", + "Lesezeichen fehlgeschlagen: {err}", + "Aggiunta ai preferiti non riuscita: {err}", + "Falha ao adicionar favorito: {err}", + "Nie udało się dodać zakładki: {err}", + "添加书签失败:{err}", + "ブックマークの追加に失敗しました: {err}", + "북마크 추가 실패: {err}", + "Yer imi eklenemedi: {err}", + ], + ), + ( + "panels.remote.no_active_connection", + [ + "No active connection", + "Нет активного подключения", + "Sin conexión activa", + "Aucune connexion active", + "Keine aktive Verbindung", + "Nessuna connessione attiva", + "Nenhuma conexão ativa", + "Brak aktywnego połączenia", + "没有活动连接", + "アクティブな接続がありません", + "활성 연결이 없습니다", + "Etkin bağlantı yok", + ], + ), + ( + "panels.remote.cannot_move_cross_connection", + [ + "Cannot move between different connections", + "Нельзя перемещать между разными подключениями", + "No se puede mover entre conexiones diferentes", + "Impossible de déplacer entre différentes connexions", + "Verschieben zwischen unterschiedlichen Verbindungen nicht möglich", + "Impossibile spostare tra connessioni diverse", + "Não é possível mover entre conexões diferentes", + "Nie można przenosić między różnymi połączeniami", + "无法在不同连接之间移动", + "異なる接続間では移動できません", + "서로 다른 연결 간에는 이동할 수 없습니다", + "Farklı bağlantılar arasında taşınamaz", + ], + ), + ( + "panels.remote.moving", + [ + "Moving {name}…", + "Перемещение {name}…", + "Moviendo {name}…", + "Déplacement de {name}…", + "{name} wird verschoben…", + "Spostamento di {name}…", + "Movendo {name}…", + "Przenoszenie {name}…", + "正在移动 {name}…", + "{name} を移動中…", + "{name} 이동 중…", + "{name} taşınıyor…", + ], + ), + ( + "panels.remote.download_to_local", + [ + "Download to Local", + "Скачать локально", + "Descargar a local", + "Télécharger en local", + "Lokal herunterladen", + "Scarica in locale", + "Baixar para local", + "Pobierz lokalnie", + "下载到本地", + "ローカルへダウンロード", + "로컬로 다운로드", + "Yerele İndir", + ], + ), +]; diff --git a/src/i18n/strings/settings.rs b/src/i18n/strings/settings.rs new file mode 100644 index 0000000..904e788 --- /dev/null +++ b/src/i18n/strings/settings.rs @@ -0,0 +1,831 @@ +//! Диалог Settings: навигация и разделы (General/Appearance/Connections/ +//! History/Security/Transfers/About). +use super::Entry; + +pub const ENTRIES: &[Entry] = &[ + // ── Заголовок диалога и навигация ─────────────────────────────────── + ( + "settings.title", + [ + "Settings", + "Настройки", + "Configuración", + "Paramètres", + "Einstellungen", + "Impostazioni", + "Configurações", + "Ustawienia", + "设置", + "設定", + "설정", + "Ayarlar", + ], + ), + ( + "settings.nav.group_general", + [ + "GENERAL", "ОБЩЕЕ", "GENERAL", "GÉNÉRAL", "ALLGEMEIN", "GENERALE", "GERAL", "OGÓLNE", + "通用", "一般", "일반", "GENEL", + ], + ), + ( + "settings.nav.group_connections", + [ + "CONNECTIONS", + "ПОДКЛЮЧЕНИЯ", + "CONEXIONES", + "CONNEXIONS", + "VERBINDUNGEN", + "CONNESSIONI", + "CONEXÕES", + "POŁĄCZENIA", + "连接", + "接続", + "연결", + "BAĞLANTILAR", + ], + ), + ( + "settings.nav.group_transfers", + [ + "TRANSFERS", + "ПЕРЕДАЧИ", + "TRANSFERENCIAS", + "TRANSFERTS", + "ÜBERTRAGUNGEN", + "TRASFERIMENTI", + "TRANSFERÊNCIAS", + "TRANSFERY", + "传输", + "転送", + "전송", + "AKTARIMLAR", + ], + ), + ( + "settings.nav.group_about", + [ + "ABOUT", "О ПРОГРАММЕ", "ACERCA DE", "À PROPOS", "ÜBER", "INFORMAZIONI", "SOBRE", + "O PROGRAMIE", "关于", "情報", "정보", "HAKKINDA", + ], + ), + ( + "settings.nav.general", + [ + "General", "Общие", "General", "Général", "Allgemein", "Generale", "Geral", "Ogólne", + "通用", "一般", "일반", "Genel", + ], + ), + ( + "settings.nav.appearance", + [ + "Appearance", + "Внешний вид", + "Apariencia", + "Apparence", + "Erscheinungsbild", + "Aspetto", + "Aparência", + "Wygląd", + "外观", + "外観", + "외관", + "Görünüm", + ], + ), + ( + "settings.nav.connections", + [ + "Sites & Bookmarks", + "Сайты и закладки", + "Sitios y marcadores", + "Sites et favoris", + "Seiten & Lesezeichen", + "Siti e preferiti", + "Sites e favoritos", + "Serwery i zakładki", + "站点与书签", + "サイトとブックマーク", + "사이트 및 북마크", + "Siteler ve Yer İmleri", + ], + ), + ( + "settings.nav.history", + [ + "History", "История", "Historial", "Historique", "Verlauf", "Cronologia", + "Histórico", "Historia", "历史记录", "履歴", "기록", "Geçmiş", + ], + ), + ( + "settings.nav.security", + [ + "Security", + "Безопасность", + "Seguridad", + "Sécurité", + "Sicherheit", + "Sicurezza", + "Segurança", + "Bezpieczeństwo", + "安全", + "セキュリティ", + "보안", + "Güvenlik", + ], + ), + ( + "settings.nav.transfers", + [ + "Transfers", + "Передачи", + "Transferencias", + "Transferts", + "Übertragungen", + "Trasferimenti", + "Transferências", + "Transfery", + "传输", + "転送", + "전송", + "Aktarımlar", + ], + ), + ( + "settings.nav.about", + [ + "About", "О программе", "Acerca de", "À propos", "Über", "Informazioni", "Sobre", + "O programie", "关于", "情報", "정보", "Hakkında", + ], + ), + // ── General ────────────────────────────────────────────────────────── + ( + "settings.general.confirm_delete", + [ + "Confirm before deleting files", + "Подтверждать перед удалением файлов", + "Confirmar antes de eliminar archivos", + "Confirmer avant de supprimer des fichiers", + "Vor dem Löschen von Dateien bestätigen", + "Conferma prima di eliminare i file", + "Confirmar antes de excluir arquivos", + "Potwierdzaj przed usunięciem plików", + "删除文件前进行确认", + "ファイル削除前に確認する", + "파일 삭제 전 확인", + "Dosyaları silmeden önce onayla", + ], + ), + ( + "settings.general.confirm_delete_hint", + [ + "Show a confirmation dialog before deleting local or remote files.", + "Показывать диалог подтверждения перед удалением локальных или удалённых файлов.", + "Muestra un cuadro de confirmación antes de eliminar archivos locales o remotos.", + "Afficher une boîte de dialogue de confirmation avant de supprimer des fichiers locaux ou distants.", + "Zeigt vor dem Löschen lokaler oder entfernter Dateien einen Bestätigungsdialog an.", + "Mostra una finestra di conferma prima di eliminare file locali o remoti.", + "Mostra uma caixa de confirmação antes de excluir arquivos locais ou remotos.", + "Wyświetla okno potwierdzenia przed usunięciem plików lokalnych lub zdalnych.", + "删除本地或远程文件前显示确认对话框。", + "ローカルまたはリモートのファイルを削除する前に確認ダイアログを表示します。", + "로컬 또는 원격 파일을 삭제하기 전에 확인 대화 상자를 표시합니다.", + "Yerel veya uzak dosyaları silmeden önce bir onay penceresi gösterir.", + ], + ), + ( + "settings.general.startup_folder", + [ + "Startup folder", + "Стартовая папка", + "Carpeta de inicio", + "Dossier de démarrage", + "Startordner", + "Cartella di avvio", + "Pasta inicial", + "Folder startowy", + "启动文件夹", + "起動時のフォルダ", + "시작 폴더", + "Başlangıç klasörü", + ], + ), + ( + "settings.general.startup_folder_hint", + [ + "Local pane opens here when LoFlum starts.", + "Локальная панель будет открываться здесь при запуске LoFlum.", + "El panel local se abrirá aquí al iniciar LoFlum.", + "Le panneau local s'ouvrira ici au démarrage de LoFlum.", + "Der lokale Bereich wird hier geöffnet, wenn LoFlum startet.", + "Il pannello locale si aprirà qui all'avvio di LoFlum.", + "O painel local abrirá aqui quando o LoFlum iniciar.", + "Panel lokalny otworzy się tutaj po uruchomieniu LoFlum.", + "LoFlum 启动时,本地面板将在此处打开。", + "LoFlum 起動時にローカルパネルがここで開きます。", + "LoFlum을 시작하면 로컬 패널이 여기에서 열립니다.", + "LoFlum başlatıldığında yerel panel burada açılır.", + ], + ), + ( + "settings.general.home_directory", + [ + "Home directory", + "Домашняя папка", + "Directorio personal", + "Dossier personnel", + "Home-Verzeichnis", + "Cartella Home", + "Diretório inicial", + "Katalog domowy", + "主目录", + "ホームディレクトリ", + "홈 디렉터리", + "Ana dizin", + ], + ), + ( + "settings.general.use_current_folder", + [ + "Use Current Folder", + "Использовать текущую папку", + "Usar carpeta actual", + "Utiliser le dossier actuel", + "Aktuellen Ordner verwenden", + "Usa cartella corrente", + "Usar pasta atual", + "Użyj bieżącego folderu", + "使用当前文件夹", + "現在のフォルダを使用", + "현재 폴더 사용", + "Geçerli Klasörü Kullan", + ], + ), + ( + "settings.general.reset_to_home", + [ + "Reset to Home", + "Сбросить на домашнюю", + "Restablecer a inicio", + "Réinitialiser au dossier personnel", + "Auf Home zurücksetzen", + "Ripristina su Home", + "Redefinir para inicial", + "Przywróć domyślny", + "重置为主目录", + "ホームにリセット", + "홈으로 재설정", + "Ana Dizine Sıfırla", + ], + ), + ( + "settings.general.language", + [ + "Language", "Язык", "Idioma", "Langue", "Sprache", "Lingua", "Idioma", "Język", "语言", + "言語", "언어", "Dil", + ], + ), + ( + "settings.general.language_hint", + [ + "Interface language.", + "Язык интерфейса.", + "Idioma de la interfaz.", + "Langue de l'interface.", + "Sprache der Benutzeroberfläche.", + "Lingua dell'interfaccia.", + "Idioma da interface.", + "Język interfejsu.", + "界面语言。", + "インターフェースの言語。", + "인터페이스 언어.", + "Arayüz dili.", + ], + ), + // ── Appearance ─────────────────────────────────────────────────────── + ( + "settings.appearance.hint", + [ + "Choose which panels are visible in the main window.", + "Выберите, какие панели отображать в главном окне.", + "Elige qué paneles son visibles en la ventana principal.", + "Choisissez les panneaux visibles dans la fenêtre principale.", + "Wählen Sie, welche Bereiche im Hauptfenster sichtbar sind.", + "Scegli quali pannelli sono visibili nella finestra principale.", + "Escolha quais painéis ficam visíveis na janela principal.", + "Wybierz, które panele są widoczne w oknie głównym.", + "选择主窗口中显示哪些面板。", + "メインウィンドウに表示するパネルを選択します。", + "메인 창에 표시할 패널을 선택하세요.", + "Ana penceredeki hangi panellerin görüneceğini seçin.", + ], + ), + ( + "settings.appearance.toolbar", + [ + "Toolbar", + "Панель инструментов", + "Barra de herramientas", + "Barre d'outils", + "Symbolleiste", + "Barra degli strumenti", + "Barra de ferramentas", + "Pasek narzędzi", + "工具栏", + "ツールバー", + "도구 모음", + "Araç Çubuğu", + ], + ), + ( + "settings.appearance.sidebar", + [ + "Sidebar", "Боковая панель", "Barra lateral", "Barre latérale", "Seitenleiste", + "Barra laterale", "Barra lateral", "Pasek boczny", "侧边栏", "サイドバー", "사이드바", + "Kenar Çubuğu", + ], + ), + ( + "settings.appearance.status_bar", + [ + "Status bar", + "Строка состояния", + "Barra de estado", + "Barre d'état", + "Statusleiste", + "Barra di stato", + "Barra de status", + "Pasek stanu", + "状态栏", + "ステータスバー", + "상태 표시줄", + "Durum Çubuğu", + ], + ), + ( + "settings.appearance.transfer_queue", + [ + "Transfer queue", + "Очередь передач", + "Cola de transferencias", + "File d'attente des transferts", + "Übertragungswarteschlange", + "Coda dei trasferimenti", + "Fila de transferências", + "Kolejka transferów", + "传输队列", + "転送キュー", + "전송 대기열", + "Aktarım Kuyruğu", + ], + ), + // ── Connections (Sites & Bookmarks) ───────────────────────────────── + ( + "settings.connections.sites", + [ + "Sites", "Сайты", "Sitios", "Sites", "Seiten", "Siti", "Sites", "Serwery", "站点", + "サイト", "사이트", "Siteler", + ], + ), + ( + "settings.connections.no_sites", + [ + "No saved sites yet. Save one from the connection history.", + "Пока нет сохранённых сайтов. Сохраните один из истории подключений.", + "Aún no hay sitios guardados. Guarda uno desde el historial de conexiones.", + "Aucun site enregistré pour l'instant. Enregistrez-en un depuis l'historique des connexions.", + "Noch keine gespeicherten Seiten. Speichern Sie eine aus dem Verbindungsverlauf.", + "Nessun sito salvato. Salvane uno dalla cronologia delle connessioni.", + "Ainda não há sites salvos. Salve um a partir do histórico de conexões.", + "Brak zapisanych serwerów. Zapisz jeden z historii połączeń.", + "尚无已保存的站点。可从连接历史记录中保存一个。", + "保存済みのサイトはまだありません。接続履歴から保存してください。", + "저장된 사이트가 아직 없습니다. 연결 기록에서 저장해 보세요.", + "Henüz kayıtlı site yok. Bağlantı geçmişinden bir tane kaydedin.", + ], + ), + ( + "settings.connections.delete_site_hover", + [ + "Delete site", + "Удалить сайт", + "Eliminar sitio", + "Supprimer le site", + "Seite löschen", + "Elimina sito", + "Excluir site", + "Usuń serwer", + "删除站点", + "サイトを削除", + "사이트 삭제", + "Siteyi sil", + ], + ), + ( + "settings.connections.removed_site", + [ + "Removed site \"{name}\"", + "Сайт «{name}» удалён", + "Sitio \"{name}\" eliminado", + "Site « {name} » supprimé", + "Seite „{name}\" entfernt", + "Sito \"{name}\" rimosso", + "Site \"{name}\" removido", + "Usunięto serwer „{name}\"", + "已删除站点\u{201c}{name}\u{201d}", + "サイト「{name}」を削除しました", + "사이트 \"{name}\" 삭제됨", + "\"{name}\" sitesi kaldırıldı", + ], + ), + ( + "settings.connections.bookmarks_title", + [ + "Bookmarks", "Закладки", "Marcadores", "Favoris", "Lesezeichen", "Preferiti", + "Favoritos", "Zakładki", "书签", "ブックマーク", "북마크", "Yer İmleri", + ], + ), + ( + "settings.connections.no_bookmarks", + [ + "No bookmarks yet. Star a folder in the local pane.", + "Пока нет закладок. Отметьте папку звёздочкой в локальной панели.", + "Aún no hay marcadores. Marca una carpeta con una estrella en el panel local.", + "Aucun favori pour l'instant. Ajoutez une étoile à un dossier dans le panneau local.", + "Noch keine Lesezeichen. Markieren Sie einen Ordner im lokalen Bereich mit einem Stern.", + "Nessun preferito. Contrassegna una cartella con una stella nel pannello locale.", + "Ainda não há favoritos. Marque uma pasta com estrela no painel local.", + "Brak zakładek. Oznacz folder gwiazdką w panelu lokalnym.", + "尚无书签。在本地面板中为文件夹加星标即可。", + "ブックマークはまだありません。ローカルパネルでフォルダにスターを付けてください。", + "북마크가 아직 없습니다. 로컬 패널에서 폴더에 별표를 지정하세요.", + "Henüz yer imi yok. Yerel paneldeki bir klasörü yıldızlayın.", + ], + ), + ( + "settings.connections.bookmark_removed", + [ + "Bookmark removed", + "Закладка удалена", + "Marcador eliminado", + "Favori supprimé", + "Lesezeichen entfernt", + "Preferito rimosso", + "Favorito removido", + "Usunięto zakładkę", + "已移除书签", + "ブックマークを削除しました", + "북마크 제거됨", + "Yer imi kaldırıldı", + ], + ), + // ── History ────────────────────────────────────────────────────────── + ( + "settings.history.title", + [ + "Connection History", + "История подключений", + "Historial de conexiones", + "Historique des connexions", + "Verbindungsverlauf", + "Cronologia connessioni", + "Histórico de conexões", + "Historia połączeń", + "连接历史记录", + "接続履歴", + "연결 기록", + "Bağlantı Geçmişi", + ], + ), + ( + "settings.history.empty", + [ + "No history yet.", + "Пока нет истории.", + "Aún no hay historial.", + "Aucun historique pour l'instant.", + "Noch kein Verlauf.", + "Nessuna cronologia.", + "Ainda não há histórico.", + "Brak historii.", + "暂无历史记录。", + "履歴はまだありません。", + "아직 기록이 없습니다.", + "Henüz geçmiş yok.", + ], + ), + ( + "settings.history.confirm_clear", + [ + "Delete all connection history? This cannot be undone.", + "Удалить всю историю подключений? Это действие необратимо.", + "¿Eliminar todo el historial de conexiones? Esta acción no se puede deshacer.", + "Supprimer tout l'historique des connexions ? Cette action est irréversible.", + "Gesamten Verbindungsverlauf löschen? Dies kann nicht rückgängig gemacht werden.", + "Eliminare tutta la cronologia delle connessioni? Questa azione non può essere annullata.", + "Excluir todo o histórico de conexões? Esta ação não pode ser desfeita.", + "Usunąć całą historię połączeń? Tej operacji nie można cofnąć.", + "删除全部连接历史记录?此操作无法撤销。", + "接続履歴をすべて削除しますか?この操作は元に戻せません。", + "모든 연결 기록을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", + "Tüm bağlantı geçmişi silinsin mi? Bu işlem geri alınamaz.", + ], + ), + ( + "settings.history.delete_all", + [ + "Delete All", "Удалить всё", "Eliminar todo", "Tout supprimer", "Alles löschen", + "Elimina tutto", "Excluir tudo", "Usuń wszystko", "全部删除", "すべて削除", + "모두 삭제", "Tümünü Sil", + ], + ), + ( + "settings.history.clear_all", + [ + "Clear All History", + "Очистить всю историю", + "Borrar todo el historial", + "Effacer tout l'historique", + "Gesamten Verlauf löschen", + "Cancella tutta la cronologia", + "Limpar todo o histórico", + "Wyczyść całą historię", + "清除全部历史记录", + "すべての履歴を消去", + "전체 기록 지우기", + "Tüm Geçmişi Temizle", + ], + ), + ( + "settings.history.cleared", + [ + "History cleared", + "История очищена", + "Historial borrado", + "Historique effacé", + "Verlauf gelöscht", + "Cronologia cancellata", + "Histórico apagado", + "Wyczyszczono historię", + "历史记录已清除", + "履歴を消去しました", + "기록이 지워졌습니다", + "Geçmiş temizlendi", + ], + ), + // ── Security ───────────────────────────────────────────────────────── + ( + "settings.security.hint", + [ + "Passwords for saved connections are stored in your system keychain.", + "Пароли для сохранённых подключений хранятся в системной связке ключей.", + "Las contraseñas de las conexiones guardadas se almacenan en el llavero del sistema.", + "Les mots de passe des connexions enregistrées sont stockés dans le trousseau système.", + "Passwörter für gespeicherte Verbindungen werden im System-Schlüsselbund gespeichert.", + "Le password delle connessioni salvate sono conservate nel portachiavi di sistema.", + "As senhas das conexões salvas são armazenadas no chaveiro do sistema.", + "Hasła zapisanych połączeń są przechowywane w systemowym pęku kluczy.", + "已保存连接的密码存储在系统钥匙串中。", + "保存済み接続のパスワードはシステムのキーチェーンに保存されます。", + "저장된 연결의 비밀번호는 시스템 키체인에 저장됩니다.", + "Kayıtlı bağlantıların parolaları sistem anahtarlığında saklanır.", + ], + ), + ( + "settings.security.no_connections", + [ + "No saved connections.", + "Нет сохранённых подключений.", + "No hay conexiones guardadas.", + "Aucune connexion enregistrée.", + "Keine gespeicherten Verbindungen.", + "Nessuna connessione salvata.", + "Nenhuma conexão salva.", + "Brak zapisanych połączeń.", + "没有已保存的连接。", + "保存済みの接続はありません。", + "저장된 연결이 없습니다.", + "Kayıtlı bağlantı yok.", + ], + ), + ( + "settings.security.forget_password_hover", + [ + "Forget saved password", + "Забыть сохранённый пароль", + "Olvidar contraseña guardada", + "Oublier le mot de passe enregistré", + "Gespeichertes Passwort vergessen", + "Dimentica password salvata", + "Esquecer senha salva", + "Zapomnij zapisane hasło", + "忘记已保存的密码", + "保存済みのパスワードを削除", + "저장된 비밀번호 삭제", + "Kayıtlı parolayı unut", + ], + ), + ( + "settings.security.password_forgotten", + [ + "Password forgotten", + "Пароль забыт", + "Contraseña olvidada", + "Mot de passe oublié", + "Passwort vergessen", + "Password dimenticata", + "Senha esquecida", + "Zapomniano hasło", + "已忘记密码", + "パスワードを削除しました", + "비밀번호가 삭제되었습니다", + "Parola unutuldu", + ], + ), + ( + "settings.security.forget_all", + [ + "Forget All Saved Passwords", + "Забыть все сохранённые пароли", + "Olvidar todas las contraseñas guardadas", + "Oublier tous les mots de passe enregistrés", + "Alle gespeicherten Passwörter vergessen", + "Dimentica tutte le password salvate", + "Esquecer todas as senhas salvas", + "Zapomnij wszystkie zapisane hasła", + "忘记所有已保存的密码", + "保存済みのパスワードをすべて削除", + "저장된 모든 비밀번호 삭제", + "Kayıtlı Tüm Parolaları Unut", + ], + ), + ( + "settings.security.all_forgotten", + [ + "All saved passwords forgotten", + "Все сохранённые пароли забыты", + "Se olvidaron todas las contraseñas guardadas", + "Tous les mots de passe enregistrés ont été oubliés", + "Alle gespeicherten Passwörter wurden vergessen", + "Tutte le password salvate sono state dimenticate", + "Todas as senhas salvas foram esquecidas", + "Zapomniano wszystkie zapisane hasła", + "已忘记所有保存的密码", + "保存済みのパスワードをすべて削除しました", + "저장된 모든 비밀번호가 삭제되었습니다", + "Kayıtlı tüm parolalar unutuldu", + ], + ), + // ── Transfers ──────────────────────────────────────────────────────── + ( + "settings.transfers.concurrent_title", + [ + "Concurrent transfers", + "Одновременные передачи", + "Transferencias simultáneas", + "Transferts simultanés", + "Gleichzeitige Übertragungen", + "Trasferimenti simultanei", + "Transferências simultâneas", + "Równoczesne transfery", + "并发传输数", + "同時転送数", + "동시 전송", + "Eşzamanlı Aktarımlar", + ], + ), + ( + "settings.transfers.concurrent_hint", + [ + "How many uploads/downloads can run at the same time.", + "Сколько загрузок/скачиваний может выполняться одновременно.", + "Cuántas subidas/descargas pueden ejecutarse al mismo tiempo.", + "Combien d'envois/téléchargements peuvent s'exécuter simultanément.", + "Wie viele Uploads/Downloads gleichzeitig laufen können.", + "Quanti caricamenti/download possono essere eseguiti contemporaneamente.", + "Quantos envios/downloads podem ser executados ao mesmo tempo.", + "Ile wysyłek/pobrań może działać jednocześnie.", + "可同时运行的上传/下载数量。", + "同時に実行できるアップロード/ダウンロードの数。", + "동시에 실행할 수 있는 업로드/다운로드 수입니다.", + "Aynı anda kaç yükleme/indirme çalışabileceği.", + ], + ), + ( + "settings.transfers.auto_remove_title", + [ + "Auto-remove completed transfers", + "Автоудаление завершённых передач", + "Eliminar automáticamente transferencias completadas", + "Suppression automatique des transferts terminés", + "Abgeschlossene Übertragungen automatisch entfernen", + "Rimuovi automaticamente i trasferimenti completati", + "Remover automaticamente transferências concluídas", + "Automatyczne usuwanie ukończonych transferów", + "自动移除已完成的传输", + "完了した転送を自動的に削除", + "완료된 전송 자동 제거", + "Tamamlanan Aktarımları Otomatik Kaldır", + ], + ), + ( + "settings.transfers.auto_remove_hint", + [ + "Clear finished transfers from the queue automatically.", + "Автоматически очищать завершённые передачи из очереди.", + "Elimina automáticamente las transferencias finalizadas de la cola.", + "Efface automatiquement les transferts terminés de la file d'attente.", + "Entfernt abgeschlossene Übertragungen automatisch aus der Warteschlange.", + "Rimuove automaticamente i trasferimenti terminati dalla coda.", + "Remove automaticamente as transferências concluídas da fila.", + "Automatycznie usuwa zakończone transfery z kolejki.", + "自动从队列中清除已完成的传输。", + "完了した転送をキューから自動的に消去します。", + "완료된 전송을 대기열에서 자동으로 지웁니다.", + "Tamamlanan aktarımları kuyruktan otomatik olarak temizler.", + ], + ), + ( + "settings.transfers.never", + [ + "Never", "Никогда", "Nunca", "Jamais", "Nie", "Mai", "Nunca", "Nigdy", "从不", "しない", + "안 함", "Asla", + ], + ), + ( + "settings.transfers.10s", + [ + "10s", "10с", "10s", "10s", "10 s", "10s", "10s", "10s", "10秒", "10秒", "10초", "10sn", + ], + ), + ( + "settings.transfers.30s", + [ + "30s", "30с", "30s", "30s", "30 s", "30s", "30s", "30s", "30秒", "30秒", "30초", "30sn", + ], + ), + ( + "settings.transfers.1min", + [ + "1 min", "1 мин", "1 min", "1 min", "1 Min", "1 min", "1 min", "1 min", "1分钟", "1分", + "1분", "1 dk", + ], + ), + // ── About ──────────────────────────────────────────────────────────── + ( + "settings.about.title", + [ + "About LoFlum", + "О LoFlum", + "Acerca de LoFlum", + "À propos de LoFlum", + "Über LoFlum", + "Informazioni su LoFlum", + "Sobre o LoFlum", + "O programie LoFlum", + "关于 LoFlum", + "LoFlum について", + "LoFlum 정보", + "LoFlum Hakkında", + ], + ), + ( + "settings.about.version", + [ + "Version", "Версия", "Versión", "Version", "Version", "Versione", "Versão", "Wersja", + "版本", "バージョン", "버전", "Sürüm", + ], + ), + ( + "settings.about.license", + [ + "License", "Лицензия", "Licencia", "Licence", "Lizenz", "Licenza", "Licença", + "Licencja", "许可证", "ライセンス", "라이선스", "Lisans", + ], + ), + ( + "settings.about.database", + [ + "Database", "База данных", "Base de datos", "Base de données", "Datenbank", + "Database", "Banco de dados", "Baza danych", "数据库", "データベース", "데이터베이스", + "Veritabanı", + ], + ), + ( + "settings.about.reveal_in_finder", + [ + "Reveal in Finder", + "Показать в Finder", + "Mostrar en Finder", + "Afficher dans le Finder", + "Im Finder anzeigen", + "Mostra nel Finder", + "Mostrar no Finder", + "Pokaż w Finderze", + "在访达中显示", + "Finder で表示", + "Finder에서 보기", + "Finder'da Göster", + ], + ), +]; diff --git a/src/i18n/strings/toolbar.rs b/src/i18n/strings/toolbar.rs new file mode 100644 index 0000000..f483bb9 --- /dev/null +++ b/src/i18n/strings/toolbar.rs @@ -0,0 +1,251 @@ +//! Тулбар, попап History, статус-бар, панель очереди передач. +use super::Entry; + +pub const ENTRIES: &[Entry] = &[ + ( + "toolbar.new_connection", + [ + "New Connection", + "Новое подключение", + "Nueva conexión", + "Nouvelle connexion", + "Neue Verbindung", + "Nuova connessione", + "Nova conexão", + "Nowe połączenie", + "新建连接", + "新規接続", + "새 연결", + "Yeni Bağlantı", + ], + ), + ( + "toolbar.queue_label", + [ + "Queue", "Очередь", "Cola", "File d'attente", "Warteschlange", "Coda", "Fila", + "Kolejka", "队列", "キュー", "대기열", "Kuyruk", + ], + ), + ( + "toolbar.upload_queued", + [ + "Upload queued: {name}", + "Загрузка добавлена в очередь: {name}", + "Subida en cola: {name}", + "Envoi mis en file d'attente : {name}", + "Upload eingereiht: {name}", + "Caricamento in coda: {name}", + "Envio na fila: {name}", + "Wysyłanie dodane do kolejki: {name}", + "已加入上传队列:{name}", + "アップロードをキューに追加しました: {name}", + "업로드 대기열에 추가됨: {name}", + "Yükleme kuyruğa eklendi: {name}", + ], + ), + ( + "toolbar.download_queued", + [ + "Download queued: {name}", + "Скачивание добавлено в очередь: {name}", + "Descarga en cola: {name}", + "Téléchargement mis en file d'attente : {name}", + "Download eingereiht: {name}", + "Download in coda: {name}", + "Download na fila: {name}", + "Pobieranie dodane do kolejki: {name}", + "已加入下载队列:{name}", + "ダウンロードをキューに追加しました: {name}", + "다운로드 대기열에 추가됨: {name}", + "İndirme kuyruğa eklendi: {name}", + ], + ), + ( + "history.recent_connections", + [ + "Recent connections", + "Недавние подключения", + "Conexiones recientes", + "Connexions récentes", + "Letzte Verbindungen", + "Connessioni recenti", + "Conexões recentes", + "Ostatnie połączenia", + "最近的连接", + "最近の接続", + "최근 연결", + "Son Bağlantılar", + ], + ), + ( + "history.no_history", + [ + "No history yet", + "Пока нет истории", + "Aún no hay historial", + "Aucun historique pour l'instant", + "Noch kein Verlauf", + "Nessuna cronologia", + "Ainda não há histórico", + "Brak historii", + "暂无历史记录", + "履歴はまだありません", + "아직 기록이 없습니다", + "Henüz geçmiş yok", + ], + ), + ( + "status.not_connected", + [ + "Not connected", + "Не подключено", + "Sin conexión", + "Non connecté", + "Nicht verbunden", + "Non connesso", + "Não conectado", + "Brak połączenia", + "未连接", + "未接続", + "연결 안 됨", + "Bağlı değil", + ], + ), + ( + "queue.title", + [ + "Transfer Queue ({n})", + "Очередь передач ({n})", + "Cola de transferencias ({n})", + "File de transferts ({n})", + "Übertragungswarteschlange ({n})", + "Coda trasferimenti ({n})", + "Fila de transferências ({n})", + "Kolejka transferów ({n})", + "传输队列 ({n})", + "転送キュー ({n})", + "전송 대기열 ({n})", + "Aktarım Kuyruğu ({n})", + ], + ), + ( + "queue.done_suffix", + [ + "{done}/{total} done", + "{done}/{total} готово", + "{done}/{total} completado", + "{done}/{total} terminé", + "{done}/{total} fertig", + "{done}/{total} completato", + "{done}/{total} concluído", + "{done}/{total} gotowe", + "{done}/{total} 已完成", + "{done}/{total} 完了", + "{done}/{total} 완료", + "{done}/{total} tamamlandı", + ], + ), + ( + "queue.failed_suffix", + [ + "{n} failed", + "Ошибок: {n}", + "{n} con errores", + "{n} échoué(s)", + "{n} fehlgeschlagen", + "{n} falliti", + "{n} falharam", + "Błędów: {n}", + "{n} 个失败", + "失敗 {n} 件", + "실패 {n}개", + "{n} başarısız", + ], + ), + ( + "queue.state_queued", + [ + "Queued", "В очереди", "En cola", "En attente", "Wartend", "In coda", "Na fila", + "W kolejce", "排队中", "待機中", "대기 중", "Sırada", + ], + ), + ( + "queue.state_uploading", + [ + "Uploading", + "Загрузка…", + "Subiendo…", + "Envoi…", + "Wird hochgeladen…", + "Caricamento…", + "Enviando…", + "Wysyłanie…", + "上传中…", + "アップロード中…", + "업로드 중…", + "Yükleniyor…", + ], + ), + ( + "queue.state_downloading", + [ + "Downloading", + "Скачивание…", + "Descargando…", + "Téléchargement…", + "Wird heruntergeladen…", + "Download in corso…", + "Baixando…", + "Pobieranie…", + "下载中…", + "ダウンロード中…", + "다운로드 중…", + "İndiriliyor…", + ], + ), + ( + "queue.state_complete", + [ + "Complete", "Готово", "Completado", "Terminé", "Fertig", "Completato", "Concluído", + "Gotowe", "已完成", "完了", "완료", "Tamamlandı", + ], + ), + ( + "queue.state_failed", + [ + "Failed", "Ошибка", "Fallido", "Échec", "Fehlgeschlagen", "Non riuscito", "Falhou", + "Niepowodzenie", "失败", "失敗", "실패", "Başarısız", + ], + ), + ( + "queue.state_paused", + [ + "Paused", "Приостановлено", "Pausado", "En pause", "Pausiert", "In pausa", "Pausado", + "Wstrzymane", "已暂停", "一時停止", "일시 중지", "Duraklatıldı", + ], + ), + ( + "queue.state_cancelled", + [ + "Cancelled", "Отменено", "Cancelado", "Annulé", "Abgebrochen", "Annullato", + "Cancelado", "Anulowane", "已取消", "キャンセル済み", "취소됨", "İptal edildi", + ], + ), + ( + "queue.state_retrying", + [ + "Retrying", + "Повтор…", + "Reintentando…", + "Nouvelle tentative…", + "Erneuter Versuch…", + "Nuovo tentativo…", + "Tentando novamente…", + "Ponawianie…", + "正在重试…", + "再試行中…", + "재시도 중…", + "Yeniden deneniyor…", + ], + ), +]; diff --git a/src/i18n/strings/welcome.rs b/src/i18n/strings/welcome.rs new file mode 100644 index 0000000..288400e --- /dev/null +++ b/src/i18n/strings/welcome.rs @@ -0,0 +1,3 @@ +use super::Entry; + +pub const ENTRIES: &[Entry] = &[]; diff --git a/src/lib.rs b/src/lib.rs index 48b787c..9ba4c34 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ pub mod domain; pub mod fs; +pub mod i18n; pub mod protocols; pub mod storage; pub mod transfer; @@ -9,6 +10,7 @@ use crate::fs::remote::RemoteRegistry; use crate::transfer::queue::TransferQueue; use crate::ui::app::FileManagerApp; use std::sync::Arc; +use std::sync::atomic::AtomicU32; pub fn run() { tracing_subscriber::fmt().with_target(false).init(); @@ -35,9 +37,18 @@ pub fn run() { let registry = Arc::new(RemoteRegistry::default()); let queue = TransferQueue::default(); - transfer::worker::spawn_worker(queue.clone(), registry.clone(), rt_handle); + // Реальное значение подтягивается из БД внутри FileManagerApp::new — + // здесь только общий на воркер и UI атомик, который Settings → Transfers + // меняет на лету. + let max_concurrent = Arc::new(AtomicU32::new(2)); + transfer::worker::spawn_worker( + queue.clone(), + registry.clone(), + rt_handle, + max_concurrent.clone(), + ); - let app = FileManagerApp::new(registry, queue, rt, db, sites); + let app = FileManagerApp::new(registry, queue, rt, db, sites, max_concurrent); let icon = load_app_icon(); diff --git a/src/protocols/ftp.rs b/src/protocols/ftp.rs deleted file mode 100644 index 141ceb3..0000000 --- a/src/protocols/ftp.rs +++ /dev/null @@ -1,259 +0,0 @@ -use anyhow::Result; -use async_trait::async_trait; -use futures_lite::io::{AsyncReadExt, AsyncWriteExt}; -use std::str::FromStr; -use std::time::SystemTime; -use tokio::fs::File; -use tokio::io::{AsyncReadExt as TokioReadExt, AsyncWriteExt as TokioWriteExt}; -use tokio::sync::Mutex; - -use suppaftp::{AsyncFtpStream, list::File as FtpListFile}; - -use super::{ProgressAction, RemoteFs}; -use crate::domain::file_entry::{EntryKind, FileEntry}; - -const CHUNK_SIZE: usize = 64 * 1024; - -pub struct FtpClient { - pub conn: Mutex>, -} - -impl FtpClient { - pub async fn connect(host: &str, port: u16, user: &str, pass: &str) -> Result { - let addr = format!("{}:{}", host, port); - let mut stream = AsyncFtpStream::connect(&addr) - .await - .map_err(|e| anyhow::anyhow!("FTP connect failed: {}", e))?; - stream - .login(user, pass) - .await - .map_err(|e| anyhow::anyhow!("FTP login failed: {}", e))?; - Ok(Self { - conn: Mutex::new(Some(stream)), - }) - } - - fn parse_list_entry(line: &str) -> Option { - let file = FtpListFile::from_str(line).ok()?; - let kind = if file.is_directory() { - EntryKind::Dir - } else if file.is_symlink() { - EntryKind::Symlink - } else { - EntryKind::File - }; - - let modified = file - .modified() - .duration_since(SystemTime::UNIX_EPOCH) - .ok() - .map(|d| d.as_secs() as i64); - - Some(FileEntry { - name: file.name().to_string(), - path: file.name().to_string(), - kind, - size: Some(file.size() as u64), - modified, - permissions: None, - }) - } -} - -#[async_trait] -impl RemoteFs for FtpClient { - async fn list(&self, path: &str) -> Result> { - let mut guard = self.conn.lock().await; - let conn = guard - .as_mut() - .ok_or_else(|| anyhow::anyhow!("not connected"))?; - let lines = conn - .list(Some(path)) - .await - .map_err(|e| anyhow::anyhow!("FTP list failed: {}", e))?; - - let mut entries: Vec = lines - .iter() - .filter_map(|line| Self::parse_list_entry(line)) - .collect(); - - entries.sort_by(|a, b| match (&a.kind, &b.kind) { - (EntryKind::Dir, EntryKind::Dir) => a.name.cmp(&b.name), - (EntryKind::Dir, _) => std::cmp::Ordering::Less, - (_, EntryKind::Dir) => std::cmp::Ordering::Greater, - _ => a.name.cmp(&b.name), - }); - - Ok(entries) - } - - async fn upload_with_progress( - &self, - local: &str, - remote: &str, - on_progress: Option ProgressAction + Send>>, - ) -> Result<()> { - let mut file = File::open(local) - .await - .map_err(|e| anyhow::anyhow!("cannot open local file: {}", e))?; - - let mut guard = self.conn.lock().await; - let conn = guard - .as_mut() - .ok_or_else(|| anyhow::anyhow!("not connected"))?; - let mut stream = conn - .put_with_stream(remote) - .await - .map_err(|e| anyhow::anyhow!("FTP upload stream failed: {}", e))?; - - let mut buf = vec![0u8; CHUNK_SIZE]; - let mut total = 0u64; - loop { - let n = file - .read(&mut buf) - .await - .map_err(|e| anyhow::anyhow!("FTP upload read failed: {}", e))?; - if n == 0 { - break; - } - stream - .write_all(&buf[..n]) - .await - .map_err(|e| anyhow::anyhow!("FTP upload write failed: {}", e))?; - total += n as u64; - if let Some(ref cb) = on_progress { - match cb(total) { - ProgressAction::Continue => {} - ProgressAction::Cancel => { - return Err(anyhow::anyhow!("cancelled")); - } - ProgressAction::Pause => { - return Err(anyhow::anyhow!("paused")); - } - } - } - } - - conn.finalize_put_stream(stream) - .await - .map_err(|e| anyhow::anyhow!("FTP upload finalize failed: {}", e))?; - Ok(()) - } - - async fn download_with_progress( - &self, - remote: &str, - local: &str, - on_progress: Option ProgressAction + Send>>, - ) -> Result<()> { - let mut guard = self.conn.lock().await; - let conn = guard - .as_mut() - .ok_or_else(|| anyhow::anyhow!("not connected"))?; - let mut stream = conn - .retr_as_stream(remote) - .await - .map_err(|e| anyhow::anyhow!("FTP download stream failed: {}", e))?; - - let mut file = File::create(local) - .await - .map_err(|e| anyhow::anyhow!("cannot create local file: {}", e))?; - - let mut buf = vec![0u8; CHUNK_SIZE]; - let mut total = 0u64; - loop { - let n = stream - .read(&mut buf) - .await - .map_err(|e| anyhow::anyhow!("FTP download read failed: {}", e))?; - if n == 0 { - break; - } - file.write_all(&buf[..n]) - .await - .map_err(|e| anyhow::anyhow!("FTP download write failed: {}", e))?; - total += n as u64; - if let Some(ref cb) = on_progress { - match cb(total) { - ProgressAction::Continue => {} - ProgressAction::Cancel => { - return Err(anyhow::anyhow!("cancelled")); - } - ProgressAction::Pause => { - return Err(anyhow::anyhow!("paused")); - } - } - } - } - - conn.finalize_retr_stream(stream) - .await - .map_err(|e| anyhow::anyhow!("FTP download finalize failed: {}", e))?; - Ok(()) - } - - async fn mkdir(&self, path: &str) -> Result<()> { - let mut guard = self.conn.lock().await; - let conn = guard - .as_mut() - .ok_or_else(|| anyhow::anyhow!("not connected"))?; - conn.mkdir(path) - .await - .map_err(|e| anyhow::anyhow!("FTP mkdir failed: {}", e))?; - Ok(()) - } - - async fn rename(&self, from: &str, to: &str) -> Result<()> { - let mut guard = self.conn.lock().await; - let conn = guard - .as_mut() - .ok_or_else(|| anyhow::anyhow!("not connected"))?; - conn.rename(from, to) - .await - .map_err(|e| anyhow::anyhow!("FTP rename failed: {}", e))?; - Ok(()) - } - - async fn delete(&self, path: &str) -> Result<()> { - let mut guard = self.conn.lock().await; - let conn = guard - .as_mut() - .ok_or_else(|| anyhow::anyhow!("not connected"))?; - if conn.rm(path).await.is_err() { - conn.rmdir(path) - .await - .map_err(|e| anyhow::anyhow!("FTP delete failed: {}", e))?; - } - Ok(()) - } - - async fn stat(&self, path: &str) -> Result { - let mut guard = self.conn.lock().await; - let conn = guard - .as_mut() - .ok_or_else(|| anyhow::anyhow!("not connected"))?; - let size = conn - .size(path) - .await - .map_err(|e| anyhow::anyhow!("FTP stat failed: {}", e))?; - let modified = conn - .mdtm(path) - .await - .map(|dt| dt.and_utc().timestamp()) - .ok(); - - let name = std::path::Path::new(path) - .file_name() - .map(|s| s.to_string_lossy().to_string()) - .unwrap_or_else(|| path.to_string()); - - Ok(FileEntry { - name, - path: path.to_string(), - kind: EntryKind::File, - size: Some(size as u64), - modified, - permissions: None, - }) - } -} diff --git a/src/protocols/ftp/mod.rs b/src/protocols/ftp/mod.rs new file mode 100644 index 0000000..6c61d93 --- /dev/null +++ b/src/protocols/ftp/mod.rs @@ -0,0 +1,57 @@ +use anyhow::Result; +use std::str::FromStr; +use std::time::SystemTime; +use tokio::sync::Mutex; + +use suppaftp::{AsyncFtpStream, list::File as FtpListFile}; + +use crate::domain::file_entry::{EntryKind, FileEntry}; + +mod ops; +mod transfer; + +pub struct FtpClient { + pub conn: Mutex>, +} + +impl FtpClient { + pub async fn connect(host: &str, port: u16, user: &str, pass: &str) -> Result { + let addr = format!("{}:{}", host, port); + let mut stream = AsyncFtpStream::connect(&addr) + .await + .map_err(|e| anyhow::anyhow!("FTP connect failed: {}", e))?; + stream + .login(user, pass) + .await + .map_err(|e| anyhow::anyhow!("FTP login failed: {}", e))?; + Ok(Self { + conn: Mutex::new(Some(stream)), + }) + } + + pub(super) fn parse_list_entry(line: &str) -> Option { + let file = FtpListFile::from_str(line).ok()?; + let kind = if file.is_directory() { + EntryKind::Dir + } else if file.is_symlink() { + EntryKind::Symlink + } else { + EntryKind::File + }; + + let modified = file + .modified() + .duration_since(SystemTime::UNIX_EPOCH) + .ok() + .map(|d| d.as_secs() as i64); + + Some(FileEntry { + name: file.name().to_string(), + path: file.name().to_string(), + kind, + size: Some(file.size() as u64), + modified, + permissions: None, + }) + } +} diff --git a/src/protocols/ftp/ops.rs b/src/protocols/ftp/ops.rs new file mode 100644 index 0000000..6c99ec2 --- /dev/null +++ b/src/protocols/ftp/ops.rs @@ -0,0 +1,126 @@ +//! Реализация `RemoteFs` для FTP. Чанкованные upload/download — в [`super::transfer`]. +use anyhow::Result; +use async_trait::async_trait; + +use super::{FtpClient, transfer}; +use crate::domain::file_entry::{EntryKind, FileEntry}; +use crate::protocols::{ProgressAction, RemoteFs}; + +#[async_trait] +impl RemoteFs for FtpClient { + async fn list(&self, path: &str) -> Result> { + let mut guard = self.conn.lock().await; + let conn = guard + .as_mut() + .ok_or_else(|| anyhow::anyhow!("not connected"))?; + let lines = conn + .list(Some(path)) + .await + .map_err(|e| anyhow::anyhow!("FTP list failed: {}", e))?; + + let mut entries: Vec = lines + .iter() + .filter_map(|line| Self::parse_list_entry(line)) + .collect(); + + entries.sort_by(|a, b| match (&a.kind, &b.kind) { + (EntryKind::Dir, EntryKind::Dir) => a.name.cmp(&b.name), + (EntryKind::Dir, _) => std::cmp::Ordering::Less, + (_, EntryKind::Dir) => std::cmp::Ordering::Greater, + _ => a.name.cmp(&b.name), + }); + + Ok(entries) + } + + async fn upload_with_progress( + &self, + local: &str, + remote: &str, + on_progress: Option ProgressAction + Send>>, + ) -> Result<()> { + let mut guard = self.conn.lock().await; + let conn = guard + .as_mut() + .ok_or_else(|| anyhow::anyhow!("not connected"))?; + transfer::upload(conn, local, remote, on_progress).await + } + + async fn download_with_progress( + &self, + remote: &str, + local: &str, + on_progress: Option ProgressAction + Send>>, + ) -> Result<()> { + let mut guard = self.conn.lock().await; + let conn = guard + .as_mut() + .ok_or_else(|| anyhow::anyhow!("not connected"))?; + transfer::download(conn, remote, local, on_progress).await + } + + async fn mkdir(&self, path: &str) -> Result<()> { + let mut guard = self.conn.lock().await; + let conn = guard + .as_mut() + .ok_or_else(|| anyhow::anyhow!("not connected"))?; + conn.mkdir(path) + .await + .map_err(|e| anyhow::anyhow!("FTP mkdir failed: {}", e))?; + Ok(()) + } + + async fn rename(&self, from: &str, to: &str) -> Result<()> { + let mut guard = self.conn.lock().await; + let conn = guard + .as_mut() + .ok_or_else(|| anyhow::anyhow!("not connected"))?; + conn.rename(from, to) + .await + .map_err(|e| anyhow::anyhow!("FTP rename failed: {}", e))?; + Ok(()) + } + + async fn delete(&self, path: &str) -> Result<()> { + let mut guard = self.conn.lock().await; + let conn = guard + .as_mut() + .ok_or_else(|| anyhow::anyhow!("not connected"))?; + if conn.rm(path).await.is_err() { + conn.rmdir(path) + .await + .map_err(|e| anyhow::anyhow!("FTP delete failed: {}", e))?; + } + Ok(()) + } + + async fn stat(&self, path: &str) -> Result { + let mut guard = self.conn.lock().await; + let conn = guard + .as_mut() + .ok_or_else(|| anyhow::anyhow!("not connected"))?; + let size = conn + .size(path) + .await + .map_err(|e| anyhow::anyhow!("FTP stat failed: {}", e))?; + let modified = conn + .mdtm(path) + .await + .map(|dt| dt.and_utc().timestamp()) + .ok(); + + let name = std::path::Path::new(path) + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| path.to_string()); + + Ok(FileEntry { + name, + path: path.to_string(), + kind: EntryKind::File, + size: Some(size as u64), + modified, + permissions: None, + }) + } +} diff --git a/src/protocols/ftp/transfer.rs b/src/protocols/ftp/transfer.rs new file mode 100644 index 0000000..df58232 --- /dev/null +++ b/src/protocols/ftp/transfer.rs @@ -0,0 +1,101 @@ +//! Чанкованная загрузка/выгрузка по FTP с колбэком прогресса (pause/cancel). +use anyhow::Result; +use futures_lite::io::{AsyncReadExt, AsyncWriteExt}; +use suppaftp::AsyncFtpStream; +use tokio::fs::File; +use tokio::io::{AsyncReadExt as TokioReadExt, AsyncWriteExt as TokioWriteExt}; + +use crate::protocols::ProgressAction; + +const CHUNK_SIZE: usize = 64 * 1024; + +type ProgressCb = Option ProgressAction + Send>>; + +/// Проверяет действие прогресса; возвращает Err для pause/cancel (прерывает цикл). +fn check(cb: &ProgressCb, total: u64) -> Result<()> { + if let Some(cb) = cb { + match cb(total) { + ProgressAction::Continue => {} + ProgressAction::Cancel => return Err(anyhow::anyhow!("cancelled")), + ProgressAction::Pause => return Err(anyhow::anyhow!("paused")), + } + } + Ok(()) +} + +pub(super) async fn upload( + conn: &mut AsyncFtpStream, + local: &str, + remote: &str, + on_progress: ProgressCb, +) -> Result<()> { + let mut file = File::open(local) + .await + .map_err(|e| anyhow::anyhow!("cannot open local file: {}", e))?; + + let mut stream = conn + .put_with_stream(remote) + .await + .map_err(|e| anyhow::anyhow!("FTP upload stream failed: {}", e))?; + + let mut buf = vec![0u8; CHUNK_SIZE]; + let mut total = 0u64; + loop { + let n = file + .read(&mut buf) + .await + .map_err(|e| anyhow::anyhow!("FTP upload read failed: {}", e))?; + if n == 0 { + break; + } + stream + .write_all(&buf[..n]) + .await + .map_err(|e| anyhow::anyhow!("FTP upload write failed: {}", e))?; + total += n as u64; + check(&on_progress, total)?; + } + + conn.finalize_put_stream(stream) + .await + .map_err(|e| anyhow::anyhow!("FTP upload finalize failed: {}", e))?; + Ok(()) +} + +pub(super) async fn download( + conn: &mut AsyncFtpStream, + remote: &str, + local: &str, + on_progress: ProgressCb, +) -> Result<()> { + let mut stream = conn + .retr_as_stream(remote) + .await + .map_err(|e| anyhow::anyhow!("FTP download stream failed: {}", e))?; + + let mut file = File::create(local) + .await + .map_err(|e| anyhow::anyhow!("cannot create local file: {}", e))?; + + let mut buf = vec![0u8; CHUNK_SIZE]; + let mut total = 0u64; + loop { + let n = stream + .read(&mut buf) + .await + .map_err(|e| anyhow::anyhow!("FTP download read failed: {}", e))?; + if n == 0 { + break; + } + file.write_all(&buf[..n]) + .await + .map_err(|e| anyhow::anyhow!("FTP download write failed: {}", e))?; + total += n as u64; + check(&on_progress, total)?; + } + + conn.finalize_retr_stream(stream) + .await + .map_err(|e| anyhow::anyhow!("FTP download finalize failed: {}", e))?; + Ok(()) +} diff --git a/src/protocols/sftp.rs b/src/protocols/sftp.rs deleted file mode 100644 index ba1868e..0000000 --- a/src/protocols/sftp.rs +++ /dev/null @@ -1,343 +0,0 @@ -use anyhow::{Context, Result, bail}; -use async_trait::async_trait; -use ssh2::{CheckResult, HashType, KnownHostFileKind, Session}; -use std::io::{Read, Write}; -use std::net::TcpStream; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex}; - -use super::{ProgressAction, RemoteFs}; -use crate::domain::file_entry::{EntryKind, FileEntry}; - -const CHUNK_SIZE: usize = 64 * 1024; - -pub struct SftpClient { - session: Arc>, -} - -impl SftpClient { - /// Подключение по паролю - pub fn connect_password(host: &str, port: u16, user: &str, password: &str) -> Result { - let tcp = TcpStream::connect(format!("{}:{}", host, port)).context("TCP connect failed")?; - let mut session = Session::new().context("SSH session init failed")?; - session.set_tcp_stream(tcp); - session.handshake().context("SSH handshake failed")?; - verify_host_key(&session, host, port)?; - session - .userauth_password(user, password) - .context("SSH password auth failed")?; - Ok(Self { - session: Arc::new(Mutex::new(session)), - }) - } - - /// Подключение по ключу - pub fn connect_key(host: &str, port: u16, user: &str, key_path: &str) -> Result { - let tcp = TcpStream::connect(format!("{}:{}", host, port)).context("TCP connect failed")?; - let mut session = Session::new().context("SSH session init failed")?; - session.set_tcp_stream(tcp); - session.handshake().context("SSH handshake failed")?; - verify_host_key(&session, host, port)?; - session - .userauth_pubkey_file(user, None, Path::new(key_path), None) - .context("SSH key auth failed")?; - Ok(Self { - session: Arc::new(Mutex::new(session)), - }) - } -} - -fn known_hosts_path() -> Option { - dirs::home_dir().map(|p| p.join(".ssh/known_hosts")) -} - -fn fingerprint_hex(session: &Session) -> String { - session - .host_key_hash(HashType::Sha256) - .map(|h| { - h.iter() - .map(|b| format!("{:02x}", b)) - .collect::>() - .join(":") - }) - .unwrap_or_else(|| "unknown".into()) -} - -fn verify_host_key(session: &Session, host: &str, port: u16) -> Result<()> { - let mut known = session.known_hosts().context("known_hosts init failed")?; - - if let Some(ref path) = known_hosts_path() { - let _ = known.read_file(path, KnownHostFileKind::OpenSSH); - } - - let (key, key_type) = session - .host_key() - .context("no host key received from server")?; - - match known.check_port(host, port, key) { - CheckResult::Match => {} // known and verified - CheckResult::Mismatch => { - bail!( - "SSH host key mismatch for {}!\n\ - The server's host key has changed since the last connection.\n\ - This could mean someone is intercepting the connection (MITM attack).\n\ - Fingerprint (SHA256): {}", - host, - fingerprint_hex(session) - ); - } - CheckResult::NotFound => { - tracing::info!( - "Unknown host key for {}, adding to known_hosts (SHA256: {})", - host, - fingerprint_hex(session) - ); - known - .add(host, key, "loflum", key_type.into()) - .context("failed to add host key to known_hosts")?; - if let Some(ref path) = known_hosts_path() - && let Err(e) = known.write_file(path, KnownHostFileKind::OpenSSH) - { - tracing::warn!("failed to write known_hosts: {}", e); - } - } - CheckResult::Failure => { - bail!("known_hosts check failed for {}", host); - } - } - - Ok(()) -} - -#[async_trait] -impl RemoteFs for SftpClient { - async fn list(&self, path: &str) -> Result> { - let session = self.session.clone(); - let path = path.to_string(); - tokio::task::spawn_blocking(move || { - let sftp = session - .lock() - .unwrap() - .sftp() - .context("SFTP subsystem failed")?; - let entries = sftp.readdir(Path::new(&path)).context("readdir failed")?; - - let result = entries - .into_iter() - .map(|(pb, stat)| { - let kind = if stat.is_dir() { - EntryKind::Dir - } else if stat.file_type().is_symlink() { - EntryKind::Symlink - } else { - EntryKind::File - }; - FileEntry { - name: pb - .file_name() - .unwrap_or_default() - .to_string_lossy() - .to_string(), - path: pb.to_string_lossy().to_string(), - kind, - size: stat.size, - modified: stat.mtime.map(|t| t as i64), - permissions: None, - } - }) - .collect(); - - Ok(result) - }) - .await - .map_err(|e| anyhow::anyhow!("SFTP list task failed: {}", e))? - } - - async fn upload_with_progress( - &self, - local: &str, - remote: &str, - on_progress: Option ProgressAction + Send>>, - ) -> Result<()> { - let session = self.session.clone(); - let local = local.to_string(); - let remote = remote.to_string(); - tokio::task::spawn_blocking(move || { - let sftp = session - .lock() - .unwrap() - .sftp() - .context("SFTP subsystem failed")?; - let mut local_file = std::fs::File::open(&local).context("open local file failed")?; - let mut remote_file = sftp - .create(Path::new(&remote)) - .context("create remote file failed")?; - - let mut buf = vec![0u8; CHUNK_SIZE]; - let mut total = 0u64; - loop { - let n = local_file - .read(&mut buf) - .context("read local file failed")?; - if n == 0 { - break; - } - remote_file - .write_all(&buf[..n]) - .context("write remote file failed")?; - total += n as u64; - if let Some(ref cb) = on_progress { - match cb(total) { - ProgressAction::Continue => {} - ProgressAction::Cancel => { - return Err(anyhow::anyhow!("cancelled")); - } - ProgressAction::Pause => { - return Err(anyhow::anyhow!("paused")); - } - } - } - } - Ok(()) - }) - .await - .map_err(|e| anyhow::anyhow!("SFTP upload task failed: {}", e))? - } - - async fn download_with_progress( - &self, - remote: &str, - local: &str, - on_progress: Option ProgressAction + Send>>, - ) -> Result<()> { - let session = self.session.clone(); - let remote = remote.to_string(); - let local = local.to_string(); - tokio::task::spawn_blocking(move || { - let sftp = session - .lock() - .unwrap() - .sftp() - .context("SFTP subsystem failed")?; - let mut remote_file = sftp - .open(Path::new(&remote)) - .context("open remote file failed")?; - let mut local_file = - std::fs::File::create(&local).context("create local file failed")?; - - let mut buf = vec![0u8; CHUNK_SIZE]; - let mut total = 0u64; - loop { - let n = remote_file - .read(&mut buf) - .context("read remote file failed")?; - if n == 0 { - break; - } - local_file - .write_all(&buf[..n]) - .context("write local file failed")?; - total += n as u64; - if let Some(ref cb) = on_progress { - match cb(total) { - ProgressAction::Continue => {} - ProgressAction::Cancel => { - return Err(anyhow::anyhow!("cancelled")); - } - ProgressAction::Pause => { - return Err(anyhow::anyhow!("paused")); - } - } - } - } - Ok(()) - }) - .await - .map_err(|e| anyhow::anyhow!("SFTP download task failed: {}", e))? - } - - async fn mkdir(&self, path: &str) -> Result<()> { - let session = self.session.clone(); - let path = path.to_string(); - tokio::task::spawn_blocking(move || { - let sftp = session - .lock() - .unwrap() - .sftp() - .context("SFTP subsystem failed")?; - sftp.mkdir(Path::new(&path), 0o755) - .context("mkdir failed")?; - Ok(()) - }) - .await - .map_err(|e| anyhow::anyhow!("SFTP mkdir task failed: {}", e))? - } - - async fn rename(&self, from: &str, to: &str) -> Result<()> { - let session = self.session.clone(); - let from = from.to_string(); - let to = to.to_string(); - tokio::task::spawn_blocking(move || { - let sftp = session - .lock() - .unwrap() - .sftp() - .context("SFTP subsystem failed")?; - sftp.rename(Path::new(&from), Path::new(&to), None) - .context("rename failed")?; - Ok(()) - }) - .await - .map_err(|e| anyhow::anyhow!("SFTP rename task failed: {}", e))? - } - - async fn delete(&self, path: &str) -> Result<()> { - let session = self.session.clone(); - let path = path.to_string(); - tokio::task::spawn_blocking(move || { - let sftp = session - .lock() - .unwrap() - .sftp() - .context("SFTP subsystem failed")?; - // пробуем как файл, потом как директорию - if sftp.unlink(Path::new(&path)).is_err() { - sftp.rmdir(Path::new(&path)).context("delete failed")?; - } - Ok(()) - }) - .await - .map_err(|e| anyhow::anyhow!("SFTP delete task failed: {}", e))? - } - - async fn stat(&self, path: &str) -> Result { - let session = self.session.clone(); - let path = path.to_string(); - tokio::task::spawn_blocking(move || { - let sftp = session - .lock() - .unwrap() - .sftp() - .context("SFTP subsystem failed")?; - let stat = sftp.stat(Path::new(&path)).context("stat failed")?; - Ok(FileEntry { - name: Path::new(&path) - .file_name() - .unwrap_or_default() - .to_string_lossy() - .to_string(), - path: path.to_string(), - kind: if stat.is_dir() { - EntryKind::Dir - } else { - EntryKind::File - }, - size: stat.size, - modified: stat.mtime.map(|t| t as i64), - permissions: None, - }) - }) - .await - .map_err(|e| anyhow::anyhow!("SFTP stat task failed: {}", e))? - } -} diff --git a/src/protocols/sftp/hostkey.rs b/src/protocols/sftp/hostkey.rs new file mode 100644 index 0000000..34862b9 --- /dev/null +++ b/src/protocols/sftp/hostkey.rs @@ -0,0 +1,66 @@ +//! Проверка SSH host key через ~/.ssh/known_hosts (TOFU: неизвестный ключ добавляется). +use anyhow::{Context, Result, bail}; +use ssh2::{CheckResult, HashType, KnownHostFileKind, Session}; +use std::path::PathBuf; + +fn known_hosts_path() -> Option { + dirs::home_dir().map(|p| p.join(".ssh/known_hosts")) +} + +fn fingerprint_hex(session: &Session) -> String { + session + .host_key_hash(HashType::Sha256) + .map(|h| { + h.iter() + .map(|b| format!("{:02x}", b)) + .collect::>() + .join(":") + }) + .unwrap_or_else(|| "unknown".into()) +} + +pub(super) fn verify_host_key(session: &Session, host: &str, port: u16) -> Result<()> { + let mut known = session.known_hosts().context("known_hosts init failed")?; + + if let Some(ref path) = known_hosts_path() { + let _ = known.read_file(path, KnownHostFileKind::OpenSSH); + } + + let (key, key_type) = session + .host_key() + .context("no host key received from server")?; + + match known.check_port(host, port, key) { + CheckResult::Match => {} // known and verified + CheckResult::Mismatch => { + bail!( + "SSH host key mismatch for {}!\n\ + The server's host key has changed since the last connection.\n\ + This could mean someone is intercepting the connection (MITM attack).\n\ + Fingerprint (SHA256): {}", + host, + fingerprint_hex(session) + ); + } + CheckResult::NotFound => { + tracing::info!( + "Unknown host key for {}, adding to known_hosts (SHA256: {})", + host, + fingerprint_hex(session) + ); + known + .add(host, key, "loflum", key_type.into()) + .context("failed to add host key to known_hosts")?; + if let Some(ref path) = known_hosts_path() + && let Err(e) = known.write_file(path, KnownHostFileKind::OpenSSH) + { + tracing::warn!("failed to write known_hosts: {}", e); + } + } + CheckResult::Failure => { + bail!("known_hosts check failed for {}", host); + } + } + + Ok(()) +} diff --git a/src/protocols/sftp/mod.rs b/src/protocols/sftp/mod.rs new file mode 100644 index 0000000..711311e --- /dev/null +++ b/src/protocols/sftp/mod.rs @@ -0,0 +1,93 @@ +use anyhow::{Context, Result}; +use ssh2::Session; +use std::net::TcpStream; +use std::path::Path; +use std::sync::{Arc, Mutex}; + +mod hostkey; +mod ops; +mod transfer; + +pub struct SftpClient { + session: Arc>, +} + +impl SftpClient { + /// Подключение по паролю + pub fn connect_password(host: &str, port: u16, user: &str, password: &str) -> Result { + let session = handshake(host, port)?; + session + .userauth_password(user, password) + .context("SSH password auth failed")?; + Ok(Self { + session: Arc::new(Mutex::new(session)), + }) + } + + /// Подключение по явно выбранному ключу. `passphrase` — пароль из формы + /// (если задан): им расшифровывается защищённый паролем ключ. + pub fn connect_key( + host: &str, + port: u16, + user: &str, + key_path: &str, + passphrase: Option<&str>, + ) -> Result { + let session = handshake(host, port)?; + session + .userauth_pubkey_file(user, None, Path::new(key_path), passphrase) + .with_context(|| format!("SSH key auth failed ({})", key_path))?; + Ok(Self { + session: Arc::new(Mutex::new(session)), + }) + } + + /// Автоматический подбор SSH-аутентификации, когда пароль не задан и ключ + /// не выбран: сперва ssh-agent, затем стандартные ключи из ~/.ssh. + pub fn connect_auto(host: &str, port: u16, user: &str) -> Result { + let session = handshake(host, port)?; + + // 1) ssh-agent — покрывает ключи с passphrase, добавленные в агент. + if session.userauth_agent(user).is_ok() && session.authenticated() { + return Ok(Self { + session: Arc::new(Mutex::new(session)), + }); + } + + // 2) стандартные ключи в ~/.ssh (без passphrase). + let ssh_dir = dirs::home_dir() + .context("Cannot resolve home directory")? + .join(".ssh"); + let mut tried = vec!["ssh-agent".to_string()]; + for name in ["id_ed25519", "id_ecdsa", "id_rsa"] { + let key = ssh_dir.join(name); + if !key.is_file() { + continue; + } + if session.userauth_pubkey_file(user, None, &key, None).is_ok() + && session.authenticated() + { + return Ok(Self { + session: Arc::new(Mutex::new(session)), + }); + } + tried.push(format!("~/.ssh/{}", name)); + } + + anyhow::bail!( + "SSH auth failed: no password given, and none of [{}] were accepted. \ + Pick a key file manually or enter a password.", + tried.join(", ") + ) + } +} + +/// TCP + SSH handshake + проверка host key (общий пролог обоих способов аутентификации). +fn handshake(host: &str, port: u16) -> Result { + let tcp = TcpStream::connect(format!("{}:{}", host, port)).context("TCP connect failed")?; + let mut session = Session::new().context("SSH session init failed")?; + session.set_tcp_stream(tcp); + session.handshake().context("SSH handshake failed")?; + hostkey::verify_host_key(&session, host, port)?; + Ok(session) +} diff --git a/src/protocols/sftp/ops.rs b/src/protocols/sftp/ops.rs new file mode 100644 index 0000000..13054e3 --- /dev/null +++ b/src/protocols/sftp/ops.rs @@ -0,0 +1,169 @@ +//! Реализация `RemoteFs` для SFTP: всё через `spawn_blocking` (ssh2 синхронный). +//! Чанкованные upload/download вынесены в [`super::transfer`]. +use anyhow::{Context, Result}; +use async_trait::async_trait; +use std::path::Path; + +use super::{SftpClient, transfer}; +use crate::domain::file_entry::{EntryKind, FileEntry}; +use crate::protocols::{ProgressAction, RemoteFs}; + +#[async_trait] +impl RemoteFs for SftpClient { + async fn list(&self, path: &str) -> Result> { + let session = self.session.clone(); + let path = path.to_string(); + tokio::task::spawn_blocking(move || { + let sftp = session + .lock() + .unwrap() + .sftp() + .context("SFTP subsystem failed")?; + let entries = sftp.readdir(Path::new(&path)).context("readdir failed")?; + + let result = entries + .into_iter() + .map(|(pb, stat)| { + let kind = if stat.is_dir() { + EntryKind::Dir + } else if stat.file_type().is_symlink() { + EntryKind::Symlink + } else { + EntryKind::File + }; + FileEntry { + name: pb + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(), + path: pb.to_string_lossy().to_string(), + kind, + size: stat.size, + modified: stat.mtime.map(|t| t as i64), + permissions: None, + } + }) + .collect(); + + Ok(result) + }) + .await + .map_err(|e| anyhow::anyhow!("SFTP list task failed: {}", e))? + } + + async fn upload_with_progress( + &self, + local: &str, + remote: &str, + on_progress: Option ProgressAction + Send>>, + ) -> Result<()> { + transfer::upload( + self.session.clone(), + local.to_string(), + remote.to_string(), + on_progress, + ) + .await + } + + async fn download_with_progress( + &self, + remote: &str, + local: &str, + on_progress: Option ProgressAction + Send>>, + ) -> Result<()> { + transfer::download( + self.session.clone(), + remote.to_string(), + local.to_string(), + on_progress, + ) + .await + } + + async fn mkdir(&self, path: &str) -> Result<()> { + let session = self.session.clone(); + let path = path.to_string(); + tokio::task::spawn_blocking(move || { + let sftp = session + .lock() + .unwrap() + .sftp() + .context("SFTP subsystem failed")?; + sftp.mkdir(Path::new(&path), 0o755) + .context("mkdir failed")?; + Ok(()) + }) + .await + .map_err(|e| anyhow::anyhow!("SFTP mkdir task failed: {}", e))? + } + + async fn rename(&self, from: &str, to: &str) -> Result<()> { + let session = self.session.clone(); + let from = from.to_string(); + let to = to.to_string(); + tokio::task::spawn_blocking(move || { + let sftp = session + .lock() + .unwrap() + .sftp() + .context("SFTP subsystem failed")?; + sftp.rename(Path::new(&from), Path::new(&to), None) + .context("rename failed")?; + Ok(()) + }) + .await + .map_err(|e| anyhow::anyhow!("SFTP rename task failed: {}", e))? + } + + async fn delete(&self, path: &str) -> Result<()> { + let session = self.session.clone(); + let path = path.to_string(); + tokio::task::spawn_blocking(move || { + let sftp = session + .lock() + .unwrap() + .sftp() + .context("SFTP subsystem failed")?; + // пробуем как файл, потом как директорию + if sftp.unlink(Path::new(&path)).is_err() { + sftp.rmdir(Path::new(&path)).context("delete failed")?; + } + Ok(()) + }) + .await + .map_err(|e| anyhow::anyhow!("SFTP delete task failed: {}", e))? + } + + async fn stat(&self, path: &str) -> Result { + let session = self.session.clone(); + let path = path.to_string(); + tokio::task::spawn_blocking(move || { + let sftp = session + .lock() + .unwrap() + .sftp() + .context("SFTP subsystem failed")?; + let stat = sftp.stat(Path::new(&path)).context("stat failed")?; + Ok(FileEntry { + name: Path::new(&path) + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(), + path: path.to_string(), + kind: if stat.is_dir() { + EntryKind::Dir + } else { + EntryKind::File + }, + size: stat.size, + modified: stat.mtime.map(|t| t as i64), + permissions: None, + }) + }) + .await + .map_err(|e| anyhow::anyhow!("SFTP stat task failed: {}", e))? + } +} diff --git a/src/protocols/sftp/transfer.rs b/src/protocols/sftp/transfer.rs new file mode 100644 index 0000000..148353c --- /dev/null +++ b/src/protocols/sftp/transfer.rs @@ -0,0 +1,100 @@ +//! Чанкованная загрузка/выгрузка по SFTP с колбэком прогресса (pause/cancel). +use anyhow::{Context, Result}; +use ssh2::Session; +use std::io::{Read, Write}; +use std::path::Path; +use std::sync::{Arc, Mutex}; + +use crate::protocols::ProgressAction; + +const CHUNK_SIZE: usize = 64 * 1024; + +type ProgressCb = Option ProgressAction + Send>>; + +/// Проверяет действие прогресса; возвращает Err для pause/cancel (прерывает цикл). +fn check(cb: &ProgressCb, total: u64) -> Result<()> { + if let Some(cb) = cb { + match cb(total) { + ProgressAction::Continue => {} + ProgressAction::Cancel => return Err(anyhow::anyhow!("cancelled")), + ProgressAction::Pause => return Err(anyhow::anyhow!("paused")), + } + } + Ok(()) +} + +pub(super) async fn upload( + session: Arc>, + local: String, + remote: String, + on_progress: ProgressCb, +) -> Result<()> { + tokio::task::spawn_blocking(move || { + let sftp = session + .lock() + .unwrap() + .sftp() + .context("SFTP subsystem failed")?; + let mut local_file = std::fs::File::open(&local).context("open local file failed")?; + let mut remote_file = sftp + .create(Path::new(&remote)) + .context("create remote file failed")?; + + let mut buf = vec![0u8; CHUNK_SIZE]; + let mut total = 0u64; + loop { + let n = local_file + .read(&mut buf) + .context("read local file failed")?; + if n == 0 { + break; + } + remote_file + .write_all(&buf[..n]) + .context("write remote file failed")?; + total += n as u64; + check(&on_progress, total)?; + } + Ok(()) + }) + .await + .map_err(|e| anyhow::anyhow!("SFTP upload task failed: {}", e))? +} + +pub(super) async fn download( + session: Arc>, + remote: String, + local: String, + on_progress: ProgressCb, +) -> Result<()> { + tokio::task::spawn_blocking(move || { + let sftp = session + .lock() + .unwrap() + .sftp() + .context("SFTP subsystem failed")?; + let mut remote_file = sftp + .open(Path::new(&remote)) + .context("open remote file failed")?; + let mut local_file = std::fs::File::create(&local).context("create local file failed")?; + + let mut buf = vec![0u8; CHUNK_SIZE]; + let mut total = 0u64; + loop { + let n = remote_file + .read(&mut buf) + .context("read remote file failed")?; + if n == 0 { + break; + } + local_file + .write_all(&buf[..n]) + .context("write local file failed")?; + total += n as u64; + check(&on_progress, total)?; + } + Ok(()) + }) + .await + .map_err(|e| anyhow::anyhow!("SFTP download task failed: {}", e))? +} diff --git a/src/storage/db.rs b/src/storage/db.rs deleted file mode 100644 index 9ece001..0000000 --- a/src/storage/db.rs +++ /dev/null @@ -1,240 +0,0 @@ -use crate::domain::connection::Protocol; -use crate::domain::site::Site; -use anyhow::Result; -use rusqlite::{Connection, params}; - -fn protocol_to_str(protocol: &Protocol) -> &'static str { - match protocol { - Protocol::Sftp => "sftp", - Protocol::Ftp => "ftp", - Protocol::Ftps => "ftps", - } -} - -fn protocol_from_str(s: &str) -> Protocol { - match s { - "sftp" => Protocol::Sftp, - "ftps" => Protocol::Ftps, - _ => Protocol::Ftp, - } -} - -pub fn init_tables(conn: &Connection) -> Result<()> { - conn.execute_batch( - " - PRAGMA journal_mode=WAL; - - CREATE TABLE IF NOT EXISTS sites ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - protocol TEXT NOT NULL, - host TEXT NOT NULL, - port INTEGER NOT NULL, - username TEXT NOT NULL, - key_path TEXT, - folder TEXT, - note TEXT - ); - - CREATE TABLE IF NOT EXISTS connection_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - host TEXT NOT NULL, - port INTEGER NOT NULL, - username TEXT NOT NULL, - connected_at TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS bookmarks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - path TEXT NOT NULL - ); - ", - )?; - - // Миграция: старые базы созданы без conn_id/protocol/key_path — добавляем, - // если их ещё нет (ошибку "duplicate column" просто игнорируем). - let _ = conn.execute("ALTER TABLE connection_history ADD COLUMN conn_id TEXT", []); - let _ = conn.execute( - "ALTER TABLE connection_history ADD COLUMN protocol TEXT NOT NULL DEFAULT 'sftp'", - [], - ); - let _ = conn.execute( - "ALTER TABLE connection_history ADD COLUMN key_path TEXT", - [], - ); - // На случай старых записей без уникальности (host,port,username) — оставляем - // только самую свежую строку на каждую цель перед созданием индекса. - conn.execute( - "DELETE FROM connection_history WHERE id NOT IN ( - SELECT MAX(id) FROM connection_history GROUP BY host, port, username - )", - [], - )?; - conn.execute( - "CREATE UNIQUE INDEX IF NOT EXISTS idx_history_target - ON connection_history(host, port, username)", - [], - )?; - - Ok(()) -} - -pub fn get_bookmarks(conn: &Connection) -> Result> { - let mut stmt = conn.prepare("SELECT id, name, path FROM bookmarks ORDER BY id")?; - let rows = stmt - .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))? - .filter_map(|r| r.ok()) - .collect(); - Ok(rows) -} - -pub fn add_bookmark(conn: &Connection, name: &str, path: &str) -> Result { - conn.execute( - "INSERT INTO bookmarks (name, path) VALUES (?1, ?2)", - params![name, path], - )?; - Ok(conn.last_insert_rowid()) -} - -pub fn remove_bookmark(conn: &Connection, id: i64) -> Result<()> { - conn.execute("DELETE FROM bookmarks WHERE id = ?1", params![id])?; - Ok(()) -} - -/// Одна запись истории подключений: host, port, username, время, conn_id -/// (стабильный id — под ним же лежит пароль в keychain), протокол, key_path. -pub type HistoryRow = ( - String, - u16, - String, - String, - String, - Protocol, - Option, -); - -/// Ищет уже существующий conn_id для этой цели (host,port,username), чтобы -/// повторные подключения писали пароль в keychain под тем же id, а не заводили новый. -pub fn find_history_conn_id( - conn: &Connection, - host: &str, - port: u16, - username: &str, -) -> Result> { - let id: Option = conn - .query_row( - "SELECT conn_id FROM connection_history WHERE host = ?1 AND port = ?2 AND username = ?3", - params![host, port, username], - |row| row.get(0), - ) - .ok(); - Ok(id) -} - -#[allow(clippy::too_many_arguments)] -pub fn add_history_entry( - conn: &Connection, - host: &str, - port: u16, - username: &str, - conn_id: &str, - protocol: &Protocol, - key_path: Option<&str>, -) -> Result<()> { - use chrono::Local; - let connected_at = Local::now().format("%H:%M %d.%m").to_string(); - let proto = protocol_to_str(protocol); - conn.execute( - "INSERT INTO connection_history (host, port, username, connected_at, conn_id, protocol, key_path) - VALUES (?1,?2,?3,?4,?5,?6,?7) - ON CONFLICT(host, port, username) DO UPDATE SET - connected_at = excluded.connected_at, - protocol = excluded.protocol, - key_path = excluded.key_path, - conn_id = COALESCE(connection_history.conn_id, excluded.conn_id)", - params![host, port, username, connected_at, conn_id, proto, key_path], - )?; - // Храним только последние 20 записей - conn.execute( - "DELETE FROM connection_history WHERE id NOT IN ( - SELECT id FROM connection_history ORDER BY id DESC LIMIT 20 - )", - [], - )?; - Ok(()) -} - -pub fn get_history(conn: &Connection) -> Result> { - let mut stmt = conn.prepare( - "SELECT host, port, username, connected_at, conn_id, protocol, key_path - FROM connection_history ORDER BY id DESC LIMIT 20", - )?; - let rows = stmt - .query_map([], |row| { - let proto_str: String = row.get(5)?; - Ok(( - row.get(0)?, - row.get(1)?, - row.get(2)?, - row.get(3)?, - row.get(4)?, - protocol_from_str(&proto_str), - row.get(6)?, - )) - })? - .filter_map(|r| r.ok()) - .collect(); - Ok(rows) -} - -pub fn get_sites(conn: &Connection) -> Result> { - let mut stmt = conn.prepare( - "SELECT id, name, protocol, host, port, username, key_path, folder, note FROM sites ORDER BY name", - )?; - let sites = stmt - .query_map([], |row| { - let proto_str: String = row.get(2)?; - let protocol = protocol_from_str(&proto_str); - Ok(Site { - id: row.get(0)?, - name: row.get(1)?, - protocol, - host: row.get(3)?, - port: row.get(4)?, - username: row.get(5)?, - key_path: row.get(6)?, - folder: row.get(7)?, - note: row.get(8)?, - }) - })? - .filter_map(|r| r.ok()) - .collect(); - Ok(sites) -} - -pub fn save_site(conn: &Connection, site: &Site) -> Result<()> { - let proto = protocol_to_str(&site.protocol); - conn.execute( - "INSERT OR REPLACE INTO sites - (id, name, protocol, host, port, username, key_path, folder, note) - VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)", - params![ - site.id, - site.name, - proto, - site.host, - site.port, - site.username, - site.key_path, - site.folder, - site.note - ], - )?; - Ok(()) -} - -pub fn delete_site(conn: &Connection, id: &str) -> Result<()> { - conn.execute("DELETE FROM sites WHERE id = ?1", params![id])?; - Ok(()) -} diff --git a/src/storage/db/bookmarks.rs b/src/storage/db/bookmarks.rs new file mode 100644 index 0000000..598d529 --- /dev/null +++ b/src/storage/db/bookmarks.rs @@ -0,0 +1,25 @@ +//! Таблица закладок локальных папок. +use anyhow::Result; +use rusqlite::{Connection, params}; + +pub fn get_bookmarks(conn: &Connection) -> Result> { + let mut stmt = conn.prepare("SELECT id, name, path FROM bookmarks ORDER BY id")?; + let rows = stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))? + .filter_map(|r| r.ok()) + .collect(); + Ok(rows) +} + +pub fn add_bookmark(conn: &Connection, name: &str, path: &str) -> Result { + conn.execute( + "INSERT INTO bookmarks (name, path) VALUES (?1, ?2)", + params![name, path], + )?; + Ok(conn.last_insert_rowid()) +} + +pub fn remove_bookmark(conn: &Connection, id: i64) -> Result<()> { + conn.execute("DELETE FROM bookmarks WHERE id = ?1", params![id])?; + Ok(()) +} diff --git a/src/storage/db/history.rs b/src/storage/db/history.rs new file mode 100644 index 0000000..c773003 --- /dev/null +++ b/src/storage/db/history.rs @@ -0,0 +1,98 @@ +//! Таблица истории подключений (последние 20 целей, уникальных по host/port/user). +use anyhow::Result; +use rusqlite::{Connection, params}; + +use super::{protocol_from_str, protocol_to_str}; +use crate::domain::connection::Protocol; + +/// Одна запись истории: host, port, username, время, conn_id (стабильный id — +/// под ним же лежит пароль в keychain), протокол, key_path. +pub type HistoryRow = ( + String, + u16, + String, + String, + String, + Protocol, + Option, +); + +/// Ищет уже существующий conn_id для этой цели (host,port,username), чтобы +/// повторные подключения писали пароль в keychain под тем же id, а не заводили новый. +pub fn find_history_conn_id( + conn: &Connection, + host: &str, + port: u16, + username: &str, +) -> Result> { + let id: Option = conn + .query_row( + "SELECT conn_id FROM connection_history WHERE host = ?1 AND port = ?2 AND username = ?3", + params![host, port, username], + |row| row.get(0), + ) + .ok(); + Ok(id) +} + +#[allow(clippy::too_many_arguments)] +pub fn add_history_entry( + conn: &Connection, + host: &str, + port: u16, + username: &str, + conn_id: &str, + protocol: &Protocol, + key_path: Option<&str>, +) -> Result<()> { + use chrono::Local; + let connected_at = Local::now().format("%H:%M %d.%m").to_string(); + let proto = protocol_to_str(protocol); + conn.execute( + "INSERT INTO connection_history (host, port, username, connected_at, conn_id, protocol, key_path) + VALUES (?1,?2,?3,?4,?5,?6,?7) + ON CONFLICT(host, port, username) DO UPDATE SET + connected_at = excluded.connected_at, + protocol = excluded.protocol, + key_path = excluded.key_path, + conn_id = COALESCE(connection_history.conn_id, excluded.conn_id)", + params![host, port, username, connected_at, conn_id, proto, key_path], + )?; + // Храним только последние 20 записей + conn.execute( + "DELETE FROM connection_history WHERE id NOT IN ( + SELECT id FROM connection_history ORDER BY id DESC LIMIT 20 + )", + [], + )?; + Ok(()) +} + +/// Полностью очищает историю подключений (Settings → History → Clear All). +pub fn clear_history(conn: &Connection) -> Result<()> { + conn.execute("DELETE FROM connection_history", [])?; + Ok(()) +} + +pub fn get_history(conn: &Connection) -> Result> { + let mut stmt = conn.prepare( + "SELECT host, port, username, connected_at, conn_id, protocol, key_path + FROM connection_history ORDER BY id DESC LIMIT 20", + )?; + let rows = stmt + .query_map([], |row| { + let proto_str: String = row.get(5)?; + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + protocol_from_str(&proto_str), + row.get(6)?, + )) + })? + .filter_map(|r| r.ok()) + .collect(); + Ok(rows) +} diff --git a/src/storage/db/mod.rs b/src/storage/db/mod.rs new file mode 100644 index 0000000..6fa80e1 --- /dev/null +++ b/src/storage/db/mod.rs @@ -0,0 +1,98 @@ +//! SQLite-хранилище: схема + операции по таблицам (bookmarks/history/sites). +use crate::domain::connection::Protocol; +use anyhow::Result; +use rusqlite::Connection; + +mod bookmarks; +mod history; +mod settings; +mod sites; + +pub use bookmarks::{add_bookmark, get_bookmarks, remove_bookmark}; +pub use history::{ + HistoryRow, add_history_entry, clear_history, find_history_conn_id, get_history, +}; +pub use settings::{get_bool, get_setting, get_u32, set_bool, set_setting, set_u32}; +pub use sites::{delete_site, get_sites, save_site}; + +pub(super) fn protocol_to_str(protocol: &Protocol) -> &'static str { + match protocol { + Protocol::Sftp => "sftp", + Protocol::Ftp => "ftp", + Protocol::Ftps => "ftps", + } +} + +pub(super) fn protocol_from_str(s: &str) -> Protocol { + match s { + "sftp" => Protocol::Sftp, + "ftps" => Protocol::Ftps, + _ => Protocol::Ftp, + } +} + +pub fn init_tables(conn: &Connection) -> Result<()> { + conn.execute_batch( + " + PRAGMA journal_mode=WAL; + + CREATE TABLE IF NOT EXISTS sites ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + protocol TEXT NOT NULL, + host TEXT NOT NULL, + port INTEGER NOT NULL, + username TEXT NOT NULL, + key_path TEXT, + folder TEXT, + note TEXT + ); + + CREATE TABLE IF NOT EXISTS connection_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host TEXT NOT NULL, + port INTEGER NOT NULL, + username TEXT NOT NULL, + connected_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS bookmarks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + path TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + ", + )?; + + // Миграция: старые базы созданы без conn_id/protocol/key_path — добавляем, + // если их ещё нет (ошибку "duplicate column" просто игнорируем). + let _ = conn.execute("ALTER TABLE connection_history ADD COLUMN conn_id TEXT", []); + let _ = conn.execute( + "ALTER TABLE connection_history ADD COLUMN protocol TEXT NOT NULL DEFAULT 'sftp'", + [], + ); + let _ = conn.execute( + "ALTER TABLE connection_history ADD COLUMN key_path TEXT", + [], + ); + // На случай старых записей без уникальности (host,port,username) — оставляем + // только самую свежую строку на каждую цель перед созданием индекса. + conn.execute( + "DELETE FROM connection_history WHERE id NOT IN ( + SELECT MAX(id) FROM connection_history GROUP BY host, port, username + )", + [], + )?; + conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_history_target + ON connection_history(host, port, username)", + [], + )?; + + Ok(()) +} diff --git a/src/storage/db/settings.rs b/src/storage/db/settings.rs new file mode 100644 index 0000000..49581ed --- /dev/null +++ b/src/storage/db/settings.rs @@ -0,0 +1,43 @@ +//! Таблица произвольных настроек приложения (ключ-значение) — используется +//! диалогом Settings для того, что должно переживать перезапуск (в отличие +//! от видимости панелей, которая остаётся только на время сессии). +use anyhow::Result; +use rusqlite::{Connection, params}; + +pub fn get_setting(conn: &Connection, key: &str) -> Option { + conn.query_row( + "SELECT value FROM app_settings WHERE key = ?1", + params![key], + |row| row.get(0), + ) + .ok() +} + +pub fn set_setting(conn: &Connection, key: &str, value: &str) -> Result<()> { + conn.execute( + "INSERT INTO app_settings (key, value) VALUES (?1, ?2) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + params![key, value], + )?; + Ok(()) +} + +pub fn get_bool(conn: &Connection, key: &str, default: bool) -> bool { + get_setting(conn, key) + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +pub fn get_u32(conn: &Connection, key: &str, default: u32) -> u32 { + get_setting(conn, key) + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +pub fn set_bool(conn: &Connection, key: &str, value: bool) { + let _ = set_setting(conn, key, &value.to_string()); +} + +pub fn set_u32(conn: &Connection, key: &str, value: u32) { + let _ = set_setting(conn, key, &value.to_string()); +} diff --git a/src/storage/db/sites.rs b/src/storage/db/sites.rs new file mode 100644 index 0000000..38079ab --- /dev/null +++ b/src/storage/db/sites.rs @@ -0,0 +1,57 @@ +//! Таблица сохранённых серверов (Sites). +use anyhow::Result; +use rusqlite::{Connection, params}; + +use super::{protocol_from_str, protocol_to_str}; +use crate::domain::site::Site; + +pub fn get_sites(conn: &Connection) -> Result> { + let mut stmt = conn.prepare( + "SELECT id, name, protocol, host, port, username, key_path, folder, note FROM sites ORDER BY name", + )?; + let sites = stmt + .query_map([], |row| { + let proto_str: String = row.get(2)?; + let protocol = protocol_from_str(&proto_str); + Ok(Site { + id: row.get(0)?, + name: row.get(1)?, + protocol, + host: row.get(3)?, + port: row.get(4)?, + username: row.get(5)?, + key_path: row.get(6)?, + folder: row.get(7)?, + note: row.get(8)?, + }) + })? + .filter_map(|r| r.ok()) + .collect(); + Ok(sites) +} + +pub fn save_site(conn: &Connection, site: &Site) -> Result<()> { + let proto = protocol_to_str(&site.protocol); + conn.execute( + "INSERT OR REPLACE INTO sites + (id, name, protocol, host, port, username, key_path, folder, note) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)", + params![ + site.id, + site.name, + proto, + site.host, + site.port, + site.username, + site.key_path, + site.folder, + site.note + ], + )?; + Ok(()) +} + +pub fn delete_site(conn: &Connection, id: &str) -> Result<()> { + conn.execute("DELETE FROM sites WHERE id = ?1", params![id])?; + Ok(()) +} diff --git a/src/transfer/worker.rs b/src/transfer/worker.rs index b3d8d16..dab30a9 100644 --- a/src/transfer/worker.rs +++ b/src/transfer/worker.rs @@ -1,171 +1,140 @@ use std::sync::Arc; +use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering}; use std::time::{Duration, Instant}; use tokio::time::sleep; -use crate::domain::transfer::{TaskState, TransferKind}; +use crate::domain::transfer::{TaskState, TransferKind, TransferTask}; use crate::fs::remote::RemoteRegistry; use crate::protocols::ProgressAction; use crate::transfer::progress::ProgressThrottle; use crate::transfer::queue::TransferQueue; -const POLL_INTERVAL_MS: u64 = 500; -const PAUSED_SLEEP_MS: u64 = 1000; +const POLL_INTERVAL_MS: u64 = 200; +/// Уменьшает счётчик активных передач при выходе из скоупа — в том числе при +/// панике внутри задачи (Drop отрабатывает и во время размотки стека), иначе +/// упавшая задача навсегда съедала бы один слот параллелизма. +struct InFlightGuard(Arc); +impl Drop for InFlightGuard { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::Relaxed); + } +} + +/// Запускает диспетчер передач: раз в тик подбирает из очереди задачи со +/// статусом `Queued` и запускает их параллельно, пока число активных передач +/// меньше `max_concurrent` — значение читается заново на каждой итерации, так +/// что Settings → Transfers меняет параллелизм на лету, без перезапуска. pub fn spawn_worker( queue: TransferQueue, registry: Arc, rt_handle: tokio::runtime::Handle, + max_concurrent: Arc, ) { + let in_flight = Arc::new(AtomicUsize::new(0)); rt_handle.spawn(async move { loop { - let front = queue.all().into_iter().next(); - match front.as_ref().map(|t| &t.state) { - None => { - sleep(Duration::from_millis(POLL_INTERVAL_MS)).await; - continue; - } - Some(TaskState::Paused) => { - sleep(Duration::from_millis(PAUSED_SLEEP_MS)).await; - continue; - } - Some(TaskState::Cancelled) => { - if let Some(t) = queue.pop() { - queue.remove(&t.id); - } - continue; - } - Some(TaskState::Running) => { - // Anomalous state: a previous worker died or the task - // was left running. Pop it and mark failed so the queue - // does not spin forever. - if let Some(mut t) = queue.pop() { - t.state = TaskState::Failed("stale running task".into()); - queue.update_state(&t.id, t.state.clone()); - } - continue; - } - Some(TaskState::Completed) | Some(TaskState::Failed(_)) => { - // Terminal tasks should not block new work. Move them to - // the back of the queue so they remain visible in the UI. - if let Some(t) = queue.pop() { - queue.push(t); - } - continue; - } - Some(TaskState::Retrying(_)) => { - // Not implemented yet: demote to queued so it can run. - if let Some(mut t) = queue.pop() { - t.state = TaskState::Queued; - queue.push(t); - } - continue; - } - Some(TaskState::Queued) => { - // Pop and run below. - } - } - - let mut task = match queue.pop() { - Some(t) => t, - None => continue, - }; - - // State may have changed between peek and pop. - if task.state == TaskState::Cancelled { - queue.remove(&task.id); - continue; - } - - task.state = TaskState::Running; - queue.update_state(&task.id, TaskState::Running); - - let fs = match registry.get(&task.connection_id) { - Some(fs) => fs, - None => { - task.state = TaskState::Failed("connection not found".into()); - queue.update_state(&task.id, task.state.clone()); - continue; - } - }; - - // Progress callback: throttles queue updates to ~10 FPS, - // computes current speed, and reacts to Cancel/Pause requests. - let queue_for_progress = queue.clone(); - let task_id_for_progress = task.id.clone(); - let throttle = Arc::new(std::sync::Mutex::new(ProgressThrottle::default())); - let last_sample = Arc::new(std::sync::Mutex::new((Instant::now(), 0u64))); - let on_progress: Option ProgressAction + Send>> = - Some(Box::new(move |transferred: u64| { - // Check for user-initiated cancel/pause first. - if let Some(t) = queue_for_progress.get(&task_id_for_progress) { - match t.state { - TaskState::Cancelled => return ProgressAction::Cancel, - TaskState::Paused => return ProgressAction::Pause, - _ => {} - } - } - - let speed = { - let mut guard = last_sample.lock().unwrap(); - let (last_time, last_bytes) = *guard; - let now = Instant::now(); - let elapsed = now.duration_since(last_time).as_secs_f64(); - let speed = if elapsed > 0.0 { - ((transferred.saturating_sub(last_bytes)) as f64 / elapsed) as u64 - } else { - 0 - }; - *guard = (now, transferred); - speed - }; - if throttle.lock().unwrap().should_emit() { - queue_for_progress.update_progress( - &task_id_for_progress, - transferred, - speed, - ); - } - ProgressAction::Continue - })); - - let result = match task.kind { - TransferKind::Upload => { - fs.upload_with_progress(&task.local_path, &task.remote_path, on_progress) - .await - } - TransferKind::Download => { - fs.download_with_progress(&task.remote_path, &task.local_path, on_progress) - .await - } - }; - - match result { - Ok(()) => { - queue.update_state(&task.id, TaskState::Completed); - queue.update_progress(&task.id, task.total_bytes, 0); - } - Err(e) => { - let msg = e.to_string(); - if msg == "cancelled" { - queue.update_state(&task.id, TaskState::Cancelled); - queue.remove(&task.id); - } else if msg == "paused" { - let transferred = queue - .get(&task.id) - .map(|t| t.transferred_bytes) - .unwrap_or(0); - let mut paused_task = task.clone(); - paused_task.state = TaskState::Paused; - paused_task.transferred_bytes = transferred; - paused_task.speed = None; - paused_task.eta_secs = None; - queue.update_state(&task.id, TaskState::Paused); - queue.update_progress(&task.id, transferred, 0); - } else { - queue.update_state(&task.id, TaskState::Failed(msg)); - } - } + let limit = max_concurrent.load(Ordering::Relaxed).max(1) as usize; + while in_flight.load(Ordering::Relaxed) < limit { + let Some(task) = queue + .all() + .into_iter() + .find(|t| t.state == TaskState::Queued) + else { + break; + }; + queue.update_state(&task.id, TaskState::Running); + in_flight.fetch_add(1, Ordering::Relaxed); + + let queue = queue.clone(); + let registry = registry.clone(); + let guard_counter = in_flight.clone(); + tokio::spawn(async move { + let _guard = InFlightGuard(guard_counter); + run_transfer(task, queue, registry).await; + }); } + sleep(Duration::from_millis(POLL_INTERVAL_MS)).await; } }); } + +/// Выполняет одну задачу передачи целиком и записывает итог в очередь. +async fn run_transfer(task: TransferTask, queue: TransferQueue, registry: Arc) { + let fs = match registry.get(&task.connection_id) { + Some(fs) => fs, + None => { + queue.update_state(&task.id, TaskState::Failed("connection not found".into())); + return; + } + }; + + // Progress callback: троттлит обновления очереди до ~10 FPS, считает + // текущую скорость и реагирует на отмену/паузу, выставленные пользователем. + let queue_for_progress = queue.clone(); + let task_id_for_progress = task.id.clone(); + let throttle = Arc::new(std::sync::Mutex::new(ProgressThrottle::default())); + let last_sample = Arc::new(std::sync::Mutex::new((Instant::now(), 0u64))); + let on_progress: Option ProgressAction + Send>> = + Some(Box::new(move |transferred: u64| { + if let Some(t) = queue_for_progress.get(&task_id_for_progress) { + match t.state { + TaskState::Cancelled => return ProgressAction::Cancel, + TaskState::Paused => return ProgressAction::Pause, + _ => {} + } + } + + let speed = { + let mut guard = last_sample.lock().unwrap(); + let (last_time, last_bytes) = *guard; + let now = Instant::now(); + let elapsed = now.duration_since(last_time).as_secs_f64(); + let speed = if elapsed > 0.0 { + ((transferred.saturating_sub(last_bytes)) as f64 / elapsed) as u64 + } else { + 0 + }; + *guard = (now, transferred); + speed + }; + if throttle.lock().unwrap().should_emit() { + queue_for_progress.update_progress(&task_id_for_progress, transferred, speed); + } + ProgressAction::Continue + })); + + let result = match task.kind { + TransferKind::Upload => { + fs.upload_with_progress(&task.local_path, &task.remote_path, on_progress) + .await + } + TransferKind::Download => { + fs.download_with_progress(&task.remote_path, &task.local_path, on_progress) + .await + } + }; + + match result { + Ok(()) => { + queue.update_state(&task.id, TaskState::Completed); + queue.update_progress(&task.id, task.total_bytes, 0); + } + Err(e) => { + let msg = e.to_string(); + if msg == "cancelled" { + queue.update_state(&task.id, TaskState::Cancelled); + queue.remove(&task.id); + } else if msg == "paused" { + let transferred = queue + .get(&task.id) + .map(|t| t.transferred_bytes) + .unwrap_or(0); + queue.update_state(&task.id, TaskState::Paused); + queue.update_progress(&task.id, transferred, 0); + } else { + queue.update_state(&task.id, TaskState::Failed(msg)); + } + } + } +} diff --git a/src/ui/app.rs b/src/ui/app.rs deleted file mode 100644 index 887f06f..0000000 --- a/src/ui/app.rs +++ /dev/null @@ -1,1215 +0,0 @@ -use std::sync::Arc; - -use egui::{CentralPanel, RichText, SidePanel, TopBottomPanel, Visuals}; - -use crate::domain::connection::ConnectionStatus; -use crate::domain::file_entry::{EntryKind, FileEntry}; -use crate::domain::site::Site; -use crate::fs::remote::RemoteRegistry; -use crate::transfer::queue::TransferQueue; -use crate::ui::dialogs::connection; -use crate::ui::panels::{local_pane, queue, remote_pane, sidebar, status_bar, tabs, toolbar}; -use crate::ui::theme::*; - -pub struct FileManagerApp { - pub state: crate::ui::state::AppState, - pub registry: Arc, - pub queue: TransferQueue, - pub rt: tokio::runtime::Runtime, - pub db: Arc>, - pub sites: Vec, - pub first_frame: bool, -} - -impl FileManagerApp { - pub fn new( - registry: Arc, - queue: TransferQueue, - rt: tokio::runtime::Runtime, - db: Arc>, - sites: Vec, - ) -> Self { - let mut state = crate::ui::state::AppState { - local_path: crate::fs::local::home_dir(), - ..Default::default() - }; - local_pane::refresh_local(&mut state); - - state.reload_history(&db); - - if let Ok(rows) = crate::storage::db::get_bookmarks(&db.lock().unwrap()) { - state.bookmarks = rows - .into_iter() - .map(|(id, name, path)| crate::ui::state::Bookmark { id, name, path }) - .collect(); - } - - Self { - state, - registry, - queue, - rt, - db, - sites, - first_frame: true, - } - } - - fn poll_pending(&mut self) { - // --- Connect result --- - let opt = self.state.pending_connect.take(); - if let Some(pc) = opt.as_ref() { - let mut guard = pc.result.lock().unwrap(); - if let Some(res) = guard.take() { - drop(guard); - match res { - Ok((params, entries)) => { - let path = "/".to_string(); - let mut list = vec![FileEntry { - name: "..".into(), - path: path.clone(), - kind: EntryKind::Dir, - size: None, - modified: None, - permissions: None, - }]; - list.extend(entries); - - let tab = crate::ui::state::ConnectionTab { - id: params.id.clone(), - label: params.label.clone(), - params: params.clone(), - status: ConnectionStatus::Connected, - remote_path: path, - remote_entries: list, - remote_selected: None, - loading: false, - }; - self.state.tabs.push(tab); - self.state.active_tab = self.state.tabs.len() - 1; - self.state.status_message = format!("Connected to {}", params.host); - self.state.connect_loading = false; - self.state.show_connect_dialog = false; - - // Один и тот же host/port/username всегда должен использовать - // один conn_id — под ним лежит пароль в keychain, иначе повторное - // подключение через "New Connection" развело бы историю и keychain. - if let Ok(conn) = self.db.lock() { - let canonical_id = crate::storage::db::find_history_conn_id( - &conn, - ¶ms.host, - params.port, - ¶ms.username, - ) - .ok() - .flatten() - .unwrap_or_else(|| params.id.clone()); - - if let Some(password) = ¶ms.password { - let _ = crate::storage::keychain::store_password( - &canonical_id, - password, - ); - } - - let _ = crate::storage::db::add_history_entry( - &conn, - ¶ms.host, - params.port, - ¶ms.username, - &canonical_id, - ¶ms.protocol, - params.key_path.as_deref(), - ); - } - self.state.reload_history(&self.db); - } - Err(e) => { - self.state.status_message = format!("Connection failed: {}", e); - self.state.connect_error = e; - self.state.connect_loading = false; - } - } - } else { - drop(guard); - self.state.pending_connect = opt; - } - } - - // --- Remote list results --- - let mut done: Vec = Vec::new(); - for (i, pending) in self.state.pending_remote_list.iter().enumerate() { - let mut guard = pending.result.lock().unwrap(); - if let Some(res) = guard.take() { - let tab_idx = pending.tab_idx; - match res { - Ok(entries) => { - let path = if tab_idx < self.state.tabs.len() { - self.state.tabs[tab_idx].remote_path.clone() - } else { - "/".into() - }; - let mut list = Vec::new(); - if path != "/" { - let parent = remote_pane::remote_parent(&path); - list.push(FileEntry { - name: "..".into(), - path: parent, - kind: EntryKind::Dir, - size: None, - modified: None, - permissions: None, - }); - } - list.extend(entries); - if tab_idx < self.state.tabs.len() { - self.state.tabs[tab_idx].remote_entries = list; - self.state.tabs[tab_idx].loading = false; - } - } - Err(e) => { - if tab_idx < self.state.tabs.len() { - self.state.tabs[tab_idx].loading = false; - } - self.state.status_message = format!("Remote list error: {}", e); - } - } - done.push(i); - } - } - for i in done.into_iter().rev() { - self.state.pending_remote_list.remove(i); - } - - // --- Toolbar action flags --- - if self.state.pending_refresh { - self.state.pending_refresh = false; - if let Some(idx) = self.active_tab_idx() { - remote_pane::trigger_list(&mut self.state, idx, &self.registry, self.rt.handle()); - } - } - - // --- История: переподключение по клику --- - if let Some(entry) = self.state.pending_history_reconnect.take() { - connection::reconnect_from_history( - &mut self.state, - &self.registry, - self.rt.handle(), - &entry, - ); - } - - // --- История: "Save" → постоянный Site --- - if let Some(entry) = self.state.pending_history_save.take() { - match connection::save_history_as_site(&self.db, &mut self.sites, &entry) { - Ok(()) => self.state.status_message = "Saved to sites".into(), - Err(e) => self.state.status_message = format!("Save failed: {}", e), - } - } - - // --- Mkdir result --- - let opt = self.state.pending_mkdir_result.take(); - if let Some(res) = opt.as_ref() { - let mut guard = res.lock().unwrap(); - if let Some(r) = guard.take() { - drop(guard); - match r { - Ok(()) => { - self.state.status_message = "Folder created".into(); - self.state.mkdir_name.clear(); - if let Some(idx) = self.active_tab_idx() { - remote_pane::trigger_list( - &mut self.state, - idx, - &self.registry, - self.rt.handle(), - ); - } - } - Err(e) => { - self.state.status_message = format!("Create folder failed: {}", e); - } - } - } else { - drop(guard); - self.state.pending_mkdir_result = opt; - } - } - - // --- Delete result --- - let opt = self.state.pending_delete_result.take(); - if let Some(res) = opt.as_ref() { - let mut guard = res.lock().unwrap(); - if let Some(r) = guard.take() { - drop(guard); - match r { - Ok(()) => { - self.state.status_message = "Deleted".into(); - self.state.delete_name.clear(); - if let Some(idx) = self.active_tab_idx() { - remote_pane::trigger_list( - &mut self.state, - idx, - &self.registry, - self.rt.handle(), - ); - } - } - Err(e) => { - self.state.status_message = format!("Delete failed: {}", e); - } - } - } else { - drop(guard); - self.state.pending_delete_result = opt; - } - } - - // --- Rename result --- - let opt = self.state.pending_rename_result.take(); - if let Some(res) = opt.as_ref() { - let mut guard = res.lock().unwrap(); - if let Some(r) = guard.take() { - drop(guard); - match r { - Ok(()) => { - self.state.status_message = "Renamed".into(); - self.state.rename_old_name.clear(); - self.state.rename_new_name.clear(); - if let Some(idx) = self.active_tab_idx() { - remote_pane::trigger_list( - &mut self.state, - idx, - &self.registry, - self.rt.handle(), - ); - } - } - Err(e) => { - self.state.status_message = format!("Rename failed: {}", e); - } - } - } else { - drop(guard); - self.state.pending_rename_result = opt; - } - } - } - - fn active_tab_idx(&self) -> Option { - if self.state.tabs.is_empty() { - return None; - } - let idx = self.state.active_tab.min(self.state.tabs.len() - 1); - if self.state.tabs[idx].status == ConnectionStatus::Connected { - Some(idx) - } else { - None - } - } - - fn start_mkdir(&mut self, tab_idx: usize, name: String) { - let tab = &self.state.tabs[tab_idx]; - let connection_id = tab.id.clone(); - let remote_path = tab.remote_path.clone(); - let registry = self.registry.clone(); - let path = format!("{}/{}", remote_path.trim_end_matches('/'), name); - - let result = Arc::new(std::sync::Mutex::new(None::>)); - let result_clone = result.clone(); - - self.rt.handle().spawn(async move { - let fs = match registry.get(&connection_id) { - Some(fs) => fs, - None => { - *result_clone.lock().unwrap() = Some(Err("connection not found".into())); - return; - } - }; - let r = fs.mkdir(&path).await.map_err(|e| e.to_string()); - *result_clone.lock().unwrap() = Some(r); - }); - - self.state.pending_mkdir_result = Some(result); - } - - fn start_delete(&mut self, tab_idx: usize, name: String) { - let tab = &self.state.tabs[tab_idx]; - let connection_id = tab.id.clone(); - let registry = self.registry.clone(); - - let entry_path = tab - .remote_entries - .iter() - .find(|e| e.name == name) - .map(|e| e.path.clone()); - - if name == ".." { - self.state.status_message = "Cannot delete parent entry".into(); - return; - } - - let Some(path) = entry_path else { - self.state.status_message = "Selected entry not found".into(); - return; - }; - - let result = Arc::new(std::sync::Mutex::new(None::>)); - let result_clone = result.clone(); - - self.rt.handle().spawn(async move { - let fs = match registry.get(&connection_id) { - Some(fs) => fs, - None => { - *result_clone.lock().unwrap() = Some(Err("connection not found".into())); - return; - } - }; - let r = fs.delete(&path).await.map_err(|e| e.to_string()); - *result_clone.lock().unwrap() = Some(r); - }); - - self.state.pending_delete_result = Some(result); - } - - fn start_rename(&mut self, tab_idx: usize, old_name: String, new_name: String) { - let tab = &self.state.tabs[tab_idx]; - let connection_id = tab.id.clone(); - let registry = self.registry.clone(); - - let Some(entry) = tab.remote_entries.iter().find(|e| e.name == old_name) else { - self.state.status_message = "Selected entry not found".into(); - return; - }; - - let from = entry.path.clone(); - let parent_dir = remote_pane::remote_parent(&from); - let to = format!("{}/{}", parent_dir.trim_end_matches('/'), new_name); - - let result = Arc::new(std::sync::Mutex::new(None::>)); - let result_clone = result.clone(); - - self.rt.handle().spawn(async move { - let fs = match registry.get(&connection_id) { - Some(fs) => fs, - None => { - *result_clone.lock().unwrap() = Some(Err("connection not found".into())); - return; - } - }; - let r = fs.rename(&from, &to).await.map_err(|e| e.to_string()); - *result_clone.lock().unwrap() = Some(r); - }); - - self.state.pending_rename_result = Some(result); - } - - fn start_local_mkdir(&mut self, name: String) { - let path = format!("{}/{}", self.state.local_path.trim_end_matches('/'), name); - match crate::fs::local::mkdir(&path) { - Ok(()) => { - self.state.status_message = "Folder created".into(); - local_pane::refresh_local(&mut self.state); - } - Err(e) => self.state.status_message = format!("Create folder failed: {}", e), - } - } - - fn start_local_delete(&mut self, name: String) { - if name == ".." { - self.state.status_message = "Cannot delete parent entry".into(); - return; - } - let Some(entry) = self.state.local_entries.iter().find(|e| e.name == name) else { - self.state.status_message = "Selected entry not found".into(); - return; - }; - match crate::fs::local::delete(&entry.path) { - Ok(()) => { - self.state.status_message = "Deleted".into(); - local_pane::refresh_local(&mut self.state); - } - Err(e) => self.state.status_message = format!("Delete failed: {}", e), - } - } - - fn start_local_rename(&mut self, old_name: String, new_name: String) { - let Some(entry) = self.state.local_entries.iter().find(|e| e.name == old_name) else { - self.state.status_message = "Selected entry not found".into(); - return; - }; - let from = entry.path.clone(); - let parent_dir = std::path::Path::new(&from) - .parent() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|| self.state.local_path.clone()); - let to = format!("{}/{}", parent_dir.trim_end_matches('/'), new_name); - match crate::fs::local::rename(&from, &to) { - Ok(()) => { - self.state.status_message = "Renamed".into(); - local_pane::refresh_local(&mut self.state); - } - Err(e) => self.state.status_message = format!("Rename failed: {}", e), - } - } - - fn apply_visuals(&self, ctx: &egui::Context) { - let mut vis = Visuals::dark(); - vis.window_fill = BG_PANEL; - vis.panel_fill = BG_CONTENT; - vis.extreme_bg_color = BG_BASE; - vis.code_bg_color = BG_BASE; - vis.override_text_color = Some(TEXT_PRIMARY); - vis.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, BORDER); - vis.widgets.inactive.bg_fill = BG_CONTENT; - vis.widgets.hovered.bg_fill = BG_ROW_HOVER; - vis.widgets.active.bg_fill = ACCENT_DIM; - vis.selection.bg_fill = BG_ROW_SEL; - vis.selection.stroke = egui::Stroke::new(1.0, ACCENT); - vis.window_rounding = egui::Rounding::same(8.0); - ctx.set_visuals(vis); - - ctx.set_fonts(system_fonts()); - - let mut style = ctx.style().as_ref().clone(); - style.spacing.item_spacing = egui::vec2(4.0, 2.0); - style.spacing.button_padding = egui::vec2(8.0, 4.0); - ctx.set_style(style); - } -} - -impl eframe::App for FileManagerApp { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - if self.first_frame { - self.first_frame = false; - egui_extras::install_image_loaders(ctx); - self.apply_visuals(ctx); - ctx.request_repaint(); - } - - self.poll_pending(); - crate::ui::menu::poll_menu_events(&mut self.state); - self.state.queue_tasks = self.queue.all(); - self.state.connected_count = self - .state - .tabs - .iter() - .filter(|t| t.status == ConnectionStatus::Connected) - .count(); - - if self.state.tabs.is_empty() { - self.render_welcome_screen(ctx); - } else { - self.render_main_ui(ctx); - } - - // ── Connection dialog ──────────────────────────────────────────────── - if self.state.show_connect_dialog { - connection::render(ctx, &mut self.state, &self.registry, self.rt.handle()); - } - - // ── Remote operation dialogs ───────────────────────────────────────── - self.render_remote_op_dialogs(ctx); - - // ── Settings ───────────────────────────────────────────────────────── - if self.state.show_settings_dialog { - self.render_settings_dialog(ctx); - } - - // 200ms repaint для обновления прогресса - ctx.request_repaint_after(std::time::Duration::from_millis(200)); - } -} - -impl FileManagerApp { - /// Стартовый экран — показывается, пока нет ни одного открытого соединения. - /// Только фон, логотип, кнопка нового подключения и список недавних серверов. - fn render_welcome_screen(&mut self, ctx: &egui::Context) { - CentralPanel::default() - .frame(egui::Frame::none().fill(BG_BASE)) - .show(ctx, |ui| { - ui.vertical_centered(|ui| { - let avail_h = ui.available_height(); - ui.add_space((avail_h * 0.16).max(24.0)); - - let icon_bytes: &[u8] = include_bytes!("icons/app_icon.png"); - let icon_img = egui::Image::from_bytes("bytes://welcome_app_icon", icon_bytes) - .rounding(RADIUS_LG) - .fit_to_exact_size(egui::vec2(72.0, 72.0)); - ui.add(icon_img); - - ui.add_space(14.0); - ui.label( - RichText::new("LoFlum") - .color(TEXT_PRIMARY) - .size(20.0) - .strong(), - ); - ui.add_space(4.0); - ui.label(RichText::new("FTP/SFTP client").color(TEXT_HINT).size(12.5)); - - ui.add_space(20.0); - let btn = egui::Button::image_and_text( - crate::ui::icons::image( - crate::ui::icons::Icon::AddCircleBold, - 15.0, - ON_ACCENT, - ), - RichText::new("New Connection") - .color(ON_ACCENT) - .size(12.5) - .strong(), - ) - .fill(ACCENT) - .rounding(RADIUS_MD) - .min_size(egui::vec2(170.0, 36.0)); - if ui.add(btn).clicked() { - self.state.show_connect_dialog = true; - } - - ui.add_space(28.0); - - let list_w = 320.0_f32.min(ui.available_width() - 40.0); - if !self.state.history.is_empty() { - ui.label( - RichText::new("RECENT CONNECTIONS") - .color(TEXT_HINT) - .size(10.5) - .strong(), - ); - ui.add_space(8.0); - - const ROW_H: f32 = 34.0; - egui::Frame::none().show(ui, |ui| { - ui.set_width(list_w); - let history: Vec<_> = - self.state.history.iter().take(8).cloned().collect(); - for entry in &history { - let row_rect = egui::Rect::from_min_size( - ui.cursor().min, - egui::vec2(list_w, ROW_H), - ); - let is_hovered = ui.rect_contains_pointer(row_rect); - let bg = if is_hovered { - BG_ROW_HOVER - } else { - egui::Color32::TRANSPARENT - }; - - let label = format!("{}@{}:{}", entry.user, entry.host, entry.port); - let row = egui::Frame::none() - .fill(bg) - .rounding(RADIUS_MD) - .inner_margin(egui::Margin::symmetric(10.0, 7.0)) - .show(ui, |ui| { - ui.set_width(list_w - 20.0); - ui.horizontal(|ui| { - crate::ui::icons::icon( - ui, - crate::ui::icons::Icon::ServerSquare, - 13.0, - TEXT_DIM, - ); - ui.add_space(8.0); - ui.label( - RichText::new(&label) - .color(TEXT_PRIMARY) - .size(12.5), - ); - ui.with_layout( - egui::Layout::right_to_left(egui::Align::Center), - |ui| { - ui.label( - RichText::new(&entry.time) - .color(TEXT_HINT) - .size(10.5), - ); - }, - ); - }); - }); - - let resp = row.response.interact(egui::Sense::click()); - if is_hovered { - ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); - } - if resp.clicked() { - self.state.pending_history_reconnect = Some(entry.clone()); - } - resp.context_menu(|ui| { - if ui.button("Edit").clicked() { - connection::edit_history_entry(&mut self.state, entry); - ui.close_menu(); - } - if ui.button("Save").clicked() { - self.state.pending_history_save = Some(entry.clone()); - ui.close_menu(); - } - }); - } - }); - } - }); - }); - } - - fn render_main_ui(&mut self, ctx: &egui::Context) { - // ── Toolbar ────────────────────────────────────────────────────────── - TopBottomPanel::top("toolbar") - .frame(egui::Frame::none().fill(BG_TOOLBAR)) - .show(ctx, |ui| { - toolbar::render(ui, &mut self.state, &self.queue); - }); - - // ── Tab bar ────────────────────────────────────────────────────────── - TopBottomPanel::top("tabbar") - .frame(egui::Frame::none().fill(BG_BASE)) - .show(ctx, |ui| { - tabs::render(ui, &mut self.state); - }); - - // ── Status bar ─────────────────────────────────────────────────────── - TopBottomPanel::bottom("status_bar") - .frame(egui::Frame::none().fill(BG_BASE)) - .exact_height(STATUS_H) - .show(ctx, |ui| { - status_bar::render(ui, &self.state); - }); - - // ── Queue panel ─────────────────────────────────────────────────────── - let tasks = self.state.queue_tasks.clone(); - let q_h = if self.state.show_queue && !tasks.is_empty() { - QUEUE_EXPANDED_H - } else { - QUEUE_COLLAPSED_H - }; - TopBottomPanel::bottom("queue_panel") - .frame( - egui::Frame::none() - .fill(BG_QUEUE) - .stroke(egui::Stroke::new(1.0, BORDER)), - ) - .resizable(false) - .exact_height(q_h) - .show(ctx, |ui| { - queue::render(ui, &mut self.state, &tasks); - }); - - // ── Sidebar ─────────────────────────────────────────────────────────── - SidePanel::left("sidebar") - .frame( - egui::Frame::none() - .fill(BG_PANEL) - .stroke(egui::Stroke::new(1.0, BORDER)), - ) - .resizable(true) - .default_width(SIDEBAR_W) - .width_range(120.0..=260.0) - .show(ctx, |ui| { - sidebar::render(ui, &mut self.state, &self.db); - }); - - // ── Main content: Local | Remote ───────────────────────────────────── - CentralPanel::default() - .frame(egui::Frame::none().fill(BG_CONTENT)) - .show(ctx, |ui| { - let has_connection = self.active_tab_idx().is_some(); - let total_w = ui.available_width(); - let total_h = ui.available_height(); - let sep_w = 1.0; - let half = (total_w - sep_w) * 0.5; - - ui.horizontal(|ui| { - // ── LOCAL PANE ────────────────────────────────────────── - ui.allocate_ui_with_layout( - egui::vec2(half, total_h), - egui::Layout::top_down(egui::Align::LEFT), - |ui| { - local_pane::render( - ui, - &mut self.state, - &self.queue, - &self.registry, - self.rt.handle(), - &self.db, - ); - }, - ); - - // разделитель - let (_, sep_rect) = ui.allocate_space(egui::vec2(sep_w, total_h)); - ui.painter().rect_filled(sep_rect, 0.0, BORDER); - - // ── REMOTE PANE ───────────────────────────────────────── - ui.allocate_ui_with_layout( - egui::vec2(half, total_h), - egui::Layout::top_down(egui::Align::LEFT), - |ui| { - if has_connection { - let idx = self.state.active_tab.min(self.state.tabs.len() - 1); - - remote_pane::render( - ui, - &mut self.state, - idx, - &self.registry, - self.rt.handle(), - &self.queue, - ); - } else { - // Empty state - pane_header(ui, "REMOTE", half); - ui.with_layout( - egui::Layout::centered_and_justified(egui::Direction::TopDown), - |ui| { - ui.vertical_centered(|ui| { - ui.add_space(ui.available_height() * 0.28); - - let circle_d = 56.0; - let (rect, _) = ui.allocate_exact_size( - egui::vec2(circle_d, circle_d), - egui::Sense::hover(), - ); - ui.painter().circle_filled( - rect.center(), - circle_d / 2.0, - BG_TAB_ACTIVE, - ); - let icon_img = crate::ui::icons::image( - crate::ui::icons::Icon::ServerSquare, - 26.0, - TEXT_HINT, - ); - icon_img.paint_at( - ui, - egui::Rect::from_center_size( - rect.center(), - egui::vec2(26.0, 26.0), - ), - ); - - ui.add_space(10.0); - ui.label( - RichText::new("Remote") - .color(TEXT_DIM) - .size(14.0) - .strong(), - ); - ui.add_space(4.0); - ui.label( - RichText::new( - "Connect to a server to browse files", - ) - .color(TEXT_HINT) - .size(12.0), - ); - ui.add_space(16.0); - let btn = egui::Button::image_and_text( - crate::ui::icons::image( - crate::ui::icons::Icon::AddCircleBold, - 15.0, - ON_ACCENT, - ), - RichText::new("New Connection") - .color(ON_ACCENT) - .size(12.5) - .strong(), - ) - .fill(ACCENT) - .rounding(RADIUS_MD) - .min_size(egui::vec2(150.0, 34.0)); - if ui.add(btn).clicked() { - self.state.show_connect_dialog = true; - } - }); - }, - ); - } - }, - ); - }); - }); - } -} - -impl FileManagerApp { - fn render_remote_op_dialogs(&mut self, ctx: &egui::Context) { - let screen = ctx.screen_rect(); - let center = screen.center(); - - // затемнённый оверлей под диалогами - if self.state.show_mkdir_dialog - || self.state.show_delete_dialog - || self.state.show_rename_dialog - { - egui::Area::new(egui::Id::new("remote_op_overlay")) - .order(egui::Order::Background) - .show(ctx, |ui| { - ui.painter().rect_filled( - screen, - 0.0, - egui::Color32::from_rgba_premultiplied(0, 0, 0, 120), - ); - }); - } - - // --- New Folder --- - if self.state.show_mkdir_dialog { - let mut open = true; - let mut clicked_ok = false; - egui::Window::new("new_folder_dialog") - .collapsible(false) - .title_bar(false) - .resizable(false) - .default_pos(center) - .pivot(egui::Align2::CENTER_CENTER) - .frame( - egui::Frame::none() - .fill(BG_PANEL) - .stroke(egui::Stroke::new(1.0, BORDER)) - .rounding(RADIUS_LG) - .inner_margin(egui::Margin::same(16.0)), - ) - .open(&mut open) - .show(ctx, |ui| { - ui.set_width(260.0); - ui.label(RichText::new("New Folder").color(TEXT_PRIMARY).strong()); - ui.add_space(12.0); - ui.text_edit_singleline(&mut self.state.mkdir_name); - ui.add_space(16.0); - ui.horizontal(|ui| { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let ok = - egui::Button::new(RichText::new("OK").color(ON_ACCENT).size(12.5)) - .fill(ACCENT) - .rounding(RADIUS_MD) - .min_size(egui::vec2(70.0, 30.0)); - if ui.add(ok).clicked() { - clicked_ok = true; - } - ui.add_space(8.0); - let cancel = egui::Button::new( - RichText::new("Cancel").color(TEXT_DIM).size(12.5), - ) - .fill(egui::Color32::TRANSPARENT) - .min_size(egui::vec2(70.0, 30.0)); - if ui.add(cancel).clicked() { - self.state.show_mkdir_dialog = false; - self.state.mkdir_name.clear(); - } - }); - }); - }); - - if clicked_ok && !self.state.mkdir_name.is_empty() { - let name = self.state.mkdir_name.clone(); - match self.state.op_target { - crate::ui::state::Pane::Local => self.start_local_mkdir(name), - crate::ui::state::Pane::Remote => { - if let Some(idx) = self.active_tab_idx() { - self.start_mkdir(idx, name); - } - } - } - self.state.show_mkdir_dialog = false; - } - if !open { - self.state.show_mkdir_dialog = false; - self.state.mkdir_name.clear(); - } - } - - // --- Delete --- - if self.state.show_delete_dialog { - let mut open = true; - let mut clicked_ok = false; - let name = self.state.delete_name.clone(); - egui::Window::new("delete_dialog") - .collapsible(false) - .title_bar(false) - .resizable(false) - .default_pos(center) - .pivot(egui::Align2::CENTER_CENTER) - .frame( - egui::Frame::none() - .fill(BG_PANEL) - .stroke(egui::Stroke::new(1.0, BORDER)) - .rounding(RADIUS_LG) - .inner_margin(egui::Margin::same(16.0)), - ) - .open(&mut open) - .show(ctx, |ui| { - ui.set_width(260.0); - ui.horizontal(|ui| { - crate::ui::icons::icon( - ui, - crate::ui::icons::Icon::DangerTriangle, - 18.0, - RED, - ); - ui.add_space(8.0); - ui.vertical(|ui| { - ui.label( - RichText::new(format!("Delete \"{}\"?", name)) - .color(TEXT_PRIMARY) - .strong(), - ); - ui.label( - RichText::new("This action cannot be undone.") - .color(TEXT_DIM) - .size(11.0), - ); - }); - }); - ui.add_space(16.0); - ui.horizontal(|ui| { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let ok = - egui::Button::new(RichText::new("OK").color(ON_ACCENT).size(12.5)) - .fill(RED) - .rounding(RADIUS_MD) - .min_size(egui::vec2(70.0, 30.0)); - if ui.add(ok).clicked() { - clicked_ok = true; - } - ui.add_space(8.0); - let cancel = egui::Button::new( - RichText::new("Cancel").color(TEXT_DIM).size(12.5), - ) - .fill(egui::Color32::TRANSPARENT) - .min_size(egui::vec2(70.0, 30.0)); - if ui.add(cancel).clicked() { - self.state.show_delete_dialog = false; - self.state.delete_name.clear(); - } - }); - }); - }); - - if clicked_ok { - match self.state.op_target { - crate::ui::state::Pane::Local => self.start_local_delete(name), - crate::ui::state::Pane::Remote => { - if let Some(idx) = self.active_tab_idx() { - self.start_delete(idx, name); - } - } - } - self.state.show_delete_dialog = false; - } - if !open { - self.state.show_delete_dialog = false; - self.state.delete_name.clear(); - } - } - - // --- Rename --- - if self.state.show_rename_dialog { - let mut open = true; - let mut clicked_ok = false; - egui::Window::new("rename_dialog") - .collapsible(false) - .title_bar(false) - .resizable(false) - .default_pos(center) - .pivot(egui::Align2::CENTER_CENTER) - .frame( - egui::Frame::none() - .fill(BG_PANEL) - .stroke(egui::Stroke::new(1.0, BORDER)) - .rounding(RADIUS_LG) - .inner_margin(egui::Margin::same(16.0)), - ) - .open(&mut open) - .show(ctx, |ui| { - ui.set_width(260.0); - ui.label(RichText::new("Rename").color(TEXT_PRIMARY).strong()); - ui.add_space(12.0); - ui.text_edit_singleline(&mut self.state.rename_new_name); - ui.add_space(16.0); - ui.horizontal(|ui| { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let ok = - egui::Button::new(RichText::new("OK").color(ON_ACCENT).size(12.5)) - .fill(ACCENT) - .rounding(RADIUS_MD) - .min_size(egui::vec2(70.0, 30.0)); - if ui.add(ok).clicked() { - clicked_ok = true; - } - ui.add_space(8.0); - let cancel = egui::Button::new( - RichText::new("Cancel").color(TEXT_DIM).size(12.5), - ) - .fill(egui::Color32::TRANSPARENT) - .min_size(egui::vec2(70.0, 30.0)); - if ui.add(cancel).clicked() { - self.state.show_rename_dialog = false; - self.state.rename_new_name.clear(); - self.state.rename_old_name.clear(); - } - }); - }); - }); - - if clicked_ok && !self.state.rename_new_name.is_empty() { - let old_name = self.state.rename_old_name.clone(); - let new_name = self.state.rename_new_name.clone(); - match self.state.op_target { - crate::ui::state::Pane::Local => self.start_local_rename(old_name, new_name), - crate::ui::state::Pane::Remote => { - if let Some(idx) = self.active_tab_idx() { - self.start_rename(idx, old_name, new_name); - } - } - } - self.state.show_rename_dialog = false; - } - if !open { - self.state.show_rename_dialog = false; - self.state.rename_new_name.clear(); - self.state.rename_old_name.clear(); - } - } - } - - fn render_settings_dialog(&mut self, ctx: &egui::Context) { - let screen = ctx.screen_rect(); - let center = screen.center(); - - egui::Area::new(egui::Id::new("settings_overlay")) - .fixed_pos(egui::Pos2::ZERO) - .order(egui::Order::Background) - .show(ctx, |ui| { - let r = ui.allocate_rect(screen, egui::Sense::click()); - ui.painter() - .rect_filled(screen, 0.0, egui::Color32::from_black_alpha(160)); - if r.clicked() { - self.state.show_settings_dialog = false; - } - }); - - let mut open = true; - egui::Window::new("settings_dialog") - .collapsible(false) - .title_bar(false) - .resizable(false) - .default_pos(center) - .pivot(egui::Align2::CENTER_CENTER) - .frame( - egui::Frame::none() - .fill(BG_PANEL) - .stroke(egui::Stroke::new(1.0, BORDER)) - .rounding(RADIUS_LG) - .inner_margin(egui::Margin::same(20.0)), - ) - .open(&mut open) - .show(ctx, |ui| { - ui.set_width(340.0); - ui.horizontal(|ui| { - crate::ui::icons::icon(ui, crate::ui::icons::Icon::Settings, 16.0, ACCENT); - ui.add_space(8.0); - ui.label( - RichText::new("Settings") - .color(TEXT_PRIMARY) - .size(15.0) - .strong(), - ); - }); - ui.add_space(16.0); - - settings_row(ui, "Version", env!("CARGO_PKG_VERSION")); - let db_path = dirs::data_dir() - .unwrap_or_else(|| std::path::PathBuf::from(".")) - .join("loflum") - .join("loflum.db"); - settings_row(ui, "Sites database", &db_path.to_string_lossy()); - - ui.add_space(16.0); - ui.label( - RichText::new("More settings are on the way.") - .color(TEXT_HINT) - .size(11.0), - ); - - ui.add_space(16.0); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let close = - egui::Button::new(RichText::new("Close").color(ON_ACCENT).size(12.5)) - .fill(ACCENT) - .rounding(RADIUS_MD) - .min_size(egui::vec2(80.0, 30.0)); - if ui.add(close).clicked() { - self.state.show_settings_dialog = false; - } - }); - }); - - if !open { - self.state.show_settings_dialog = false; - } - } -} - -fn settings_row(ui: &mut egui::Ui, label: &str, value: &str) { - ui.horizontal(|ui| { - ui.label(RichText::new(label).color(TEXT_DIM).size(11.5)); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.label(RichText::new(value).color(TEXT_HINT).monospace().size(10.5)); - }); - }); - ui.add_space(6.0); -} - -/// Загружает системный шрифт (SF Pro / SF Mono на macOS) вместо egui-дефолта, -/// чтобы интерфейс выглядел нативно, как в исходном макете. На других ОС -/// файлы просто отсутствуют — тогда остаются встроенные шрифты egui. -fn system_fonts() -> egui::FontDefinitions { - let mut fonts = egui::FontDefinitions::default(); - - let candidates: &[(&str, &str, egui::FontFamily)] = &[ - ( - "system-sans", - "/System/Library/Fonts/SFNS.ttf", - egui::FontFamily::Proportional, - ), - ( - "system-mono", - "/System/Library/Fonts/SFNSMono.ttf", - egui::FontFamily::Monospace, - ), - ]; - - for (name, path, family) in candidates { - if let Ok(bytes) = std::fs::read(path) { - fonts - .font_data - .insert((*name).to_owned(), egui::FontData::from_owned(bytes)); - fonts - .families - .entry(family.clone()) - .or_default() - .insert(0, (*name).to_owned()); - } - } - - fonts -} - -fn pane_header(ui: &mut egui::Ui, label: &str, width: f32) { - egui::Frame::none() - .fill(BG_BASE) - .inner_margin(egui::Margin::symmetric(12.0, 0.0)) - .show(ui, |ui| { - ui.allocate_ui_with_layout( - egui::vec2(width - 24.0, 30.0), - egui::Layout::left_to_right(egui::Align::Center), - |ui| { - ui.label(RichText::new(label).color(TEXT_DIM).size(10.5).strong()); - }, - ); - }); - - let (_, sep) = ui.allocate_space(egui::vec2(width, 1.0)); - ui.painter().rect_filled(sep, 0.0, BORDER); -} diff --git a/src/ui/app/dialogs.rs b/src/ui/app/dialogs.rs new file mode 100644 index 0000000..7699f59 --- /dev/null +++ b/src/ui/app/dialogs.rs @@ -0,0 +1,164 @@ +//! Модальные диалоги ФС (New Folder / Delete / Rename) — общий каркас модалки +//! (окно/кнопки) вынесен в [`crate::ui::widgets::modal`]. +use egui::{Context, RichText, Ui}; + +use super::FileManagerApp; +// Алиас `tr`: в этом файле `t` — анимационная «открытость» (f32), не перевод. +use crate::i18n::{t as tr, tf}; +use crate::ui::icons; +use crate::ui::state::Pane; +use crate::ui::theme::*; +use crate::ui::widgets::modal::{self, Outcome}; +use crate::ui::widgets::overlay; + +impl FileManagerApp { + pub(super) fn render_remote_op_dialogs(&mut self, ctx: &Context) { + // Settings → General → "Confirm before deleting" выключен — удаляем + // сразу, до расчёта затемнения, чтобы не мелькнуть окном на 1 кадр. + if self.state.show_delete_dialog && !self.state.confirm_before_delete { + let name = self.state.delete_name.clone(); + self.dispatch_delete(name); + self.state.show_delete_dialog = false; + } + + // «Открытость» считается каждый кадр для каждого диалога (см. док к + // overlay::openness) — окна дорисовываются, пока доигрывает fade-out. + let t_mkdir = overlay::openness(ctx, "mkdir_dlg", self.state.show_mkdir_dialog); + let t_delete = overlay::openness(ctx, "delete_dlg", self.state.show_delete_dialog); + let t_rename = overlay::openness(ctx, "rename_dlg", self.state.show_rename_dialog); + + let t_any = t_mkdir.max(t_delete).max(t_rename); + if t_any > 0.0 { + let _ = overlay::dim(ctx, "remote_op_overlay", t_any); + } + + if t_mkdir > 0.0 { + match text_modal( + ctx, + "new_folder_dialog", + t_mkdir, + tr("common.new_folder"), + &mut self.state.mkdir_name, + ) { + Outcome::Confirm if !self.state.mkdir_name.is_empty() => { + let name = self.state.mkdir_name.clone(); + self.dispatch_mkdir(name); + self.state.show_mkdir_dialog = false; + } + // Поля не чистим при отмене: открыватели инициализируют их + // сами, а очистка здесь заставила бы контент мигать в fade-out. + Outcome::Cancel => self.state.show_mkdir_dialog = false, + _ => {} + } + } + + if t_delete > 0.0 { + let name = self.state.delete_name.clone(); + match confirm_modal(ctx, "delete_dialog", t_delete, &name) { + Outcome::Confirm => { + self.dispatch_delete(name); + self.state.show_delete_dialog = false; + } + Outcome::Cancel => self.state.show_delete_dialog = false, + _ => {} + } + } + + if t_rename > 0.0 { + match text_modal( + ctx, + "rename_dialog", + t_rename, + tr("common.rename"), + &mut self.state.rename_new_name, + ) { + Outcome::Confirm if !self.state.rename_new_name.is_empty() => { + let old_name = self.state.rename_old_name.clone(); + let new_name = self.state.rename_new_name.clone(); + self.dispatch_rename(old_name, new_name); + self.state.show_rename_dialog = false; + } + Outcome::Cancel => self.state.show_rename_dialog = false, + _ => {} + } + } + } + + fn dispatch_mkdir(&mut self, name: String) { + match self.state.op_target { + Pane::Local => self.start_local_mkdir(name), + Pane::Remote => { + if let Some(idx) = self.active_tab_idx() { + self.start_mkdir(idx, name); + } + } + } + } + + fn dispatch_delete(&mut self, name: String) { + match self.state.op_target { + Pane::Local => self.start_local_delete(name), + Pane::Remote => { + if let Some(idx) = self.active_tab_idx() { + self.start_delete(idx, name); + } + } + } + } + + fn dispatch_rename(&mut self, old_name: String, new_name: String) { + match self.state.op_target { + Pane::Local => self.start_local_rename(old_name, new_name), + Pane::Remote => { + if let Some(idx) = self.active_tab_idx() { + self.start_rename(idx, old_name, new_name); + } + } + } + } +} + +/// Модалка с одним текстовым полем (New Folder / Rename): фокус в поле сразу, +/// Enter подтверждает. +fn text_modal(ctx: &Context, id: &str, t: f32, title: &str, value: &mut String) -> Outcome { + let mut outcome = Outcome::Idle; + modal::window(ctx, id, t, |ui| { + ui.label(RichText::new(title).color(TEXT_PRIMARY).strong()); + ui.add_space(12.0); + let resp = ui.text_edit_singleline(value); + if t >= 1.0 { + resp.request_focus(); + } + let entered = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + ui.add_space(16.0); + let row = modal::ok_cancel_row(ui, false); + outcome = if entered { Outcome::Confirm } else { row }; + }); + outcome +} + +/// Диалог подтверждения удаления (красная кнопка OK). +fn confirm_modal(ctx: &Context, id: &str, t: f32, name: &str) -> Outcome { + let mut outcome = Outcome::Idle; + modal::window(ctx, id, t, |ui: &mut Ui| { + ui.horizontal(|ui| { + icons::icon(ui, icons::Icon::DangerTriangle, 18.0, RED); + ui.add_space(8.0); + ui.vertical(|ui| { + ui.label( + RichText::new(tf("dialogs.confirm_delete_title", &[("{name}", name)])) + .color(TEXT_PRIMARY) + .strong(), + ); + ui.label( + RichText::new(tr("dialogs.cannot_undo")) + .color(TEXT_DIM) + .size(11.0), + ); + }); + }); + ui.add_space(16.0); + outcome = modal::ok_cancel_row(ui, true); + }); + outcome +} diff --git a/src/ui/app/mod.rs b/src/ui/app/mod.rs new file mode 100644 index 0000000..ba6a03d --- /dev/null +++ b/src/ui/app/mod.rs @@ -0,0 +1,187 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::time::Instant; + +use crate::domain::connection::ConnectionStatus; +use crate::domain::site::Site; +use crate::fs::remote::RemoteRegistry; +use crate::transfer::queue::TransferQueue; +use crate::ui::dialogs::connection; +use crate::ui::dock::PaneTab; +use crate::ui::panels::local_pane; +use crate::ui::widgets::overlay; + +mod dialogs; +mod ops; +mod poll; +mod results; +mod screens; +mod settings; +mod visuals; +mod welcome; + +pub struct FileManagerApp { + pub state: crate::ui::state::AppState, + pub registry: Arc, + pub queue: TransferQueue, + pub rt: tokio::runtime::Runtime, + pub db: Arc>, + pub sites: Vec, + pub first_frame: bool, + pub dock_state: egui_dock::DockState, + /// Момент, когда задача передачи впервые замечена в состоянии Completed — + /// нужен для авто-очистки очереди (Settings → Transfers). + completed_since: HashMap, + /// Какой экран был в прошлом кадре (welcome/main) + момент переключения — + /// для фейда при переходе между экранами. None до первого кадра. + prev_is_welcome: Option, + screen_changed_at: f64, +} + +impl FileManagerApp { + pub fn new( + registry: Arc, + queue: TransferQueue, + rt: tokio::runtime::Runtime, + db: Arc>, + sites: Vec, + max_concurrent: Arc, + ) -> Self { + let prefs = settings::prefs::load(&db); + max_concurrent.store(prefs.max_concurrent, Ordering::Relaxed); + crate::i18n::set_lang(prefs.language.unwrap_or_else(crate::i18n::detect_system_lang)); + + let start_path = if !prefs.default_local_folder.is_empty() + && std::path::Path::new(&prefs.default_local_folder).is_dir() + { + prefs.default_local_folder.clone() + } else { + crate::fs::local::home_dir() + }; + + let mut state = crate::ui::state::AppState { + local_path: start_path, + confirm_before_delete: prefs.confirm_before_delete, + default_local_folder: prefs.default_local_folder, + auto_clear_completed_secs: prefs.auto_clear_completed_secs, + max_concurrent, + ..Default::default() + }; + local_pane::refresh_local(&mut state); + + state.reload_history(&db); + + if let Ok(rows) = crate::storage::db::get_bookmarks(&db.lock().unwrap()) { + state.bookmarks = rows + .into_iter() + .map(|(id, name, path)| crate::ui::state::Bookmark { id, name, path }) + .collect(); + } + + Self { + state, + registry, + queue, + rt, + db, + sites, + first_frame: true, + dock_state: egui_dock::DockState::new(vec![PaneTab::Local]), + completed_since: HashMap::new(), + prev_is_welcome: None, + screen_changed_at: f64::NEG_INFINITY, + } + } + + fn active_tab_idx(&self) -> Option { + if self.state.tabs.is_empty() { + return None; + } + let idx = self.state.active_tab.min(self.state.tabs.len() - 1); + if self.state.tabs[idx].status == ConnectionStatus::Connected { + Some(idx) + } else { + None + } + } +} + +impl eframe::App for FileManagerApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + if self.first_frame { + self.first_frame = false; + egui_extras::install_image_loaders(ctx); + self.apply_visuals(ctx); + ctx.request_repaint(); + } + + self.poll_pending(); + crate::ui::menu::poll_menu_events(&mut self.state); + self.state.queue_tasks = self.queue.all(); + self.sweep_completed_tasks(); + + // ── Esc закрывает верхний из открытых диалогов ─────────────────────── + if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + let s = &mut self.state; + if s.show_mkdir_dialog { + s.show_mkdir_dialog = false; + } else if s.show_delete_dialog { + s.show_delete_dialog = false; + } else if s.show_rename_dialog { + s.show_rename_dialog = false; + } else if s.show_connect_dialog && !s.connect_loading { + s.show_connect_dialog = false; + } else if s.show_settings_dialog { + s.show_settings_dialog = false; + } + } + + // ── Переход welcome ↔ main с фейдом ────────────────────────────────── + let is_welcome = self.state.tabs.is_empty(); + if self.prev_is_welcome != Some(is_welcome) { + // На самом первом кадре не анимируем — окно и так только появилось. + if self.prev_is_welcome.is_some() { + self.screen_changed_at = ctx.input(|i| i.time); + } + self.prev_is_welcome = Some(is_welcome); + } + let screen_fade = + (((ctx.input(|i| i.time) - self.screen_changed_at) / 0.2).clamp(0.0, 1.0)) as f32; + if screen_fade < 1.0 { + ctx.request_repaint(); + } + + if is_welcome { + self.render_welcome_screen(ctx, screen_fade); + } else { + self.render_main_ui(ctx, screen_fade); + } + + // «Открытость» диалогов тикает каждый кадр (см. док к overlay::openness), + // окна рисуются, пока не доиграл fade-out. + // ── Connection dialog ──────────────────────────────────────────────── + let t_connect = overlay::openness(ctx, "connect_dlg", self.state.show_connect_dialog); + if t_connect > 0.0 { + connection::render( + ctx, + &mut self.state, + &self.registry, + self.rt.handle(), + t_connect, + ); + } + + // ── Remote operation dialogs ───────────────────────────────────────── + self.render_remote_op_dialogs(ctx); + + // ── Settings ───────────────────────────────────────────────────────── + let t_settings = overlay::openness(ctx, "settings_dlg", self.state.show_settings_dialog); + if t_settings > 0.0 { + self.render_settings_dialog(ctx, t_settings); + } + + // 200ms repaint для обновления прогресса + ctx.request_repaint_after(std::time::Duration::from_millis(200)); + } +} diff --git a/src/ui/app/ops.rs b/src/ui/app/ops.rs new file mode 100644 index 0000000..b016969 --- /dev/null +++ b/src/ui/app/ops.rs @@ -0,0 +1,152 @@ +//! Запуск операций над ФС из диалогов (mkdir/delete/rename), локально и удалённо. +use std::sync::Arc; + +use super::FileManagerApp; +use crate::ui::panels::{local_pane, remote_pane}; + +impl FileManagerApp { + pub(super) fn start_mkdir(&mut self, tab_idx: usize, name: String) { + let tab = &self.state.tabs[tab_idx]; + let connection_id = tab.id.clone(); + let remote_path = tab.remote_path.clone(); + let registry = self.registry.clone(); + let path = format!("{}/{}", remote_path.trim_end_matches('/'), name); + + let result = Arc::new(std::sync::Mutex::new(None::>)); + let result_clone = result.clone(); + + self.rt.handle().spawn(async move { + let fs = match registry.get(&connection_id) { + Some(fs) => fs, + None => { + *result_clone.lock().unwrap() = Some(Err("connection not found".into())); + return; + } + }; + let r = fs.mkdir(&path).await.map_err(|e| e.to_string()); + *result_clone.lock().unwrap() = Some(r); + }); + + self.state.pending_mkdir_result = Some(result); + } + + pub(super) fn start_delete(&mut self, tab_idx: usize, name: String) { + let tab = &self.state.tabs[tab_idx]; + let connection_id = tab.id.clone(); + let registry = self.registry.clone(); + + let entry_path = tab + .remote_entries + .iter() + .find(|e| e.name == name) + .map(|e| e.path.clone()); + + if name == ".." { + self.state.status_message = "Cannot delete parent entry".into(); + return; + } + + let Some(path) = entry_path else { + self.state.status_message = "Selected entry not found".into(); + return; + }; + + let result = Arc::new(std::sync::Mutex::new(None::>)); + let result_clone = result.clone(); + + self.rt.handle().spawn(async move { + let fs = match registry.get(&connection_id) { + Some(fs) => fs, + None => { + *result_clone.lock().unwrap() = Some(Err("connection not found".into())); + return; + } + }; + let r = fs.delete(&path).await.map_err(|e| e.to_string()); + *result_clone.lock().unwrap() = Some(r); + }); + + self.state.pending_delete_result = Some(result); + } + + pub(super) fn start_rename(&mut self, tab_idx: usize, old_name: String, new_name: String) { + let tab = &self.state.tabs[tab_idx]; + let connection_id = tab.id.clone(); + let registry = self.registry.clone(); + + let Some(entry) = tab.remote_entries.iter().find(|e| e.name == old_name) else { + self.state.status_message = "Selected entry not found".into(); + return; + }; + + let from = entry.path.clone(); + let parent_dir = remote_pane::remote_parent(&from); + let to = format!("{}/{}", parent_dir.trim_end_matches('/'), new_name); + + let result = Arc::new(std::sync::Mutex::new(None::>)); + let result_clone = result.clone(); + + self.rt.handle().spawn(async move { + let fs = match registry.get(&connection_id) { + Some(fs) => fs, + None => { + *result_clone.lock().unwrap() = Some(Err("connection not found".into())); + return; + } + }; + let r = fs.rename(&from, &to).await.map_err(|e| e.to_string()); + *result_clone.lock().unwrap() = Some(r); + }); + + self.state.pending_rename_result = Some(result); + } + + pub(super) fn start_local_mkdir(&mut self, name: String) { + let path = format!("{}/{}", self.state.local_path.trim_end_matches('/'), name); + match crate::fs::local::mkdir(&path) { + Ok(()) => { + self.state.status_message = "Folder created".into(); + local_pane::refresh_local(&mut self.state); + } + Err(e) => self.state.status_message = format!("Create folder failed: {}", e), + } + } + + pub(super) fn start_local_delete(&mut self, name: String) { + if name == ".." { + self.state.status_message = "Cannot delete parent entry".into(); + return; + } + let Some(entry) = self.state.local_entries.iter().find(|e| e.name == name) else { + self.state.status_message = "Selected entry not found".into(); + return; + }; + match crate::fs::local::delete(&entry.path) { + Ok(()) => { + self.state.status_message = "Deleted".into(); + local_pane::refresh_local(&mut self.state); + } + Err(e) => self.state.status_message = format!("Delete failed: {}", e), + } + } + + pub(super) fn start_local_rename(&mut self, old_name: String, new_name: String) { + let Some(entry) = self.state.local_entries.iter().find(|e| e.name == old_name) else { + self.state.status_message = "Selected entry not found".into(); + return; + }; + let from = entry.path.clone(); + let parent_dir = std::path::Path::new(&from) + .parent() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| self.state.local_path.clone()); + let to = format!("{}/{}", parent_dir.trim_end_matches('/'), new_name); + match crate::fs::local::rename(&from, &to) { + Ok(()) => { + self.state.status_message = "Renamed".into(); + local_pane::refresh_local(&mut self.state); + } + Err(e) => self.state.status_message = format!("Rename failed: {}", e), + } + } +} diff --git a/src/ui/app/poll.rs b/src/ui/app/poll.rs new file mode 100644 index 0000000..8a7b8ba --- /dev/null +++ b/src/ui/app/poll.rs @@ -0,0 +1,135 @@ +//! Опрос результатов асинхронных операций раз в кадр из `update`. +//! Разбор connect/list — в [`super::results`], здесь — диспетчер, флаги действий +//! и результаты mkdir/delete/rename. +use std::sync::Arc; + +use super::FileManagerApp; +use crate::ui::dialogs::connection; +use crate::ui::panels::remote_pane; + +impl FileManagerApp { + /// Убирает завершённые задачи из очереди передач через + /// `auto_clear_completed_secs` секунд после завершения (0 — никогда). + /// Вызывается каждый кадр, сразу после обновления `state.queue_tasks`. + pub(super) fn sweep_completed_tasks(&mut self) { + use crate::domain::transfer::TaskState; + use std::collections::HashSet; + + let now = std::time::Instant::now(); + let live_ids: HashSet<&str> = self + .state + .queue_tasks + .iter() + .map(|t| t.id.as_str()) + .collect(); + self.completed_since + .retain(|id, _| live_ids.contains(id.as_str())); + + for task in &self.state.queue_tasks { + if task.state == TaskState::Completed { + self.completed_since.entry(task.id.clone()).or_insert(now); + } + } + + let secs = self.state.auto_clear_completed_secs; + if secs == 0 { + return; + } + let expired: Vec = self + .completed_since + .iter() + .filter(|(_, since)| now.duration_since(**since).as_secs() >= secs as u64) + .map(|(id, _)| id.clone()) + .collect(); + for id in expired { + self.queue.remove(&id); + self.completed_since.remove(&id); + } + } + + pub(super) fn poll_pending(&mut self) { + self.poll_connect(); + self.poll_remote_list(); + self.poll_action_flags(); + self.poll_op_results(); + } + + // --- Toolbar/history action flags --- + fn poll_action_flags(&mut self) { + if self.state.pending_refresh { + self.state.pending_refresh = false; + if let Some(idx) = self.active_tab_idx() { + remote_pane::trigger_list(&mut self.state, idx, &self.registry, self.rt.handle()); + } + } + + // История: переподключение по клику + if let Some(entry) = self.state.pending_history_reconnect.take() { + connection::reconnect_from_history( + &mut self.state, + &self.registry, + self.rt.handle(), + &entry, + ); + } + + // История: «Save» → постоянный Site + if let Some(entry) = self.state.pending_history_save.take() { + match connection::save_history_as_site(&self.db, &mut self.sites, &entry) { + Ok(()) => self.state.status_message = "Saved to sites".into(), + Err(e) => self.state.status_message = format!("Save failed: {}", e), + } + } + } + + // --- mkdir/delete/rename results --- + fn poll_op_results(&mut self) { + if let Some(res) = take_ready(&mut self.state.pending_mkdir_result) { + self.on_op_done(res, "Folder created", "Create folder failed"); + } + if let Some(res) = take_ready(&mut self.state.pending_delete_result) { + self.on_op_done(res, "Deleted", "Delete failed"); + } + if let Some(res) = take_ready(&mut self.state.pending_rename_result) { + self.on_op_done(res, "Renamed", "Rename failed"); + } + } + + /// Общая обработка результата операции над ФС: статус + refresh списка при успехе. + fn on_op_done(&mut self, res: Result<(), String>, ok_msg: &str, err_prefix: &str) { + match res { + Ok(()) => { + self.state.status_message = ok_msg.into(); + self.state.mkdir_name.clear(); + self.state.delete_name.clear(); + self.state.rename_old_name.clear(); + self.state.rename_new_name.clear(); + if let Some(idx) = self.active_tab_idx() { + remote_pane::trigger_list( + &mut self.state, + idx, + &self.registry, + self.rt.handle(), + ); + } + } + Err(e) => self.state.status_message = format!("{}: {}", err_prefix, e), + } + } +} + +/// Забирает готовый результат из отложенной операции (и очищает слот), если он есть. +type PendingResult = Arc>>>; +fn take_ready(slot: &mut Option) -> Option> { + let opt = slot.take(); + let res = opt.as_ref()?; + let mut guard = res.lock().unwrap(); + if let Some(r) = guard.take() { + drop(guard); + Some(r) + } else { + drop(guard); + *slot = opt; + None + } +} diff --git a/src/ui/app/results.rs b/src/ui/app/results.rs new file mode 100644 index 0000000..ecf0be7 --- /dev/null +++ b/src/ui/app/results.rs @@ -0,0 +1,143 @@ +//! Разбор результатов подключения и листинга удалённой директории. +use super::FileManagerApp; +use crate::domain::connection::{ConnectionParams, ConnectionStatus}; +use crate::domain::file_entry::{EntryKind, FileEntry}; +use crate::ui::dock::PaneTab; +use crate::ui::panels::remote_pane; +use crate::ui::state::ConnectionTab; + +impl FileManagerApp { + // --- Connect result --- + pub(super) fn poll_connect(&mut self) { + let opt = self.state.pending_connect.take(); + let Some(pc) = opt.as_ref() else { + return; + }; + let mut guard = pc.result.lock().unwrap(); + let Some(res) = guard.take() else { + drop(guard); + self.state.pending_connect = opt; + return; + }; + drop(guard); + + match res { + Ok((params, entries)) => { + let path = "/".to_string(); + let mut list = vec![FileEntry { + name: "..".into(), + path: path.clone(), + kind: EntryKind::Dir, + size: None, + modified: None, + permissions: None, + }]; + list.extend(entries); + + let tab = ConnectionTab { + id: params.id.clone(), + label: params.label.clone(), + params: params.clone(), + status: ConnectionStatus::Connected, + remote_path: path, + remote_entries: list, + remote_selected: None, + loading: false, + }; + self.state.tabs.push(tab); + self.state.active_tab = self.state.tabs.len() - 1; + self.dock_state + .push_to_focused_leaf(PaneTab::Remote(params.id.clone())); + self.state.status_message = format!("Connected to {}", params.host); + self.state.connect_loading = false; + self.state.show_connect_dialog = false; + + self.persist_connection(¶ms); + self.state.reload_history(&self.db); + } + Err(e) => { + self.state.status_message = format!("Connection failed: {}", e); + self.state.connect_error = e; + self.state.connect_loading = false; + } + } + } + + /// Один и тот же host/port/username всегда должен использовать один conn_id — + /// под ним лежит пароль в keychain, иначе повторное подключение через + /// «New Connection» развело бы историю и keychain. + fn persist_connection(&self, params: &ConnectionParams) { + if let Ok(conn) = self.db.lock() { + let canonical_id = crate::storage::db::find_history_conn_id( + &conn, + ¶ms.host, + params.port, + ¶ms.username, + ) + .ok() + .flatten() + .unwrap_or_else(|| params.id.clone()); + + if let Some(password) = ¶ms.password { + let _ = crate::storage::keychain::store_password(&canonical_id, password); + } + + let _ = crate::storage::db::add_history_entry( + &conn, + ¶ms.host, + params.port, + ¶ms.username, + &canonical_id, + ¶ms.protocol, + params.key_path.as_deref(), + ); + } + } + + // --- Remote list results --- + pub(super) fn poll_remote_list(&mut self) { + let mut done: Vec = Vec::new(); + for (i, pending) in self.state.pending_remote_list.iter().enumerate() { + let mut guard = pending.result.lock().unwrap(); + if let Some(res) = guard.take() { + let tab_idx = pending.tab_idx; + match res { + Ok(entries) => { + let path = if tab_idx < self.state.tabs.len() { + self.state.tabs[tab_idx].remote_path.clone() + } else { + "/".into() + }; + let mut list = Vec::new(); + if path != "/" { + let parent = remote_pane::remote_parent(&path); + list.push(FileEntry { + name: "..".into(), + path: parent, + kind: EntryKind::Dir, + size: None, + modified: None, + permissions: None, + }); + } + list.extend(entries); + if tab_idx < self.state.tabs.len() { + self.state.tabs[tab_idx].remote_entries = list; + self.state.tabs[tab_idx].loading = false; + } + } + Err(e) => { + if tab_idx < self.state.tabs.len() { + self.state.tabs[tab_idx].loading = false; + } + self.state.status_message = format!("Remote list error: {}", e); + } + } + done.push(i); + } + } + for i in done.into_iter().rev() { + self.state.pending_remote_list.remove(i); + } + } +} diff --git a/src/ui/app/screens.rs b/src/ui/app/screens.rs new file mode 100644 index 0000000..7b6851c --- /dev/null +++ b/src/ui/app/screens.rs @@ -0,0 +1,106 @@ +//! Основная рабочая область: тулбар, статус-бар, очередь, сайдбар и док-вкладки. +use egui::{CentralPanel, SidePanel, TopBottomPanel}; + +use super::FileManagerApp; +use crate::ui::dock::{AppTabViewer, PaneTab}; +use crate::ui::panels::{queue, sidebar, status_bar, toolbar}; +use crate::ui::theme::*; + +impl FileManagerApp { + pub(super) fn render_main_ui(&mut self, ctx: &egui::Context, fade: f32) { + // Панели используют show_animated: тумблеры View → Toolbar/Sidebar/... + // плавно въезжают/выезжают вместо мгновенного скачка раскладки. + // ── Toolbar ────────────────────────────────────────────────────────── + TopBottomPanel::top("toolbar") + .frame(egui::Frame::none().fill(BG_TOOLBAR)) + .show_animated(ctx, self.state.show_toolbar, |ui| { + toolbar::render(ui, &mut self.state, &self.queue); + }); + + // ── Status bar ─────────────────────────────────────────────────────── + TopBottomPanel::bottom("status_bar") + .frame(egui::Frame::none().fill(BG_BASE)) + .exact_height(STATUS_H) + .show_animated(ctx, self.state.show_status_bar, |ui| { + status_bar::render(ui, &self.state); + }); + + // ── Queue panel ────────────────────────────────────────────────────── + if self.state.show_queue_panel { + let tasks = self.state.queue_tasks.clone(); + // Плавное раскрытие/сворачивание очереди по клику на заголовок. + let expanded = self.state.show_queue && !tasks.is_empty(); + let t = ctx.animate_bool_with_time(egui::Id::new("queue_expanded"), expanded, 0.18); + let q_h = egui::lerp(QUEUE_COLLAPSED_H..=QUEUE_EXPANDED_H, t); + TopBottomPanel::bottom("queue_panel") + .frame( + egui::Frame::none() + .fill(BG_QUEUE) + .stroke(egui::Stroke::new(1.0, BORDER)), + ) + .resizable(false) + .exact_height(q_h) + .show(ctx, |ui| { + queue::render(ui, &mut self.state, &tasks); + }); + } + + // ── Sidebar ────────────────────────────────────────────────────────── + SidePanel::left("sidebar") + .frame( + egui::Frame::none() + .fill(BG_PANEL) + .stroke(egui::Stroke::new(1.0, BORDER)), + ) + .resizable(true) + .default_width(SIDEBAR_W) + .width_range(120.0..=260.0) + .show_animated(ctx, self.state.show_sidebar, |ui| { + sidebar::render(ui, &mut self.state, &self.db); + }); + + // ── Main content: модульная рабочая область (Local + подключения) ──── + // Каждое подключение — своя вкладка, которую можно перетаскивать, + // разбивать на сплиты или закрывать независимо от остальных. + CentralPanel::default() + .frame(egui::Frame::none().fill(BG_CONTENT)) + .show(ctx, |ui| { + ui.multiply_opacity(crate::ui::widgets::overlay::ease_out(fade)); + let mut closed_remote: Option = None; + let mut viewer = AppTabViewer { + state: &mut self.state, + queue: &self.queue, + registry: &self.registry, + rt_handle: self.rt.handle(), + db: &self.db, + closed_remote: &mut closed_remote, + }; + let style = egui_dock::Style::from_egui(ui.style()); + egui_dock::DockArea::new(&mut self.dock_state) + .style(style) + .show_add_buttons(false) + .show_close_buttons(true) + .show_inside(ui, &mut viewer); + + if let Some((_rect, tab)) = self.dock_state.find_active_focused() { + match tab { + PaneTab::Local => self.state.active_pane = crate::ui::state::Pane::Local, + PaneTab::Remote(id) => { + if let Some(idx) = self.state.tabs.iter().position(|t| &t.id == id) { + self.state.active_tab = idx; + self.state.active_pane = crate::ui::state::Pane::Remote; + } + } + } + } + + if let Some(id) = closed_remote { + self.registry.remove(&id); + self.state.tabs.retain(|t| t.id != id); + if self.state.active_tab >= self.state.tabs.len() { + self.state.active_tab = self.state.tabs.len().saturating_sub(1); + } + } + }); + } +} diff --git a/src/ui/app/settings/about.rs b/src/ui/app/settings/about.rs new file mode 100644 index 0000000..863d4b8 --- /dev/null +++ b/src/ui/app/settings/about.rs @@ -0,0 +1,36 @@ +//! Settings → About: версия, путь к БД, лицензия. +use egui::{RichText, Ui}; + +use super::{FileManagerApp, section_title, value_row}; +use crate::i18n::t; +use crate::ui::theme::*; +use crate::ui::widgets::button; + +impl FileManagerApp { + pub(super) fn render_about(&mut self, ui: &mut Ui) { + section_title(ui, t("settings.about.title")); + + value_row(ui, t("settings.about.version"), env!("CARGO_PKG_VERSION")); + value_row(ui, t("settings.about.license"), env!("CARGO_PKG_LICENSE")); + + let db_path = dirs::data_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join("loflum") + .join("loflum.db"); + value_row(ui, t("settings.about.database"), &db_path.to_string_lossy()); + + ui.add_space(10.0); + let reveal = egui::Button::new( + RichText::new(t("settings.about.reveal_in_finder")) + .color(TEXT_PRIMARY) + .size(11.5), + ) + .rounding(RADIUS_MD) + .min_size(egui::vec2(0.0, 28.0)); + if button::ghost(ui, reveal).clicked() + && let Some(dir) = db_path.parent() + { + let _ = crate::fs::local::open(&dir.to_string_lossy()); + } + } +} diff --git a/src/ui/app/settings/connections.rs b/src/ui/app/settings/connections.rs new file mode 100644 index 0000000..98f88ce --- /dev/null +++ b/src/ui/app/settings/connections.rs @@ -0,0 +1,192 @@ +//! Settings → Sites & Bookmarks / History / Security — всё, что относится к +//! сохранённым подключениям, в одном файле (соответствует группе "CONNECTIONS" +//! в левом меню). +use egui::{RichText, Ui}; + +use super::{FileManagerApp, empty_state, hint, list_row_with_action, section_title}; +use crate::i18n::{t, tf}; +use crate::ui::icons::Icon; +use crate::ui::theme::*; +use crate::ui::widgets::button; + +impl FileManagerApp { + pub(super) fn render_connections(&mut self, ui: &mut Ui) { + section_title(ui, t("settings.connections.sites")); + if self.sites.is_empty() { + empty_state(ui, t("settings.connections.no_sites")); + } else { + let mut remove_idx: Option = None; + for (i, site) in self.sites.iter().enumerate() { + let sublabel = format!("{}@{}:{}", site.username, site.host, site.port); + if list_row_with_action( + ui, + &site.name, + &sublabel, + Icon::Trash, + t("settings.connections.delete_site_hover"), + ) { + remove_idx = Some(i); + } + } + if let Some(i) = remove_idx { + let site = self.sites.remove(i); + if let Ok(conn) = self.db.lock() { + let _ = crate::storage::db::delete_site(&conn, &site.id); + } + let _ = crate::storage::keychain::delete_password(&site.id); + self.state.status_message = + tf("settings.connections.removed_site", &[("{name}", &site.name)]); + } + } + + ui.add_space(20.0); + section_title(ui, t("settings.connections.bookmarks_title")); + if self.state.bookmarks.is_empty() { + empty_state(ui, t("settings.connections.no_bookmarks")); + } else { + let mut remove_id: Option = None; + for bm in &self.state.bookmarks { + if list_row_with_action( + ui, + &bm.name, + &bm.path, + Icon::Trash, + t("panels.sidebar.remove_bookmark"), + ) { + remove_id = Some(bm.id); + } + } + if let Some(id) = remove_id { + self.state.bookmarks.retain(|b| b.id != id); + if let Ok(conn) = self.db.lock() { + let _ = crate::storage::db::remove_bookmark(&conn, id); + } + self.state.status_message = t("settings.connections.bookmark_removed").into(); + } + } + } + + pub(super) fn render_history_section(&mut self, ui: &mut Ui) { + section_title(ui, t("settings.history.title")); + + if self.state.history.is_empty() { + empty_state(ui, t("settings.history.empty")); + } else { + for entry in &self.state.history { + ui.horizontal(|ui| { + ui.label( + RichText::new(format!("{}@{}:{}", entry.user, entry.host, entry.port)) + .color(TEXT_PRIMARY) + .size(12.5), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(RichText::new(&entry.time).color(TEXT_HINT).size(10.5)); + }); + }); + ui.add_space(4.0); + } + } + + ui.add_space(16.0); + + if self.state.history_clear_confirm { + hint(ui, t("settings.history.confirm_clear")); + ui.add_space(6.0); + ui.horizontal(|ui| { + let confirm = egui::Button::new( + RichText::new(t("settings.history.delete_all")) + .color(ON_ACCENT) + .size(12.0), + ) + .rounding(RADIUS_MD) + .min_size(egui::vec2(0.0, 28.0)); + if button::danger(ui, confirm).clicked() { + if let Ok(conn) = self.db.lock() { + let _ = crate::storage::db::clear_history(&conn); + } + self.state.history.clear(); + self.state.history_clear_confirm = false; + self.state.status_message = t("settings.history.cleared").into(); + } + ui.add_space(8.0); + let cancel = egui::Button::new( + RichText::new(t("common.cancel")).color(TEXT_DIM).size(12.0), + ) + .rounding(RADIUS_MD) + .min_size(egui::vec2(0.0, 28.0)); + if button::ghost(ui, cancel).clicked() { + self.state.history_clear_confirm = false; + } + }); + } else if !self.state.history.is_empty() { + let clear = egui::Button::new( + RichText::new(t("settings.history.clear_all")) + .color(RED) + .size(12.0), + ) + .rounding(RADIUS_MD) + .min_size(egui::vec2(0.0, 28.0)); + if button::ghost(ui, clear).clicked() { + self.state.history_clear_confirm = true; + } + } + } + + /// Не показываем, есть ли пароль у конкретной записи — это потребовало бы + /// вызвать `keychain::get_password`, который может дёрнуть системный запрос + /// доступа к keychain на каждую запись при каждом открытии Settings. + pub(super) fn render_security(&mut self, ui: &mut Ui) { + section_title(ui, t("settings.nav.security")); + hint(ui, t("settings.security.hint")); + ui.add_space(12.0); + + if self.sites.is_empty() { + empty_state(ui, t("settings.security.no_connections")); + } else { + let mut forget_id: Option = None; + for site in &self.sites { + ui.horizontal(|ui| { + ui.label(RichText::new(&site.name).color(TEXT_PRIMARY).size(12.5)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let btn = egui::Button::image(crate::ui::icons::image( + Icon::LockPassword, + 13.0, + TEXT_DIM, + )) + .rounding(RADIUS_SM) + .min_size(egui::vec2(26.0, 26.0)); + if button::ghost(ui, btn) + .on_hover_text(t("settings.security.forget_password_hover")) + .clicked() + { + forget_id = Some(site.id.clone()); + } + }); + }); + ui.add_space(4.0); + } + if let Some(id) = forget_id { + let _ = crate::storage::keychain::delete_password(&id); + self.state.status_message = t("settings.security.password_forgotten").into(); + } + } + + ui.add_space(18.0); + let forget_all = egui::Button::new( + RichText::new(t("settings.security.forget_all")) + .color(RED) + .size(12.0), + ) + .rounding(RADIUS_MD) + .min_size(egui::vec2(0.0, 28.0)); + if button::ghost(ui, forget_all).clicked() { + for site in &self.sites { + let _ = crate::storage::keychain::delete_password(&site.id); + } + for entry in &self.state.history { + let _ = crate::storage::keychain::delete_password(&entry.conn_id); + } + self.state.status_message = t("settings.security.all_forgotten").into(); + } + } +} diff --git a/src/ui/app/settings/general.rs b/src/ui/app/settings/general.rs new file mode 100644 index 0000000..f48484f --- /dev/null +++ b/src/ui/app/settings/general.rs @@ -0,0 +1,157 @@ +//! Settings → General & Appearance: подтверждение удаления, стартовая папка, +//! язык интерфейса, видимость панелей (в согласии с меню Window на macOS). +use egui::{RichText, Ui}; + +use super::prefs::{KEY_CONFIRM_DELETE, KEY_DEFAULT_LOCAL_FOLDER, KEY_LANGUAGE}; +use super::{FileManagerApp, hint, section_title}; +use crate::i18n::{Lang, t}; +use crate::ui::theme::*; +use crate::ui::widgets::button; + +impl FileManagerApp { + pub(super) fn render_general(&mut self, ui: &mut Ui) { + section_title(ui, t("settings.nav.general")); + + let changed = ui + .checkbox( + &mut self.state.confirm_before_delete, + RichText::new(t("settings.general.confirm_delete")) + .color(TEXT_PRIMARY) + .size(12.5), + ) + .changed(); + hint(ui, t("settings.general.confirm_delete_hint")); + if changed { + let value = self.state.confirm_before_delete; + self.save_pref_bool(KEY_CONFIRM_DELETE, value); + } + + ui.add_space(18.0); + ui.label( + RichText::new(t("settings.general.language")) + .color(TEXT_PRIMARY) + .size(12.5) + .strong(), + ); + hint(ui, t("settings.general.language_hint")); + ui.add_space(6.0); + if let Some(lang) = language_picker(ui) { + self.set_language(ui, lang); + } + + ui.add_space(18.0); + ui.label( + RichText::new(t("settings.general.startup_folder")) + .color(TEXT_PRIMARY) + .size(12.5) + .strong(), + ); + hint(ui, t("settings.general.startup_folder_hint")); + ui.add_space(6.0); + + let current = if self.state.default_local_folder.is_empty() { + t("settings.general.home_directory").to_string() + } else { + self.state.default_local_folder.clone() + }; + ui.label( + RichText::new(¤t) + .color(TEXT_DIM) + .monospace() + .size(11.0), + ); + ui.add_space(8.0); + + ui.horizontal(|ui| { + let use_current = egui::Button::new( + RichText::new(t("settings.general.use_current_folder")) + .color(TEXT_PRIMARY) + .size(11.5), + ) + .rounding(RADIUS_MD) + .min_size(egui::vec2(0.0, 28.0)); + if button::ghost(ui, use_current).clicked() { + let path = self.state.local_path.clone(); + self.state.default_local_folder = path.clone(); + self.save_pref_str(KEY_DEFAULT_LOCAL_FOLDER, &path); + } + + ui.add_space(8.0); + let reset = egui::Button::new( + RichText::new(t("settings.general.reset_to_home")) + .color(TEXT_DIM) + .size(11.5), + ) + .rounding(RADIUS_MD) + .min_size(egui::vec2(0.0, 28.0)); + if button::ghost(ui, reset).clicked() { + self.state.default_local_folder.clear(); + self.save_pref_str(KEY_DEFAULT_LOCAL_FOLDER, ""); + } + }); + } + + pub(super) fn render_appearance(&mut self, ui: &mut Ui) { + section_title(ui, t("settings.nav.appearance")); + hint(ui, t("settings.appearance.hint")); + ui.add_space(10.0); + + appearance_checkbox( + ui, + t("settings.appearance.toolbar"), + &mut self.state.show_toolbar, + ); + appearance_checkbox( + ui, + t("settings.appearance.sidebar"), + &mut self.state.show_sidebar, + ); + appearance_checkbox( + ui, + t("settings.appearance.status_bar"), + &mut self.state.show_status_bar, + ); + appearance_checkbox( + ui, + t("settings.appearance.transfer_queue"), + &mut self.state.show_queue_panel, + ); + } + + /// Сохраняет выбор языка и сразу переключает `t()`/шрифты — эффект виден + /// в этом же кадре, без перезапуска (см. правило про мгновенный отклик). + fn set_language(&self, ui: &Ui, lang: Lang) { + crate::i18n::set_lang(lang); + self.refresh_fonts(ui.ctx()); + self.save_pref_str(KEY_LANGUAGE, lang.code()); + } +} + +fn appearance_checkbox(ui: &mut Ui, label: &str, value: &mut bool) { + ui.checkbox(value, RichText::new(label).color(TEXT_PRIMARY).size(12.5)); + ui.add_space(4.0); +} + +/// Выпадающий список из всех 12 языков — названия на них самих (Español, +/// 日本語...), чтобы найти свой язык, даже не понимая текущего. Возвращает +/// выбранный язык, если пользователь только что кликнул по другому пункту. +fn language_picker(ui: &mut Ui) -> Option { + let current = crate::i18n::current(); + let mut picked = None; + egui::ComboBox::from_id_salt("language_picker") + .width(200.0) + .selected_text( + RichText::new(current.native_name()) + .color(TEXT_PRIMARY) + .size(12.0), + ) + .show_ui(ui, |ui| { + for lang in Lang::ALL { + let selected = lang == current; + if ui.selectable_label(selected, lang.native_name()).clicked() && !selected { + picked = Some(lang); + } + } + }); + picked +} diff --git a/src/ui/app/settings/mod.rs b/src/ui/app/settings/mod.rs new file mode 100644 index 0000000..6021a19 --- /dev/null +++ b/src/ui/app/settings/mod.rs @@ -0,0 +1,219 @@ +//! Диалог Settings — popup с меню разделов слева и содержимым справа +//! (General/Appearance/Connections/History/Security/Transfers/About). +use egui::{Align2, Context, RichText, Ui}; + +use super::FileManagerApp; +use crate::ui::state::SettingsSection; +use crate::ui::theme::*; +use crate::ui::widgets::{button, overlay}; + +mod about; +mod connections; +mod general; +mod nav; +pub(super) mod prefs; +mod transfers; + +const NAV_HEIGHT: f32 = 420.0; + +impl FileManagerApp { + pub(super) fn render_settings_dialog(&mut self, ctx: &Context, t: f32) { + if overlay::dim(ctx, "settings_overlay", t) { + self.state.show_settings_dialog = false; + } + + egui::Window::new("settings_dialog") + .collapsible(false) + .title_bar(false) + .resizable(false) + .movable(false) + .anchor(Align2::CENTER_CENTER, overlay::slide_offset(t)) + .interactable(t >= 1.0) + .frame(overlay::panel_frame()) + .show(ctx, |ui| { + ui.set_opacity(overlay::ease_out(t)); + ui.set_width(600.0); + header(ui, &mut self.state.show_settings_dialog); + ui.add_space(12.0); + let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0)); + ui.painter().rect_filled(sep, 0.0, BORDER); + ui.add_space(14.0); + + ui.horizontal_top(|ui| { + // ScrollArea наследует layout родителя, а здесь он + // горизонтальный — без ui.vertical() содержимое скроллов + // растекается в одну строку и окно вылезает за экран. + ui.vertical(|ui| { + egui::ScrollArea::vertical() + .id_salt("settings_nav_scroll") + .max_height(NAV_HEIGHT) + .show(ui, |ui| { + nav::render(ui, &mut self.state.settings_section); + }); + }); + + let (_, vsep) = ui.allocate_space(egui::vec2(1.0, NAV_HEIGHT)); + ui.painter().rect_filled(vsep, 0.0, BORDER); + ui.add_space(18.0); + + ui.vertical(|ui| { + // Кросс-фейд содержимого при переключении раздела: + // фиксируем момент смены и плавно поднимаем прозрачность. + if self.state.settings_section != self.state.settings_prev_section { + self.state.settings_prev_section = self.state.settings_section; + self.state.settings_section_changed_at = ui.input(|i| i.time); + } + let fade = ((ui.input(|i| i.time) - self.state.settings_section_changed_at) + / 0.18) + .clamp(0.0, 1.0) as f32; + if fade < 1.0 { + ui.ctx().request_repaint(); + } + ui.multiply_opacity(overlay::ease_out(fade)); + + egui::ScrollArea::vertical() + .id_salt("settings_content_scroll") + .max_height(NAV_HEIGHT) + .show(ui, |ui| { + ui.set_width(370.0); + match self.state.settings_section { + SettingsSection::General => self.render_general(ui), + SettingsSection::Appearance => self.render_appearance(ui), + SettingsSection::Connections => self.render_connections(ui), + SettingsSection::History => self.render_history_section(ui), + SettingsSection::Security => self.render_security(ui), + SettingsSection::Transfers => self.render_transfers(ui), + SettingsSection::About => self.render_about(ui), + } + }); + }); + }); + }); + } +} + +fn header(ui: &mut Ui, show: &mut bool) { + ui.horizontal(|ui| { + crate::ui::icons::icon(ui, crate::ui::icons::Icon::Settings, 16.0, ACCENT); + ui.add_space(8.0); + ui.label( + RichText::new(crate::i18n::t("settings.title")) + .color(TEXT_PRIMARY) + .size(15.0) + .strong(), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let close = egui::Button::new(RichText::new("✕").color(TEXT_DIM).size(12.0)) + .rounding(RADIUS_SM) + .min_size(egui::vec2(24.0, 24.0)); + if button::ghost(ui, close).clicked() { + *show = false; + } + }); + }); +} + +/// Заголовок раздела ("General", "Transfers"...), одинаковый во всех секциях. +pub(super) fn section_title(ui: &mut Ui, text: &str) { + ui.label(RichText::new(text).color(TEXT_PRIMARY).size(15.0).strong()); + ui.add_space(12.0); +} + +/// Подсказка серым текстом под заголовком/контролом. +pub(super) fn hint(ui: &mut Ui, text: &str) { + ui.label(RichText::new(text).color(TEXT_HINT).size(11.0)); +} + +/// Строка "метка — значение" только для чтения (версия, путь к БД и т.п.). +/// Длинные значения (пути) усекаются с «…», полный текст — по наведению. +pub(super) fn value_row(ui: &mut Ui, label: &str, value: &str) { + ui.horizontal(|ui| { + ui.label(RichText::new(label).color(TEXT_DIM).size(11.5)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let text = RichText::new(value).color(TEXT_HINT).monospace().size(10.5); + let resp = ui.add(egui::Label::new(text).truncate()); + if resp.hovered() && value.len() > 32 { + resp.on_hover_text(value); + } + }); + }); + ui.add_space(6.0); +} + +/// Пустое состояние списка ("No saved sites yet." и т.п.). +pub(super) fn empty_state(ui: &mut Ui, text: &str) { + ui.label(RichText::new(text).color(TEXT_HINT).size(11.5)); +} + +/// Строка элемента списка с кнопкой-иконкой справа (удалить/забыть и т.п.). +/// Возвращает `true`, если по кнопке кликнули. +pub(super) fn list_row_with_action( + ui: &mut Ui, + label: &str, + sublabel: &str, + action_icon: crate::ui::icons::Icon, + action_hover: &str, +) -> bool { + let mut clicked = false; + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.label(RichText::new(label).color(TEXT_PRIMARY).size(12.5)); + if !sublabel.is_empty() { + ui.label(RichText::new(sublabel).color(TEXT_HINT).size(10.5)); + } + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let btn = egui::Button::image(crate::ui::icons::image(action_icon, 13.0, TEXT_DIM)) + .rounding(RADIUS_SM) + .min_size(egui::vec2(26.0, 26.0)); + if button::ghost(ui, btn).on_hover_text(action_hover).clicked() { + clicked = true; + } + }); + }); + ui.add_space(4.0); + clicked +} + +/// Сегментированный ряд из равных по ширине кнопок (используется в Transfers +/// для выбора числа/интервала) — тот же паттерн, что и выбор протокола в +/// диалоге New Connection. +pub(super) fn segmented_row( + ui: &mut Ui, + options: &[(&str, T)], + current: T, +) -> Option { + let mut picked = None; + let gap = 4.0; + let n = options.len().max(1) as f32; + // На первом кадре свежего egui::Window ширина ещё не устоялась и может + // быть меньше суммарных отступов — clamp защищает от negative desired_size. + let seg_w = ((ui.available_width() - gap * (n - 1.0) - 6.0) / n).max(0.0); + + egui::Frame::none() + .fill(BG_CONTENT) + .stroke(egui::Stroke::new(1.0, BORDER)) + .rounding(RADIUS_MD) + .inner_margin(egui::Margin::same(3.0)) + .show(ui, |ui| { + ui.spacing_mut().item_spacing.x = gap; + ui.horizontal(|ui| { + for (label, value) in options { + let active = *value == current; + let tc = if active { ON_ACCENT } else { TEXT_DIM }; + let btn = egui::Button::new(RichText::new(*label).color(tc).size(11.5)) + .rounding(RADIUS_SM) + .min_size(egui::vec2(seg_w, 26.0)); + let resp = if active { + button::accent(ui, btn) + } else { + button::ghost(ui, btn) + }; + if resp.clicked() { + picked = Some(*value); + } + } + }); + }); + picked +} diff --git a/src/ui/app/settings/nav.rs b/src/ui/app/settings/nav.rs new file mode 100644 index 0000000..8472226 --- /dev/null +++ b/src/ui/app/settings/nav.rs @@ -0,0 +1,107 @@ +//! Левое меню диалога Settings: сгруппированные пункты разделов. +use egui::{RichText, Ui}; + +use crate::i18n::t; +use crate::ui::icons::{self, Icon}; +use crate::ui::state::SettingsSection; +use crate::ui::theme::*; +use crate::ui::widgets::row::clickable_row; + +struct NavItem { + section: SettingsSection, + label_key: &'static str, + icon: Icon, +} + +struct NavGroup { + label_key: &'static str, + items: &'static [NavItem], +} + +const GENERAL: &[NavItem] = &[ + NavItem { + section: SettingsSection::General, + label_key: "settings.nav.general", + icon: Icon::Settings, + }, + NavItem { + section: SettingsSection::Appearance, + label_key: "settings.nav.appearance", + icon: Icon::Monitor, + }, +]; + +const CONNECTIONS: &[NavItem] = &[ + NavItem { + section: SettingsSection::Connections, + label_key: "settings.nav.connections", + icon: Icon::ServerSquare, + }, + NavItem { + section: SettingsSection::History, + label_key: "settings.nav.history", + icon: Icon::History, + }, + NavItem { + section: SettingsSection::Security, + label_key: "settings.nav.security", + icon: Icon::ShieldKeyhole, + }, +]; + +const TRANSFERS: &[NavItem] = &[NavItem { + section: SettingsSection::Transfers, + label_key: "settings.nav.transfers", + icon: Icon::Upload, +}]; + +const ABOUT: &[NavItem] = &[NavItem { + section: SettingsSection::About, + label_key: "settings.nav.about", + icon: Icon::Document, +}]; + +const GROUPS: &[NavGroup] = &[ + NavGroup { + label_key: "settings.nav.group_general", + items: GENERAL, + }, + NavGroup { + label_key: "settings.nav.group_connections", + items: CONNECTIONS, + }, + NavGroup { + label_key: "settings.nav.group_transfers", + items: TRANSFERS, + }, + NavGroup { + label_key: "settings.nav.group_about", + items: ABOUT, + }, +]; + +pub(super) fn render(ui: &mut Ui, active: &mut SettingsSection) { + ui.set_width(168.0); + for group in GROUPS { + ui.label( + RichText::new(t(group.label_key)) + .color(TEXT_HINT) + .size(10.0) + .strong(), + ); + ui.add_space(4.0); + for item in group.items { + let is_active = *active == item.section; + let icon_col = if is_active { ACCENT } else { TEXT_DIM }; + let resp = clickable_row(ui, is_active, 30.0, |ui| { + icons::icon(ui, item.icon, 14.0, icon_col); + ui.add_space(8.0); + ui.label(RichText::new(t(item.label_key)).color(TEXT_PRIMARY).size(12.5)); + }); + if resp.clicked() { + *active = item.section; + } + } + ui.add_space(10.0); + } +} diff --git a/src/ui/app/settings/prefs.rs b/src/ui/app/settings/prefs.rs new file mode 100644 index 0000000..4403ac1 --- /dev/null +++ b/src/ui/app/settings/prefs.rs @@ -0,0 +1,67 @@ +//! Ключи настроек в БД, значения по умолчанию и helpers сохранения — +//! общие для всех разделов Settings. +use super::FileManagerApp; + +pub(super) const KEY_CONFIRM_DELETE: &str = "confirm_before_delete"; +pub(super) const KEY_DEFAULT_LOCAL_FOLDER: &str = "default_local_folder"; +pub(super) const KEY_AUTO_CLEAR_SECS: &str = "auto_clear_completed_secs"; +pub(super) const KEY_MAX_CONCURRENT: &str = "max_concurrent_transfers"; +pub(super) const KEY_LANGUAGE: &str = "language"; +pub(super) const DEFAULT_MAX_CONCURRENT: u32 = 2; + +/// Настройки, загруженные из БД при старте (см. `FileManagerApp::new`). +pub(in crate::ui::app) struct Prefs { + pub confirm_before_delete: bool, + pub default_local_folder: String, + pub auto_clear_completed_secs: u32, + pub max_concurrent: u32, + /// `None` — язык ещё ни разу не выбирался явно, стартуем от локали ОС. + pub language: Option, +} + +pub(in crate::ui::app) fn load( + db: &std::sync::Arc>, +) -> Prefs { + let Ok(conn) = db.lock() else { + return Prefs { + confirm_before_delete: true, + default_local_folder: String::new(), + auto_clear_completed_secs: 0, + max_concurrent: DEFAULT_MAX_CONCURRENT, + language: None, + }; + }; + Prefs { + confirm_before_delete: crate::storage::db::get_bool(&conn, KEY_CONFIRM_DELETE, true), + default_local_folder: crate::storage::db::get_setting(&conn, KEY_DEFAULT_LOCAL_FOLDER) + .unwrap_or_default(), + auto_clear_completed_secs: crate::storage::db::get_u32(&conn, KEY_AUTO_CLEAR_SECS, 0), + max_concurrent: crate::storage::db::get_u32( + &conn, + KEY_MAX_CONCURRENT, + DEFAULT_MAX_CONCURRENT, + ), + language: crate::storage::db::get_setting(&conn, KEY_LANGUAGE) + .and_then(|c| crate::i18n::Lang::from_code(&c)), + } +} + +impl FileManagerApp { + pub(super) fn save_pref_bool(&self, key: &str, value: bool) { + if let Ok(conn) = self.db.lock() { + crate::storage::db::set_bool(&conn, key, value); + } + } + + pub(super) fn save_pref_u32(&self, key: &str, value: u32) { + if let Ok(conn) = self.db.lock() { + crate::storage::db::set_u32(&conn, key, value); + } + } + + pub(super) fn save_pref_str(&self, key: &str, value: &str) { + if let Ok(conn) = self.db.lock() { + let _ = crate::storage::db::set_setting(&conn, key, value); + } + } +} diff --git a/src/ui/app/settings/transfers.rs b/src/ui/app/settings/transfers.rs new file mode 100644 index 0000000..6bea485 --- /dev/null +++ b/src/ui/app/settings/transfers.rs @@ -0,0 +1,59 @@ +//! Settings → Transfers: параллелизм передач и авто-очистка завершённых задач. +use std::sync::atomic::Ordering; + +use egui::{RichText, Ui}; + +use super::prefs::{KEY_AUTO_CLEAR_SECS, KEY_MAX_CONCURRENT}; +use super::{FileManagerApp, hint, section_title, segmented_row}; +use crate::i18n::t; +use crate::ui::theme::TEXT_PRIMARY; + +const CONCURRENCY_OPTIONS: &[(&str, u32)] = + &[("1", 1), ("2", 2), ("3", 3), ("4", 4), ("5", 5), ("6", 6)]; + +impl FileManagerApp { + pub(super) fn render_transfers(&mut self, ui: &mut Ui) { + section_title(ui, t("settings.nav.transfers")); + + ui.label( + RichText::new(t("settings.transfers.concurrent_title")) + .color(TEXT_PRIMARY) + .size(12.5) + .strong(), + ); + hint(ui, t("settings.transfers.concurrent_hint")); + ui.add_space(6.0); + + let current = self + .state + .max_concurrent + .load(Ordering::Relaxed) + .clamp(1, 6); + if let Some(picked) = segmented_row(ui, CONCURRENCY_OPTIONS, current) { + self.state.max_concurrent.store(picked, Ordering::Relaxed); + self.save_pref_u32(KEY_MAX_CONCURRENT, picked); + } + + ui.add_space(18.0); + ui.label( + RichText::new(t("settings.transfers.auto_remove_title")) + .color(TEXT_PRIMARY) + .size(12.5) + .strong(), + ); + hint(ui, t("settings.transfers.auto_remove_hint")); + ui.add_space(6.0); + + let auto_clear_options: &[(&str, u32)] = &[ + (t("settings.transfers.never"), 0), + (t("settings.transfers.10s"), 10), + (t("settings.transfers.30s"), 30), + (t("settings.transfers.1min"), 60), + ]; + let current = self.state.auto_clear_completed_secs; + if let Some(picked) = segmented_row(ui, auto_clear_options, current) { + self.state.auto_clear_completed_secs = picked; + self.save_pref_u32(KEY_AUTO_CLEAR_SECS, picked); + } + } +} diff --git a/src/ui/app/visuals.rs b/src/ui/app/visuals.rs new file mode 100644 index 0000000..f0699e6 --- /dev/null +++ b/src/ui/app/visuals.rs @@ -0,0 +1,101 @@ +//! Тема egui (цвета/отступы) и системный шрифт. +use egui::Visuals; + +use super::FileManagerApp; +use crate::ui::theme::*; + +impl FileManagerApp { + pub(super) fn apply_visuals(&self, ctx: &egui::Context) { + let mut vis = Visuals::dark(); + vis.window_fill = BG_PANEL; + vis.panel_fill = BG_CONTENT; + vis.extreme_bg_color = BG_BASE; + vis.code_bg_color = BG_BASE; + vis.override_text_color = Some(TEXT_PRIMARY); + vis.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, BORDER); + vis.widgets.inactive.bg_fill = BG_CONTENT; + vis.widgets.hovered.bg_fill = BG_ROW_HOVER; + vis.widgets.active.bg_fill = ACCENT_DIM; + vis.selection.bg_fill = BG_ROW_SEL; + vis.selection.stroke = egui::Stroke::new(1.0, ACCENT); + vis.window_rounding = egui::Rounding::same(8.0); + ctx.set_visuals(vis); + + self.refresh_fonts(ctx); + + let mut style = ctx.style().as_ref().clone(); + style.spacing.item_spacing = egui::vec2(4.0, 2.0); + style.spacing.button_padding = egui::vec2(8.0, 4.0); + ctx.set_style(style); + } + + /// Перезагружает шрифты под текущий язык (см. [`crate::i18n::current`]) — + /// вызывается при старте и заново при смене языка в Settings, потому что + /// SF Pro не содержит глифов CJK (китайский/японский/корейский). + pub(super) fn refresh_fonts(&self, ctx: &egui::Context) { + ctx.set_fonts(system_fonts(crate::i18n::current())); + } +} + +/// Загружает системный шрифт (SF Pro / SF Mono на macOS) вместо egui-дефолта, +/// чтобы интерфейс выглядел нативно, плюс CJK-шрифты фолбэком (SF Pro не +/// содержит китайских/японских/корейских глифов — без фолбэка был бы tofu). +/// На других ОС файлы просто отсутствуют — тогда остаются встроенные шрифты egui. +fn system_fonts(lang: crate::i18n::Lang) -> egui::FontDefinitions { + use crate::i18n::Lang; + + let mut fonts = egui::FontDefinitions::default(); + + // Основной шрифт всегда первый в списке: даёт консистентную латиницу и + // кириллицу во всём интерфейсе. CJK-фонты идут строго ПОСЛЕ — иначе их + // собственное начертание латинских букв/цифр перекрасило бы весь текст, + // а не только иероглифы/кану/хангыль, которых нет в SF Pro. + let mut candidates: Vec<(&str, &str, egui::FontFamily)> = vec![ + ( + "system-sans", + "/System/Library/Fonts/SFNS.ttf", + egui::FontFamily::Proportional, + ), + ( + "system-mono", + "/System/Library/Fonts/SFNSMono.ttf", + egui::FontFamily::Monospace, + ), + ]; + + // CJK Unified Ideographs пересекаются между китайским/японским/корейским + // (ханьцзы/кандзи/ханча — одни кодпоинты), поэтому шрифт активного языка + // ставим первым среди CJK-фолбэков: он и определит начертание общих иероглифов. + let mut cjk: Vec<(&str, &str)> = vec![ + ("cjk-ja", "/System/Library/Fonts/ヒラギノ角ゴシック W4.ttc"), + ("cjk-zh", "/System/Library/Fonts/Hiragino Sans GB.ttc"), + ("cjk-ko", "/System/Library/Fonts/AppleSDGothicNeo.ttc"), + ]; + let active = match lang { + Lang::Ja => Some("cjk-ja"), + Lang::Zh => Some("cjk-zh"), + Lang::Ko => Some("cjk-ko"), + _ => None, + }; + if let Some(active) = active + && let Some(pos) = cjk.iter().position(|(name, _)| *name == active) + { + cjk.swap(0, pos); + } + candidates.extend(cjk.into_iter().map(|(n, p)| (n, p, egui::FontFamily::Proportional))); + + for (name, path, family) in candidates { + if let Ok(bytes) = std::fs::read(path) { + fonts + .font_data + .entry(name.to_owned()) + .or_insert_with(|| egui::FontData::from_owned(bytes)); + let list = fonts.families.entry(family).or_default(); + if !list.iter().any(|n| n == name) { + list.push(name.to_owned()); + } + } + } + + fonts +} diff --git a/src/ui/app/welcome.rs b/src/ui/app/welcome.rs new file mode 100644 index 0000000..3accbb5 --- /dev/null +++ b/src/ui/app/welcome.rs @@ -0,0 +1,126 @@ +//! Стартовый экран — пока нет ни одного открытого соединения: логотип, +//! кнопка нового подключения, индикатор подключения и список недавних серверов. +use egui::{CentralPanel, RichText}; + +use super::FileManagerApp; +use crate::ui::dialogs::connection; +use crate::ui::icons::{self, Icon}; +use crate::ui::theme::*; +use crate::ui::widgets::{button, row::clickable_row}; + +impl FileManagerApp { + pub(super) fn render_welcome_screen(&mut self, ctx: &egui::Context, fade: f32) { + CentralPanel::default() + .frame(egui::Frame::none().fill(BG_BASE)) + .show(ctx, |ui| { + ui.multiply_opacity(crate::ui::widgets::overlay::ease_out(fade)); + ui.vertical_centered(|ui| { + let avail_h = ui.available_height(); + ui.add_space((avail_h * 0.16).max(24.0)); + + let icon_bytes: &[u8] = include_bytes!("../icons/app_icon.png"); + let icon_img = egui::Image::from_bytes("bytes://welcome_app_icon", icon_bytes) + .rounding(RADIUS_LG) + .fit_to_exact_size(egui::vec2(72.0, 72.0)); + ui.add(icon_img); + + ui.add_space(14.0); + ui.label( + RichText::new("LoFlum") + .color(TEXT_PRIMARY) + .size(20.0) + .strong(), + ); + ui.add_space(4.0); + ui.label(RichText::new("FTP/SFTP client").color(TEXT_HINT).size(12.5)); + + ui.add_space(20.0); + let btn = egui::Button::image_and_text( + icons::image(Icon::AddCircleBold, 15.0, ON_ACCENT), + RichText::new("New Connection") + .color(ON_ACCENT) + .size(12.5) + .strong(), + ) + .rounding(RADIUS_MD) + .min_size(egui::vec2(170.0, 36.0)); + if button::accent(ui, btn).clicked() { + self.state.show_connect_dialog = true; + } + + self.connect_status(ui); + + ui.add_space(28.0); + self.recent_connections(ui); + }); + }); + } + + /// Мгновенная обратная связь при подключении из истории/приветствия: спиннер, + /// а при неудаче — текст ошибки (иначе провал был бы беззвучным, т.к. на + /// welcome-экране нет статус-бара). + /// + /// Спиннер — единственный виджет, добавленный напрямую (без `ui.horizontal`): + /// `ui.horizontal` по умолчанию растягивает свой прямоугольник на всю + /// доступную ширину панели, и `vertical_centered` центрирует уже растянутый + /// на всю ширину блок — то есть фактически не центрирует ничего, и + /// содержимое просто прилипает к левому краю. Одиночный виджет всегда + /// имеет свой собственный размер, поэтому центрируется корректно. + fn connect_status(&self, ui: &mut egui::Ui) { + if self.state.connect_loading { + ui.add_space(14.0); + ui.spinner(); + } else if !self.state.connect_error.is_empty() { + ui.add_space(14.0); + ui.label( + RichText::new(&self.state.connect_error) + .color(RED) + .size(11.5), + ); + } + } + + fn recent_connections(&mut self, ui: &mut egui::Ui) { + if self.state.history.is_empty() { + return; + } + let list_w = 320.0_f32.min(ui.available_width() - 40.0); + + ui.label( + RichText::new("RECENT CONNECTIONS") + .color(TEXT_HINT) + .size(10.5) + .strong(), + ); + ui.add_space(8.0); + + egui::Frame::none().show(ui, |ui| { + ui.set_width(list_w); + let history: Vec<_> = self.state.history.iter().take(8).cloned().collect(); + for entry in &history { + let label = format!("{}@{}:{}", entry.user, entry.host, entry.port); + let resp = clickable_row(ui, false, 34.0, |ui| { + icons::icon(ui, Icon::ServerSquare, 13.0, TEXT_DIM); + ui.add_space(8.0); + ui.label(RichText::new(&label).color(TEXT_PRIMARY).size(12.5)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(RichText::new(&entry.time).color(TEXT_HINT).size(10.5)); + }); + }); + if resp.clicked() { + self.state.pending_history_reconnect = Some(entry.clone()); + } + crate::ui::widgets::context_menu::context_menu(&resp, |ui| { + if ui.button("Edit").clicked() { + connection::edit_history_entry(&mut self.state, entry); + ui.close_menu(); + } + if ui.button("Save").clicked() { + self.state.pending_history_save = Some(entry.clone()); + ui.close_menu(); + } + }); + } + }); + } +} diff --git a/src/ui/dialogs/connection.rs b/src/ui/dialogs/connection.rs deleted file mode 100644 index 0da451a..0000000 --- a/src/ui/dialogs/connection.rs +++ /dev/null @@ -1,426 +0,0 @@ -use egui::{Color32, RichText, TextEdit, Ui}; -use std::sync::Arc; - -use crate::domain::connection::{ConnectionParams, Protocol}; -use crate::domain::file_entry::FileEntry; -use crate::fs::remote::RemoteRegistry; -use crate::protocols::{RemoteFs, ftp::FtpClient, sftp::SftpClient}; -use crate::storage::keychain; -use crate::ui::state::{AppState, PendingConnect}; -use crate::ui::theme::*; - -pub fn render( - ctx: &egui::Context, - state: &mut AppState, - registry: &Arc, - rt_handle: &tokio::runtime::Handle, -) { - let protocols = ["SFTP", "FTP", "FTPS"]; - let default_ports: [u16; 3] = [22, 21, 990]; - - // Центрируем окно - let screen = ctx.screen_rect(); - let win_size = egui::vec2(380.0, 340.0); - let win_pos = egui::pos2( - (screen.width() - win_size.x) * 0.5, - (screen.height() - win_size.y) * 0.4, - ); - - egui::Area::new(egui::Id::new("connect_overlay")) - .fixed_pos(egui::Pos2::ZERO) - .order(egui::Order::Background) - .show(ctx, |ui| { - let r = ui.allocate_rect(screen, egui::Sense::click()); - ui.painter() - .rect_filled(screen, 0.0, Color32::from_black_alpha(160)); - if r.clicked() && !state.connect_loading { - state.show_connect_dialog = false; - } - }); - - egui::Area::new(egui::Id::new("connect_dialog")) - .fixed_pos(win_pos) - .order(egui::Order::Foreground) - .show(ctx, |ui| { - egui::Frame::none() - .fill(BG_PANEL) - .stroke(egui::Stroke::new(1.0, BORDER)) - .rounding(RADIUS_LG) - .shadow(egui::Shadow { - offset: egui::vec2(0.0, 8.0), - blur: 24.0, - spread: 0.0, - color: Color32::from_black_alpha(120), - }) - .inner_margin(egui::Margin::same(24.0)) - .show(ui, |ui| { - ui.set_width(win_size.x - 48.0); - - // Заголовок - ui.horizontal(|ui| { - ui.label( - RichText::new("New Connection") - .color(TEXT_PRIMARY) - .size(16.0) - .strong(), - ); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let close = - egui::Button::new(RichText::new("×").color(TEXT_HINT).size(14.0)) - .fill(Color32::TRANSPARENT); - if ui.add(close).clicked() { - state.show_connect_dialog = false; - } - }); - }); - - ui.add_space(16.0); - - // Протокол — переключатели - ui.horizontal(|ui| { - for (i, p) in protocols.iter().enumerate() { - let active = state.connect_protocol == i; - let bg = if active { ACCENT_DIM } else { BG_CONTENT }; - let tc = if active { TEXT_PRIMARY } else { TEXT_DIM }; - let btn = egui::Button::new(RichText::new(*p).color(tc).size(11.5)) - .fill(bg) - .rounding(RADIUS_MD) - .min_size(egui::vec2(64.0, 28.0)); - if ui.add(btn).clicked() { - state.connect_protocol = i; - state.connect_port = default_ports[i].to_string(); - } - } - }); - - ui.add_space(14.0); - - // Поля ввода — Host / Username+Port / Password, как в макете - let field_w = ui.available_width(); - - field(ui, "Host", &mut state.connect_host, field_w, false); - ui.add_space(8.0); - - ui.horizontal(|ui| { - let user_w = field_w - 80.0 - 8.0; - field(ui, "Username", &mut state.connect_user, user_w, false); - ui.add_space(8.0); - field(ui, "Port", &mut state.connect_port, 80.0, false); - }); - ui.add_space(8.0); - - field(ui, "Password", &mut state.connect_pass, field_w, true); - - if state.connect_protocol == 0 { - ui.add_space(8.0); - ui.horizontal(|ui| { - field( - ui, - "Key path (optional)", - &mut state.connect_key_path, - field_w - 80.0 - 8.0, - false, - ); - ui.add_space(8.0); - // Browse placeholder — open system picker if rfd added - let browse = egui::Button::new( - RichText::new("Browse").color(TEXT_DIM).size(11.0), - ) - .fill(BG_CONTENT) - .min_size(egui::vec2(72.0, 32.0)) - .rounding(RADIUS_MD); - ui.add_enabled(false, browse); - }); - } - - ui.add_space(8.0); - field( - ui, - "Label (optional)", - &mut state.connect_label, - field_w, - false, - ); - - // Ошибка - if !state.connect_error.is_empty() { - ui.add_space(10.0); - egui::Frame::none() - .fill(Color32::from_rgb(42, 27, 22)) - .stroke(egui::Stroke::new(1.0, Color32::from_rgb(70, 40, 34))) - .rounding(RADIUS_MD) - .inner_margin(egui::Margin::symmetric(10.0, 8.0)) - .show(ui, |ui| { - ui.horizontal(|ui| { - crate::ui::icons::icon( - ui, - crate::ui::icons::Icon::DangerTriangle, - 14.0, - RED, - ); - ui.add_space(6.0); - ui.label( - RichText::new(&state.connect_error).color(RED).size(11.0), - ); - }); - }); - } - - ui.add_space(16.0); - - // Кнопки - ui.horizontal(|ui| { - let cancel = - egui::Button::new(RichText::new("Cancel").color(TEXT_DIM).size(12.5)) - .fill(Color32::TRANSPARENT) - .rounding(RADIUS_MD) - .min_size(egui::vec2(90.0, 30.0)); - if ui.add(cancel).clicked() && !state.connect_loading { - state.show_connect_dialog = false; - } - - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.add_enabled_ui(!state.connect_loading, |ui| { - let connect_label = if state.connect_loading { - "Connecting…" - } else { - "Connect" - }; - let connect_btn = egui::Button::new( - RichText::new(connect_label) - .color(ON_ACCENT) - .size(12.5) - .strong(), - ) - .fill(ACCENT) - .rounding(RADIUS_MD) - .min_size(egui::vec2(110.0, 30.0)); - - if ui.add(connect_btn).clicked() { - do_connect(state, registry, rt_handle); - } - }); - - if state.connect_loading { - ui.add_space(8.0); - ui.spinner(); - } - }); - }); - }); - }); -} - -fn field(ui: &mut Ui, label: &str, value: &mut String, width: f32, password: bool) { - ui.label(RichText::new(label).color(TEXT_DIM).size(11.0)); - let te = TextEdit::singleline(value) - .password(password) - .desired_width(width) - .margin(egui::Margin::symmetric(8.0, 6.0)) - .font(egui::TextStyle::Body); - ui.add(te); -} - -fn do_connect( - state: &mut AppState, - registry: &Arc, - rt_handle: &tokio::runtime::Handle, -) { - state.connect_error.clear(); - - if state.connect_host.trim().is_empty() { - state.connect_error = "Host is required.".into(); - return; - } - if state.connect_user.trim().is_empty() { - state.connect_error = "Username is required.".into(); - return; - } - if !state.connect_port.trim().is_empty() && state.connect_port.trim().parse::().is_err() { - state.connect_error = "Port must be a number between 1 and 65535.".into(); - return; - } - - let protocol = match state.connect_protocol { - 0 => Protocol::Sftp, - 1 => Protocol::Ftp, - _ => Protocol::Ftps, - }; - - let port: u16 = state.connect_port.parse().unwrap_or(match protocol { - Protocol::Sftp => 22, - Protocol::Ftp => 21, - Protocol::Ftps => 990, - }); - - let params = ConnectionParams { - id: uuid::Uuid::new_v4().to_string(), - label: if state.connect_label.is_empty() { - format!( - "{} ({})", - state.connect_host, - match protocol { - Protocol::Sftp => "SFTP", - Protocol::Ftp => "FTP", - Protocol::Ftps => "FTPS", - } - ) - } else { - state.connect_label.clone() - }, - protocol, - host: state.connect_host.clone(), - port, - username: state.connect_user.clone(), - password: if state.connect_pass.is_empty() { - None - } else { - Some(state.connect_pass.clone()) - }, - key_path: if state.connect_key_path.is_empty() { - None - } else { - Some(state.connect_key_path.clone()) - }, - }; - - spawn_connect(state, registry, rt_handle, params); -} - -/// Общий код запуска подключения — используется и диалогом New Connection, -/// и переподключением из истории (см. `reconnect_from_history`). -pub fn spawn_connect( - state: &mut AppState, - registry: &Arc, - rt_handle: &tokio::runtime::Handle, - params: ConnectionParams, -) { - let registry = registry.clone(); - let params_clone = params.clone(); - let result = Arc::new(std::sync::Mutex::new(None)); - let result_clone = result.clone(); - - state.connect_loading = true; - rt_handle.spawn(async move { - let r = connect_async(®istry, ¶ms_clone).await; - *result_clone.lock().unwrap() = Some(r); - }); - - state.pending_connect = Some(PendingConnect { result }); -} - -/// Переподключение по клику на запись истории — пароль (если есть) уже лежит -/// в keychain под `entry.conn_id`, поэтому диалог не нужен. -pub fn reconnect_from_history( - state: &mut AppState, - registry: &Arc, - rt_handle: &tokio::runtime::Handle, - entry: &crate::ui::state::HistoryEntry, -) { - state.connect_error.clear(); - let params = ConnectionParams { - id: entry.conn_id.clone(), - label: format!("{}@{}", entry.user, entry.host), - protocol: entry.protocol.clone(), - host: entry.host.clone(), - port: entry.port, - username: entry.user.clone(), - password: None, - key_path: entry.key_path.clone(), - }; - spawn_connect(state, registry, rt_handle, params); -} - -fn protocol_index(protocol: &Protocol) -> usize { - match protocol { - Protocol::Sftp => 0, - Protocol::Ftp => 1, - Protocol::Ftps => 2, - } -} - -/// "Изменить" в меню истории — открывает диалог New Connection, предзаполненный -/// этой записью (включая пароль из keychain, если он там есть). -pub fn edit_history_entry(state: &mut AppState, entry: &crate::ui::state::HistoryEntry) { - state.connect_error.clear(); - state.connect_host = entry.host.clone(); - state.connect_user = entry.user.clone(); - state.connect_port = entry.port.to_string(); - state.connect_protocol = protocol_index(&entry.protocol); - state.connect_key_path = entry.key_path.clone().unwrap_or_default(); - state.connect_pass = keychain::get_password(&entry.conn_id).unwrap_or_default(); - state.connect_label.clear(); - state.show_connect_dialog = true; -} - -/// "Сохранить" в меню истории — превращает разовое подключение в постоянный Site. -pub fn save_history_as_site( - db: &Arc>, - sites: &mut Vec, - entry: &crate::ui::state::HistoryEntry, -) -> Result<(), String> { - let site = crate::domain::site::Site { - id: entry.conn_id.clone(), - name: format!("{}@{}", entry.user, entry.host), - protocol: entry.protocol.clone(), - host: entry.host.clone(), - port: entry.port, - username: entry.user.clone(), - key_path: entry.key_path.clone(), - folder: None, - note: None, - }; - let conn = db - .lock() - .map_err(|_| "database lock poisoned".to_string())?; - crate::storage::db::save_site(&conn, &site).map_err(|e| e.to_string())?; - drop(conn); - if let Some(existing) = sites.iter_mut().find(|s| s.id == site.id) { - *existing = site; - } else { - sites.push(site); - } - Ok(()) -} - -async fn connect_async( - registry: &RemoteRegistry, - params: &ConnectionParams, -) -> Result<(ConnectionParams, Vec), String> { - let password = if let Some(p) = ¶ms.password { - p.clone() - } else { - keychain::get_password(¶ms.id).map_err(|e| e.to_string())? - }; - - let fs: Arc = match params.protocol { - Protocol::Sftp => { - if let Some(key_path) = ¶ms.key_path { - Arc::new( - SftpClient::connect_key(¶ms.host, params.port, ¶ms.username, key_path) - .map_err(|e| e.to_string())?, - ) - } else { - Arc::new( - SftpClient::connect_password( - ¶ms.host, - params.port, - ¶ms.username, - &password, - ) - .map_err(|e| e.to_string())?, - ) - } - } - Protocol::Ftp => Arc::new( - FtpClient::connect(¶ms.host, params.port, ¶ms.username, &password) - .await - .map_err(|e| e.to_string())?, - ), - Protocol::Ftps => return Err("FTPS not yet implemented".into()), - }; - - registry.insert(params.id.clone(), fs.clone()); - let entries = fs.list("/").await.map_err(|e| e.to_string())?; - Ok((params.clone(), entries)) -} diff --git a/src/ui/dialogs/connection/actions.rs b/src/ui/dialogs/connection/actions.rs new file mode 100644 index 0000000..10109c4 --- /dev/null +++ b/src/ui/dialogs/connection/actions.rs @@ -0,0 +1,159 @@ +//! Логика подключения: сборка параметров и запуск async-коннекта. +use std::sync::Arc; + +use crate::domain::connection::{ConnectionParams, Protocol}; +use crate::domain::file_entry::FileEntry; +use crate::fs::remote::RemoteRegistry; +use crate::protocols::{RemoteFs, ftp::FtpClient, sftp::SftpClient}; +use crate::storage::keychain; +use crate::ui::state::{AppState, PendingConnect}; + +pub(super) fn do_connect( + state: &mut AppState, + registry: &Arc, + rt_handle: &tokio::runtime::Handle, +) { + state.connect_error.clear(); + + if state.connect_host.trim().is_empty() { + state.connect_error = "Host is required.".into(); + return; + } + if state.connect_user.trim().is_empty() { + state.connect_error = "Username is required.".into(); + return; + } + if !state.connect_port.trim().is_empty() && state.connect_port.trim().parse::().is_err() { + state.connect_error = "Port must be a number between 1 and 65535.".into(); + return; + } + + let protocol = match state.connect_protocol { + 0 => Protocol::Sftp, + 1 => Protocol::Ftp, + _ => Protocol::Ftps, + }; + + let port: u16 = state.connect_port.parse().unwrap_or(match protocol { + Protocol::Sftp => 22, + Protocol::Ftp => 21, + Protocol::Ftps => 990, + }); + + let params = ConnectionParams { + id: uuid::Uuid::new_v4().to_string(), + label: if state.connect_label.is_empty() { + format!( + "{} ({})", + state.connect_host, + match protocol { + Protocol::Sftp => "SFTP", + Protocol::Ftp => "FTP", + Protocol::Ftps => "FTPS", + } + ) + } else { + state.connect_label.clone() + }, + protocol, + host: state.connect_host.clone(), + port, + username: state.connect_user.clone(), + password: if state.connect_pass.is_empty() { + None + } else { + Some(state.connect_pass.clone()) + }, + key_path: if state.connect_key_path.is_empty() { + None + } else { + Some(state.connect_key_path.clone()) + }, + }; + + spawn_connect(state, registry, rt_handle, params); +} + +/// Общий код запуска подключения — используется и диалогом New Connection, +/// и переподключением из истории (см. `reconnect_from_history`). +pub fn spawn_connect( + state: &mut AppState, + registry: &Arc, + rt_handle: &tokio::runtime::Handle, + params: ConnectionParams, +) { + let registry = registry.clone(); + let params_clone = params.clone(); + let result = Arc::new(std::sync::Mutex::new(None)); + let result_clone = result.clone(); + + state.connect_loading = true; + rt_handle.spawn(async move { + let r = connect_async(®istry, ¶ms_clone).await; + *result_clone.lock().unwrap() = Some(r); + }); + + state.pending_connect = Some(PendingConnect { result }); +} + +async fn connect_async( + registry: &RemoteRegistry, + params: &ConnectionParams, +) -> Result<(ConnectionParams, Vec), String> { + // Пароль резолвим лениво по месту: для ключевой/автоматической SSH-аутен- + // тификации он не нужен, и обязательный поход в keychain раньше валил + // подключение по ключу с пустым паролем ("нет пароля в keychain"). + let stored_password = || -> Option { + params + .password + .clone() + .or_else(|| keychain::get_password(¶ms.id).ok()) + }; + + let fs: Arc = match params.protocol { + Protocol::Sftp => { + if let Some(key_path) = ¶ms.key_path { + // Явно выбранный ключ; пароль из формы (если есть) — passphrase. + Arc::new( + SftpClient::connect_key( + ¶ms.host, + params.port, + ¶ms.username, + key_path, + params.password.as_deref(), + ) + .map_err(|e| e.to_string())?, + ) + } else if let Some(password) = stored_password() { + Arc::new( + SftpClient::connect_password( + ¶ms.host, + params.port, + ¶ms.username, + &password, + ) + .map_err(|e| e.to_string())?, + ) + } else { + // Пароль пуст и ключ не выбран — ищем ключ сами (агент, ~/.ssh). + Arc::new( + SftpClient::connect_auto(¶ms.host, params.port, ¶ms.username) + .map_err(|e| e.to_string())?, + ) + } + } + Protocol::Ftp => { + let password = stored_password().ok_or("Password is required for FTP connections.")?; + Arc::new( + FtpClient::connect(¶ms.host, params.port, ¶ms.username, &password) + .await + .map_err(|e| e.to_string())?, + ) + } + Protocol::Ftps => return Err("FTPS not yet implemented".into()), + }; + + registry.insert(params.id.clone(), fs.clone()); + let entries = fs.list("/").await.map_err(|e| e.to_string())?; + Ok((params.clone(), entries)) +} diff --git a/src/ui/dialogs/connection/form.rs b/src/ui/dialogs/connection/form.rs new file mode 100644 index 0000000..9fc6153 --- /dev/null +++ b/src/ui/dialogs/connection/form.rs @@ -0,0 +1,173 @@ +//! Поля диалога подключения: сегментированный выбор протокола, текстовые +//! поля с рамкой и подсветкой фокуса, блок ошибки. +use egui::{Color32, RichText, Stroke, TextEdit, Ui}; + +use crate::ui::state::AppState; +use crate::ui::theme::*; +use crate::ui::widgets::button; + +const FIELD_HEIGHT: f32 = 32.0; + +/// Сегментированный переключатель протокола — три равные по ширине секции +/// в одной рамке, а не отдельные кнопки вразнобой (было похоже на кривой +/// список из-за неравных промежутков и незаполненного остатка ширины). +pub(super) fn protocol_row( + ui: &mut Ui, + state: &mut AppState, + protocols: &[&str; 3], + default_ports: &[u16; 3], +) { + let gap = 4.0; + let total_w = ui.available_width(); + let seg_w = ((total_w - gap * 2.0 - 6.0) / 3.0).max(0.0); + + egui::Frame::none() + .fill(BG_CONTENT) + .stroke(Stroke::new(1.0, BORDER)) + .rounding(RADIUS_MD) + .inner_margin(egui::Margin::same(3.0)) + .show(ui, |ui| { + ui.spacing_mut().item_spacing.x = gap; + ui.horizontal(|ui| { + for (i, p) in protocols.iter().enumerate() { + let active = state.connect_protocol == i; + let tc = if active { ON_ACCENT } else { TEXT_DIM }; + let btn = egui::Button::new(RichText::new(*p).color(tc).size(11.5).strong()) + .rounding(RADIUS_SM) + .min_size(egui::vec2(seg_w, 26.0)); + let resp = if active { + button::accent(ui, btn) + } else { + button::ghost(ui, btn) + }; + if resp.clicked() { + state.connect_protocol = i; + state.connect_port = default_ports[i].to_string(); + } + } + }); + }); +} + +pub(super) fn fields(ui: &mut Ui, state: &mut AppState) { + let field_w = ui.available_width(); + + field(ui, "Host", &mut state.connect_host, field_w, false); + ui.add_space(10.0); + + ui.horizontal(|ui| { + let user_w = (field_w - 80.0 - 8.0).max(0.0); + field(ui, "Username", &mut state.connect_user, user_w, false); + ui.add_space(8.0); + field(ui, "Port", &mut state.connect_port, 80.0, false); + }); + ui.add_space(10.0); + + field(ui, "Password", &mut state.connect_pass, field_w, true); + + if state.connect_protocol == 0 { + ui.add_space(3.0); + ui.label( + RichText::new("Leave empty to sign in with an SSH key (agent or ~/.ssh).") + .color(TEXT_HINT) + .size(10.0), + ); + ui.add_space(10.0); + key_field(ui, state, field_w); + } + + ui.add_space(10.0); + field( + ui, + "Label (optional)", + &mut state.connect_label, + field_w, + false, + ); +} + +pub(super) fn error_box(ui: &mut Ui, state: &AppState) { + if state.connect_error.is_empty() { + return; + } + let bg = Color32::from_rgba_unmultiplied(RED.r(), RED.g(), RED.b(), 26); + let border = Color32::from_rgba_unmultiplied(RED.r(), RED.g(), RED.b(), 100); + ui.add_space(12.0); + egui::Frame::none() + .fill(bg) + .stroke(Stroke::new(1.0, border)) + .rounding(RADIUS_MD) + .inner_margin(egui::Margin::symmetric(10.0, 8.0)) + .show(ui, |ui| { + ui.horizontal(|ui| { + crate::ui::icons::icon(ui, crate::ui::icons::Icon::DangerTriangle, 14.0, RED); + ui.add_space(6.0); + ui.label(RichText::new(&state.connect_error).color(RED).size(11.0)); + }); + }); +} + +/// Поле пути к SSH-ключу с кнопкой Browse… (нативный диалог выбора файла). +fn key_field(ui: &mut Ui, state: &mut AppState, field_w: f32) { + const BROWSE_W: f32 = 72.0; + ui.label( + RichText::new("Key file (optional)") + .color(TEXT_DIM) + .size(11.0), + ); + ui.add_space(4.0); + ui.horizontal(|ui| { + field_input( + ui, + &mut state.connect_key_path, + (field_w - BROWSE_W - 8.0).max(0.0), + false, + ); + ui.add_space(8.0); + let browse = egui::Button::new(RichText::new("Browse…").color(TEXT_DIM).size(11.5)) + .rounding(RADIUS_MD) + .min_size(egui::vec2(BROWSE_W, FIELD_HEIGHT)); + if button::ghost(ui, browse).clicked() { + let ssh_dir = dirs::home_dir() + .map(|h| h.join(".ssh")) + .filter(|d| d.is_dir()) + .or_else(dirs::home_dir); + let mut dlg = rfd::FileDialog::new().set_title("Choose SSH key"); + if let Some(dir) = ssh_dir { + dlg = dlg.set_directory(dir); + } + if let Some(path) = dlg.pick_file() { + state.connect_key_path = path.display().to_string(); + } + } + }); +} + +/// Текстовое поле с меткой, рамкой и подсветкой фокуса/hover в акцентном цвете — +/// раньше поля были обычными TextEdit без рамки, из-за чего вся форма выглядела +/// незакреплённой/неровной на фоне остального интерфейса с рамками у карточек. +fn field(ui: &mut Ui, label: &str, value: &mut String, width: f32, password: bool) { + ui.label(RichText::new(label).color(TEXT_DIM).size(11.0)); + ui.add_space(4.0); + field_input(ui, value, width, password); +} + +/// Само поле ввода без метки (для составных рядов вроде «путь + Browse»). +fn field_input(ui: &mut Ui, value: &mut String, width: f32, password: bool) { + ui.scope(|ui| { + let w = &mut ui.visuals_mut().widgets; + w.inactive.rounding = RADIUS_MD.into(); + w.inactive.bg_stroke = Stroke::new(1.0, BORDER); + w.hovered.rounding = RADIUS_MD.into(); + w.hovered.bg_stroke = Stroke::new(1.0, ACCENT_DIM); + ui.visuals_mut().selection.stroke = Stroke::new(1.0, ACCENT); + + let te = TextEdit::singleline(value) + .password(password) + .desired_width(width) + .min_size(egui::vec2(width, FIELD_HEIGHT)) + .margin(egui::Margin::symmetric(10.0, 8.0)) + .font(egui::TextStyle::Body); + ui.add(te); + }); +} diff --git a/src/ui/dialogs/connection/history.rs b/src/ui/dialogs/connection/history.rs new file mode 100644 index 0000000..aea82dd --- /dev/null +++ b/src/ui/dialogs/connection/history.rs @@ -0,0 +1,82 @@ +//! Действия над записями истории: переподключение, «Изменить», «Сохранить» как Site. +use std::sync::Arc; + +use super::actions::spawn_connect; +use crate::domain::connection::{ConnectionParams, Protocol}; +use crate::fs::remote::RemoteRegistry; +use crate::storage::keychain; +use crate::ui::state::AppState; + +fn protocol_index(protocol: &Protocol) -> usize { + match protocol { + Protocol::Sftp => 0, + Protocol::Ftp => 1, + Protocol::Ftps => 2, + } +} + +/// Переподключение по клику на запись истории — пароль (если есть) уже лежит +/// в keychain под `entry.conn_id`, поэтому диалог не нужен. +pub fn reconnect_from_history( + state: &mut AppState, + registry: &Arc, + rt_handle: &tokio::runtime::Handle, + entry: &crate::ui::state::HistoryEntry, +) { + state.connect_error.clear(); + let params = ConnectionParams { + id: entry.conn_id.clone(), + label: format!("{}@{}", entry.user, entry.host), + protocol: entry.protocol.clone(), + host: entry.host.clone(), + port: entry.port, + username: entry.user.clone(), + password: None, + key_path: entry.key_path.clone(), + }; + spawn_connect(state, registry, rt_handle, params); +} + +/// «Изменить» в меню истории — открывает диалог New Connection, предзаполненный +/// этой записью (включая пароль из keychain, если он там есть). +pub fn edit_history_entry(state: &mut AppState, entry: &crate::ui::state::HistoryEntry) { + state.connect_error.clear(); + state.connect_host = entry.host.clone(); + state.connect_user = entry.user.clone(); + state.connect_port = entry.port.to_string(); + state.connect_protocol = protocol_index(&entry.protocol); + state.connect_key_path = entry.key_path.clone().unwrap_or_default(); + state.connect_pass = keychain::get_password(&entry.conn_id).unwrap_or_default(); + state.connect_label.clear(); + state.show_connect_dialog = true; +} + +/// «Сохранить» в меню истории — превращает разовое подключение в постоянный Site. +pub fn save_history_as_site( + db: &Arc>, + sites: &mut Vec, + entry: &crate::ui::state::HistoryEntry, +) -> Result<(), String> { + let site = crate::domain::site::Site { + id: entry.conn_id.clone(), + name: format!("{}@{}", entry.user, entry.host), + protocol: entry.protocol.clone(), + host: entry.host.clone(), + port: entry.port, + username: entry.user.clone(), + key_path: entry.key_path.clone(), + folder: None, + note: None, + }; + let conn = db + .lock() + .map_err(|_| "database lock poisoned".to_string())?; + crate::storage::db::save_site(&conn, &site).map_err(|e| e.to_string())?; + drop(conn); + if let Some(existing) = sites.iter_mut().find(|s| s.id == site.id) { + *existing = site; + } else { + sites.push(site); + } + Ok(()) +} diff --git a/src/ui/dialogs/connection/mod.rs b/src/ui/dialogs/connection/mod.rs new file mode 100644 index 0000000..5c081c6 --- /dev/null +++ b/src/ui/dialogs/connection/mod.rs @@ -0,0 +1,9 @@ +//! Диалог «New Connection» и связанные действия (подключение, история → Site). +mod actions; +mod form; +mod history; +mod view; + +pub use actions::spawn_connect; +pub use history::{edit_history_entry, reconnect_from_history, save_history_as_site}; +pub use view::render; diff --git a/src/ui/dialogs/connection/view.rs b/src/ui/dialogs/connection/view.rs new file mode 100644 index 0000000..ff60c60 --- /dev/null +++ b/src/ui/dialogs/connection/view.rs @@ -0,0 +1,137 @@ +//! Отрисовка диалога «New Connection» (оболочка окна; поля — в [`super::form`]). +use egui::{Align, Color32, Layout, RichText, Ui}; +use std::sync::Arc; + +use super::actions::do_connect; +use super::form; +// Алиас `tr`: параметр `t` в этой функции — анимационная «открытость» (f32). +use crate::i18n::t as tr; +use crate::fs::remote::RemoteRegistry; +use crate::ui::state::AppState; +use crate::ui::theme::*; +use crate::ui::widgets::{button, overlay}; + +const DIALOG_WIDTH: f32 = 332.0; + +pub fn render( + ctx: &egui::Context, + state: &mut AppState, + registry: &Arc, + rt_handle: &tokio::runtime::Handle, + t: f32, +) { + let protocols = ["SFTP", "FTP", "FTPS"]; + let default_ports: [u16; 3] = [22, 21, 990]; + + // Клик по затемнению закрывает диалог (кроме момента подключения). + if overlay::dim(ctx, "connect_overlay", t) && !state.connect_loading { + state.show_connect_dialog = false; + } + + // `egui::Window` с anchor(CENTER_CENTER) авто-подгоняет размер под контент + // и всегда центрирует его заново — в отличие от Area с заранее посчитанным + // размером окна, где реальная высота полей могла разойтись с предположенной + // и диалог "уезжал" от центра. + egui::Window::new("connect_dialog") + .title_bar(false) + .collapsible(false) + .resizable(false) + .movable(false) + .anchor(egui::Align2::CENTER_CENTER, overlay::slide_offset(t)) + .interactable(t >= 1.0) + .frame( + egui::Frame::none() + .fill(BG_PANEL) + .stroke(egui::Stroke::new(1.0, BORDER)) + .rounding(RADIUS_LG) + .shadow(egui::Shadow { + offset: egui::vec2(0.0, 10.0), + blur: 28.0, + spread: 0.0, + color: Color32::from_black_alpha(130), + }) + .inner_margin(egui::Margin::same(22.0)), + ) + .show(ctx, |ui| { + ui.set_opacity(overlay::ease_out(t)); + ui.set_width(DIALOG_WIDTH); + header(ui, state); + ui.add_space(18.0); + form::protocol_row(ui, state, &protocols, &default_ports); + ui.add_space(16.0); + form::fields(ui, state); + form::error_box(ui, state); + ui.add_space(18.0); + buttons(ui, state, registry, rt_handle); + + // Enter из любого поля запускает подключение (submit формы). + let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); + if enter && t >= 1.0 && state.show_connect_dialog && !state.connect_loading { + do_connect(state, registry, rt_handle); + } + }); +} + +fn header(ui: &mut Ui, state: &mut AppState) { + ui.horizontal(|ui| { + crate::ui::icons::icon(ui, crate::ui::icons::Icon::ServerSquare, 15.0, ACCENT); + ui.add_space(8.0); + ui.label( + RichText::new("New Connection") + .color(TEXT_PRIMARY) + .size(15.5) + .strong(), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let close = egui::Button::new(RichText::new("✕").color(TEXT_DIM).size(12.0)) + .rounding(RADIUS_SM) + .min_size(egui::vec2(24.0, 24.0)); + if button::ghost(ui, close).clicked() { + state.show_connect_dialog = false; + } + }); + }); +} + +fn buttons( + ui: &mut Ui, + state: &mut AppState, + registry: &Arc, + rt_handle: &tokio::runtime::Handle, +) { + ui.horizontal(|ui| { + let cancel = egui::Button::new(RichText::new("Cancel").color(TEXT_DIM).size(12.5)) + .rounding(RADIUS_MD) + .min_size(egui::vec2(90.0, 32.0)); + if button::ghost(ui, cancel).clicked() && !state.connect_loading { + state.show_connect_dialog = false; + } + + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.add_enabled_ui(!state.connect_loading, |ui| { + let connect_label = if state.connect_loading { + "Connecting…" + } else { + "Connect" + }; + let connect_btn = egui::Button::new( + RichText::new(connect_label) + .color(ON_ACCENT) + .size(12.5) + .strong(), + ) + .rounding(RADIUS_MD) + .min_size(egui::vec2(114.0, 32.0)); + + if button::accent(ui, connect_btn).clicked() { + do_connect(state, registry, rt_handle); + } + }); + + if state.connect_loading { + ui.add_space(8.0); + ui.spinner(); + } + }); + }); +} diff --git a/src/ui/dock.rs b/src/ui/dock.rs new file mode 100644 index 0000000..bd1b122 --- /dev/null +++ b/src/ui/dock.rs @@ -0,0 +1,102 @@ +//! Модульная система панелей: Local и каждое подключение — перетаскиваемые, +//! разбиваемые и закрываемые вкладки внутри одной рабочей области (egui_dock). +use crate::domain::connection::ConnectionStatus; +use crate::fs::remote::RemoteRegistry; +use crate::transfer::queue::TransferQueue; +use crate::ui::panels::{local_pane, remote_pane}; +use crate::ui::state::AppState; +use crate::ui::theme::*; +use egui::{RichText, Ui, WidgetText}; +use std::sync::{Arc, Mutex}; + +/// Одна вкладка рабочей области — локальная ФС или конкретное удалённое соединение +/// (по стабильному id, а не индексу — индексы сдвигаются при закрытии других вкладок). +#[derive(Clone, PartialEq, Eq)] +pub enum PaneTab { + Local, + Remote(String), +} + +pub struct AppTabViewer<'a> { + pub state: &'a mut AppState, + pub queue: &'a TransferQueue, + pub registry: &'a Arc, + pub rt_handle: &'a tokio::runtime::Handle, + pub db: &'a Arc>, + /// Если пользователь закрыл вкладку с удалённым соединением — сюда кладём его id, + /// вызывающий код (app.rs) разрывает соединение после `show_inside`. + pub closed_remote: &'a mut Option, +} + +impl egui_dock::TabViewer for AppTabViewer<'_> { + type Tab = PaneTab; + + fn title(&mut self, tab: &mut PaneTab) -> WidgetText { + match tab { + PaneTab::Local => RichText::new("Local").into(), + PaneTab::Remote(id) => match self.state.tabs.iter().find(|t| &t.id == id) { + Some(t) => { + let (dot, col) = match t.status { + ConnectionStatus::Connected => ("● ", GREEN), + ConnectionStatus::Connecting => ("◐ ", YELLOW), + ConnectionStatus::Error(_) => ("× ", RED), + ConnectionStatus::Disconnected => ("○ ", TEXT_HINT), + }; + RichText::new(format!("{dot}{}", t.label)).color(col).into() + } + None => RichText::new("Remote").color(TEXT_HINT).into(), + }, + } + } + + fn id(&mut self, tab: &mut PaneTab) -> egui::Id { + match tab { + PaneTab::Local => egui::Id::new("pane_tab_local"), + PaneTab::Remote(id) => egui::Id::new(("pane_tab_remote", id.clone())), + } + } + + fn ui(&mut self, ui: &mut Ui, tab: &mut PaneTab) { + match tab { + PaneTab::Local => { + local_pane::render( + ui, + self.state, + self.queue, + self.registry, + self.rt_handle, + self.db, + ); + } + PaneTab::Remote(id) => { + if let Some(idx) = self.state.tabs.iter().position(|t| &t.id == id) { + self.state.active_tab = idx; + remote_pane::render( + ui, + self.state, + idx, + self.registry, + self.rt_handle, + self.queue, + ); + } else { + ui.vertical_centered(|ui| { + ui.add_space(24.0); + ui.label(RichText::new("Disconnected").color(TEXT_HINT)); + }); + } + } + } + } + + fn closeable(&mut self, tab: &mut PaneTab) -> bool { + matches!(tab, PaneTab::Remote(_)) + } + + fn on_close(&mut self, tab: &mut PaneTab) -> bool { + if let PaneTab::Remote(id) = tab { + *self.closed_remote = Some(id.clone()); + } + true + } +} diff --git a/src/ui/icons.rs b/src/ui/icons.rs index b7681a3..b3a80a6 100644 --- a/src/ui/icons.rs +++ b/src/ui/icons.rs @@ -3,6 +3,7 @@ use egui::{Color32, Ui}; macro_rules! icon_set { ($($key:ident => $file:literal),* $(,)?) => { + #[derive(Clone, Copy, PartialEq, Eq)] pub enum Icon { $($key),* } fn bytes(icon: &Icon) -> &'static [u8] { diff --git a/src/ui/icons/svg/add-circle-linear.svg b/src/ui/icons/svg/add-circle-linear.svg index 34dad30..f4eb551 100644 --- a/src/ui/icons/svg/add-circle-linear.svg +++ b/src/ui/icons/svg/add-circle-linear.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/ui/icons/svg/add-square-linear.svg b/src/ui/icons/svg/add-square-linear.svg index 3293451..6a44752 100644 --- a/src/ui/icons/svg/add-square-linear.svg +++ b/src/ui/icons/svg/add-square-linear.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/ui/icons/svg/alt-arrow-down-linear.svg b/src/ui/icons/svg/alt-arrow-down-linear.svg index 77a426d..072b30f 100644 --- a/src/ui/icons/svg/alt-arrow-down-linear.svg +++ b/src/ui/icons/svg/alt-arrow-down-linear.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/ui/icons/svg/alt-arrow-up-linear.svg b/src/ui/icons/svg/alt-arrow-up-linear.svg index 5644a65..cf5c621 100644 --- a/src/ui/icons/svg/alt-arrow-up-linear.svg +++ b/src/ui/icons/svg/alt-arrow-up-linear.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/ui/icons/svg/clock-circle-linear.svg b/src/ui/icons/svg/clock-circle-linear.svg index 550f088..dbd98e9 100644 --- a/src/ui/icons/svg/clock-circle-linear.svg +++ b/src/ui/icons/svg/clock-circle-linear.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/ui/icons/svg/close-circle-linear.svg b/src/ui/icons/svg/close-circle-linear.svg index a4c1e86..678c2d9 100644 --- a/src/ui/icons/svg/close-circle-linear.svg +++ b/src/ui/icons/svg/close-circle-linear.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/ui/icons/svg/database-linear.svg b/src/ui/icons/svg/database-linear.svg index a74a9bb..b496ead 100644 --- a/src/ui/icons/svg/database-linear.svg +++ b/src/ui/icons/svg/database-linear.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/ui/icons/svg/download-linear.svg b/src/ui/icons/svg/download-linear.svg index 3f9cffe..4e4d84d 100644 --- a/src/ui/icons/svg/download-linear.svg +++ b/src/ui/icons/svg/download-linear.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/ui/icons/svg/folder-with-files-linear.svg b/src/ui/icons/svg/folder-with-files-linear.svg index 5c350a8..7db7ad4 100644 --- a/src/ui/icons/svg/folder-with-files-linear.svg +++ b/src/ui/icons/svg/folder-with-files-linear.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/ui/icons/svg/history-linear.svg b/src/ui/icons/svg/history-linear.svg index 27c1774..8bed5a7 100644 --- a/src/ui/icons/svg/history-linear.svg +++ b/src/ui/icons/svg/history-linear.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/ui/icons/svg/pause-circle-linear.svg b/src/ui/icons/svg/pause-circle-linear.svg index d85a057..4c64ceb 100644 --- a/src/ui/icons/svg/pause-circle-linear.svg +++ b/src/ui/icons/svg/pause-circle-linear.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/ui/icons/svg/pen-linear.svg b/src/ui/icons/svg/pen-linear.svg index 6133da3..8ceb9d2 100644 --- a/src/ui/icons/svg/pen-linear.svg +++ b/src/ui/icons/svg/pen-linear.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/ui/icons/svg/play-circle-linear.svg b/src/ui/icons/svg/play-circle-linear.svg index 5566507..5973b24 100644 --- a/src/ui/icons/svg/play-circle-linear.svg +++ b/src/ui/icons/svg/play-circle-linear.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/ui/icons/svg/server-square-linear.svg b/src/ui/icons/svg/server-square-linear.svg index ec11364..1a05272 100644 --- a/src/ui/icons/svg/server-square-linear.svg +++ b/src/ui/icons/svg/server-square-linear.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/ui/icons/svg/ssd-square-linear.svg b/src/ui/icons/svg/ssd-square-linear.svg index e84f97c..bbad24a 100644 --- a/src/ui/icons/svg/ssd-square-linear.svg +++ b/src/ui/icons/svg/ssd-square-linear.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/ui/icons/svg/trash-bin-minimalistic-linear.svg b/src/ui/icons/svg/trash-bin-minimalistic-linear.svg index 75d1a4e..692b80c 100644 --- a/src/ui/icons/svg/trash-bin-minimalistic-linear.svg +++ b/src/ui/icons/svg/trash-bin-minimalistic-linear.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/ui/icons/svg/upload-linear.svg b/src/ui/icons/svg/upload-linear.svg index 1acf54d..c1b8026 100644 --- a/src/ui/icons/svg/upload-linear.svg +++ b/src/ui/icons/svg/upload-linear.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/ui/menu.rs b/src/ui/menu.rs index 5594189..b97d5a7 100644 --- a/src/ui/menu.rs +++ b/src/ui/menu.rs @@ -6,6 +6,18 @@ use crate::ui::state::AppState; mod ids { pub const SETTINGS: &str = "loflum-settings"; pub const NEW_CONNECTION: &str = "loflum-new-connection"; + pub const SHOW_TOOLBAR: &str = "loflum-show-toolbar"; + pub const SHOW_SIDEBAR: &str = "loflum-show-sidebar"; + pub const SHOW_STATUS_BAR: &str = "loflum-show-status-bar"; + pub const SHOW_QUEUE: &str = "loflum-show-queue"; +} + +// muda использует Rc внутри (не Send/Sync) — держим пункты меню, отражающие +// видимость панелей, в thread_local, чтобы синхронизировать галочки с AppState. +#[cfg(target_os = "macos")] +thread_local! { + static PANEL_CHECK_ITEMS: std::cell::RefCell> = + const { std::cell::RefCell::new(None) }; } /// Строит нативное меню macOS: App-меню (с "Settings…"), File, Edit, Window. @@ -60,14 +72,30 @@ pub fn setup_native_menu() { &PredefinedMenuItem::select_all(None), ]); + let show_toolbar = muda::CheckMenuItem::with_id(ids::SHOW_TOOLBAR, "Toolbar", true, true, None); + let show_sidebar = muda::CheckMenuItem::with_id(ids::SHOW_SIDEBAR, "Sidebar", true, true, None); + let show_status_bar = + muda::CheckMenuItem::with_id(ids::SHOW_STATUS_BAR, "Status Bar", true, true, None); + let show_queue = + muda::CheckMenuItem::with_id(ids::SHOW_QUEUE, "Transfer Queue", true, true, None); + let window_menu = Submenu::new("Window", true); let _ = window_menu.append_items(&[ &PredefinedMenuItem::minimize(None), &PredefinedMenuItem::maximize(None), &PredefinedMenuItem::separator(), + &show_toolbar, + &show_sidebar, + &show_status_bar, + &show_queue, + &PredefinedMenuItem::separator(), &PredefinedMenuItem::bring_all_to_front(None), ]); + PANEL_CHECK_ITEMS.with(|cell| { + *cell.borrow_mut() = Some([show_toolbar, show_sidebar, show_status_bar, show_queue]); + }); + let _ = menu.append_items(&[&app_menu, &file_menu, &edit_menu, &window_menu]); menu.init_for_nsapp(); @@ -89,8 +117,28 @@ pub fn poll_menu_events(state: &mut AppState) { state.show_settings_dialog = true; } else if id == ids::NEW_CONNECTION { state.show_connect_dialog = true; + } else if id == ids::SHOW_TOOLBAR { + state.show_toolbar = !state.show_toolbar; + } else if id == ids::SHOW_SIDEBAR { + state.show_sidebar = !state.show_sidebar; + } else if id == ids::SHOW_STATUS_BAR { + state.show_status_bar = !state.show_status_bar; + } else if id == ids::SHOW_QUEUE { + state.show_queue_panel = !state.show_queue_panel; } } + + // Держим галочки в меню в согласии с state — независимо от того, что + // именно их поменяло (клик по меню или чекбокс в Settings). + PANEL_CHECK_ITEMS.with(|cell| { + if let Some(items) = cell.borrow().as_ref() { + let [toolbar, sidebar, status_bar, queue] = items; + toolbar.set_checked(state.show_toolbar); + sidebar.set_checked(state.show_sidebar); + status_bar.set_checked(state.show_status_bar); + queue.set_checked(state.show_queue_panel); + } + }); } #[cfg(not(target_os = "macos"))] diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 8011042..a43d17d 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,8 +1,10 @@ pub mod app; pub mod dialogs; +pub mod dock; pub mod drag; pub mod icons; pub mod menu; pub mod panels; pub mod state; pub mod theme; +pub mod widgets; diff --git a/src/ui/panels/file_pane.rs b/src/ui/panels/file_pane.rs deleted file mode 100644 index 42a46cc..0000000 --- a/src/ui/panels/file_pane.rs +++ /dev/null @@ -1,398 +0,0 @@ -//! Переиспользуемая таблица файлов с нормальными колонками -use crate::domain::file_entry::{EntryKind, FileEntry}; -use crate::ui::drag::DragPayload; -use crate::ui::icons::{self, Icon, file_icon_for}; -use crate::ui::theme::*; -use egui::{Color32, Id, RichText, Ui}; - -pub fn format_size(bytes: Option) -> String { - match bytes { - None => String::new(), - Some(b) if b < 1024 => format!("{} B", b), - Some(b) if b < 1024 * 1024 => format!("{:.1} KB", b as f64 / 1024.0), - Some(b) if b < 1024 * 1024 * 1024 => format!("{:.1} MB", b as f64 / (1024.0 * 1024.0)), - Some(b) => format!("{:.2} GB", b as f64 / (1024.0 * 1024.0 * 1024.0)), - } -} - -pub fn format_time(ts: Option) -> String { - match ts.and_then(|t| chrono::DateTime::from_timestamp(t, 0)) { - Some(dt) => dt.format("%d.%m.%Y %H:%M").to_string(), - None => String::new(), - } -} - -#[derive(Clone, Copy, PartialEq)] -pub enum SortCol { - Name, - Size, - Kind, - Modified, -} - -#[derive(Clone, Copy, PartialEq)] -pub enum SortDir { - Asc, - Desc, -} - -/// Действие, запрошенное через контекстное меню (ПКМ) на строке. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum RowAction { - Open, - Rename, - Delete, - Bookmark, - CopyPath, - /// Загрузка/выгрузка этой конкретной строки — направление решает вызывающая сторона. - Transfer, -} - -pub struct FileTableResponse { - pub double_clicked: Option, - pub clicked: Option, - /// Payload, перетащенный на папку (или "..") в этой таблице — (целевая папка, что бросили). - pub dropped_on_dir: Option<(FileEntry, std::sync::Arc)>, - /// Действие из контекстного меню (ПКМ) — (строка, действие). - pub context_action: Option<(FileEntry, RowAction)>, -} - -pub fn file_table( - ui: &mut Ui, - id: &str, - entries: &[FileEntry], - selected: &mut Option, - make_drag_payload: F, - context_menu: C, -) -> FileTableResponse -where - F: Fn(&FileEntry) -> Option, - C: FnMut(&mut Ui, &FileEntry) -> Option, -{ - ui.push_id(id, |ui| { - file_table_inner(ui, id, entries, selected, make_drag_payload, context_menu) - }) - .inner -} - -fn file_table_inner( - ui: &mut Ui, - id: &str, - entries: &[FileEntry], - selected: &mut Option, - make_drag_payload: F, - mut context_menu: C, -) -> FileTableResponse -where - F: Fn(&FileEntry) -> Option, - C: FnMut(&mut Ui, &FileEntry) -> Option, -{ - // Сортировка — читаем из egui memory - let sort_id = Id::new((id, "sort")); - let (sort_col, sort_dir) = ui - .ctx() - .data_mut(|d| *d.get_temp_mut_or(sort_id, (SortCol::Name, SortDir::Asc))); - - let mut sorted = entries.to_vec(); - let dotdot_entry = sorted.iter().position(|e| e.name == ".."); - let dotdot = dotdot_entry.map(|i| sorted.remove(i)); - - sorted.sort_by(|a, b| { - // папки всегда выше - let dir_cmp = match (&b.kind, &a.kind) { - (EntryKind::Dir, EntryKind::Dir) => std::cmp::Ordering::Equal, - (EntryKind::Dir, _) => std::cmp::Ordering::Greater, - (_, EntryKind::Dir) => std::cmp::Ordering::Less, - _ => std::cmp::Ordering::Equal, - }; - if dir_cmp != std::cmp::Ordering::Equal { - return dir_cmp; - } - - let ord = match sort_col { - SortCol::Name => a.name.to_lowercase().cmp(&b.name.to_lowercase()), - SortCol::Size => a.size.unwrap_or(0).cmp(&b.size.unwrap_or(0)), - SortCol::Kind => format!("{:?}", a.kind).cmp(&format!("{:?}", b.kind)), - SortCol::Modified => a.modified.unwrap_or(0).cmp(&b.modified.unwrap_or(0)), - }; - if sort_dir == SortDir::Desc { - ord.reverse() - } else { - ord - } - }); - - if let Some(dd) = dotdot { - sorted.insert(0, dd); - } - - let mut double_clicked: Option = None; - let mut clicked: Option = None; - let mut new_sort: Option<(SortCol, SortDir)> = None; - let mut dropped_on_dir: Option<(FileEntry, std::sync::Arc)> = None; - let mut context_action: Option<(FileEntry, RowAction)> = None; - - // Заголовки колонок - let header_frame = egui::Frame::none() - .fill(BG_HEADER) - .inner_margin(egui::Margin::symmetric(12.0, 0.0)); - - header_frame.show(ui, |ui| { - let width = ui.available_width(); - ui.allocate_ui_with_layout( - egui::vec2(width, 24.0), - egui::Layout::left_to_right(egui::Align::Center), - |ui| { - let name_r = sort_header(ui, "Name", 0.0, sort_col == SortCol::Name, sort_dir); - if name_r { - new_sort = Some(toggle_sort(SortCol::Name, sort_col, sort_dir)); - } - - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let mod_r = sort_header( - ui, - "Modified", - 140.0, - sort_col == SortCol::Modified, - sort_dir, - ); - if mod_r { - new_sort = Some(toggle_sort(SortCol::Modified, sort_col, sort_dir)); - } - - let type_r = sort_header(ui, "Type", 40.0, sort_col == SortCol::Kind, sort_dir); - if type_r { - new_sort = Some(toggle_sort(SortCol::Kind, sort_col, sort_dir)); - } - - let size_r = sort_header(ui, "Size", 70.0, sort_col == SortCol::Size, sort_dir); - if size_r { - new_sort = Some(toggle_sort(SortCol::Size, sort_col, sort_dir)); - } - }); - }, - ); - }); - - if let Some(s) = new_sort { - ui.ctx().data_mut(|d| { - d.insert_temp(sort_id, s); - }); - } - - // разделитель - let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0)); - ui.painter().rect_filled(sep, 0.0, BORDER); - - // Список файлов - egui::ScrollArea::vertical() - .id_salt((id, "scroll")) - .auto_shrink([false; 2]) - .show(ui, |ui| { - for entry in &sorted { - let is_selected = selected.as_deref() == Some(&entry.name); - let row_id = Id::new((id, "row", &entry.name)); - - // Строка, которую сейчас тащат — её содержимое рисуется в - // плавающем слое поверх курсора (см. dnd_drag_source), поэтому - // сама она не может быть целью для дропа, а на исходном месте - // не должно оставаться подсветки выбора/ховера — там пусто. - let is_being_dragged = ui.ctx().is_being_dragged(row_id); - - let row_rect = egui::Rect::from_min_size( - ui.cursor().min, - egui::vec2(ui.available_width(), ROW_H), - ); - let is_hovered = !is_being_dragged && ui.rect_contains_pointer(row_rect); - let is_drop_target = !is_being_dragged - && entry.kind == EntryKind::Dir - && egui::DragAndDrop::has_payload_of_type::(ui.ctx()); - let is_drop_hover = is_drop_target && is_hovered; - - let bg = if is_being_dragged { - Color32::TRANSPARENT - } else if is_drop_hover { - ACCENT_DIM - } else if is_selected { - BG_ROW_SEL - } else if is_hovered { - BG_ROW_HOVER - } else { - Color32::TRANSPARENT - }; - let stroke = if is_drop_hover { - egui::Stroke::new(1.5, ACCENT) - } else { - egui::Stroke::NONE - }; - - let row_width = ui.available_width(); - let response = egui::Frame::none() - .fill(bg) - .stroke(stroke) - .rounding(RADIUS_SM) - .inner_margin(egui::Margin::symmetric(12.0, 0.0)) - .show(ui, |ui| { - ui.allocate_ui_with_layout( - egui::vec2(row_width - 24.0, ROW_H), - egui::Layout::left_to_right(egui::Align::Center), - |ui| { - // иконка (цвет по типу) + имя (единый цвет, как в спеке) - let (icon, icon_col) = if entry.name == ".." { - (Icon::ArrowUp, TEXT_HINT) - } else { - match entry.kind { - EntryKind::Dir => (Icon::Folder, YELLOW), - EntryKind::Symlink => (Icon::Link, YELLOW), - EntryKind::File => { - let ext = entry - .name - .rsplit('.') - .next() - .unwrap_or("") - .to_lowercase(); - file_icon_for(&ext) - } - } - }; - let name_col = if entry.name == ".." { - TEXT_HINT - } else { - TEXT_PRIMARY - }; - - let draw_row = |ui: &mut Ui| { - icons::icon(ui, icon, 14.0, icon_col); - ui.add_space(6.0); - ui.label(RichText::new(&entry.name).color(name_col).size(12.5)); - }; - - if let Some(payload) = make_drag_payload(entry) { - ui.dnd_drag_source(row_id, payload, |ui| { - ui.horizontal(draw_row); - }); - } else { - draw_row(ui); - } - - // правые колонки - ui.with_layout( - egui::Layout::right_to_left(egui::Align::Center), - |ui| { - ui.label( - RichText::new(format_time(entry.modified)) - .color(TEXT_HINT) - .monospace() - .size(10.5), - ); - ui.add_space(6.0); - - let (type_str, type_col) = match entry.kind { - EntryKind::Dir => ("Dir", TEXT_DIM), - EntryKind::File => ("File", TEXT_HINT), - EntryKind::Symlink => ("Link", YELLOW), - }; - let type_label = egui::Label::new( - RichText::new(type_str).color(type_col).size(11.0), - ) - .truncate(); - ui.add_sized([38.0, ROW_H], type_label); - - ui.label( - RichText::new(format_size(entry.size)) - .color(TEXT_DIM) - .monospace() - .size(11.0), - ); - ui.add_space(4.0); - }, - ); - }, - ); - }) - .response; - - // Frame::show() only senses hover, not clicks — add a real click - // sense on the full row rect (separate id from the drag source, - // which only senses drag over the icon+name sub-area). Without - // this, every press was interpreted as a drag start and plain - // clicks/double-clicks never fired. - let click_response = - ui.interact(response.rect, row_id.with("click"), egui::Sense::click()); - let response = response | click_response; - - if response.clicked() { - *selected = Some(entry.name.clone()); - clicked = Some(entry.name.clone()); - } - if response.double_clicked() { - double_clicked = Some(entry.name.clone()); - } - if is_drop_target - && let Some(payload) = response.dnd_release_payload::() - { - dropped_on_dir = Some((entry.clone(), payload)); - } - - if entry.name != ".." { - // Правый клик выбирает строку — иначе меню открывается - // над предыдущим выделением, что сбивает с толку. - if response.secondary_clicked() { - *selected = Some(entry.name.clone()); - } - response.context_menu(|ui| { - if let Some(action) = context_menu(ui, entry) { - context_action = Some((entry.clone(), action)); - ui.close_menu(); - } - }); - } - } - }); - - FileTableResponse { - double_clicked, - clicked, - dropped_on_dir, - context_action, - } -} - -/// Единообразная кнопка-пункт для контекстных меню (ПКМ на строке). -pub fn context_menu_item(ui: &mut Ui, icon: Icon, label: &str, danger: bool) -> bool { - let color = if danger { RED } else { TEXT_PRIMARY }; - let btn = egui::Button::image_and_text( - icons::image(icon, 13.0, color), - RichText::new(label).size(12.0).color(color), - ) - .fill(Color32::TRANSPARENT) - .min_size(egui::vec2(170.0, 26.0)); - ui.add(btn).clicked() -} - -fn sort_header(ui: &mut Ui, label: &str, min_w: f32, active: bool, dir: SortDir) -> bool { - let arrow = if active { - if dir == SortDir::Asc { " ↑" } else { " ↓" } - } else { - "" - }; - let full = format!("{}{}", label, arrow); - let col = if active { ACCENT } else { TEXT_DIM }; - let btn = egui::Button::new(RichText::new(full).color(col).size(10.5).strong()) - .fill(Color32::TRANSPARENT) - .min_size(egui::vec2(min_w, 20.0)); - ui.add(btn).clicked() -} - -fn toggle_sort(col: SortCol, cur_col: SortCol, cur_dir: SortDir) -> (SortCol, SortDir) { - if col == cur_col { - let new_dir = if cur_dir == SortDir::Asc { - SortDir::Desc - } else { - SortDir::Asc - }; - (col, new_dir) - } else { - (col, SortDir::Asc) - } -} diff --git a/src/ui/panels/file_pane/mod.rs b/src/ui/panels/file_pane/mod.rs new file mode 100644 index 0000000..4ea9944 --- /dev/null +++ b/src/ui/panels/file_pane/mod.rs @@ -0,0 +1,93 @@ +//! Переиспользуемая таблица файлов: публичный API, типы и форматтеры. +//! Рендер строк — в [`table`], сортировка — в [`sort`]. +use crate::domain::file_entry::FileEntry; +use crate::ui::drag::DragPayload; +use crate::ui::icons::{self, Icon}; +use crate::ui::theme::*; +use crate::ui::widgets::button; +use egui::{RichText, Ui}; + +mod row; +mod sort; +mod table; + +pub fn format_size(bytes: Option) -> String { + match bytes { + None => String::new(), + Some(b) if b < 1024 => format!("{} B", b), + Some(b) if b < 1024 * 1024 => format!("{:.1} KB", b as f64 / 1024.0), + Some(b) if b < 1024 * 1024 * 1024 => format!("{:.1} MB", b as f64 / (1024.0 * 1024.0)), + Some(b) => format!("{:.2} GB", b as f64 / (1024.0 * 1024.0 * 1024.0)), + } +} + +pub fn format_time(ts: Option) -> String { + match ts.and_then(|t| chrono::DateTime::from_timestamp(t, 0)) { + Some(dt) => dt.format("%d.%m.%Y %H:%M").to_string(), + None => String::new(), + } +} + +#[derive(Clone, Copy, PartialEq)] +pub enum SortCol { + Name, + Size, + Kind, + Modified, +} + +#[derive(Clone, Copy, PartialEq)] +pub enum SortDir { + Asc, + Desc, +} + +/// Действие, запрошенное через контекстное меню (ПКМ) на строке. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum RowAction { + Open, + Rename, + Delete, + Bookmark, + CopyPath, + /// Загрузка/выгрузка этой конкретной строки — направление решает вызывающая сторона. + Transfer, +} + +pub struct FileTableResponse { + pub double_clicked: Option, + pub clicked: Option, + /// Payload, перетащенный на папку (или "..") в этой таблице — (целевая папка, что бросили). + pub dropped_on_dir: Option<(FileEntry, std::sync::Arc)>, + /// Действие из контекстного меню (ПКМ) — (строка, действие). + pub context_action: Option<(FileEntry, RowAction)>, +} + +pub fn file_table( + ui: &mut Ui, + id: &str, + entries: &[FileEntry], + selected: &mut Option, + make_drag_payload: F, + context_menu: C, +) -> FileTableResponse +where + F: Fn(&FileEntry) -> Option, + C: FnMut(&mut Ui, &FileEntry) -> Option, +{ + ui.push_id(id, |ui| { + table::file_table_inner(ui, id, entries, selected, make_drag_payload, context_menu) + }) + .inner +} + +/// Единообразный пункт для контекстных меню (ПКМ на строке) с подсветкой на hover. +pub fn context_menu_item(ui: &mut Ui, icon: Icon, label: &str, danger: bool) -> bool { + let color = if danger { RED } else { TEXT_PRIMARY }; + let btn = egui::Button::image_and_text( + icons::image(icon, 13.0, color), + RichText::new(label).size(12.0).color(color), + ) + .min_size(egui::vec2(170.0, 26.0)); + button::ghost(ui, btn).clicked() +} diff --git a/src/ui/panels/file_pane/row.rs b/src/ui/panels/file_pane/row.rs new file mode 100644 index 0000000..ed0fac3 --- /dev/null +++ b/src/ui/panels/file_pane/row.rs @@ -0,0 +1,120 @@ +//! Отрисовка шапки колонок и содержимого одной строки таблицы файлов. +use egui::{Id, RichText, Ui}; + +use super::sort::{sort_header, toggle_sort}; +use super::{SortCol, SortDir, format_size, format_time}; +use crate::domain::file_entry::{EntryKind, FileEntry}; +use crate::ui::drag::DragPayload; +use crate::ui::icons::{self, Icon, file_icon_for}; +use crate::ui::theme::*; + +/// Шапка с кликабельными заголовками колонок. +pub(super) fn render_header( + ui: &mut Ui, + sort_col: SortCol, + sort_dir: SortDir, + new_sort: &mut Option<(SortCol, SortDir)>, +) { + let header_frame = egui::Frame::none() + .fill(BG_HEADER) + .inner_margin(egui::Margin::symmetric(12.0, 0.0)); + + header_frame.show(ui, |ui| { + let width = ui.available_width(); + ui.allocate_ui_with_layout( + egui::vec2(width, 24.0), + egui::Layout::left_to_right(egui::Align::Center), + |ui| { + use crate::i18n::t; + if sort_header(ui, t("panels.col_name"), 0.0, sort_col == SortCol::Name, sort_dir) + { + *new_sort = Some(toggle_sort(SortCol::Name, sort_col, sort_dir)); + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if sort_header( + ui, + t("panels.col_modified"), + 140.0, + sort_col == SortCol::Modified, + sort_dir, + ) { + *new_sort = Some(toggle_sort(SortCol::Modified, sort_col, sort_dir)); + } + if sort_header(ui, t("panels.col_type"), 40.0, sort_col == SortCol::Kind, sort_dir) { + *new_sort = Some(toggle_sort(SortCol::Kind, sort_col, sort_dir)); + } + if sort_header(ui, t("panels.col_size"), 70.0, sort_col == SortCol::Size, sort_dir) { + *new_sort = Some(toggle_sort(SortCol::Size, sort_col, sort_dir)); + } + }); + }, + ); + }); +} + +/// Содержимое одной строки: иконка+имя (drag source) слева, метаданные справа. +pub(super) fn draw_row_content(ui: &mut Ui, entry: &FileEntry, row_id: Id, make_drag_payload: &F) +where + F: Fn(&FileEntry) -> Option, +{ + let (icon, icon_col) = if entry.name == ".." { + (Icon::ArrowUp, TEXT_HINT) + } else { + match entry.kind { + EntryKind::Dir => (Icon::Folder, YELLOW), + EntryKind::Symlink => (Icon::Link, YELLOW), + EntryKind::File => { + let ext = entry.name.rsplit('.').next().unwrap_or("").to_lowercase(); + file_icon_for(&ext) + } + } + }; + let name_col = if entry.name == ".." { + TEXT_HINT + } else { + TEXT_PRIMARY + }; + + let draw = |ui: &mut Ui| { + icons::icon(ui, icon, 14.0, icon_col); + ui.add_space(6.0); + ui.label(RichText::new(&entry.name).color(name_col).size(12.5)); + }; + + if let Some(payload) = make_drag_payload(entry) { + ui.dnd_drag_source(row_id, payload, |ui| { + ui.horizontal(draw); + }); + } else { + draw(ui); + } + + // правые колонки + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label( + RichText::new(format_time(entry.modified)) + .color(TEXT_HINT) + .monospace() + .size(10.5), + ); + ui.add_space(6.0); + + let (type_str, type_col) = match entry.kind { + EntryKind::Dir => (crate::i18n::t("panels.type_dir"), TEXT_DIM), + EntryKind::File => (crate::i18n::t("panels.type_file"), TEXT_HINT), + EntryKind::Symlink => (crate::i18n::t("panels.type_link"), YELLOW), + }; + let type_label = + egui::Label::new(RichText::new(type_str).color(type_col).size(11.0)).truncate(); + ui.add_sized([38.0, ROW_H], type_label); + + ui.label( + RichText::new(format_size(entry.size)) + .color(TEXT_DIM) + .monospace() + .size(11.0), + ); + ui.add_space(4.0); + }); +} diff --git a/src/ui/panels/file_pane/sort.rs b/src/ui/panels/file_pane/sort.rs new file mode 100644 index 0000000..95bd0c7 --- /dev/null +++ b/src/ui/panels/file_pane/sort.rs @@ -0,0 +1,81 @@ +//! Сортировка таблицы файлов: заголовки-переключатели и сам компаратор. +use egui::{RichText, Ui}; + +use super::{SortCol, SortDir}; +use crate::domain::file_entry::{EntryKind, FileEntry}; +use crate::ui::theme::*; +use crate::ui::widgets::button; + +/// Сортирует записи по колонке/направлению, держа папки выше файлов, а ".." — первым. +pub(super) fn sort_entries( + entries: &[FileEntry], + sort_col: SortCol, + sort_dir: SortDir, +) -> Vec { + let mut sorted = entries.to_vec(); + let dotdot_pos = sorted.iter().position(|e| e.name == ".."); + let dotdot = dotdot_pos.map(|i| sorted.remove(i)); + + sorted.sort_by(|a, b| { + // папки всегда выше + let dir_cmp = match (&b.kind, &a.kind) { + (EntryKind::Dir, EntryKind::Dir) => std::cmp::Ordering::Equal, + (EntryKind::Dir, _) => std::cmp::Ordering::Greater, + (_, EntryKind::Dir) => std::cmp::Ordering::Less, + _ => std::cmp::Ordering::Equal, + }; + if dir_cmp != std::cmp::Ordering::Equal { + return dir_cmp; + } + + let ord = match sort_col { + SortCol::Name => a.name.to_lowercase().cmp(&b.name.to_lowercase()), + SortCol::Size => a.size.unwrap_or(0).cmp(&b.size.unwrap_or(0)), + SortCol::Kind => format!("{:?}", a.kind).cmp(&format!("{:?}", b.kind)), + SortCol::Modified => a.modified.unwrap_or(0).cmp(&b.modified.unwrap_or(0)), + }; + if sort_dir == SortDir::Desc { + ord.reverse() + } else { + ord + } + }); + + if let Some(dd) = dotdot { + sorted.insert(0, dd); + } + sorted +} + +/// Кликабельный заголовок колонки со стрелкой активной сортировки. +pub(super) fn sort_header( + ui: &mut Ui, + label: &str, + min_w: f32, + active: bool, + dir: SortDir, +) -> bool { + let arrow = if active { + if dir == SortDir::Asc { " ↑" } else { " ↓" } + } else { + "" + }; + let full = format!("{}{}", label, arrow); + let col = if active { ACCENT } else { TEXT_DIM }; + let btn = egui::Button::new(RichText::new(full).color(col).size(10.5).strong()) + .min_size(egui::vec2(min_w, 20.0)); + button::ghost(ui, btn).clicked() +} + +pub(super) fn toggle_sort(col: SortCol, cur_col: SortCol, cur_dir: SortDir) -> (SortCol, SortDir) { + if col == cur_col { + let new_dir = if cur_dir == SortDir::Asc { + SortDir::Desc + } else { + SortDir::Asc + }; + (col, new_dir) + } else { + (col, SortDir::Asc) + } +} diff --git a/src/ui/panels/file_pane/table.rs b/src/ui/panels/file_pane/table.rs new file mode 100644 index 0000000..a8a780a --- /dev/null +++ b/src/ui/panels/file_pane/table.rs @@ -0,0 +1,150 @@ +//! Рендер строк таблицы файлов (шапка, список, drag&drop, контекстное меню). +use egui::{Color32, Id, Ui}; + +use super::row::{draw_row_content, render_header}; +use super::sort::sort_entries; +use super::{FileTableResponse, RowAction, SortCol, SortDir}; +use crate::domain::file_entry::{EntryKind, FileEntry}; +use crate::ui::drag::DragPayload; +use crate::ui::theme::*; + +pub(super) fn file_table_inner( + ui: &mut Ui, + id: &str, + entries: &[FileEntry], + selected: &mut Option, + make_drag_payload: F, + mut context_menu: C, +) -> FileTableResponse +where + F: Fn(&FileEntry) -> Option, + C: FnMut(&mut Ui, &FileEntry) -> Option, +{ + // Сортировка — читаем из egui memory + let sort_id = Id::new((id, "sort")); + let (sort_col, sort_dir) = ui + .ctx() + .data_mut(|d| *d.get_temp_mut_or(sort_id, (SortCol::Name, SortDir::Asc))); + + let sorted = sort_entries(entries, sort_col, sort_dir); + + let mut double_clicked: Option = None; + let mut clicked: Option = None; + let mut new_sort: Option<(SortCol, SortDir)> = None; + let mut dropped_on_dir: Option<(FileEntry, std::sync::Arc)> = None; + let mut context_action: Option<(FileEntry, RowAction)> = None; + + render_header(ui, sort_col, sort_dir, &mut new_sort); + + if let Some(s) = new_sort { + ui.ctx().data_mut(|d| { + d.insert_temp(sort_id, s); + }); + } + + // разделитель + let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0)); + ui.painter().rect_filled(sep, 0.0, BORDER); + + // Список файлов + egui::ScrollArea::vertical() + .id_salt((id, "scroll")) + .auto_shrink([false; 2]) + .show(ui, |ui| { + for entry in &sorted { + let is_selected = selected.as_deref() == Some(&entry.name); + let row_id = Id::new((id, "row", &entry.name)); + + // Строка, которую сейчас тащат — её содержимое рисуется в + // плавающем слое поверх курсора (см. dnd_drag_source), поэтому + // сама она не может быть целью для дропа, а на исходном месте + // не должно оставаться подсветки выбора/ховера — там пусто. + let is_being_dragged = ui.ctx().is_being_dragged(row_id); + + let row_rect = egui::Rect::from_min_size( + ui.cursor().min, + egui::vec2(ui.available_width(), ROW_H), + ); + let is_hovered = !is_being_dragged && ui.rect_contains_pointer(row_rect); + let is_drop_target = !is_being_dragged + && entry.kind == EntryKind::Dir + && egui::DragAndDrop::has_payload_of_type::(ui.ctx()); + let is_drop_hover = is_drop_target && is_hovered; + + let bg = if is_being_dragged { + Color32::TRANSPARENT + } else if is_drop_hover { + ACCENT_DIM + } else if is_selected { + BG_ROW_SEL + } else if is_hovered { + BG_ROW_HOVER + } else { + Color32::TRANSPARENT + }; + let stroke = if is_drop_hover { + egui::Stroke::new(1.5, ACCENT) + } else { + egui::Stroke::NONE + }; + + let row_width = ui.available_width(); + let response = egui::Frame::none() + .fill(bg) + .stroke(stroke) + .rounding(RADIUS_SM) + .inner_margin(egui::Margin::symmetric(12.0, 0.0)) + .show(ui, |ui| { + ui.allocate_ui_with_layout( + egui::vec2(row_width - 24.0, ROW_H), + egui::Layout::left_to_right(egui::Align::Center), + |ui| draw_row_content(ui, entry, row_id, &make_drag_payload), + ); + }) + .response; + + // Frame::show() only senses hover, not clicks — add a real click + // sense on the full row rect (separate id from the drag source, + // which only senses drag over the icon+name sub-area). Without + // this, every press was interpreted as a drag start and plain + // clicks/double-clicks never fired. + let click_response = + ui.interact(response.rect, row_id.with("click"), egui::Sense::click()); + let response = response | click_response; + + if response.clicked() { + *selected = Some(entry.name.clone()); + clicked = Some(entry.name.clone()); + } + if response.double_clicked() { + double_clicked = Some(entry.name.clone()); + } + if is_drop_target + && let Some(payload) = response.dnd_release_payload::() + { + dropped_on_dir = Some((entry.clone(), payload)); + } + + if entry.name != ".." { + // Правый клик выбирает строку — иначе меню открывается + // над предыдущим выделением, что сбивает с толку. + if response.secondary_clicked() { + *selected = Some(entry.name.clone()); + } + crate::ui::widgets::context_menu::context_menu(&response, |ui| { + if let Some(action) = context_menu(ui, entry) { + context_action = Some((entry.clone(), action)); + ui.close_menu(); + } + }); + } + } + }); + + FileTableResponse { + double_clicked, + clicked, + dropped_on_dir, + context_action, + } +} diff --git a/src/ui/panels/local_pane.rs b/src/ui/panels/local_pane.rs deleted file mode 100644 index 6c4856a..0000000 --- a/src/ui/panels/local_pane.rs +++ /dev/null @@ -1,414 +0,0 @@ -use crate::domain::connection::ConnectionStatus; -use crate::domain::file_entry::{EntryKind, FileEntry}; -use crate::domain::transfer::{TransferKind, TransferTask}; -use crate::fs::local; -use crate::fs::remote::RemoteRegistry; -use crate::transfer::queue::TransferQueue; -use crate::ui::drag::{DragPayload, make_local_payload}; -use crate::ui::icons::{self, Icon}; -use crate::ui::panels::file_pane::{ - FileTableResponse, RowAction, context_menu_item, file_table, format_size, -}; -use crate::ui::state::{AppState, Pane}; -use crate::ui::theme::*; -use egui::{RichText, Ui}; -use std::sync::{Arc, Mutex}; - -fn parent_path(path: &str) -> String { - std::path::Path::new(path) - .parent() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|| path.to_string()) -} - -fn make_parent_entry(path: &str) -> FileEntry { - FileEntry { - name: "..".into(), - path: parent_path(path), - kind: EntryKind::Dir, - size: None, - modified: None, - permissions: None, - } -} - -pub fn render( - ui: &mut Ui, - state: &mut AppState, - queue: &TransferQueue, - registry: &Arc, - _rt_handle: &tokio::runtime::Handle, - db: &Arc>, -) { - let frame = egui::Frame::none() - .fill(BG_CONTENT) - .inner_margin(egui::Margin::same(0.0)); - - let (_inner, dropped) = ui.dnd_drop_zone::(frame, |ui| { - render_inner(ui, state, queue, registry, db); - }); - - if let Some(payload_arc) = dropped { - let dest_dir = state.local_path.clone(); - handle_drop(state, queue, registry, &payload_arc, &dest_dir); - } -} - -/// Обрабатывает бросок файла в текущую директорию (dest_dir) — общий код -/// для дропа на весь пейн и дропа на конкретную папку в списке. -fn handle_drop( - state: &mut AppState, - queue: &TransferQueue, - registry: &Arc, - payload: &DragPayload, - dest_dir: &str, -) { - match payload { - DragPayload::RemoteFile(remote_path, file_name, conn_id) => { - if registry.get(conn_id).is_some() { - let local_path_str = format!("{}/{}", dest_dir.trim_end_matches('/'), file_name); - let task = TransferTask::new( - TransferKind::Download, - conn_id.clone(), - local_path_str, - remote_path.clone(), - file_name.clone(), - 0, - ); - queue.push(task); - state.status_message = format!("Download queued: {}", file_name); - } else { - state.status_message = "Connection no longer active".into(); - } - } - DragPayload::LocalFile(src_path, file_name) => { - if std::path::Path::new(dest_dir) - == std::path::Path::new(src_path) - .parent() - .unwrap_or(std::path::Path::new("")) - { - return; // бросили туда же, откуда взяли - } - match local::move_into(src_path, dest_dir) { - Ok(()) => { - state.status_message = format!("Moved {}", file_name); - refresh_local(state); - } - Err(e) => state.status_message = format!("Move failed: {}", e), - } - } - } -} - -fn render_inner( - ui: &mut Ui, - state: &mut AppState, - queue: &TransferQueue, - registry: &Arc, - db: &Arc>, -) { - // Хлебные крошки + путь - render_path_bar(ui, state); - - // разделитель - let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0)); - ui.painter().rect_filled(sep, 0.0, BORDER); - - // Таблица - let entries = state.local_entries.clone(); - let mut selected = state.local_selected.clone(); - - let has_connection = state - .active_tab_ref() - .map(|t| t.status == ConnectionStatus::Connected) - .unwrap_or(false); - - let FileTableResponse { - double_clicked, - clicked, - dropped_on_dir, - context_action, - } = file_table( - ui, - "local_table", - &entries, - &mut selected, - |entry| { - if has_connection { - make_local_payload(entry) - } else { - None - } - }, - |ui, entry| build_context_menu(ui, entry, has_connection), - ); - - state.local_selected = selected; - if clicked.is_some() { - state.active_pane = Pane::Local; - } - - if let Some((target_entry, payload)) = dropped_on_dir { - handle_drop(state, queue, registry, &payload, &target_entry.path); - } - - if let Some(name) = double_clicked { - open_entry(state, &name); - } - - if let Some((entry, action)) = context_action { - handle_row_action(ui.ctx(), state, queue, db, &entry, action); - } - - // Нижняя полоска - render_footer(ui, state, db); -} - -fn build_context_menu(ui: &mut Ui, entry: &FileEntry, has_connection: bool) -> Option { - if context_menu_item(ui, Icon::Folder, "Open", false) { - return Some(RowAction::Open); - } - if entry.name != ".." { - if context_menu_item(ui, Icon::Pen, "Rename", false) { - return Some(RowAction::Rename); - } - if context_menu_item(ui, Icon::Trash, "Delete", true) { - return Some(RowAction::Delete); - } - ui.separator(); - if entry.kind == EntryKind::Dir - && context_menu_item(ui, Icon::Star, "Add to Bookmarks", false) - { - return Some(RowAction::Bookmark); - } - if context_menu_item(ui, Icon::Document, "Copy Path", false) { - return Some(RowAction::CopyPath); - } - if entry.kind == EntryKind::File && has_connection { - ui.separator(); - if context_menu_item(ui, Icon::Upload, "Upload to Server", false) { - return Some(RowAction::Transfer); - } - } - } - None -} - -fn open_entry(state: &mut AppState, name: &str) { - if name == ".." { - state.local_path = parent_path(&state.local_path); - refresh_local(state); - } else if let Some(entry) = state.local_entries.iter().find(|e| e.name == name) { - if entry.kind == EntryKind::Dir { - state.local_path = entry.path.clone(); - refresh_local(state); - } else { - match local::open(&entry.path) { - Ok(()) => state.status_message = format!("Opening {}", entry.name), - Err(e) => state.status_message = format!("Open failed: {}", e), - } - } - } -} - -fn handle_row_action( - ctx: &egui::Context, - state: &mut AppState, - queue: &TransferQueue, - db: &Arc>, - entry: &FileEntry, - action: RowAction, -) { - match action { - RowAction::Open => open_entry(state, &entry.name), - RowAction::Rename => { - state.op_target = Pane::Local; - state.show_rename_dialog = true; - state.rename_old_name = entry.name.clone(); - state.rename_new_name = entry.name.clone(); - } - RowAction::Delete => { - state.op_target = Pane::Local; - state.show_delete_dialog = true; - state.delete_name = entry.name.clone(); - } - RowAction::Bookmark => add_bookmark(state, db, &entry.name, &entry.path), - RowAction::CopyPath => { - ctx.copy_text(entry.path.clone()); - state.status_message = "Path copied".into(); - } - RowAction::Transfer => { - if let Some(tab) = state.active_tab_ref() { - let connection_id = tab.id.clone(); - let remote_path = - format!("{}/{}", tab.remote_path.trim_end_matches('/'), entry.name); - let task = TransferTask::new( - TransferKind::Upload, - connection_id, - entry.path.clone(), - remote_path, - entry.name.clone(), - 0, - ); - queue.push(task); - state.status_message = format!("Upload queued: {}", entry.name); - } - } - } -} - -fn add_bookmark( - state: &mut AppState, - db: &Arc>, - name: &str, - path: &str, -) { - if state.bookmarks.iter().any(|b| b.path == path) { - state.status_message = "Already bookmarked".into(); - return; - } - let Ok(conn) = db.lock() else { - return; - }; - match crate::storage::db::add_bookmark(&conn, name, path) { - Ok(id) => { - state.bookmarks.push(crate::ui::state::Bookmark { - id, - name: name.to_string(), - path: path.to_string(), - }); - state.status_message = "Bookmark added".into(); - } - Err(e) => state.status_message = format!("Bookmark failed: {}", e), - } -} - -fn render_path_bar(ui: &mut Ui, state: &mut AppState) { - let frame = egui::Frame::none() - .fill(BG_BASE) - .inner_margin(egui::Margin::symmetric(12.0, 0.0)); - - frame.show(ui, |ui| { - let width = ui.available_width(); - ui.allocate_ui_with_layout( - egui::vec2(width, 30.0), - egui::Layout::left_to_right(egui::Align::Center), - |ui| { - ui.label(RichText::new("LOCAL").color(TEXT_DIM).size(10.5).strong()); - ui.add_space(10.0); - - // иконка home - let home = dirs::home_dir() - .unwrap_or_default() - .to_string_lossy() - .to_string(); - let is_home = state.local_path == home; - let home_col = if is_home { ACCENT } else { TEXT_DIM }; - let home_btn = egui::Button::image(icons::image(Icon::Home, 13.0, home_col)) - .fill(if is_home { - BG_ROW_SEL - } else { - egui::Color32::TRANSPARENT - }) - .rounding(RADIUS_SM) - .min_size(egui::vec2(22.0, 22.0)); - if ui.add(home_btn).clicked() { - state.local_path = home; - refresh_local(state); - } - - ui.add_space(4.0); - - // Крошки - let path_clone = state.local_path.clone(); - let parts: Vec<&str> = path_clone.split('/').filter(|s| !s.is_empty()).collect(); - let mut acc = String::new(); - - for (i, part) in parts.iter().enumerate() { - acc.push('/'); - acc.push_str(part); - let is_last = i == parts.len() - 1; - - if is_last { - ui.label(RichText::new(*part).color(TEXT_PRIMARY).size(12.0).strong()); - } else { - let path_snap = acc.clone(); - let link = - egui::Label::new(RichText::new(*part).color(TEXT_DIM).size(12.0)) - .sense(egui::Sense::click()); - if ui.add(link).clicked() { - state.local_path = path_snap; - refresh_local(state); - } - ui.label(RichText::new("/").color(TEXT_HINT).size(12.0)); - } - } - }, - ); - }); -} - -fn render_footer(ui: &mut Ui, state: &mut AppState, db: &Arc>) { - let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0)); - ui.painter().rect_filled(sep, 0.0, BORDER); - - let frame = egui::Frame::none() - .fill(BG_PANEL) - .inner_margin(egui::Margin::symmetric(8.0, 3.0)); - - frame.show(ui, |ui| { - ui.horizontal(|ui| { - let count = state - .local_entries - .iter() - .filter(|e| e.name != "..") - .count(); - let total_size: u64 = state.local_entries.iter().filter_map(|e| e.size).sum(); - - ui.label( - RichText::new(format!("{} items", count)) - .color(TEXT_DIM) - .size(11.0), - ); - - if total_size > 0 { - ui.label( - RichText::new(format!("({})", format_size(Some(total_size)))) - .color(TEXT_HINT) - .size(11.0), - ); - } - - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - // кнопка добавить закладку - let star = egui::Button::image(icons::image(Icon::Star, 12.0, YELLOW)) - .fill(egui::Color32::TRANSPARENT) - .min_size(egui::vec2(20.0, 20.0)); - if ui.add(star).on_hover_text("Bookmark this folder").clicked() { - let name = std::path::Path::new(&state.local_path) - .file_name() - .map(|s| s.to_string_lossy().to_string()) - .unwrap_or_else(|| "Bookmark".into()); - let path = state.local_path.clone(); - add_bookmark(state, db, &name, &path); - } - }); - }); - }); -} - -pub fn refresh_local(state: &mut AppState) { - let mut entries = Vec::new(); - if state.local_path != "/" { - entries.push(make_parent_entry(&state.local_path)); - } - match local::list(&state.local_path) { - Ok(mut list) => { - entries.append(&mut list); - state.local_entries = entries; - } - Err(e) => { - state.status_message = format!("Local error: {}", e); - } - } -} diff --git a/src/ui/panels/local_pane/actions.rs b/src/ui/panels/local_pane/actions.rs new file mode 100644 index 0000000..7733b79 --- /dev/null +++ b/src/ui/panels/local_pane/actions.rs @@ -0,0 +1,199 @@ +//! Действия над локальной ФС: drop, открытие, контекстное меню, закладки. +use std::sync::{Arc, Mutex}; + +use super::{parent_path, refresh_local}; +use crate::domain::file_entry::EntryKind; +use crate::domain::file_entry::FileEntry; +use crate::domain::transfer::{TransferKind, TransferTask}; +use crate::fs::local; +use crate::fs::remote::RemoteRegistry; +use crate::transfer::queue::TransferQueue; +use crate::ui::drag::DragPayload; +use crate::ui::icons::Icon; +use crate::ui::panels::file_pane::{RowAction, context_menu_item}; +use crate::ui::state::{AppState, Pane}; + +/// Обрабатывает бросок файла в директорию `dest_dir` — общий код для дропа +/// на весь пейн и дропа на конкретную папку в списке. +pub(super) fn handle_drop( + state: &mut AppState, + queue: &TransferQueue, + registry: &Arc, + payload: &DragPayload, + dest_dir: &str, +) { + match payload { + DragPayload::RemoteFile(remote_path, file_name, conn_id) => { + if registry.get(conn_id).is_some() { + let local_path_str = format!("{}/{}", dest_dir.trim_end_matches('/'), file_name); + let task = TransferTask::new( + TransferKind::Download, + conn_id.clone(), + local_path_str, + remote_path.clone(), + file_name.clone(), + 0, + ); + queue.push(task); + state.status_message = + crate::i18n::tf("toolbar.download_queued", &[("{name}", file_name)]); + } else { + state.status_message = crate::i18n::t("panels.local.conn_inactive").into(); + } + } + DragPayload::LocalFile(src_path, file_name) => { + if std::path::Path::new(dest_dir) + == std::path::Path::new(src_path) + .parent() + .unwrap_or(std::path::Path::new("")) + { + return; // бросили туда же, откуда взяли + } + match local::move_into(src_path, dest_dir) { + Ok(()) => { + state.status_message = + crate::i18n::tf("panels.local.moved", &[("{name}", file_name)]); + refresh_local(state); + } + Err(e) => { + state.status_message = + crate::i18n::tf("panels.local.move_failed", &[("{err}", &e.to_string())]) + } + } + } + } +} + +pub(super) fn build_context_menu( + ui: &mut egui::Ui, + entry: &FileEntry, + has_connection: bool, +) -> Option { + use crate::i18n::t; + if context_menu_item(ui, Icon::Folder, t("common.open"), false) { + return Some(RowAction::Open); + } + if entry.name != ".." { + if context_menu_item(ui, Icon::Pen, t("common.rename"), false) { + return Some(RowAction::Rename); + } + if context_menu_item(ui, Icon::Trash, t("common.delete"), true) { + return Some(RowAction::Delete); + } + ui.separator(); + if entry.kind == EntryKind::Dir + && context_menu_item(ui, Icon::Star, t("panels.local.add_to_bookmarks"), false) + { + return Some(RowAction::Bookmark); + } + if context_menu_item(ui, Icon::Document, t("common.copy_path"), false) { + return Some(RowAction::CopyPath); + } + if entry.kind == EntryKind::File && has_connection { + ui.separator(); + if context_menu_item(ui, Icon::Upload, t("panels.local.upload_to_server"), false) { + return Some(RowAction::Transfer); + } + } + } + None +} + +pub(super) fn open_entry(state: &mut AppState, name: &str) { + if name == ".." { + state.local_path = parent_path(&state.local_path); + refresh_local(state); + } else if let Some(entry) = state.local_entries.iter().find(|e| e.name == name) { + if entry.kind == EntryKind::Dir { + state.local_path = entry.path.clone(); + refresh_local(state); + } else { + match local::open(&entry.path) { + Ok(()) => { + state.status_message = + crate::i18n::tf("panels.local.opening", &[("{name}", &entry.name)]) + } + Err(e) => { + state.status_message = + crate::i18n::tf("panels.local.open_failed", &[("{err}", &e.to_string())]) + } + } + } + } +} + +pub(super) fn handle_row_action( + ctx: &egui::Context, + state: &mut AppState, + queue: &TransferQueue, + db: &Arc>, + entry: &FileEntry, + action: RowAction, +) { + match action { + RowAction::Open => open_entry(state, &entry.name), + RowAction::Rename => { + state.op_target = Pane::Local; + state.show_rename_dialog = true; + state.rename_old_name = entry.name.clone(); + state.rename_new_name = entry.name.clone(); + } + RowAction::Delete => { + state.op_target = Pane::Local; + state.show_delete_dialog = true; + state.delete_name = entry.name.clone(); + } + RowAction::Bookmark => add_bookmark(state, db, &entry.name, &entry.path), + RowAction::CopyPath => { + ctx.copy_text(entry.path.clone()); + state.status_message = crate::i18n::t("panels.path_copied").into(); + } + RowAction::Transfer => { + if let Some(tab) = state.active_tab_ref() { + let connection_id = tab.id.clone(); + let remote_path = + format!("{}/{}", tab.remote_path.trim_end_matches('/'), entry.name); + let task = TransferTask::new( + TransferKind::Upload, + connection_id, + entry.path.clone(), + remote_path, + entry.name.clone(), + 0, + ); + queue.push(task); + state.status_message = + crate::i18n::tf("toolbar.upload_queued", &[("{name}", &entry.name)]); + } + } + } +} + +pub(super) fn add_bookmark( + state: &mut AppState, + db: &Arc>, + name: &str, + path: &str, +) { + if state.bookmarks.iter().any(|b| b.path == path) { + state.status_message = crate::i18n::t("panels.local.already_bookmarked").into(); + return; + } + let Ok(conn) = db.lock() else { + return; + }; + match crate::storage::db::add_bookmark(&conn, name, path) { + Ok(id) => { + state.bookmarks.push(crate::ui::state::Bookmark { + id, + name: name.to_string(), + path: path.to_string(), + }); + state.status_message = crate::i18n::t("panels.local.bookmark_added").into(); + } + Err(e) => { + state.status_message = + crate::i18n::tf("panels.local.bookmark_failed", &[("{err}", &e.to_string())]) + } + } +} diff --git a/src/ui/panels/local_pane/bars.rs b/src/ui/panels/local_pane/bars.rs new file mode 100644 index 0000000..ec3c8ea --- /dev/null +++ b/src/ui/panels/local_pane/bars.rs @@ -0,0 +1,137 @@ +//! Верхняя (путь + home) и нижняя (счётчик + закладка) полоски локального пейна. +use egui::{RichText, Ui}; +use std::sync::{Arc, Mutex}; + +use super::actions::add_bookmark; +use super::refresh_local; +use crate::ui::icons::{self, Icon}; +use crate::ui::panels::file_pane::format_size; +use crate::ui::state::AppState; +use crate::ui::theme::*; +use crate::ui::widgets::button; + +pub(super) fn render_path_bar(ui: &mut Ui, state: &mut AppState) { + let frame = egui::Frame::none() + .fill(BG_BASE) + .inner_margin(egui::Margin::symmetric(12.0, 0.0)); + + frame.show(ui, |ui| { + let width = ui.available_width(); + ui.allocate_ui_with_layout( + egui::vec2(width, 30.0), + egui::Layout::left_to_right(egui::Align::Center), + |ui| { + ui.label( + RichText::new(crate::i18n::t("panels.local_label")) + .color(TEXT_DIM) + .size(10.5) + .strong(), + ); + ui.add_space(10.0); + + // иконка home + let home = dirs::home_dir() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + let is_home = state.local_path == home; + let home_col = if is_home { ACCENT } else { TEXT_DIM }; + let home_btn = egui::Button::image(icons::image(Icon::Home, 13.0, home_col)) + .rounding(RADIUS_SM) + .min_size(egui::vec2(22.0, 22.0)); + if button::toggle(ui, home_btn, is_home).clicked() { + state.local_path = home; + refresh_local(state); + } + + ui.add_space(4.0); + + breadcrumbs(ui, state); + }, + ); + }); +} + +/// Хлебные крошки локального пути — каждый сегмент ghost-кнопка (hover + курсор-рука). +fn breadcrumbs(ui: &mut Ui, state: &mut AppState) { + let path_clone = state.local_path.clone(); + let parts: Vec<&str> = path_clone.split('/').filter(|s| !s.is_empty()).collect(); + let mut acc = String::new(); + + for (i, part) in parts.iter().enumerate() { + acc.push('/'); + acc.push_str(part); + let is_last = i == parts.len() - 1; + + if is_last { + ui.label(RichText::new(*part).color(TEXT_PRIMARY).size(12.0).strong()); + } else { + let path_snap = acc.clone(); + let seg = egui::Button::new(RichText::new(*part).color(TEXT_DIM).size(12.0)) + .min_size(egui::vec2(0.0, 22.0)); + if button::ghost(ui, seg).clicked() { + state.local_path = path_snap; + refresh_local(state); + } + ui.label(RichText::new("/").color(TEXT_HINT).size(12.0)); + } + } +} + +pub(super) fn render_footer( + ui: &mut Ui, + state: &mut AppState, + db: &Arc>, +) { + let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0)); + ui.painter().rect_filled(sep, 0.0, BORDER); + + let frame = egui::Frame::none() + .fill(BG_PANEL) + .inner_margin(egui::Margin::symmetric(8.0, 3.0)); + + frame.show(ui, |ui| { + ui.horizontal(|ui| { + let count = state + .local_entries + .iter() + .filter(|e| e.name != "..") + .count(); + let total_size: u64 = state.local_entries.iter().filter_map(|e| e.size).sum(); + + ui.label( + RichText::new(crate::i18n::tf( + "panels.count_items", + &[("{n}", &count.to_string())], + )) + .color(TEXT_DIM) + .size(11.0), + ); + + if total_size > 0 { + ui.label( + RichText::new(format!("({})", format_size(Some(total_size)))) + .color(TEXT_HINT) + .size(11.0), + ); + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // кнопка добавить закладку + let star = egui::Button::image(icons::image(Icon::Star, 12.0, YELLOW)) + .min_size(egui::vec2(22.0, 22.0)); + if button::ghost(ui, star) + .on_hover_text(crate::i18n::t("panels.sidebar.bookmark_this_folder")) + .clicked() + { + let name = std::path::Path::new(&state.local_path) + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "Bookmark".into()); + let path = state.local_path.clone(); + add_bookmark(state, db, &name, &path); + } + }); + }); + }); +} diff --git a/src/ui/panels/local_pane/mod.rs b/src/ui/panels/local_pane/mod.rs new file mode 100644 index 0000000..bdfd683 --- /dev/null +++ b/src/ui/panels/local_pane/mod.rs @@ -0,0 +1,131 @@ +use crate::domain::connection::ConnectionStatus; +use crate::domain::file_entry::{EntryKind, FileEntry}; +use crate::fs::local; +use crate::fs::remote::RemoteRegistry; +use crate::transfer::queue::TransferQueue; +use crate::ui::drag::{DragPayload, make_local_payload}; +use crate::ui::panels::file_pane::{FileTableResponse, file_table}; +use crate::ui::state::{AppState, Pane}; +use crate::ui::theme::*; +use egui::Ui; +use std::sync::{Arc, Mutex}; + +mod actions; +mod bars; + +pub(super) fn parent_path(path: &str) -> String { + std::path::Path::new(path) + .parent() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| path.to_string()) +} + +fn make_parent_entry(path: &str) -> FileEntry { + FileEntry { + name: "..".into(), + path: parent_path(path), + kind: EntryKind::Dir, + size: None, + modified: None, + permissions: None, + } +} + +pub fn render( + ui: &mut Ui, + state: &mut AppState, + queue: &TransferQueue, + registry: &Arc, + _rt_handle: &tokio::runtime::Handle, + db: &Arc>, +) { + let frame = egui::Frame::none() + .fill(BG_CONTENT) + .inner_margin(egui::Margin::same(0.0)); + + let (_inner, dropped) = ui.dnd_drop_zone::(frame, |ui| { + render_inner(ui, state, queue, registry, db); + }); + + if let Some(payload_arc) = dropped { + let dest_dir = state.local_path.clone(); + actions::handle_drop(state, queue, registry, &payload_arc, &dest_dir); + } +} + +fn render_inner( + ui: &mut Ui, + state: &mut AppState, + queue: &TransferQueue, + registry: &Arc, + db: &Arc>, +) { + bars::render_path_bar(ui, state); + + let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0)); + ui.painter().rect_filled(sep, 0.0, BORDER); + + let entries = state.local_entries.clone(); + let mut selected = state.local_selected.clone(); + + let has_connection = state + .active_tab_ref() + .map(|t| t.status == ConnectionStatus::Connected) + .unwrap_or(false); + + let FileTableResponse { + double_clicked, + clicked, + dropped_on_dir, + context_action, + } = file_table( + ui, + "local_table", + &entries, + &mut selected, + |entry| { + if has_connection { + make_local_payload(entry) + } else { + None + } + }, + |ui, entry| actions::build_context_menu(ui, entry, has_connection), + ); + + state.local_selected = selected; + if clicked.is_some() { + state.active_pane = Pane::Local; + } + + if let Some((target_entry, payload)) = dropped_on_dir { + actions::handle_drop(state, queue, registry, &payload, &target_entry.path); + } + + if let Some(name) = double_clicked { + actions::open_entry(state, &name); + } + + if let Some((entry, action)) = context_action { + actions::handle_row_action(ui.ctx(), state, queue, db, &entry, action); + } + + bars::render_footer(ui, state, db); +} + +pub fn refresh_local(state: &mut AppState) { + let mut entries = Vec::new(); + if state.local_path != "/" { + entries.push(make_parent_entry(&state.local_path)); + } + match local::list(&state.local_path) { + Ok(mut list) => { + entries.append(&mut list); + state.local_entries = entries; + } + Err(e) => { + state.status_message = + crate::i18n::tf("panels.local.error", &[("{err}", &e.to_string())]); + } + } +} diff --git a/src/ui/panels/mod.rs b/src/ui/panels/mod.rs index be63324..f8e4266 100644 --- a/src/ui/panels/mod.rs +++ b/src/ui/panels/mod.rs @@ -4,5 +4,4 @@ pub mod queue; pub mod remote_pane; pub mod sidebar; pub mod status_bar; -pub mod tabs; pub mod toolbar; diff --git a/src/ui/panels/queue.rs b/src/ui/panels/queue.rs index 0aba8a8..ccfe434 100644 --- a/src/ui/panels/queue.rs +++ b/src/ui/panels/queue.rs @@ -4,7 +4,8 @@ use crate::ui::icons::{self, Icon}; use crate::ui::panels::file_pane::format_size; use crate::ui::state::AppState; use crate::ui::theme::*; -use egui::{Color32, RichText, Ui}; +use crate::ui::widgets::button; +use egui::{RichText, Ui}; pub fn render(ui: &mut Ui, state: &mut AppState, tasks: &[TransferTask]) { // Заголовок очереди (всегда виден) @@ -28,11 +29,8 @@ pub fn render(ui: &mut Ui, state: &mut AppState, tasks: &[TransferTask]) { .filter(|t| matches!(t.state, TaskState::Running | TaskState::Queued)) .count(); - let title = if n_active > 0 { - format!("Transfer Queue ({})", n_active) - } else { - format!("Transfer Queue ({})", tasks.len()) - }; + let n = if n_active > 0 { n_active } else { tasks.len() }; + let title = crate::i18n::tf("queue.title", &[("{n}", &n.to_string())]); let btn = egui::Button::image_and_text( icons::image(chevron, 13.0, TEXT_DIM), @@ -41,10 +39,9 @@ pub fn render(ui: &mut Ui, state: &mut AppState, tasks: &[TransferTask]) { .size(11.5) .strong(), ) - .fill(Color32::TRANSPARENT) .min_size(egui::vec2(0.0, QUEUE_COLLAPSED_H)); - if ui.add(btn).clicked() { + if button::ghost(ui, btn).clicked() { state.show_queue = !state.show_queue; } @@ -72,16 +69,22 @@ pub fn render(ui: &mut Ui, state: &mut AppState, tasks: &[TransferTask]) { .filter(|t| matches!(t.state, TaskState::Failed(_))) .count(); ui.label( - RichText::new(format!("{}/{} done", done, tasks.len())) - .color(TEXT_DIM) - .size(11.0), + RichText::new(crate::i18n::tf( + "queue.done_suffix", + &[("{done}", &done.to_string()), ("{total}", &tasks.len().to_string())], + )) + .color(TEXT_DIM) + .size(11.0), ); if failed > 0 { ui.add_space(8.0); ui.label( - RichText::new(format!("{} failed", failed)) - .color(RED) - .size(11.0), + RichText::new(crate::i18n::tf( + "queue.failed_suffix", + &[("{n}", &failed.to_string())], + )) + .color(RED) + .size(11.0), ); } } @@ -147,6 +150,7 @@ fn render_task(ui: &mut Ui, task: &TransferTask) { }; let bar = egui::ProgressBar::new(pct) .desired_width(ui.available_width() - 130.0) + .animate(matches!(task.state, TaskState::Running)) .fill(bar_col); ui.add(bar); ui.add_space(10.0); @@ -160,16 +164,34 @@ fn render_task(ui: &mut Ui, task: &TransferTask) { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let (state_str, state_col, state_icon) = match &task.state { - TaskState::Queued => ("Queued".to_string(), TEXT_HINT, None), + TaskState::Queued => (crate::i18n::t("queue.state_queued").to_string(), TEXT_HINT, None), TaskState::Running => match task.speed { Some(s) if s > 0 => (format!("{}/s", format_size(Some(s))), GREEN, None), - _ => ("Uploading".to_string(), ACCENT, None), + _ => { + let verb = match task.kind { + TransferKind::Upload => "queue.state_uploading", + TransferKind::Download => "queue.state_downloading", + }; + (crate::i18n::t(verb).to_string(), ACCENT, None) + } }, - TaskState::Completed => ("Complete".to_string(), GREEN, Some(Icon::CheckCircle)), - TaskState::Failed(_) => ("Failed".to_string(), RED, None), - TaskState::Paused => ("Paused".to_string(), YELLOW, Some(Icon::PlayCircle)), - TaskState::Cancelled => ("Cancelled".to_string(), TEXT_HINT, None), - TaskState::Retrying(_) => ("Retrying".to_string(), YELLOW, None), + TaskState::Completed => ( + crate::i18n::t("queue.state_complete").to_string(), + GREEN, + Some(Icon::CheckCircle), + ), + TaskState::Failed(_) => (crate::i18n::t("queue.state_failed").to_string(), RED, None), + TaskState::Paused => ( + crate::i18n::t("queue.state_paused").to_string(), + YELLOW, + Some(Icon::PlayCircle), + ), + TaskState::Cancelled => { + (crate::i18n::t("queue.state_cancelled").to_string(), TEXT_HINT, None) + } + TaskState::Retrying(_) => { + (crate::i18n::t("queue.state_retrying").to_string(), YELLOW, None) + } }; if let Some(ic) = state_icon { icons::icon(ui, ic, 13.0, state_col); diff --git a/src/ui/panels/remote_pane.rs b/src/ui/panels/remote_pane.rs deleted file mode 100644 index 016520f..0000000 --- a/src/ui/panels/remote_pane.rs +++ /dev/null @@ -1,496 +0,0 @@ -use egui::{RichText, Ui}; -use std::sync::Arc; - -use crate::domain::connection::ConnectionStatus; -use crate::domain::file_entry::{EntryKind, FileEntry}; -use crate::domain::transfer::{TransferKind, TransferTask}; -use crate::fs::remote::RemoteRegistry; -use crate::transfer::queue::TransferQueue; -use crate::ui::drag::{DragPayload, make_remote_payload}; -use crate::ui::icons::{self, Icon}; -use crate::ui::panels::file_pane::{ - FileTableResponse, RowAction, context_menu_item, file_table, format_size, -}; -use crate::ui::state::{AppState, Pane, PendingRemoteList}; -use crate::ui::theme::*; - -pub fn remote_parent(path: &str) -> String { - let trimmed = path.trim_end_matches('/'); - if trimmed.is_empty() || trimmed == "/" { - return "/".into(); - } - if let Some(idx) = trimmed.rfind('/') { - let p = &trimmed[..idx]; - if p.is_empty() { - "/".into() - } else { - p.to_string() - } - } else { - "/".into() - } -} - -pub fn render( - ui: &mut Ui, - state: &mut AppState, - tab_idx: usize, - registry: &Arc, - rt_handle: &tokio::runtime::Handle, - queue: &TransferQueue, -) { - let frame = egui::Frame::none() - .fill(BG_CONTENT) - .inner_margin(egui::Margin::same(0.0)); - - let (_inner, dropped) = ui.dnd_drop_zone::(frame, |ui| { - render_content(ui, state, tab_idx, registry, rt_handle, queue); - }); - - let remote_path = state.tabs[tab_idx].remote_path.clone(); - - if let Some(payload_arc) = dropped { - handle_drop( - state, - tab_idx, - queue, - registry, - rt_handle, - &payload_arc, - &remote_path, - ); - } -} - -/// Обрабатывает бросок файла в директорию dest_dir на удалённой стороне — -/// общий код для дропа на весь пейн и дропа на конкретную папку в списке. -fn handle_drop( - state: &mut AppState, - tab_idx: usize, - queue: &TransferQueue, - registry: &Arc, - rt_handle: &tokio::runtime::Handle, - payload: &DragPayload, - dest_dir: &str, -) { - let is_connected = state.tabs[tab_idx].status == ConnectionStatus::Connected; - let connection_id = state.tabs[tab_idx].id.clone(); - - match payload { - DragPayload::LocalFile(local_path, file_name) => { - if is_connected { - let dest = format!("{}/{}", dest_dir.trim_end_matches('/'), file_name); - let task = TransferTask::new( - TransferKind::Upload, - connection_id, - local_path.clone(), - dest, - file_name.clone(), - 0, - ); - queue.push(task); - state.status_message = format!("Upload queued: {}", file_name); - } else { - state.status_message = "No active connection".into(); - } - } - DragPayload::RemoteFile(remote_path, file_name, conn_id) => { - if *conn_id != connection_id { - state.status_message = "Cannot move between different connections".into(); - return; - } - let dest = format!("{}/{}", dest_dir.trim_end_matches('/'), file_name); - if dest == *remote_path { - return; - } - let registry = registry.clone(); - let from = remote_path.clone(); - let conn_id_clone = conn_id.clone(); - let result = Arc::new(std::sync::Mutex::new(None::>)); - let result_clone = result.clone(); - rt_handle.spawn(async move { - let fs = match registry.get(&conn_id_clone) { - Some(fs) => fs, - None => { - *result_clone.lock().unwrap() = Some(Err("connection not found".into())); - return; - } - }; - let r = fs.rename(&from, &dest).await.map_err(|e| e.to_string()); - *result_clone.lock().unwrap() = Some(r); - }); - state.pending_rename_result = Some(result); - state.status_message = format!("Moving {}…", file_name); - } - } -} - -fn render_content( - ui: &mut Ui, - state: &mut AppState, - tab_idx: usize, - registry: &Arc, - rt_handle: &tokio::runtime::Handle, - queue: &TransferQueue, -) { - // Path bar - render_path_bar(ui, state, tab_idx, registry, rt_handle); - - let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0)); - ui.painter().rect_filled(sep, 0.0, BORDER); - - // Loading overlay hint - if state.tabs[tab_idx].loading { - ui.horizontal(|ui| { - ui.add_space(8.0); - ui.spinner(); - ui.label(RichText::new("Loading...").color(TEXT_DIM).size(11.0)); - }); - } - - // Table - let entries; - let mut selected; - { - let tab = &state.tabs[tab_idx]; - entries = tab.remote_entries.clone(); - selected = tab.remote_selected.clone(); - } - - let is_connected = state.tabs[tab_idx].status == ConnectionStatus::Connected; - let conn_id = state.tabs[tab_idx].id.clone(); - - let table_id = format!("remote_table_{}", tab_idx); - let conn_id_for_drag = conn_id.clone(); - let FileTableResponse { - double_clicked, - clicked, - dropped_on_dir, - context_action, - } = file_table( - ui, - &table_id, - &entries, - &mut selected, - move |entry| { - if is_connected { - make_remote_payload(entry, &conn_id_for_drag) - } else { - None - } - }, - build_context_menu, - ); - - state.tabs[tab_idx].remote_selected = selected; - if clicked.is_some() { - state.active_pane = Pane::Remote; - } - - if let Some((target_entry, payload)) = dropped_on_dir { - handle_drop( - state, - tab_idx, - queue, - registry, - rt_handle, - &payload, - &target_entry.path, - ); - } - - if let Some(name) = double_clicked { - open_entry(state, tab_idx, registry, rt_handle, queue, &name); - } - - if let Some((entry, action)) = context_action { - handle_row_action( - ui.ctx(), - state, - tab_idx, - registry, - rt_handle, - queue, - &entry, - action, - ); - } - - // Footer - render_footer(ui, state, tab_idx, registry, rt_handle); -} - -fn build_context_menu(ui: &mut Ui, entry: &FileEntry) -> Option { - if context_menu_item(ui, Icon::Folder, "Open", false) { - return Some(RowAction::Open); - } - if entry.name != ".." { - if context_menu_item(ui, Icon::Pen, "Rename", false) { - return Some(RowAction::Rename); - } - if context_menu_item(ui, Icon::Trash, "Delete", true) { - return Some(RowAction::Delete); - } - ui.separator(); - if context_menu_item(ui, Icon::Document, "Copy Path", false) { - return Some(RowAction::CopyPath); - } - if entry.kind == EntryKind::File - && context_menu_item(ui, Icon::Download, "Download to Local", false) - { - return Some(RowAction::Transfer); - } - } - None -} - -fn open_entry( - state: &mut AppState, - tab_idx: usize, - registry: &Arc, - rt_handle: &tokio::runtime::Handle, - queue: &TransferQueue, - name: &str, -) { - let (nav_path, is_dir) = if name == ".." { - let parent = remote_parent(&state.tabs[tab_idx].remote_path); - (parent, true) - } else { - let entry = state.tabs[tab_idx] - .remote_entries - .iter() - .find(|e| e.name == name); - match entry { - Some(e) if e.kind == EntryKind::Dir => (e.path.clone(), true), - _ => (state.tabs[tab_idx].remote_path.clone(), false), - } - }; - - if is_dir { - state.tabs[tab_idx].remote_path = nav_path; - if !state.tabs[tab_idx].loading { - trigger_list(state, tab_idx, registry, rt_handle); - } - } else if let Some(entry) = state.tabs[tab_idx] - .remote_entries - .iter() - .find(|e| e.name == name) - .cloned() - { - queue_download(state, tab_idx, queue, &entry); - } -} - -fn queue_download(state: &mut AppState, tab_idx: usize, queue: &TransferQueue, entry: &FileEntry) { - let connection_id = state.tabs[tab_idx].id.clone(); - let task = TransferTask::new( - TransferKind::Download, - connection_id, - format!("{}/{}", state.local_path.trim_end_matches('/'), entry.name), - entry.path.clone(), - entry.name.clone(), - 0, - ); - queue.push(task); - state.status_message = format!("Download queued: {}", entry.name); -} - -#[allow(clippy::too_many_arguments)] -fn handle_row_action( - ctx: &egui::Context, - state: &mut AppState, - tab_idx: usize, - registry: &Arc, - rt_handle: &tokio::runtime::Handle, - queue: &TransferQueue, - entry: &FileEntry, - action: RowAction, -) { - match action { - RowAction::Open => open_entry(state, tab_idx, registry, rt_handle, queue, &entry.name), - RowAction::Rename => { - state.op_target = Pane::Remote; - state.show_rename_dialog = true; - state.rename_old_name = entry.name.clone(); - state.rename_new_name = entry.name.clone(); - } - RowAction::Delete => { - state.op_target = Pane::Remote; - state.show_delete_dialog = true; - state.delete_name = entry.name.clone(); - } - RowAction::CopyPath => { - ctx.copy_text(entry.path.clone()); - state.status_message = "Path copied".into(); - } - RowAction::Transfer => queue_download(state, tab_idx, queue, entry), - RowAction::Bookmark => {} - } -} - -fn render_path_bar( - ui: &mut Ui, - state: &mut AppState, - tab_idx: usize, - registry: &Arc, - rt_handle: &tokio::runtime::Handle, -) { - let frame = egui::Frame::none() - .fill(BG_BASE) - .inner_margin(egui::Margin::symmetric(12.0, 0.0)); - - frame.show(ui, |ui| { - let width = ui.available_width(); - ui.allocate_ui_with_layout( - egui::vec2(width, 30.0), - egui::Layout::left_to_right(egui::Align::Center), - |ui| { - let host = state.tabs[tab_idx].params.host.clone(); - ui.label( - RichText::new(format!("REMOTE · {}", host)) - .color(TEXT_DIM) - .size(10.5) - .strong(), - ); - ui.add_space(10.0); - - // статус точка - let (dot, dot_col) = match state.tabs[tab_idx].status { - ConnectionStatus::Connected => ("●", GREEN), - ConnectionStatus::Disconnected => ("○", TEXT_HINT), - ConnectionStatus::Connecting => ("◐", YELLOW), - ConnectionStatus::Error(_) => ("×", RED), - }; - ui.label(RichText::new(dot).color(dot_col).size(10.0)); - ui.add_space(4.0); - - // Хлебные крошки для remote path - let path_clone = state.tabs[tab_idx].remote_path.clone(); - let parts: Vec<&str> = path_clone.split('/').filter(|s| !s.is_empty()).collect(); - let mut acc = String::new(); - - // Root - let root_btn = egui::Button::new(RichText::new("/").color(TEXT_DIM).size(12.0)) - .fill(egui::Color32::TRANSPARENT) - .min_size(egui::vec2(12.0, 22.0)); - if ui.add(root_btn).clicked() { - state.tabs[tab_idx].remote_path = "/".into(); - if !state.tabs[tab_idx].loading { - trigger_list(state, tab_idx, registry, rt_handle); - } - } - - for (i, part) in parts.iter().enumerate() { - acc.push('/'); - acc.push_str(part); - let is_last = i == parts.len() - 1; - let path_snap = acc.clone(); - - if is_last { - ui.label(RichText::new(*part).color(TEXT_PRIMARY).size(12.0).strong()); - } else { - let link = - egui::Label::new(RichText::new(*part).color(TEXT_DIM).size(12.0)) - .sense(egui::Sense::click()); - if ui.add(link).clicked() { - state.tabs[tab_idx].remote_path = path_snap; - if !state.tabs[tab_idx].loading { - trigger_list(state, tab_idx, registry, rt_handle); - } - } - ui.label(RichText::new("/").color(TEXT_HINT).size(12.0)); - } - } - }, - ); - }); -} - -fn render_footer( - ui: &mut Ui, - state: &mut AppState, - tab_idx: usize, - registry: &Arc, - rt_handle: &tokio::runtime::Handle, -) { - let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0)); - ui.painter().rect_filled(sep, 0.0, BORDER); - - let frame = egui::Frame::none() - .fill(BG_PANEL) - .inner_margin(egui::Margin::symmetric(8.0, 3.0)); - - frame.show(ui, |ui| { - ui.horizontal(|ui| { - let tab = &state.tabs[tab_idx]; - let count = tab.remote_entries.iter().filter(|e| e.name != "..").count(); - let total_size: u64 = tab.remote_entries.iter().filter_map(|e| e.size).sum(); - - let label = if tab.loading { - "Loading…".to_string() - } else { - format!("{} items", count) - }; - - ui.label(RichText::new(label).color(TEXT_DIM).size(11.0)); - - if total_size > 0 { - ui.label( - RichText::new(format!("({})", format_size(Some(total_size)))) - .color(TEXT_HINT) - .size(11.0), - ); - } - - // Refresh кнопка - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let refresh = egui::Button::image(icons::image(Icon::Refresh, 13.0, ACCENT)) - .fill(egui::Color32::TRANSPARENT) - .min_size(egui::vec2(20.0, 20.0)); - if ui.add(refresh).on_hover_text("Refresh").clicked() - && !state.tabs[tab_idx].loading - { - trigger_list(state, tab_idx, registry, rt_handle); - } - }); - }); - }); -} - -pub fn trigger_list( - state: &mut AppState, - tab_idx: usize, - registry: &Arc, - rt_handle: &tokio::runtime::Handle, -) { - if state.tabs[tab_idx].status != ConnectionStatus::Connected { - state.status_message = "Not connected".into(); - return; - } - state.tabs[tab_idx].loading = true; - - let connection_id = state.tabs[tab_idx].id.clone(); - let path = state.tabs[tab_idx].remote_path.clone(); - let registry = registry.clone(); - - let result = Arc::new(std::sync::Mutex::new( - None::, String>>, - )); - let result_clone = result.clone(); - - rt_handle.spawn(async move { - let fs = match registry.get(&connection_id) { - Some(fs) => fs, - None => { - *result_clone.lock().unwrap() = Some(Err("connection not found".into())); - return; - } - }; - let r = fs.list(&path).await.map_err(|e| e.to_string()); - *result_clone.lock().unwrap() = Some(r); - }); - - state - .pending_remote_list - .push(PendingRemoteList { tab_idx, result }); -} diff --git a/src/ui/panels/remote_pane/actions.rs b/src/ui/panels/remote_pane/actions.rs new file mode 100644 index 0000000..335f1d9 --- /dev/null +++ b/src/ui/panels/remote_pane/actions.rs @@ -0,0 +1,189 @@ +//! Действия над удалённой ФС: drop-обработка, открытие записей, контекстное меню. +use egui::Ui; +use std::sync::Arc; + +use super::{remote_parent, trigger_list}; +use crate::domain::connection::ConnectionStatus; +use crate::domain::file_entry::{EntryKind, FileEntry}; +use crate::domain::transfer::{TransferKind, TransferTask}; +use crate::fs::remote::RemoteRegistry; +use crate::transfer::queue::TransferQueue; +use crate::ui::drag::DragPayload; +use crate::ui::icons::Icon; +use crate::ui::panels::file_pane::{RowAction, context_menu_item}; +use crate::ui::state::{AppState, Pane}; + +/// Обрабатывает бросок файла в директорию `dest_dir` на удалённой стороне — +/// общий код для дропа на весь пейн и дропа на конкретную папку в списке. +pub(super) fn handle_drop( + state: &mut AppState, + tab_idx: usize, + queue: &TransferQueue, + registry: &Arc, + rt_handle: &tokio::runtime::Handle, + payload: &DragPayload, + dest_dir: &str, +) { + let is_connected = state.tabs[tab_idx].status == ConnectionStatus::Connected; + let connection_id = state.tabs[tab_idx].id.clone(); + + match payload { + DragPayload::LocalFile(local_path, file_name) => { + if is_connected { + let dest = format!("{}/{}", dest_dir.trim_end_matches('/'), file_name); + let task = TransferTask::new( + TransferKind::Upload, + connection_id, + local_path.clone(), + dest, + file_name.clone(), + 0, + ); + queue.push(task); + state.status_message = + crate::i18n::tf("toolbar.upload_queued", &[("{name}", file_name)]); + } else { + state.status_message = crate::i18n::t("panels.remote.no_active_connection").into(); + } + } + DragPayload::RemoteFile(remote_path, file_name, conn_id) => { + if *conn_id != connection_id { + state.status_message = + crate::i18n::t("panels.remote.cannot_move_cross_connection").into(); + return; + } + let dest = format!("{}/{}", dest_dir.trim_end_matches('/'), file_name); + if dest == *remote_path { + return; + } + let registry = registry.clone(); + let from = remote_path.clone(); + let conn_id_clone = conn_id.clone(); + let result = Arc::new(std::sync::Mutex::new(None::>)); + let result_clone = result.clone(); + rt_handle.spawn(async move { + let fs = match registry.get(&conn_id_clone) { + Some(fs) => fs, + None => { + *result_clone.lock().unwrap() = Some(Err("connection not found".into())); + return; + } + }; + let r = fs.rename(&from, &dest).await.map_err(|e| e.to_string()); + *result_clone.lock().unwrap() = Some(r); + }); + state.pending_rename_result = Some(result); + state.status_message = + crate::i18n::tf("panels.remote.moving", &[("{name}", file_name)]); + } + } +} + +pub(super) fn build_context_menu(ui: &mut Ui, entry: &FileEntry) -> Option { + use crate::i18n::t; + if context_menu_item(ui, Icon::Folder, t("common.open"), false) { + return Some(RowAction::Open); + } + if entry.name != ".." { + if context_menu_item(ui, Icon::Pen, t("common.rename"), false) { + return Some(RowAction::Rename); + } + if context_menu_item(ui, Icon::Trash, t("common.delete"), true) { + return Some(RowAction::Delete); + } + ui.separator(); + if context_menu_item(ui, Icon::Document, t("common.copy_path"), false) { + return Some(RowAction::CopyPath); + } + if entry.kind == EntryKind::File + && context_menu_item(ui, Icon::Download, t("panels.remote.download_to_local"), false) + { + return Some(RowAction::Transfer); + } + } + None +} + +pub(super) fn open_entry( + state: &mut AppState, + tab_idx: usize, + registry: &Arc, + rt_handle: &tokio::runtime::Handle, + queue: &TransferQueue, + name: &str, +) { + let (nav_path, is_dir) = if name == ".." { + let parent = remote_parent(&state.tabs[tab_idx].remote_path); + (parent, true) + } else { + let entry = state.tabs[tab_idx] + .remote_entries + .iter() + .find(|e| e.name == name); + match entry { + Some(e) if e.kind == EntryKind::Dir => (e.path.clone(), true), + _ => (state.tabs[tab_idx].remote_path.clone(), false), + } + }; + + if is_dir { + state.tabs[tab_idx].remote_path = nav_path; + if !state.tabs[tab_idx].loading { + trigger_list(state, tab_idx, registry, rt_handle); + } + } else if let Some(entry) = state.tabs[tab_idx] + .remote_entries + .iter() + .find(|e| e.name == name) + .cloned() + { + queue_download(state, tab_idx, queue, &entry); + } +} + +fn queue_download(state: &mut AppState, tab_idx: usize, queue: &TransferQueue, entry: &FileEntry) { + let connection_id = state.tabs[tab_idx].id.clone(); + let task = TransferTask::new( + TransferKind::Download, + connection_id, + format!("{}/{}", state.local_path.trim_end_matches('/'), entry.name), + entry.path.clone(), + entry.name.clone(), + 0, + ); + queue.push(task); + state.status_message = crate::i18n::tf("toolbar.download_queued", &[("{name}", &entry.name)]); +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn handle_row_action( + ctx: &egui::Context, + state: &mut AppState, + tab_idx: usize, + registry: &Arc, + rt_handle: &tokio::runtime::Handle, + queue: &TransferQueue, + entry: &FileEntry, + action: RowAction, +) { + match action { + RowAction::Open => open_entry(state, tab_idx, registry, rt_handle, queue, &entry.name), + RowAction::Rename => { + state.op_target = Pane::Remote; + state.show_rename_dialog = true; + state.rename_old_name = entry.name.clone(); + state.rename_new_name = entry.name.clone(); + } + RowAction::Delete => { + state.op_target = Pane::Remote; + state.show_delete_dialog = true; + state.delete_name = entry.name.clone(); + } + RowAction::CopyPath => { + ctx.copy_text(entry.path.clone()); + state.status_message = crate::i18n::t("panels.path_copied").into(); + } + RowAction::Transfer => queue_download(state, tab_idx, queue, entry), + RowAction::Bookmark => {} + } +} diff --git a/src/ui/panels/remote_pane/bars.rs b/src/ui/panels/remote_pane/bars.rs new file mode 100644 index 0000000..f37ecab --- /dev/null +++ b/src/ui/panels/remote_pane/bars.rs @@ -0,0 +1,170 @@ +//! Верхняя (путь + статус) и нижняя (счётчик + refresh) полоски удалённого пейна. +use egui::{RichText, Ui}; +use std::sync::Arc; + +use super::trigger_list; +use crate::domain::connection::ConnectionStatus; +use crate::fs::remote::RemoteRegistry; +use crate::ui::icons::{self, Icon}; +use crate::ui::panels::file_pane::format_size; +use crate::ui::state::AppState; +use crate::ui::theme::*; +use crate::ui::widgets::button; + +pub(super) fn render_path_bar( + ui: &mut Ui, + state: &mut AppState, + tab_idx: usize, + registry: &Arc, + rt_handle: &tokio::runtime::Handle, +) { + let frame = egui::Frame::none() + .fill(BG_BASE) + .inner_margin(egui::Margin::symmetric(12.0, 0.0)); + + frame.show(ui, |ui| { + let width = ui.available_width(); + ui.allocate_ui_with_layout( + egui::vec2(width, 30.0), + egui::Layout::left_to_right(egui::Align::Center), + |ui| { + let host = state.tabs[tab_idx].params.host.clone(); + ui.label( + RichText::new(crate::i18n::tf("panels.remote_label", &[("{host}", &host)])) + .color(TEXT_DIM) + .size(10.5) + .strong(), + ); + ui.add_space(10.0); + + // статус точка + let (dot, dot_col) = match state.tabs[tab_idx].status { + ConnectionStatus::Connected => ("●", GREEN), + ConnectionStatus::Disconnected => ("○", TEXT_HINT), + ConnectionStatus::Connecting => ("◐", YELLOW), + ConnectionStatus::Error(_) => ("×", RED), + }; + ui.label(RichText::new(dot).color(dot_col).size(10.0)); + ui.add_space(4.0); + + breadcrumbs(ui, state, tab_idx, registry, rt_handle); + + // Индикатор загрузки — справа, не сдвигает таблицу под баром + if state.tabs[tab_idx].loading { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label( + RichText::new(crate::i18n::t("panels.loading")) + .color(TEXT_HINT) + .size(10.5), + ); + ui.add_space(4.0); + ui.spinner(); + }); + } + }, + ); + }); +} + +/// Хлебные крошки: root + сегменты пути. Каждый сегмент — ghost-кнопка с +/// подсветкой и курсором-рукой, чтобы кликабельность читалась сразу. +fn breadcrumbs( + ui: &mut Ui, + state: &mut AppState, + tab_idx: usize, + registry: &Arc, + rt_handle: &tokio::runtime::Handle, +) { + let path_clone = state.tabs[tab_idx].remote_path.clone(); + let parts: Vec<&str> = path_clone.split('/').filter(|s| !s.is_empty()).collect(); + let mut acc = String::new(); + + let root = egui::Button::new(RichText::new("/").color(TEXT_DIM).size(12.0)) + .min_size(egui::vec2(12.0, 22.0)); + if button::ghost(ui, root).clicked() { + navigate(state, tab_idx, "/".into(), registry, rt_handle); + } + + for (i, part) in parts.iter().enumerate() { + acc.push('/'); + acc.push_str(part); + let is_last = i == parts.len() - 1; + + if is_last { + ui.label(RichText::new(*part).color(TEXT_PRIMARY).size(12.0).strong()); + } else { + let path_snap = acc.clone(); + let seg = egui::Button::new(RichText::new(*part).color(TEXT_DIM).size(12.0)) + .min_size(egui::vec2(0.0, 22.0)); + if button::ghost(ui, seg).clicked() { + navigate(state, tab_idx, path_snap, registry, rt_handle); + } + ui.label(RichText::new("/").color(TEXT_HINT).size(12.0)); + } + } +} + +fn navigate( + state: &mut AppState, + tab_idx: usize, + path: String, + registry: &Arc, + rt_handle: &tokio::runtime::Handle, +) { + state.tabs[tab_idx].remote_path = path; + if !state.tabs[tab_idx].loading { + trigger_list(state, tab_idx, registry, rt_handle); + } +} + +pub(super) fn render_footer( + ui: &mut Ui, + state: &mut AppState, + tab_idx: usize, + registry: &Arc, + rt_handle: &tokio::runtime::Handle, +) { + let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0)); + ui.painter().rect_filled(sep, 0.0, BORDER); + + let frame = egui::Frame::none() + .fill(BG_PANEL) + .inner_margin(egui::Margin::symmetric(8.0, 3.0)); + + frame.show(ui, |ui| { + ui.horizontal(|ui| { + let tab = &state.tabs[tab_idx]; + let count = tab.remote_entries.iter().filter(|e| e.name != "..").count(); + let total_size: u64 = tab.remote_entries.iter().filter_map(|e| e.size).sum(); + + let label = if tab.loading { + crate::i18n::t("panels.loading").to_string() + } else { + crate::i18n::tf("panels.count_items", &[("{n}", &count.to_string())]) + }; + + ui.label(RichText::new(label).color(TEXT_DIM).size(11.0)); + + if total_size > 0 { + ui.label( + RichText::new(format!("({})", format_size(Some(total_size)))) + .color(TEXT_HINT) + .size(11.0), + ); + } + + // Refresh — тот же серый, что и в тулбаре (единый стиль) + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let refresh = egui::Button::image(icons::image(Icon::Refresh, 13.0, TEXT_DIM)) + .min_size(egui::vec2(22.0, 22.0)); + if button::ghost(ui, refresh) + .on_hover_text(crate::i18n::t("common.refresh")) + .clicked() + && !state.tabs[tab_idx].loading + { + trigger_list(state, tab_idx, registry, rt_handle); + } + }); + }); + }); +} diff --git a/src/ui/panels/remote_pane/mod.rs b/src/ui/panels/remote_pane/mod.rs new file mode 100644 index 0000000..0e3b781 --- /dev/null +++ b/src/ui/panels/remote_pane/mod.rs @@ -0,0 +1,183 @@ +use egui::Ui; +use std::sync::Arc; + +use crate::domain::connection::ConnectionStatus; +use crate::domain::file_entry::FileEntry; +use crate::fs::remote::RemoteRegistry; +use crate::transfer::queue::TransferQueue; +use crate::ui::drag::{DragPayload, make_remote_payload}; +use crate::ui::panels::file_pane::{FileTableResponse, file_table}; +use crate::ui::state::{AppState, Pane, PendingRemoteList}; + +mod actions; +mod bars; + +pub fn remote_parent(path: &str) -> String { + let trimmed = path.trim_end_matches('/'); + if trimmed.is_empty() || trimmed == "/" { + return "/".into(); + } + if let Some(idx) = trimmed.rfind('/') { + let p = &trimmed[..idx]; + if p.is_empty() { + "/".into() + } else { + p.to_string() + } + } else { + "/".into() + } +} + +pub fn render( + ui: &mut Ui, + state: &mut AppState, + tab_idx: usize, + registry: &Arc, + rt_handle: &tokio::runtime::Handle, + queue: &TransferQueue, +) { + let frame = egui::Frame::none() + .fill(crate::ui::theme::BG_CONTENT) + .inner_margin(egui::Margin::same(0.0)); + + let (_inner, dropped) = ui.dnd_drop_zone::(frame, |ui| { + render_content(ui, state, tab_idx, registry, rt_handle, queue); + }); + + let remote_path = state.tabs[tab_idx].remote_path.clone(); + + if let Some(payload_arc) = dropped { + actions::handle_drop( + state, + tab_idx, + queue, + registry, + rt_handle, + &payload_arc, + &remote_path, + ); + } +} + +fn render_content( + ui: &mut Ui, + state: &mut AppState, + tab_idx: usize, + registry: &Arc, + rt_handle: &tokio::runtime::Handle, + queue: &TransferQueue, +) { + bars::render_path_bar(ui, state, tab_idx, registry, rt_handle); + + let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0)); + ui.painter().rect_filled(sep, 0.0, crate::ui::theme::BORDER); + + // Table + let entries; + let mut selected; + { + let tab = &state.tabs[tab_idx]; + entries = tab.remote_entries.clone(); + selected = tab.remote_selected.clone(); + } + + let is_connected = state.tabs[tab_idx].status == ConnectionStatus::Connected; + let conn_id = state.tabs[tab_idx].id.clone(); + + let table_id = format!("remote_table_{}", tab_idx); + let conn_id_for_drag = conn_id.clone(); + let FileTableResponse { + double_clicked, + clicked, + dropped_on_dir, + context_action, + } = file_table( + ui, + &table_id, + &entries, + &mut selected, + move |entry| { + if is_connected { + make_remote_payload(entry, &conn_id_for_drag) + } else { + None + } + }, + actions::build_context_menu, + ); + + state.tabs[tab_idx].remote_selected = selected; + if clicked.is_some() { + state.active_pane = Pane::Remote; + } + + if let Some((target_entry, payload)) = dropped_on_dir { + actions::handle_drop( + state, + tab_idx, + queue, + registry, + rt_handle, + &payload, + &target_entry.path, + ); + } + + if let Some(name) = double_clicked { + actions::open_entry(state, tab_idx, registry, rt_handle, queue, &name); + } + + if let Some((entry, action)) = context_action { + actions::handle_row_action( + ui.ctx(), + state, + tab_idx, + registry, + rt_handle, + queue, + &entry, + action, + ); + } + + bars::render_footer(ui, state, tab_idx, registry, rt_handle); +} + +pub fn trigger_list( + state: &mut AppState, + tab_idx: usize, + registry: &Arc, + rt_handle: &tokio::runtime::Handle, +) { + if state.tabs[tab_idx].status != ConnectionStatus::Connected { + state.status_message = crate::i18n::t("status.not_connected").into(); + return; + } + state.tabs[tab_idx].loading = true; + + let connection_id = state.tabs[tab_idx].id.clone(); + let path = state.tabs[tab_idx].remote_path.clone(); + let registry = registry.clone(); + + let result = Arc::new(std::sync::Mutex::new( + None::, String>>, + )); + let result_clone = result.clone(); + + rt_handle.spawn(async move { + let fs = match registry.get(&connection_id) { + Some(fs) => fs, + None => { + *result_clone.lock().unwrap() = Some(Err("connection not found".into())); + return; + } + }; + let r = fs.list(&path).await.map_err(|e| e.to_string()); + *result_clone.lock().unwrap() = Some(r); + }); + + state + .pending_remote_list + .push(PendingRemoteList { tab_idx, result }); +} diff --git a/src/ui/panels/sidebar.rs b/src/ui/panels/sidebar.rs index dca8140..eca7474 100644 --- a/src/ui/panels/sidebar.rs +++ b/src/ui/panels/sidebar.rs @@ -1,8 +1,10 @@ //! Левая боковая панель — bookmarks + места +use crate::i18n::t; use crate::ui::icons::{self, Icon}; use crate::ui::panels::local_pane::refresh_local; use crate::ui::state::AppState; use crate::ui::theme::*; +use crate::ui::widgets::row::clickable_row; use egui::{RichText, Ui}; use std::sync::{Arc, Mutex}; @@ -12,20 +14,18 @@ pub fn render(ui: &mut Ui, state: &mut AppState, db: &Arc = None; for bm in &state.bookmarks.clone() { let is_active = state.local_path == bm.path; - sidebar_item(ui, Icon::Star, &bm.name, is_active, || { + let resp = sidebar_item(ui, Icon::Star, &bm.name, is_active, || { state.local_path = bm.path.clone(); refresh_local(state); - }) - .context_menu(|ui| { - if ui.button("Remove bookmark").clicked() { + }); + crate::ui::widgets::context_menu::context_menu(&resp, |ui| { + if ui.button(t("panels.sidebar.remove_bookmark")).clicked() { to_remove = Some(bm.id); ui.close_menu(); } @@ -123,35 +118,16 @@ fn sidebar_item( active: bool, mut on_click: impl FnMut(), ) -> egui::Response { - let bg = if active { - BG_ROW_SEL - } else { - egui::Color32::TRANSPARENT - }; - let text_col = TEXT_PRIMARY; let icon_col = if active { ACCENT } else { TEXT_DIM }; - - let resp = egui::Frame::none() - .fill(bg) - .rounding(RADIUS_SM) - .inner_margin(egui::Margin::symmetric(8.0, 0.0)) - .show(ui, |ui| { - ui.allocate_ui_with_layout( - egui::vec2(SIDEBAR_W - 32.0, 28.0), - egui::Layout::left_to_right(egui::Align::Center), - |ui| { - icons::icon(ui, icon, 14.0, icon_col); - ui.add_space(8.0); - ui.label(RichText::new(label).color(text_col).size(12.5)); - }, - ); - }); - - let response = resp.response.interact(egui::Sense::click()); - if response.clicked() { + let resp = clickable_row(ui, active, 28.0, |ui| { + icons::icon(ui, icon, 14.0, icon_col); + ui.add_space(8.0); + ui.label(RichText::new(label).color(TEXT_PRIMARY).size(12.5)); + }); + if resp.clicked() { on_click(); } - response + resp } fn quick_access_icon(name: &str) -> Icon { @@ -167,6 +143,22 @@ fn quick_access_icon(name: &str) -> Icon { } } +/// `state.quick_access[].name` — стабильный английский идентификатор (нужен +/// для матчинга иконки в [`quick_access_icon`] и не должен зависеть от языка); +/// здесь он превращается в переведённую подпись для показа в сайдбаре. +fn quick_access_label(name: &str) -> &'static str { + match name { + "Home" => t("panels.sidebar.qa_home"), + "Desktop" => t("panels.sidebar.qa_desktop"), + "Documents" => t("panels.sidebar.qa_documents"), + "Downloads" => t("panels.sidebar.qa_downloads"), + "Pictures" => t("panels.sidebar.qa_pictures"), + "Music" => t("panels.sidebar.qa_music"), + "Videos" => t("panels.sidebar.qa_videos"), + _ => "", + } +} + fn find_mounts() -> Vec { let mut result = Vec::new(); for prefix in ["/media", "/mnt"] { diff --git a/src/ui/panels/status_bar.rs b/src/ui/panels/status_bar.rs index 0fd10af..a246c4a 100644 --- a/src/ui/panels/status_bar.rs +++ b/src/ui/panels/status_bar.rs @@ -62,7 +62,11 @@ pub fn render(ui: &mut egui::Ui, state: &AppState) { .size(11.0), ); } else { - ui.label(RichText::new("Not connected").color(TEXT_HINT).size(11.0)); + ui.label( + RichText::new(crate::i18n::t("status.not_connected")) + .color(TEXT_HINT) + .size(11.0), + ); } }); }, diff --git a/src/ui/panels/tabs.rs b/src/ui/panels/tabs.rs deleted file mode 100644 index 4c93689..0000000 --- a/src/ui/panels/tabs.rs +++ /dev/null @@ -1,112 +0,0 @@ -use crate::domain::connection::ConnectionStatus; -use crate::ui::state::AppState; -use crate::ui::theme::*; -use egui::{Color32, RichText, Ui}; - -pub fn render(ui: &mut Ui, state: &mut AppState) { - let frame = egui::Frame::none() - .fill(BG_BASE) - .inner_margin(egui::Margin { - left: 6.0, - right: 6.0, - top: 0.0, - bottom: 0.0, - }); - - frame.show(ui, |ui| { - let width = ui.available_width(); - ui.allocate_ui_with_layout( - egui::vec2(width, TABBAR_H), - egui::Layout::left_to_right(egui::Align::Center), - |ui| { - let mut to_close: Option = None; - - if state.tabs.is_empty() { - ui.label(RichText::new("No connections").color(TEXT_HINT).size(11.0)); - } - - for (i, tab) in state.tabs.iter().enumerate() { - let is_active = i == state.active_tab; - - let (dot_col, dot) = match &tab.status { - ConnectionStatus::Connected => (GREEN, "●"), - ConnectionStatus::Disconnected => (TEXT_HINT, "○"), - ConnectionStatus::Connecting => (YELLOW, "◐"), - ConnectionStatus::Error(_) => (RED, "×"), - }; - - let bg = if is_active { - BG_TAB_ACTIVE - } else { - BG_TAB_IDLE - }; - let tcol = if is_active { TEXT_PRIMARY } else { TEXT_DIM }; - - let tab_response = egui::Frame::none() - .fill(bg) - .rounding(RADIUS_MD) - .inner_margin(egui::Margin { - left: 10.0, - right: 4.0, - top: 0.0, - bottom: 0.0, - }) - .show(ui, |ui| { - ui.allocate_ui_with_layout( - egui::vec2(ui.available_width().min(220.0), 24.0), - egui::Layout::left_to_right(egui::Align::Center), - |ui| { - // цветная точка статуса - ui.label(RichText::new(dot).color(dot_col).size(9.0)); - ui.add_space(4.0); - // название вкладки - let lbl = egui::Label::new( - RichText::new(&tab.label).color(tcol).size(12.0), - ) - .sense(egui::Sense::click()); - if ui.add(lbl).clicked() { - // обрабатывается ниже - } - ui.add_space(6.0); - // кнопка закрытия - let close = egui::Label::new( - RichText::new("×").color(TEXT_HINT).size(14.0), - ) - .sense(egui::Sense::click()); - if ui.add(close).clicked() { - to_close = Some(i); - } - ui.add_space(4.0); - }, - ); - }); - - if tab_response.response.clicked() { - state.active_tab = i; - } - } - - // кнопка "+" - ui.add_space(4.0); - let plus = egui::Button::new(RichText::new("+").color(TEXT_DIM).size(15.0)) - .fill(Color32::TRANSPARENT) - .rounding(RADIUS_MD) - .min_size(egui::vec2(24.0, 24.0)); - if ui.add(plus).clicked() { - state.show_connect_dialog = true; - } - - if let Some(idx) = to_close { - state.tabs.remove(idx); - if !state.tabs.is_empty() && state.active_tab >= state.tabs.len() { - state.active_tab = state.tabs.len() - 1; - } - } - }, - ); - }); - - // нижняя граница таббара - let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0)); - ui.painter().rect_filled(sep, 0.0, BORDER); -} diff --git a/src/ui/panels/toolbar.rs b/src/ui/panels/toolbar.rs deleted file mode 100644 index 2583533..0000000 --- a/src/ui/panels/toolbar.rs +++ /dev/null @@ -1,355 +0,0 @@ -use crate::domain::transfer::{TransferKind, TransferTask}; -use crate::transfer::queue::TransferQueue; -use crate::ui::icons::{self, Icon}; -use crate::ui::state::{AppState, Pane}; -use crate::ui::theme::*; -use egui::{Align, Layout, RichText, Ui}; - -pub fn render(ui: &mut Ui, state: &mut AppState, queue: &TransferQueue) { - let frame = egui::Frame::none() - .fill(BG_TOOLBAR) - .inner_margin(egui::Margin::symmetric(8.0, 0.0)); - - frame.show(ui, |ui| { - let width = ui.available_width(); - ui.allocate_ui_with_layout( - egui::vec2(width, TOOLBAR_H), - Layout::left_to_right(Align::Center), - |ui| { - // Логотип - icons::icon(ui, Icon::Folder, 17.0, ACCENT); - ui.add_space(4.0); - ui.label( - RichText::new("LoFlum") - .color(TEXT_PRIMARY) - .size(14.0) - .strong(), - ); - - ui.add_space(8.0); - separator_v(ui); - ui.add_space(8.0); - - // Новое подключение — единственная акцентная кнопка в тулбаре - let connect_btn = egui::Button::image_and_text( - icons::image(Icon::AddCircleLinear, 15.0, TEXT_PRIMARY), - RichText::new("New Connection") - .color(TEXT_PRIMARY) - .size(12.5), - ) - .fill(ACCENT_DIM) - .rounding(RADIUS_MD) - .min_size(egui::vec2(0.0, 30.0)); - if ui.add(connect_btn).clicked() { - state.show_connect_dialog = true; - } - - separator_v(ui); - - // Кнопки работы с файлами - let has_conn = state - .active_tab_ref() - .map(|t| t.status == crate::domain::connection::ConnectionStatus::Connected) - .unwrap_or(false); - - let has_local_sel = state.local_selected.is_some(); - let has_remote_sel = state - .active_tab_ref() - .and_then(|t| t.remote_selected.as_ref()) - .is_some(); - - toolbar_btn_enabled( - ui, - Icon::Upload, - "Upload", - has_conn && has_local_sel, - || { - queue_selected_upload(state, queue); - }, - ); - toolbar_btn_enabled( - ui, - Icon::Download, - "Download", - has_conn && has_remote_sel, - || { - queue_selected_download(state, queue); - }, - ); - - separator_v(ui); - - // Очередь — с счётчиком - let q_count = state.queue_tasks.len(); - let q_label = if q_count > 0 { - format!("Queue {}", q_count) - } else { - "Queue".to_string() - }; - let q_active = state.show_queue; - toolbar_btn(ui, Icon::Clock, &q_label, q_active, || { - state.show_queue = !state.show_queue; - }); - - separator_v(ui); - - let on_remote = state.active_pane == Pane::Remote; - let can_new_folder = if on_remote { has_conn } else { true }; - let can_delete_rename = if on_remote { - has_conn && has_remote_sel - } else { - has_local_sel - }; - - toolbar_icon_btn(ui, Icon::Refresh, has_conn, "Refresh", || { - // refresh remote — вызывается снаружи через state флаг - state.pending_refresh = true; - }); - toolbar_icon_btn( - ui, - Icon::FolderWithFiles, - can_new_folder, - "New Folder", - || { - state.op_target = state.active_pane; - state.show_mkdir_dialog = true; - state.mkdir_name.clear(); - }, - ); - toolbar_icon_btn(ui, Icon::Trash, can_delete_rename, "Delete", || { - let name = if on_remote { - state - .active_tab_ref() - .and_then(|t| t.remote_selected.clone()) - } else { - state.local_selected.clone() - }; - if let Some(name) = name { - state.op_target = state.active_pane; - state.show_delete_dialog = true; - state.delete_name = name; - } - }); - toolbar_icon_btn(ui, Icon::Pen, can_delete_rename, "Rename", || { - let name = if on_remote { - state - .active_tab_ref() - .and_then(|t| t.remote_selected.clone()) - } else { - state.local_selected.clone() - }; - if let Some(name) = name { - state.op_target = state.active_pane; - state.show_rename_dialog = true; - state.rename_old_name = name.clone(); - state.rename_new_name = name; - } - }); - - // Правая часть — History / Bookmarks / Settings - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - // На macOS кнопка настроек живёт в нативном верхнем меню - // (LoFlum → Settings…), здесь она не нужна. - #[cfg(not(target_os = "macos"))] - toolbar_icon_btn(ui, Icon::Settings, true, "Settings", || { - state.show_settings_dialog = true; - }); - toolbar_btn(ui, Icon::History, "History", state.show_history, || { - state.show_history = !state.show_history; - state.show_bookmarks = false; - }); - }); - }, - ); - }); - - // История поверх - if state.show_history { - render_history_popup(ui, state); - } -} - -fn render_history_popup(ui: &mut Ui, state: &mut AppState) { - let available = ui.clip_rect(); - let popup_rect = egui::Rect::from_min_size( - egui::pos2(available.max.x - 280.0, TOOLBAR_H), - egui::vec2(270.0, 200.0), - ); - - egui::Area::new(egui::Id::new("history_popup_area")) - .fixed_pos(popup_rect.min) - .order(egui::Order::Foreground) - .show(ui.ctx(), |ui| { - egui::Frame::none() - .fill(BG_PANEL) - .stroke(egui::Stroke::new(1.0, BORDER)) - .rounding(RADIUS_LG) - .inner_margin(egui::Margin::same(10.0)) - .show(ui, |ui| { - ui.set_width(260.0); - ui.horizontal(|ui| { - icons::icon(ui, Icon::ServerSquare, 12.0, TEXT_DIM); - ui.add_space(6.0); - ui.label( - RichText::new("Recent connections") - .color(TEXT_DIM) - .size(11.0) - .strong(), - ); - }); - ui.add_space(4.0); - if state.history.is_empty() { - ui.label(RichText::new("No history yet").color(TEXT_HINT)); - } else { - egui::ScrollArea::vertical() - .max_height(160.0) - .show(ui, |ui| { - for entry in &state.history.clone() { - let label = - format!("{}@{}:{}", entry.user, entry.host, entry.port); - let time = - RichText::new(&entry.time).color(TEXT_HINT).size(10.0); - let btn = ui.add( - egui::Button::new( - RichText::new(&label).color(TEXT_PRIMARY).size(12.0), - ) - .fill(egui::Color32::TRANSPARENT) - .min_size(egui::vec2(240.0, 24.0)), - ); - if btn.clicked() { - state.pending_history_reconnect = Some(entry.clone()); - state.show_history = false; - } - btn.context_menu(|ui| { - if ui.button("Edit").clicked() { - crate::ui::dialogs::connection::edit_history_entry( - state, entry, - ); - state.show_history = false; - ui.close_menu(); - } - if ui.button("Save").clicked() { - state.pending_history_save = Some(entry.clone()); - ui.close_menu(); - } - }); - ui.label(time); - } - }); - } - ui.add_space(4.0); - if ui - .add( - egui::Button::new(RichText::new("Close").color(TEXT_DIM).size(11.0)) - .fill(egui::Color32::TRANSPARENT), - ) - .clicked() - { - state.show_history = false; - } - }); - }); -} - -fn toolbar_btn(ui: &mut Ui, icon: Icon, label: &str, active: bool, mut on_click: impl FnMut()) { - let fill = if active { - BG_TAB_ACTIVE - } else { - egui::Color32::TRANSPARENT - }; - let text_col = if active { ACCENT } else { TEXT_PRIMARY }; - let btn = egui::Button::image_and_text( - icons::image(icon, 15.0, text_col), - RichText::new(label).color(text_col).size(12.5), - ) - .fill(fill) - .rounding(RADIUS_MD) - .min_size(egui::vec2(0.0, 30.0)); - if ui.add(btn).clicked() { - on_click(); - } -} - -fn toolbar_btn_enabled( - ui: &mut Ui, - icon: Icon, - label: &str, - enabled: bool, - on_click: impl FnMut(), -) { - ui.add_enabled_ui(enabled, |ui| { - toolbar_btn(ui, icon, label, false, on_click); - }); -} - -fn toolbar_icon_btn( - ui: &mut Ui, - icon: Icon, - enabled: bool, - hover: &str, - mut on_click: impl FnMut(), -) { - ui.add_enabled_ui(enabled, |ui| { - let col = if enabled { TEXT_DIM } else { TEXT_HINT }; - let btn = egui::Button::image(icons::image(icon, 16.0, col)) - .fill(egui::Color32::TRANSPARENT) - .rounding(RADIUS_MD) - .min_size(egui::vec2(30.0, 30.0)); - if ui.add(btn).on_hover_text(hover).clicked() { - on_click(); - } - }); -} - -fn separator_v(ui: &mut Ui) { - let (_, rect) = ui.allocate_space(egui::vec2(1.0, 20.0)); - ui.painter().rect_filled(rect, 0.0, BORDER); -} - -fn queue_selected_upload(state: &mut AppState, queue: &TransferQueue) { - let Some(name) = state.local_selected.clone() else { - return; - }; - let Some(tab) = state.active_tab_ref() else { - return; - }; - let connection_id = tab.id.clone(); - let remote_dir = tab.remote_path.clone(); - let local_path = format!("{}/{}", state.local_path.trim_end_matches('/'), name); - let remote_path = format!("{}/{}", remote_dir.trim_end_matches('/'), name); - let task = TransferTask::new( - TransferKind::Upload, - connection_id, - local_path, - remote_path, - name.clone(), - 0, - ); - queue.push(task); - state.status_message = format!("Upload queued: {}", name); -} - -fn queue_selected_download(state: &mut AppState, queue: &TransferQueue) { - let Some(tab) = state.active_tab_ref() else { - return; - }; - let Some(name) = tab.remote_selected.clone() else { - return; - }; - let Some(entry) = tab.remote_entries.iter().find(|e| e.name == name) else { - return; - }; - let connection_id = tab.id.clone(); - let remote_path = entry.path.clone(); - let local_path = format!("{}/{}", state.local_path.trim_end_matches('/'), name); - let task = TransferTask::new( - TransferKind::Download, - connection_id, - local_path, - remote_path, - name.clone(), - 0, - ); - queue.push(task); - state.status_message = format!("Download queued: {}", name); -} diff --git a/src/ui/panels/toolbar/actions.rs b/src/ui/panels/toolbar/actions.rs new file mode 100644 index 0000000..1ebefb4 --- /dev/null +++ b/src/ui/panels/toolbar/actions.rs @@ -0,0 +1,52 @@ +//! Постановка выбранного файла в очередь из тулбара (Upload/Download). +use crate::domain::transfer::{TransferKind, TransferTask}; +use crate::transfer::queue::TransferQueue; +use crate::ui::state::AppState; + +pub fn queue_selected_upload(state: &mut AppState, queue: &TransferQueue) { + let Some(name) = state.local_selected.clone() else { + return; + }; + let Some(tab) = state.active_tab_ref() else { + return; + }; + let connection_id = tab.id.clone(); + let remote_dir = tab.remote_path.clone(); + let local_path = format!("{}/{}", state.local_path.trim_end_matches('/'), name); + let remote_path = format!("{}/{}", remote_dir.trim_end_matches('/'), name); + let task = TransferTask::new( + TransferKind::Upload, + connection_id, + local_path, + remote_path, + name.clone(), + 0, + ); + queue.push(task); + state.status_message = crate::i18n::tf("toolbar.upload_queued", &[("{name}", &name)]); +} + +pub fn queue_selected_download(state: &mut AppState, queue: &TransferQueue) { + let Some(tab) = state.active_tab_ref() else { + return; + }; + let Some(name) = tab.remote_selected.clone() else { + return; + }; + let Some(entry) = tab.remote_entries.iter().find(|e| e.name == name) else { + return; + }; + let connection_id = tab.id.clone(); + let remote_path = entry.path.clone(); + let local_path = format!("{}/{}", state.local_path.trim_end_matches('/'), name); + let task = TransferTask::new( + TransferKind::Download, + connection_id, + local_path, + remote_path, + name.clone(), + 0, + ); + queue.push(task); + state.status_message = crate::i18n::tf("toolbar.download_queued", &[("{name}", &name)]); +} diff --git a/src/ui/panels/toolbar/buttons.rs b/src/ui/panels/toolbar/buttons.rs new file mode 100644 index 0000000..3de6895 --- /dev/null +++ b/src/ui/panels/toolbar/buttons.rs @@ -0,0 +1,72 @@ +//! Кнопки тулбара. Все построены поверх `widgets::button`, поэтому мгновенно +//! реагируют на наведение и нажатие (заливка по состояниям), а не только по клику. +use egui::{RichText, Ui, Vec2}; + +use crate::ui::icons::{self, Icon}; +use crate::ui::theme::*; +use crate::ui::widgets::button; + +/// Кнопка с иконкой и текстом; `active` — «включённое» состояние (акцентный текст + подложка). +pub fn text_btn(ui: &mut Ui, icon: Icon, label: &str, active: bool, mut on_click: impl FnMut()) { + let text_col = if active { ACCENT } else { TEXT_PRIMARY }; + let btn = egui::Button::image_and_text( + icons::image(icon, 15.0, text_col), + RichText::new(label).color(text_col).size(12.5), + ) + .rounding(RADIUS_MD) + .min_size(Vec2::new(0.0, 30.0)); + if button::toggle(ui, btn, active).clicked() { + on_click(); + } +} + +/// Иконка-переключатель без подписи (History): подсказка по наведению вместо +/// текста рядом — компактнее в узком тулбаре, поведение как у `text_btn`. +pub fn icon_toggle_btn( + ui: &mut Ui, + icon: Icon, + active: bool, + hover: &str, + mut on_click: impl FnMut(), +) { + let col = if active { ACCENT } else { TEXT_DIM }; + let btn = egui::Button::image(icons::image(icon, 16.0, col)) + .rounding(RADIUS_MD) + .min_size(Vec2::new(30.0, 30.0)); + if button::toggle(ui, btn, active) + .on_hover_text(hover) + .clicked() + { + on_click(); + } +} + +/// Кнопка с иконкой и текстом, которую можно отключить (Upload/Download). +pub fn text_btn_enabled( + ui: &mut Ui, + icon: Icon, + label: &str, + enabled: bool, + on_click: impl FnMut(), +) { + ui.add_enabled_ui(enabled, |ui| text_btn(ui, icon, label, false, on_click)); +} + +/// Иконочная кнопка с подсказкой; серая при disabled. +pub fn icon_btn(ui: &mut Ui, icon: Icon, enabled: bool, hover: &str, mut on_click: impl FnMut()) { + ui.add_enabled_ui(enabled, |ui| { + let col = if enabled { TEXT_DIM } else { TEXT_HINT }; + let btn = egui::Button::image(icons::image(icon, 16.0, col)) + .rounding(RADIUS_MD) + .min_size(Vec2::new(30.0, 30.0)); + if button::ghost(ui, btn).on_hover_text(hover).clicked() { + on_click(); + } + }); +} + +/// Вертикальный разделитель между группами кнопок. +pub fn separator_v(ui: &mut Ui) { + let (_, rect) = ui.allocate_space(Vec2::new(1.0, 20.0)); + ui.painter().rect_filled(rect, 0.0, BORDER); +} diff --git a/src/ui/panels/toolbar/history.rs b/src/ui/panels/toolbar/history.rs new file mode 100644 index 0000000..7245746 --- /dev/null +++ b/src/ui/panels/toolbar/history.rs @@ -0,0 +1,101 @@ +//! Всплывающий список недавних подключений (кнопка History в тулбаре). +use egui::{RichText, Ui}; + +// Алиас `tr`: параметр `t` в этом файле — анимационная «открытость» (f32), +// а не перевод, так что `crate::i18n::t` импортирован под другим именем. +use crate::i18n::t as tr; +use crate::ui::icons::{self, Icon}; +use crate::ui::state::AppState; +use crate::ui::theme::*; +use crate::ui::widgets::{button, overlay}; + +/// `t` — анимированная «открытость» (0 = скрыт, 1 = открыт): короткий fade +/// вместе с лёгким смещением вниз, тот же язык анимации, что и у диалогов. +pub fn render_history_popup(ui: &mut Ui, state: &mut AppState, t: f32) { + let available = ui.clip_rect(); + let slide = 6.0 * (1.0 - overlay::ease_out(t)); + let popup_rect = egui::Rect::from_min_size( + egui::pos2(available.max.x - 280.0, TOOLBAR_H + slide), + egui::vec2(270.0, 200.0), + ); + + let area = egui::Area::new(egui::Id::new("history_popup_area")) + .fixed_pos(popup_rect.min) + .order(egui::Order::Foreground) + .interactable(t >= 1.0) + .show(ui.ctx(), |ui| { + ui.set_opacity(overlay::ease_out(t)); + egui::Frame::none() + .fill(BG_PANEL) + .stroke(egui::Stroke::new(1.0, BORDER)) + .rounding(RADIUS_LG) + .inner_margin(egui::Margin::same(10.0)) + .show(ui, |ui| { + ui.set_width(260.0); + ui.horizontal(|ui| { + icons::icon(ui, Icon::ServerSquare, 12.0, TEXT_DIM); + ui.add_space(6.0); + ui.label( + RichText::new(tr("history.recent_connections")) + .color(TEXT_DIM) + .size(11.0) + .strong(), + ); + }); + ui.add_space(4.0); + if state.history.is_empty() { + ui.label(RichText::new(tr("history.no_history")).color(TEXT_HINT)); + } else { + egui::ScrollArea::vertical() + .max_height(160.0) + .show(ui, |ui| { + for entry in &state.history.clone() { + let label = + format!("{}@{}:{}", entry.user, entry.host, entry.port); + let time = + RichText::new(&entry.time).color(TEXT_HINT).size(10.0); + let btn = button::ghost( + ui, + egui::Button::new( + RichText::new(&label).color(TEXT_PRIMARY).size(12.0), + ) + .min_size(egui::vec2(240.0, 24.0)), + ); + if btn.clicked() { + state.pending_history_reconnect = Some(entry.clone()); + state.show_history = false; + } + crate::ui::widgets::context_menu::context_menu(&btn, |ui| { + if ui.button(tr("common.edit")).clicked() { + crate::ui::dialogs::connection::edit_history_entry( + state, entry, + ); + state.show_history = false; + ui.close_menu(); + } + if ui.button(tr("common.save")).clicked() { + state.pending_history_save = Some(entry.clone()); + ui.close_menu(); + } + }); + ui.label(time); + } + }); + } + }); + }); + + // Клик по пустому пространству (где угодно за пределами попапа) закрывает + // его — единственный способ убрать попап теперь, что кнопки Close нет. + // Гейт по t>=1.0 (как и interactable выше) не даёт клику, которым попап + // только открыли, тем же кадром его закрыть. + if t >= 1.0 + && ui.ctx().input(|i| i.pointer.any_click()) + && ui + .ctx() + .input(|i| i.pointer.interact_pos()) + .is_some_and(|pos| !area.response.rect.contains(pos)) + { + state.show_history = false; + } +} diff --git a/src/ui/panels/toolbar/mod.rs b/src/ui/panels/toolbar/mod.rs new file mode 100644 index 0000000..1f42509 --- /dev/null +++ b/src/ui/panels/toolbar/mod.rs @@ -0,0 +1,189 @@ +use crate::domain::connection::ConnectionStatus; +use crate::domain::transfer::TaskState; +use crate::i18n::t; +use crate::transfer::queue::TransferQueue; +use crate::ui::icons::{self, Icon}; +use crate::ui::state::{AppState, Pane}; +use crate::ui::theme::*; +use crate::ui::widgets::{button, overlay}; +use egui::{Align, Layout, RichText, Ui}; + +mod actions; +mod buttons; +mod history; + +use buttons::{icon_btn, icon_toggle_btn, separator_v, text_btn, text_btn_enabled}; + +pub fn render(ui: &mut Ui, state: &mut AppState, queue: &TransferQueue) { + let frame = egui::Frame::none() + .fill(BG_TOOLBAR) + .inner_margin(egui::Margin::symmetric(8.0, 0.0)); + + frame.show(ui, |ui| { + let width = ui.available_width(); + ui.allocate_ui_with_layout( + egui::vec2(width, TOOLBAR_H), + Layout::left_to_right(Align::Center), + |ui| { + // Логотип + icons::icon(ui, Icon::Folder, 17.0, ACCENT); + ui.add_space(4.0); + ui.label( + RichText::new("LoFlum") + .color(TEXT_PRIMARY) + .size(14.0) + .strong(), + ); + + ui.add_space(8.0); + separator_v(ui); + ui.add_space(8.0); + + // Новое подключение — единственная акцентная кнопка в тулбаре + let connect_btn = egui::Button::image_and_text( + icons::image(Icon::AddCircleLinear, 15.0, TEXT_PRIMARY), + RichText::new(t("toolbar.new_connection")) + .color(TEXT_PRIMARY) + .size(12.5), + ) + .rounding(RADIUS_MD) + .min_size(egui::vec2(0.0, 30.0)); + if button::accent_dim(ui, connect_btn).clicked() { + state.show_connect_dialog = true; + } + + separator_v(ui); + + // Кнопки работы с файлами + let has_conn = state + .active_tab_ref() + .map(|t| t.status == ConnectionStatus::Connected) + .unwrap_or(false); + + let has_local_sel = state.local_selected.is_some(); + let has_remote_sel = state + .active_tab_ref() + .and_then(|t| t.remote_selected.as_ref()) + .is_some(); + + text_btn_enabled( + ui, + Icon::Upload, + t("common.upload"), + has_conn && has_local_sel, + || { + actions::queue_selected_upload(state, queue); + }, + ); + text_btn_enabled( + ui, + Icon::Download, + t("common.download"), + has_conn && has_remote_sel, + || { + actions::queue_selected_download(state, queue); + }, + ); + + separator_v(ui); + + // Очередь — счётчик активных (в согласии с заголовком панели очереди) + let q_pending = state + .queue_tasks + .iter() + .filter(|t| matches!(t.state, TaskState::Running | TaskState::Queued)) + .count(); + let q_label = if q_pending > 0 { + format!("{} {}", t("toolbar.queue_label"), q_pending) + } else { + t("toolbar.queue_label").to_string() + }; + let q_active = state.show_queue; + text_btn(ui, Icon::Clock, &q_label, q_active, || { + state.show_queue = !state.show_queue; + }); + + separator_v(ui); + + let on_remote = state.active_pane == Pane::Remote; + let can_new_folder = if on_remote { has_conn } else { true }; + let can_delete_rename = if on_remote { + has_conn && has_remote_sel + } else { + has_local_sel + }; + + icon_btn(ui, Icon::Refresh, has_conn, t("common.refresh"), || { + // refresh remote — вызывается снаружи через state флаг + state.pending_refresh = true; + }); + icon_btn( + ui, + Icon::FolderWithFiles, + can_new_folder, + t("common.new_folder"), + || { + state.op_target = state.active_pane; + state.show_mkdir_dialog = true; + state.mkdir_name.clear(); + }, + ); + icon_btn(ui, Icon::Trash, can_delete_rename, t("common.delete"), || { + let name = selected_name(state, on_remote); + if let Some(name) = name { + state.op_target = state.active_pane; + state.show_delete_dialog = true; + state.delete_name = name; + } + }); + icon_btn(ui, Icon::Pen, can_delete_rename, t("common.rename"), || { + let name = selected_name(state, on_remote); + if let Some(name) = name { + state.op_target = state.active_pane; + state.show_rename_dialog = true; + state.rename_old_name = name.clone(); + state.rename_new_name = name; + } + }); + + // Правая часть — History / Settings + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + // На macOS кнопка настроек живёт в нативном верхнем меню + // (LoFlum → Settings…), здесь она не нужна. + #[cfg(not(target_os = "macos"))] + icon_btn(ui, Icon::Settings, true, t("common.settings"), || { + state.show_settings_dialog = true; + }); + icon_toggle_btn( + ui, + Icon::History, + state.show_history, + t("common.history"), + || { + state.show_history = !state.show_history; + state.show_bookmarks = false; + }, + ); + }); + }, + ); + }); + + // «Открытость» тикает каждый кадр (в т.ч. пока скрыто), иначе первое + // открытие происходит без анимации — см. overlay::openness. + let t_history = overlay::openness(ui.ctx(), "history_popup", state.show_history); + if t_history > 0.0 { + history::render_history_popup(ui, state, t_history); + } +} + +/// Имя выбранного элемента в активной панели (remote/local). +fn selected_name(state: &AppState, on_remote: bool) -> Option { + if on_remote { + state + .active_tab_ref() + .and_then(|t| t.remote_selected.clone()) + } else { + state.local_selected.clone() + } +} diff --git a/src/ui/state.rs b/src/ui/state.rs deleted file mode 100644 index 8c73db0..0000000 --- a/src/ui/state.rs +++ /dev/null @@ -1,252 +0,0 @@ -use std::sync::Arc; - -use crate::domain::connection::{ConnectionParams, ConnectionStatus, Protocol}; -use crate::domain::file_entry::FileEntry; -use crate::domain::transfer::TransferTask; - -#[derive(Clone)] -pub struct ConnectionTab { - pub id: String, - pub label: String, - pub params: ConnectionParams, - pub status: ConnectionStatus, - pub remote_path: String, - pub remote_entries: Vec, - pub remote_selected: Option, - pub loading: bool, -} - -/// Какая панель последней принимала клик — используется тулбаром, -/// чтобы New Folder/Rename/Delete применялись к нужной стороне (local/remote). -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum Pane { - Local, - Remote, -} - -type PendingResult = Arc>>>; - -pub struct PendingConnect { - pub result: PendingResult<(ConnectionParams, Vec)>, -} - -pub struct PendingRemoteList { - pub tab_idx: usize, - pub result: PendingResult>, -} - -#[derive(Clone)] -pub struct Bookmark { - pub id: i64, - pub name: String, - pub path: String, -} - -/// Фиксированный пункт быстрого доступа (Home/Desktop/...) — не удаляется, не хранится в БД. -#[derive(Clone)] -pub struct QuickAccess { - pub name: String, - pub path: String, -} - -#[derive(Clone)] -pub struct HistoryEntry { - pub host: String, - pub port: u16, - pub user: String, - pub time: String, - /// Стабильный id этой цели — под ним же лежит пароль в keychain. - pub conn_id: String, - pub protocol: Protocol, - pub key_path: Option, -} - -pub struct AppState { - pub tabs: Vec, - pub active_tab: usize, - - pub local_path: String, - pub local_entries: Vec, - pub local_selected: Option, - - /// Панель, в которой был последний клик — New Folder/Rename/Delete в тулбаре - /// применяются к ней. - pub active_pane: Pane, - /// К какой стороне относится открытый сейчас диалог mkdir/rename/delete. - pub op_target: Pane, - - pub show_connect_dialog: bool, - pub show_bookmarks: bool, - pub show_history: bool, - pub show_settings_dialog: bool, - - /// Фиксированные пункты быстрого доступа (Home/Desktop/...) — не удаляются, не хранятся в БД. - pub quick_access: Vec, - /// Закладки, добавленные пользователем — хранятся в БД, можно удалять. - pub bookmarks: Vec, - pub history: Vec, - - pub connect_label: String, - pub connect_host: String, - pub connect_port: String, - pub connect_user: String, - pub connect_pass: String, - pub connect_key_path: String, - pub connect_protocol: usize, - pub connect_error: String, - pub connect_loading: bool, - - pub show_queue: bool, - pub queue_tasks: Vec, - - pub status_message: String, - pub connected_count: usize, - - // action flags — выставляются тулбаром, обрабатываются в app.rs - pub pending_refresh: bool, - /// Клик по записи истории — переподключиться (новая вкладка, без диалога). - pub pending_history_reconnect: Option, - /// ПКМ → "Save" на записи истории — сохранить как постоянный Site. - pub pending_history_save: Option, - - // диалоги операций над удалённой ФС - pub show_mkdir_dialog: bool, - pub show_delete_dialog: bool, - pub show_rename_dialog: bool, - - pub mkdir_name: String, - pub delete_name: String, - pub rename_old_name: String, - pub rename_new_name: String, - - pub pending_mkdir_result: Option>, - pub pending_delete_result: Option>, - pub pending_rename_result: Option>, - - pub pending_connect: Option, - pub pending_remote_list: Vec, -} - -impl Default for AppState { - fn default() -> Self { - let home = dirs::home_dir() - .unwrap_or_default() - .to_string_lossy() - .to_string(); - Self { - tabs: Vec::new(), - active_tab: 0, - local_path: home.clone(), - local_entries: Vec::new(), - local_selected: None, - active_pane: Pane::Local, - op_target: Pane::Local, - show_connect_dialog: false, - show_bookmarks: false, - show_history: false, - show_settings_dialog: false, - quick_access: vec![ - QuickAccess { - name: "Home".into(), - path: home.clone(), - }, - QuickAccess { - name: "Desktop".into(), - path: format!("{}/Desktop", home), - }, - QuickAccess { - name: "Documents".into(), - path: format!("{}/Documents", home), - }, - QuickAccess { - name: "Downloads".into(), - path: dirs::download_dir() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|| format!("{}/Downloads", home)), - }, - QuickAccess { - name: "Pictures".into(), - path: format!("{}/Pictures", home), - }, - QuickAccess { - name: "Music".into(), - path: format!("{}/Music", home), - }, - QuickAccess { - name: "Videos".into(), - path: format!("{}/Videos", home), - }, - ], - bookmarks: Vec::new(), - history: Vec::new(), - connect_label: String::new(), - connect_host: String::new(), - connect_port: "22".into(), - connect_user: String::new(), - connect_pass: String::new(), - connect_key_path: String::new(), - connect_protocol: 0, - connect_error: String::new(), - connect_loading: false, - show_queue: false, - queue_tasks: Vec::new(), - status_message: "Ready".into(), - connected_count: 0, - pending_refresh: false, - pending_history_reconnect: None, - pending_history_save: None, - show_mkdir_dialog: false, - show_delete_dialog: false, - show_rename_dialog: false, - mkdir_name: String::new(), - delete_name: String::new(), - rename_old_name: String::new(), - rename_new_name: String::new(), - pending_mkdir_result: None, - pending_delete_result: None, - pending_rename_result: None, - pending_connect: None, - pending_remote_list: Vec::new(), - } - } -} - -impl AppState { - pub fn active_tab_mut(&mut self) -> Option<&mut ConnectionTab> { - if self.tabs.is_empty() { - return None; - } - let idx = self.active_tab.min(self.tabs.len() - 1); - Some(&mut self.tabs[idx]) - } - - pub fn active_tab_ref(&self) -> Option<&ConnectionTab> { - if self.tabs.is_empty() { - return None; - } - let idx = self.active_tab.min(self.tabs.len() - 1); - Some(&self.tabs[idx]) - } - - /// Перечитывает историю подключений из БД (после записи новой попытки). - pub fn reload_history(&mut self, db: &Arc>) { - if let Ok(conn) = db.lock() - && let Ok(rows) = crate::storage::db::get_history(&conn) - { - self.history = rows - .into_iter() - .map( - |(host, port, user, time, conn_id, protocol, key_path)| HistoryEntry { - host, - port, - user, - time, - conn_id, - protocol, - key_path, - }, - ) - .collect(); - } - } -} diff --git a/src/ui/state/defaults.rs b/src/ui/state/defaults.rs new file mode 100644 index 0000000..990f31e --- /dev/null +++ b/src/ui/state/defaults.rs @@ -0,0 +1,88 @@ +//! Начальные значения [`AppState`] (стартовые панели, быстрый доступ, поля формы). +use super::{AppState, Pane, QuickAccess, SettingsSection}; +use std::sync::Arc; +use std::sync::atomic::AtomicU32; + +impl Default for AppState { + fn default() -> Self { + let home = dirs::home_dir() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + Self { + tabs: Vec::new(), + active_tab: 0, + local_path: home.clone(), + local_entries: Vec::new(), + local_selected: None, + active_pane: Pane::Local, + op_target: Pane::Local, + show_connect_dialog: false, + show_bookmarks: false, + show_history: false, + show_settings_dialog: false, + settings_section: SettingsSection::General, + settings_prev_section: SettingsSection::General, + settings_section_changed_at: f64::NEG_INFINITY, + show_toolbar: true, + show_sidebar: true, + show_status_bar: true, + show_queue_panel: true, + confirm_before_delete: true, + default_local_folder: String::new(), + auto_clear_completed_secs: 0, + max_concurrent: Arc::new(AtomicU32::new(2)), + history_clear_confirm: false, + quick_access: quick_access(&home), + bookmarks: Vec::new(), + history: Vec::new(), + connect_label: String::new(), + connect_host: String::new(), + connect_port: "22".into(), + connect_user: String::new(), + connect_pass: String::new(), + connect_key_path: String::new(), + connect_protocol: 0, + connect_error: String::new(), + connect_loading: false, + show_queue: false, + queue_tasks: Vec::new(), + status_message: "Ready".into(), + pending_refresh: false, + pending_history_reconnect: None, + pending_history_save: None, + show_mkdir_dialog: false, + show_delete_dialog: false, + show_rename_dialog: false, + mkdir_name: String::new(), + delete_name: String::new(), + rename_old_name: String::new(), + rename_new_name: String::new(), + pending_mkdir_result: None, + pending_delete_result: None, + pending_rename_result: None, + pending_connect: None, + pending_remote_list: Vec::new(), + } + } +} + +/// Фиксированные пункты быстрого доступа относительно домашней папки. +fn quick_access(home: &str) -> Vec { + let downloads = dirs::download_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| format!("{}/Downloads", home)); + let item = |name: &str, path: String| QuickAccess { + name: name.into(), + path, + }; + vec![ + item("Home", home.to_string()), + item("Desktop", format!("{}/Desktop", home)), + item("Documents", format!("{}/Documents", home)), + item("Downloads", downloads), + item("Pictures", format!("{}/Pictures", home)), + item("Music", format!("{}/Music", home)), + item("Videos", format!("{}/Videos", home)), + ] +} diff --git a/src/ui/state/mod.rs b/src/ui/state/mod.rs new file mode 100644 index 0000000..c18b4b8 --- /dev/null +++ b/src/ui/state/mod.rs @@ -0,0 +1,134 @@ +//! Состояние UI: главная структура [`AppState`], её типы ([`types`]) и +//! начальные значения ([`defaults`]). +use std::sync::Arc; + +use crate::domain::file_entry::FileEntry; +use crate::domain::transfer::TransferTask; + +mod defaults; +mod types; + +pub use types::*; + +pub struct AppState { + pub tabs: Vec, + pub active_tab: usize, + + pub local_path: String, + pub local_entries: Vec, + pub local_selected: Option, + + /// Панель, в которой был последний клик — New Folder/Rename/Delete в тулбаре + /// применяются к ней. + pub active_pane: Pane, + /// К какой стороне относится открытый сейчас диалог mkdir/rename/delete. + pub op_target: Pane, + + pub show_connect_dialog: bool, + pub show_bookmarks: bool, + pub show_history: bool, + pub show_settings_dialog: bool, + /// Активный раздел открытого диалога Settings. + pub settings_section: SettingsSection, + /// Прошлый раздел + момент переключения (ui time) — для кросс-фейда + /// содержимого при смене раздела. + pub settings_prev_section: SettingsSection, + pub settings_section_changed_at: f64, + + /// Видимость панелей — настраивается в Window (macOS) или в Settings. + pub show_toolbar: bool, + pub show_sidebar: bool, + pub show_status_bar: bool, + pub show_queue_panel: bool, + + // ── Настройки, сохраняемые между запусками (Settings) ──────────────── + /// Спрашивать подтверждение перед удалением файла/папки. + pub confirm_before_delete: bool, + /// Стартовая локальная папка; пусто — домашняя папка ОС. + pub default_local_folder: String, + /// Через сколько секунд автоматически убирать завершённые задачи из очереди + /// передач; 0 — никогда. + pub auto_clear_completed_secs: u32, + /// Сколько передач может выполняться параллельно — общий с воркером атомик, + /// чтобы Settings → Transfers менял параллелизм на лету. + pub max_concurrent: Arc, + /// Инлайн-подтверждение "Clear All History" в разделе Settings → History. + pub history_clear_confirm: bool, + + /// Фиксированные пункты быстрого доступа (Home/Desktop/...) — не удаляются, не хранятся в БД. + pub quick_access: Vec, + /// Закладки, добавленные пользователем — хранятся в БД, можно удалять. + pub bookmarks: Vec, + pub history: Vec, + + pub connect_label: String, + pub connect_host: String, + pub connect_port: String, + pub connect_user: String, + pub connect_pass: String, + pub connect_key_path: String, + pub connect_protocol: usize, + pub connect_error: String, + pub connect_loading: bool, + + pub show_queue: bool, + pub queue_tasks: Vec, + + pub status_message: String, + + // action flags — выставляются тулбаром, обрабатываются в app.rs + pub pending_refresh: bool, + /// Клик по записи истории — переподключиться (новая вкладка, без диалога). + pub pending_history_reconnect: Option, + /// ПКМ → "Save" на записи истории — сохранить как постоянный Site. + pub pending_history_save: Option, + + // диалоги операций над удалённой ФС + pub show_mkdir_dialog: bool, + pub show_delete_dialog: bool, + pub show_rename_dialog: bool, + + pub mkdir_name: String, + pub delete_name: String, + pub rename_old_name: String, + pub rename_new_name: String, + + pub pending_mkdir_result: Option>, + pub pending_delete_result: Option>, + pub pending_rename_result: Option>, + + pub pending_connect: Option, + pub pending_remote_list: Vec, +} + +impl AppState { + pub fn active_tab_ref(&self) -> Option<&ConnectionTab> { + if self.tabs.is_empty() { + return None; + } + let idx = self.active_tab.min(self.tabs.len() - 1); + Some(&self.tabs[idx]) + } + + /// Перечитывает историю подключений из БД (после записи новой попытки). + pub fn reload_history(&mut self, db: &Arc>) { + if let Ok(conn) = db.lock() + && let Ok(rows) = crate::storage::db::get_history(&conn) + { + self.history = rows + .into_iter() + .map( + |(host, port, user, time, conn_id, protocol, key_path)| HistoryEntry { + host, + port, + user, + time, + conn_id, + protocol, + key_path, + }, + ) + .collect(); + } + } +} diff --git a/src/ui/state/types.rs b/src/ui/state/types.rs new file mode 100644 index 0000000..82b625e --- /dev/null +++ b/src/ui/state/types.rs @@ -0,0 +1,74 @@ +//! Вспомогательные типы состояния UI (вкладки, отложенные операции, история). +use std::sync::Arc; + +use crate::domain::connection::{ConnectionParams, ConnectionStatus, Protocol}; +use crate::domain::file_entry::FileEntry; + +#[derive(Clone)] +pub struct ConnectionTab { + pub id: String, + pub label: String, + pub params: ConnectionParams, + pub status: ConnectionStatus, + pub remote_path: String, + pub remote_entries: Vec, + pub remote_selected: Option, + pub loading: bool, +} + +/// Какая панель последней принимала клик — используется тулбаром, +/// чтобы New Folder/Rename/Delete применялись к нужной стороне (local/remote). +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Pane { + Local, + Remote, +} + +/// Активный раздел диалога Settings (popup: меню слева — содержимое справа). +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum SettingsSection { + General, + Appearance, + Connections, + History, + Security, + Transfers, + About, +} + +pub type PendingResult = Arc>>>; + +pub struct PendingConnect { + pub result: PendingResult<(ConnectionParams, Vec)>, +} + +pub struct PendingRemoteList { + pub tab_idx: usize, + pub result: PendingResult>, +} + +#[derive(Clone)] +pub struct Bookmark { + pub id: i64, + pub name: String, + pub path: String, +} + +/// Фиксированный пункт быстрого доступа (Home/Desktop/...) — не удаляется, не хранится в БД. +#[derive(Clone)] +pub struct QuickAccess { + pub name: String, + pub path: String, +} + +#[derive(Clone)] +pub struct HistoryEntry { + pub host: String, + pub port: u16, + pub user: String, + pub time: String, + /// Стабильный id этой цели — под ним же лежит пароль в keychain. + pub conn_id: String, + pub protocol: Protocol, + pub key_path: Option, +} diff --git a/src/ui/widgets/button.rs b/src/ui/widgets/button.rs new file mode 100644 index 0000000..493e3a2 --- /dev/null +++ b/src/ui/widgets/button.rs @@ -0,0 +1,80 @@ +//! Кнопки с корректной обратной связью на наведение/нажатие. +//! +//! egui анимирует фон кнопки только когда её `fill` не задан явно: как только +//! вызван `.fill(...)`, egui выбрасывает визуалы наведения/нажатия и заливка +//! замирает во всех состояниях (см. egui `button.rs`: `fill.unwrap_or(...)`). +//! Поэтому вместо хардкода `.fill(...)` на каждом вызове мы задаём заливку через +//! `weak_bg_fill` по состояниям в scoped-стиле — так каждая кнопка мгновенно +//! реагирует на hover и press. +use egui::{Button, Color32, Response, Stroke, Ui}; + +use crate::ui::theme::*; + +/// Множит RGB на коэффициент (сохраняя непрозрачность): `<1` — темнее, `>1` — светлее. +fn shade(c: Color32, f: f32) -> Color32 { + let m = |v: u8| ((v as f32) * f).clamp(0.0, 255.0) as u8; + Color32::from_rgb(m(c.r()), m(c.g()), m(c.b())) +} + +/// Добавляет кнопку в scoped-стиле, где заливка по состояниям = переданным цветам, +/// а рамка убрана (плоский вид, обратная связь только заливкой). +fn scoped( + ui: &mut Ui, + inactive: Color32, + hovered: Color32, + active: Color32, + btn: Button, +) -> Response { + ui.scope(|ui| { + let w = &mut ui.visuals_mut().widgets; + w.inactive.weak_bg_fill = inactive; + w.inactive.bg_stroke = Stroke::NONE; + w.hovered.weak_bg_fill = hovered; + w.hovered.bg_stroke = Stroke::NONE; + w.active.weak_bg_fill = active; + w.active.bg_stroke = Stroke::NONE; + ui.add(btn) + }) + .inner +} + +/// Плоская кнопка: прозрачная в покое, лёгкая подсветка на hover, ACCENT_DIM на press. +pub fn ghost(ui: &mut Ui, btn: Button) -> Response { + scoped(ui, Color32::TRANSPARENT, BG_ROW_HOVER, ACCENT_DIM, btn) +} + +/// Основное действие (Connect / OK / New Connection): сплошной ACCENT, темнеет на hover/press. +pub fn accent(ui: &mut Ui, btn: Button) -> Response { + scoped(ui, ACCENT, shade(ACCENT, 0.9), shade(ACCENT, 0.78), btn) +} + +/// Вторичное акцентное действие (тулбар «New Connection»). +pub fn accent_dim(ui: &mut Ui, btn: Button) -> Response { + scoped( + ui, + ACCENT_DIM, + shade(ACCENT_DIM, 1.18), + shade(ACCENT_DIM, 0.85), + btn, + ) +} + +/// Деструктивное действие (Delete): сплошной RED, темнеет на hover/press. +pub fn danger(ui: &mut Ui, btn: Button) -> Response { + scoped(ui, RED, shade(RED, 0.9), shade(RED, 0.78), btn) +} + +/// Кнопка-переключатель: `on` — активна (акцентная подложка), иначе плоская. +pub fn toggle(ui: &mut Ui, btn: Button, on: bool) -> Response { + if on { + scoped( + ui, + BG_TAB_ACTIVE, + shade(BG_TAB_ACTIVE, 1.15), + ACCENT_DIM, + btn, + ) + } else { + ghost(ui, btn) + } +} diff --git a/src/ui/widgets/context_menu.rs b/src/ui/widgets/context_menu.rs new file mode 100644 index 0000000..a05bb0c --- /dev/null +++ b/src/ui/widgets/context_menu.rs @@ -0,0 +1,47 @@ +//! Единая замена `Response::context_menu()` для всех меню по правому клику +//! (файлы, история, закладки): повторный клик ПКМ рядом с точкой, где меню +//! уже открыто — как в Finder/Explorer — закрывает его, а клик в другом +//! месте (по другой строке) сразу переоткрывает меню там. +//! +//! egui сам умеет закрывать меню кликом мимо и переоткрывать его на другом +//! элементе — этого не хватает только для случая "кликнули ПКМ второй раз +//! по тому же элементу": по умолчанию это просто пересоздаёт меню на месте +//! (no-op на вид), а не закрывает его. +use egui::{Id, Pos2, Response, Ui}; + +/// Если новый клик ПКМ по тому же элементу оказался ближе этого порога к +/// точке, где меню уже открыто — считаем, что курсор "не сдвинулся". +const SAME_SPOT_PX: f32 = 10.0; + +pub fn context_menu(response: &Response, add_contents: impl FnOnce(&mut Ui)) { + let ctx = &response.ctx; + let anchor_id = Id::new("ctx_menu_anchor").with(response.id); + let force_close_id = Id::new("ctx_menu_force_close").with(response.id); + + if response.secondary_clicked() + && let Some(click_pos) = response.interact_pointer_pos() + { + let prev_anchor: Option = ctx.data(|d| d.get_temp(anchor_id)); + let toggle_close = response.context_menu_opened() + && prev_anchor.is_some_and(|p| p.distance(click_pos) <= SAME_SPOT_PX); + + if toggle_close { + ctx.data_mut(|d| d.insert_temp(force_close_id, true)); + } else { + ctx.data_mut(|d| d.insert_temp(anchor_id, click_pos)); + } + } + + response.context_menu(|ui| { + let should_close = ui + .ctx() + .data_mut(|d| d.get_temp::(force_close_id)) + .unwrap_or(false); + if should_close { + ui.ctx().data_mut(|d| d.remove::(force_close_id)); + ui.close_menu(); + return; + } + add_contents(ui); + }); +} diff --git a/src/ui/widgets/mod.rs b/src/ui/widgets/mod.rs new file mode 100644 index 0000000..0792754 --- /dev/null +++ b/src/ui/widgets/mod.rs @@ -0,0 +1,8 @@ +//! Переиспользуемые UI-примитивы: кнопки с обратной связью, кликабельные строки, +//! модальные подложки. Вынесены сюда, чтобы отклик на действия был единообразным +//! во всём интерфейсе (см. правило про мгновенный визуальный отклик) и без копипасты. +pub mod button; +pub mod context_menu; +pub mod modal; +pub mod overlay; +pub mod row; diff --git a/src/ui/widgets/modal.rs b/src/ui/widgets/modal.rs new file mode 100644 index 0000000..7e8ef4e --- /dev/null +++ b/src/ui/widgets/modal.rs @@ -0,0 +1,67 @@ +//! Общий каркас модальных диалогов подтверждения/ввода (New Folder, Delete, +//! Rename и подобные) — раскладка окна и ряд кнопок OK/Cancel вынесены сюда, +//! чтобы не дублироваться в каждом диалоге. +use egui::{Align2, Context, RichText, Ui}; + +use super::{button, overlay}; +use crate::ui::theme::*; + +pub enum Outcome { + Idle, + Confirm, + Cancel, +} + +/// Центрированное окно модалки в общей рамке панели. `t` — анимированная +/// «открытость» из [`overlay::openness`]: прозрачность и лёгкий подъезд снизу; +/// пока окно доигрывает fade-out (t < 1 при закрытии), ввод отключён. +pub fn window(ctx: &Context, id: &str, t: f32, add_contents: impl FnOnce(&mut Ui)) { + egui::Window::new(id) + .collapsible(false) + .title_bar(false) + .resizable(false) + .movable(false) + .anchor(Align2::CENTER_CENTER, overlay::slide_offset(t)) + .interactable(t >= 1.0) + .frame(overlay::panel_frame()) + .show(ctx, |ui| { + ui.set_opacity(overlay::ease_out(t)); + ui.set_width(260.0); + add_contents(ui); + }); +} + +/// Ряд кнопок OK/Cancel справа. `danger` — красная кнопка подтверждения. +pub fn ok_cancel_row(ui: &mut Ui, danger: bool) -> Outcome { + let mut result = Outcome::Idle; + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let ok = egui::Button::new( + RichText::new(crate::i18n::t("common.ok")) + .color(ON_ACCENT) + .size(12.5), + ) + .rounding(RADIUS_MD) + .min_size(egui::vec2(70.0, 30.0)); + let ok_resp = if danger { + button::danger(ui, ok) + } else { + button::accent(ui, ok) + }; + if ok_resp.clicked() { + result = Outcome::Confirm; + } + ui.add_space(8.0); + let cancel = egui::Button::new( + RichText::new(crate::i18n::t("common.cancel")) + .color(TEXT_DIM) + .size(12.5), + ) + .min_size(egui::vec2(70.0, 30.0)); + if button::ghost(ui, cancel).clicked() { + result = Outcome::Cancel; + } + }); + }); + result +} diff --git a/src/ui/widgets/overlay.rs b/src/ui/widgets/overlay.rs new file mode 100644 index 0000000..b169f9d --- /dev/null +++ b/src/ui/widgets/overlay.rs @@ -0,0 +1,57 @@ +//! Общие примитивы модальных окон: затемняющая подложка, рамка панели и +//! анимация «открытости». Раньше каждый диалог (connect/settings/mkdir/delete/ +//! rename) строил их копипастой — теперь это одно место (DRY). +use egui::{Color32, Context, Frame, Id, Margin, Order, Sense, Stroke}; + +use crate::ui::theme::*; + +/// Длительность анимации открытия/закрытия модалок. +pub const OPEN_ANIM_SECS: f32 = 0.15; + +/// Анимированная «открытость» модалки: 0.0 — закрыта, 1.0 — открыта. +/// +/// ВАЖНО: вызывать каждый кадр, в том числе пока диалог закрыт — свежий id в +/// egui::animate_bool сразу прыгает к целевому значению, поэтому без +/// «прогрева» на закрытом состоянии первое открытие происходит без анимации. +/// Окно стоит рисовать, пока значение > 0 (это даёт fade-out после закрытия). +pub fn openness(ctx: &Context, id: &str, open: bool) -> f32 { + ctx.animate_bool_with_time(Id::new(id).with("openness"), open, OPEN_ANIM_SECS) +} + +/// Ease-out для входа: быстро стартует, мягко доезжает. +pub fn ease_out(t: f32) -> f32 { + 1.0 - (1.0 - t) * (1.0 - t) +} + +/// Рисует полноэкранную затемняющую подложку под модальным окном; `t` — +/// «открытость» (масштабирует прозрачность). Возвращает `true`, если по ней +/// кликнули (для закрытия по клику вне окна) — клики учитываются только когда +/// подложка уже видима, чтобы догоняющий fade-out не ловил случайные клики. +pub fn dim(ctx: &Context, id: &str, t: f32) -> bool { + let screen = ctx.screen_rect(); + let alpha = (160.0 * t.clamp(0.0, 1.0)) as u8; + egui::Area::new(Id::new(id)) + .fixed_pos(egui::Pos2::ZERO) + .order(Order::Background) + .show(ctx, |ui| { + let r = ui.allocate_rect(screen, Sense::click()); + ui.painter() + .rect_filled(screen, 0.0, Color32::from_black_alpha(alpha)); + r.clicked() && t > 0.5 + }) + .inner +} + +/// Стандартная рамка панели диалога (фон/рамка/скругление/отступы). +pub fn panel_frame() -> Frame { + Frame::none() + .fill(BG_PANEL) + .stroke(Stroke::new(1.0, BORDER)) + .rounding(RADIUS_LG) + .inner_margin(Margin::same(16.0)) +} + +/// Вертикальный сдвиг окна при появлении (лёгкий «подъезд» снизу). +pub fn slide_offset(t: f32) -> egui::Vec2 { + egui::vec2(0.0, 8.0 * (1.0 - ease_out(t))) +} diff --git a/src/ui/widgets/row.rs b/src/ui/widgets/row.rs new file mode 100644 index 0000000..1baee6f --- /dev/null +++ b/src/ui/widgets/row.rs @@ -0,0 +1,59 @@ +//! Кликабельная строка списка (пункты сайдбара, «недавние подключения»). +//! +//! Собрана на `Frame` + `interact(click)`, потому что это не обычная кнопка, +//! а полноширинная строка. Наведение подсвечивается, а курсор становится +//! «рукой» — чтобы любая строка сразу читалась как интерактивная (см. правило +//! про мгновенный визуальный отклик). +use egui::{Align, Color32, Layout, Response, Sense, Ui, Vec2}; + +use crate::ui::theme::*; + +/// Рисует кликабельную строку фиксированной высоты с hover-подсветкой и +/// курсором-рукой. `active` — визуально выделенное состояние (выбранный путь). +/// Возвращает `Response` клика по всей строке. +pub fn clickable_row( + ui: &mut Ui, + active: bool, + height: f32, + content: impl FnOnce(&mut Ui), +) -> Response { + let width = ui.available_width(); + let row_rect = egui::Rect::from_min_size(ui.cursor().min, egui::vec2(width, height)); + let hovered = ui.rect_contains_pointer(row_rect); + + // Hover въезжает/уходит коротким фейдом (~80мс): появление подсветки всё + // ещё мгновенное на глаз (правило мгновенного отклика), но без резкого + // мигания при проведении курсора по списку. Выделение (active) — сразу. + let hover_t = + ui.ctx() + .animate_bool_with_time(ui.next_auto_id().with("row_hover"), hovered, 0.08); + let bg = if active { + BG_ROW_SEL + } else if hover_t > 0.0 { + BG_ROW_HOVER.gamma_multiply(hover_t) + } else { + Color32::TRANSPARENT + }; + + let resp = egui::Frame::none() + .fill(bg) + .rounding(RADIUS_SM) + .inner_margin(egui::Margin::symmetric(8.0, 0.0)) + .show(ui, |ui| { + // На первом кадре, пока свежий (авто-размерный) egui::Window ещё + // не определил свою ширину, `width` может быть меньше отступа — + // без clamp'а egui паникует на assert desired_size >= 0.0. + ui.allocate_ui_with_layout( + Vec2::new((width - 16.0).max(0.0), height), + Layout::left_to_right(Align::Center), + content, + ); + }) + .response + .interact(Sense::click()); + + if hovered { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + resp +}