Хуйня не на tauri

This commit is contained in:
loki5512344 2026-07-06 01:06:43 +03:00
parent e895fe3237
commit 403d7086d4
104 changed files with 8941 additions and 4772 deletions

View file

@ -35,4 +35,6 @@ chrono = "0.4"
futures-lite = "2"
image = "0.25"
muda = "0.19.3"
egui_dock = "0.14"
rfd = "0.15"

159
src/i18n/mod.rs Normal file
View file

@ -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<Lang> {
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<Table> = 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
}

240
src/i18n/strings/common.rs Normal file
View file

@ -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",
],
),
];

233
src/i18n/strings/dialogs.rs Normal file
View file

@ -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",
],
),
];

3
src/i18n/strings/menu.rs Normal file
View file

@ -0,0 +1,3 @@
use super::Entry;
pub const ENTRIES: &[Entry] = &[];

23
src/i18n/strings/mod.rs Normal file
View file

@ -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<Item = &'static Entry> {
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())
}

538
src/i18n/strings/panels.rs Normal file
View file

@ -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",
],
),
];

View file

@ -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",
],
),
];

251
src/i18n/strings/toolbar.rs Normal file
View file

@ -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…",
],
),
];

View file

@ -0,0 +1,3 @@
use super::Entry;
pub const ENTRIES: &[Entry] = &[];

View file

@ -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();

View file

@ -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<Option<AsyncFtpStream>>,
}
impl FtpClient {
pub async fn connect(host: &str, port: u16, user: &str, pass: &str) -> Result<Self> {
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<FileEntry> {
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<Vec<FileEntry>> {
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<FileEntry> = 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<Box<dyn Fn(u64) -> 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<Box<dyn Fn(u64) -> 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<FileEntry> {
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,
})
}
}

57
src/protocols/ftp/mod.rs Normal file
View file

@ -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<Option<AsyncFtpStream>>,
}
impl FtpClient {
pub async fn connect(host: &str, port: u16, user: &str, pass: &str) -> Result<Self> {
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<FileEntry> {
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,
})
}
}

126
src/protocols/ftp/ops.rs Normal file
View file

@ -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<Vec<FileEntry>> {
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<FileEntry> = 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<Box<dyn Fn(u64) -> 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<Box<dyn Fn(u64) -> 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<FileEntry> {
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,
})
}
}

View file

@ -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<Box<dyn Fn(u64) -> 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(())
}

View file

@ -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<Mutex<Session>>,
}
impl SftpClient {
/// Подключение по паролю
pub fn connect_password(host: &str, port: u16, user: &str, password: &str) -> Result<Self> {
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<Self> {
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<PathBuf> {
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::<Vec<_>>()
.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<Vec<FileEntry>> {
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<Box<dyn Fn(u64) -> 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<Box<dyn Fn(u64) -> 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<FileEntry> {
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))?
}
}

View file

@ -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<PathBuf> {
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::<Vec<_>>()
.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(())
}

93
src/protocols/sftp/mod.rs Normal file
View file

@ -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<Mutex<Session>>,
}
impl SftpClient {
/// Подключение по паролю
pub fn connect_password(host: &str, port: u16, user: &str, password: &str) -> Result<Self> {
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<Self> {
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<Self> {
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<Session> {
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)
}

169
src/protocols/sftp/ops.rs Normal file
View file

@ -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<Vec<FileEntry>> {
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<Box<dyn Fn(u64) -> 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<Box<dyn Fn(u64) -> 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<FileEntry> {
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))?
}
}

View file

@ -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<Box<dyn Fn(u64) -> 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<Mutex<Session>>,
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<Mutex<Session>>,
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))?
}

View file

@ -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<Vec<(i64, String, String)>> {
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<i64> {
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<String>,
);
/// Ищет уже существующий conn_id для этой цели (host,port,username), чтобы
/// повторные подключения писали пароль в keychain под тем же id, а не заводили новый.
pub fn find_history_conn_id(
conn: &Connection,
host: &str,
port: u16,
username: &str,
) -> Result<Option<String>> {
let id: Option<String> = 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<Vec<HistoryRow>> {
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<Vec<Site>> {
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(())
}

View file

@ -0,0 +1,25 @@
//! Таблица закладок локальных папок.
use anyhow::Result;
use rusqlite::{Connection, params};
pub fn get_bookmarks(conn: &Connection) -> Result<Vec<(i64, String, String)>> {
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<i64> {
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(())
}

98
src/storage/db/history.rs Normal file
View file

@ -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<String>,
);
/// Ищет уже существующий conn_id для этой цели (host,port,username), чтобы
/// повторные подключения писали пароль в keychain под тем же id, а не заводили новый.
pub fn find_history_conn_id(
conn: &Connection,
host: &str,
port: u16,
username: &str,
) -> Result<Option<String>> {
let id: Option<String> = 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<Vec<HistoryRow>> {
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)
}

98
src/storage/db/mod.rs Normal file
View file

@ -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(())
}

View file

@ -0,0 +1,43 @@
//! Таблица произвольных настроек приложения (ключ-значение) — используется
//! диалогом Settings для того, что должно переживать перезапуск (в отличие
//! от видимости панелей, которая остаётся только на время сессии).
use anyhow::Result;
use rusqlite::{Connection, params};
pub fn get_setting(conn: &Connection, key: &str) -> Option<String> {
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());
}

57
src/storage/db/sites.rs Normal file
View file

@ -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<Vec<Site>> {
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(())
}

View file

@ -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<AtomicUsize>);
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<RemoteRegistry>,
rt_handle: tokio::runtime::Handle,
max_concurrent: Arc<AtomicU32>,
) {
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<Box<dyn Fn(u64) -> 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<RemoteRegistry>) {
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<Box<dyn Fn(u64) -> 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));
}
}
}
}

File diff suppressed because it is too large Load diff

164
src/ui/app/dialogs.rs Normal file
View file

@ -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
}

187
src/ui/app/mod.rs Normal file
View file

@ -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<RemoteRegistry>,
pub queue: TransferQueue,
pub rt: tokio::runtime::Runtime,
pub db: Arc<std::sync::Mutex<rusqlite::Connection>>,
pub sites: Vec<Site>,
pub first_frame: bool,
pub dock_state: egui_dock::DockState<PaneTab>,
/// Момент, когда задача передачи впервые замечена в состоянии Completed —
/// нужен для авто-очистки очереди (Settings → Transfers).
completed_since: HashMap<String, Instant>,
/// Какой экран был в прошлом кадре (welcome/main) + момент переключения —
/// для фейда при переходе между экранами. None до первого кадра.
prev_is_welcome: Option<bool>,
screen_changed_at: f64,
}
impl FileManagerApp {
pub fn new(
registry: Arc<RemoteRegistry>,
queue: TransferQueue,
rt: tokio::runtime::Runtime,
db: Arc<std::sync::Mutex<rusqlite::Connection>>,
sites: Vec<Site>,
max_concurrent: Arc<AtomicU32>,
) -> 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<usize> {
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));
}
}

152
src/ui/app/ops.rs Normal file
View file

@ -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::<Result<(), String>>));
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::<Result<(), String>>));
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::<Result<(), String>>));
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),
}
}
}

135
src/ui/app/poll.rs Normal file
View file

@ -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<String> = 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<std::sync::Mutex<Option<Result<(), String>>>>;
fn take_ready(slot: &mut Option<PendingResult>) -> Option<Result<(), String>> {
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
}
}

143
src/ui/app/results.rs Normal file
View file

@ -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(&params);
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,
&params.host,
params.port,
&params.username,
)
.ok()
.flatten()
.unwrap_or_else(|| params.id.clone());
if let Some(password) = &params.password {
let _ = crate::storage::keychain::store_password(&canonical_id, password);
}
let _ = crate::storage::db::add_history_entry(
&conn,
&params.host,
params.port,
&params.username,
&canonical_id,
&params.protocol,
params.key_path.as_deref(),
);
}
}
// --- Remote list results ---
pub(super) fn poll_remote_list(&mut self) {
let mut done: Vec<usize> = 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);
}
}
}

106
src/ui/app/screens.rs Normal file
View file

@ -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<String> = 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);
}
}
});
}
}

View file

@ -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());
}
}
}

View file

@ -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<usize> = 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<i64> = 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<String> = 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();
}
}
}

View file

@ -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(&current)
.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<Lang> {
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
}

219
src/ui/app/settings/mod.rs Normal file
View file

@ -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<T: Copy + PartialEq>(
ui: &mut Ui,
options: &[(&str, T)],
current: T,
) -> Option<T> {
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
}

107
src/ui/app/settings/nav.rs Normal file
View file

@ -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);
}
}

View file

@ -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<crate::i18n::Lang>,
}
pub(in crate::ui::app) fn load(
db: &std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>,
) -> 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);
}
}
}

View file

@ -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);
}
}
}

101
src/ui/app/visuals.rs Normal file
View file

@ -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
}

126
src/ui/app/welcome.rs Normal file
View file

@ -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();
}
});
}
});
}
}

View file

@ -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<RemoteRegistry>,
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<RemoteRegistry>,
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::<u16>().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<RemoteRegistry>,
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(&registry, &params_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<RemoteRegistry>,
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<std::sync::Mutex<rusqlite::Connection>>,
sites: &mut Vec<crate::domain::site::Site>,
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<FileEntry>), String> {
let password = if let Some(p) = &params.password {
p.clone()
} else {
keychain::get_password(&params.id).map_err(|e| e.to_string())?
};
let fs: Arc<dyn RemoteFs> = match params.protocol {
Protocol::Sftp => {
if let Some(key_path) = &params.key_path {
Arc::new(
SftpClient::connect_key(&params.host, params.port, &params.username, key_path)
.map_err(|e| e.to_string())?,
)
} else {
Arc::new(
SftpClient::connect_password(
&params.host,
params.port,
&params.username,
&password,
)
.map_err(|e| e.to_string())?,
)
}
}
Protocol::Ftp => Arc::new(
FtpClient::connect(&params.host, params.port, &params.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))
}

View file

@ -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<RemoteRegistry>,
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::<u16>().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<RemoteRegistry>,
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(&registry, &params_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<FileEntry>), String> {
// Пароль резолвим лениво по месту: для ключевой/автоматической SSH-аутен-
// тификации он не нужен, и обязательный поход в keychain раньше валил
// подключение по ключу с пустым паролем ("нет пароля в keychain").
let stored_password = || -> Option<String> {
params
.password
.clone()
.or_else(|| keychain::get_password(&params.id).ok())
};
let fs: Arc<dyn RemoteFs> = match params.protocol {
Protocol::Sftp => {
if let Some(key_path) = &params.key_path {
// Явно выбранный ключ; пароль из формы (если есть) — passphrase.
Arc::new(
SftpClient::connect_key(
&params.host,
params.port,
&params.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(
&params.host,
params.port,
&params.username,
&password,
)
.map_err(|e| e.to_string())?,
)
} else {
// Пароль пуст и ключ не выбран — ищем ключ сами (агент, ~/.ssh).
Arc::new(
SftpClient::connect_auto(&params.host, params.port, &params.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(&params.host, params.port, &params.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))
}

View file

@ -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);
});
}

View file

@ -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<RemoteRegistry>,
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<std::sync::Mutex<rusqlite::Connection>>,
sites: &mut Vec<crate::domain::site::Site>,
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(())
}

View file

@ -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;

View file

@ -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<RemoteRegistry>,
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<RemoteRegistry>,
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();
}
});
});
}

102
src/ui/dock.rs Normal file
View file

@ -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<RemoteRegistry>,
pub rt_handle: &'a tokio::runtime::Handle,
pub db: &'a Arc<Mutex<rusqlite::Connection>>,
/// Если пользователь закрыл вкладку с удалённым соединением — сюда кладём его id,
/// вызывающий код (app.rs) разрывает соединение после `show_inside`.
pub closed_remote: &'a mut Option<String>,
}
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
}
}

View file

@ -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] {

View file

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><path stroke-linecap="round" d="M15 12h-3m0 0H9m3 0V9m0 3v3"/></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="#ffffff" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><path stroke-linecap="round" d="M15 12h-3m0 0H9m3 0V9m0 3v3"/></g></svg>

Before

Width:  |  Height:  |  Size: 248 B

After

Width:  |  Height:  |  Size: 243 B

Before After
Before After

View file

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="currentColor" stroke-width="1.5"><path d="M2 12c0-4.714 0-7.071 1.464-8.536C4.93 2 7.286 2 12 2s7.071 0 8.535 1.464C22 4.93 22 7.286 22 12s0 7.071-1.465 8.535C19.072 22 16.714 22 12 22s-7.071 0-8.536-1.465C2 19.072 2 16.714 2 12Z"/><path stroke-linecap="round" d="M15 12h-3m0 0H9m3 0V9m0 3v3"/></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="#ffffff" stroke-width="1.5"><path d="M2 12c0-4.714 0-7.071 1.464-8.536C4.93 2 7.286 2 12 2s7.071 0 8.535 1.464C22 4.93 22 7.286 22 12s0 7.071-1.465 8.535C19.072 22 16.714 22 12 22s-7.071 0-8.536-1.465C2 19.072 2 16.714 2 12Z"/><path stroke-linecap="round" d="M15 12h-3m0 0H9m3 0V9m0 3v3"/></g></svg>

Before

Width:  |  Height:  |  Size: 415 B

After

Width:  |  Height:  |  Size: 410 B

Before After
Before After

View file

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m19 9l-7 6l-7-6"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" stroke="#ffffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m19 9l-7 6l-7-6"/></svg>

Before

Width:  |  Height:  |  Size: 221 B

After

Width:  |  Height:  |  Size: 216 B

Before After
Before After

View file

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m19 15l-7-6l-7 6"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" stroke="#ffffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m19 15l-7-6l-7 6"/></svg>

Before

Width:  |  Height:  |  Size: 222 B

After

Width:  |  Height:  |  Size: 217 B

Before After
Before After

View file

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l2.5 2.5"/></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="#ffffff" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l2.5 2.5"/></g></svg>

Before

Width:  |  Height:  |  Size: 260 B

After

Width:  |  Height:  |  Size: 255 B

Before After
Before After

View file

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><path stroke-linecap="round" d="m14.5 9.5l-5 5m0-5l5 5"/></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="#ffffff" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><path stroke-linecap="round" d="m14.5 9.5l-5 5m0-5l5 5"/></g></svg>

Before

Width:  |  Height:  |  Size: 243 B

After

Width:  |  Height:  |  Size: 238 B

Before After
Before After

View file

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" d="M4 18V6m16 0v12"/><path d="M12 10c4.418 0 8-1.79 8-4s-3.582-4-8-4s-8 1.79-8 4s3.582 4 8 4Zm8 2c0 2.21-3.582 4-8 4s-8-1.79-8-4m16 6c0 2.21-3.582 4-8 4s-8-1.79-8-4"/></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="#ffffff" stroke-width="1.5"><path stroke-linecap="round" d="M4 18V6m16 0v12"/><path d="M12 10c4.418 0 8-1.79 8-4s-3.582-4-8-4s-8 1.79-8 4s3.582 4 8 4Zm8 2c0 2.21-3.582 4-8 4s-8-1.79-8-4m16 6c0 2.21-3.582 4-8 4s-8-1.79-8-4"/></g></svg>

Before

Width:  |  Height:  |  Size: 350 B

After

Width:  |  Height:  |  Size: 345 B

Before After
Before After

View file

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="currentColor" stroke-linecap="round" stroke-width="1.5"><path d="M17 9.002c2.175.012 3.353.109 4.121.877C22 10.758 22 12.172 22 15v1c0 2.829 0 4.243-.879 5.122C20.243 22 18.828 22 16 22H8c-2.828 0-4.243 0-5.121-.878C2 20.242 2 18.829 2 16v-1c0-2.828 0-4.242.879-5.121c.768-.768 1.946-.865 4.121-.877"/><path stroke-linejoin="round" d="M12 2v13m0 0l-3-3.5m3 3.5l3-3.5"/></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="#ffffff" stroke-linecap="round" stroke-width="1.5"><path d="M17 9.002c2.175.012 3.353.109 4.121.877C22 10.758 22 12.172 22 15v1c0 2.829 0 4.243-.879 5.122C20.243 22 18.828 22 16 22H8c-2.828 0-4.243 0-5.121-.878C2 20.242 2 18.829 2 16v-1c0-2.828 0-4.242.879-5.121c.768-.768 1.946-.865 4.121-.877"/><path stroke-linejoin="round" d="M12 2v13m0 0l-3-3.5m3 3.5l3-3.5"/></g></svg>

Before

Width:  |  Height:  |  Size: 490 B

After

Width:  |  Height:  |  Size: 485 B

Before After
Before After

View file

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" d="M18 10h-5"/><path d="M10 3h6.5c.464 0 .697 0 .892.026a3 3 0 0 1 2.582 2.582c.026.195.026.428.026.892"/><path d="M2 6.95c0-.883 0-1.324.07-1.692A4 4 0 0 1 5.257 2.07C5.626 2 6.068 2 6.95 2c.386 0 .58 0 .766.017a4 4 0 0 1 2.18.904c.144.119.28.255.554.529L11 4c.816.816 1.224 1.224 1.712 1.495a4 4 0 0 0 .848.352C14.098 6 14.675 6 15.828 6h.374c2.632 0 3.949 0 4.804.77q.119.105.224.224c.77.855.77 2.172.77 4.804V14c0 3.771 0 5.657-1.172 6.828S17.771 22 14 22h-4c-3.771 0-5.657 0-6.828-1.172S2 17.771 2 14z"/></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="#ffffff" stroke-width="1.5"><path stroke-linecap="round" d="M18 10h-5"/><path d="M10 3h6.5c.464 0 .697 0 .892.026a3 3 0 0 1 2.582 2.582c.026.195.026.428.026.892"/><path d="M2 6.95c0-.883 0-1.324.07-1.692A4 4 0 0 1 5.257 2.07C5.626 2 6.068 2 6.95 2c.386 0 .58 0 .766.017a4 4 0 0 1 2.18.904c.144.119.28.255.554.529L11 4c.816.816 1.224 1.224 1.712 1.495a4 4 0 0 0 .848.352C14.098 6 14.675 6 15.828 6h.374c2.632 0 3.949 0 4.804.77q.119.105.224.224c.77.855.77 2.172.77 4.804V14c0 3.771 0 5.657-1.172 6.828S17.771 22 14 22h-4c-3.771 0-5.657 0-6.828-1.172S2 17.771 2 14z"/></g></svg>

Before

Width:  |  Height:  |  Size: 692 B

After

Width:  |  Height:  |  Size: 687 B

Before After
Before After

View file

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v4l2.5 2.5"/><path fill="#ffffff" d="m5.604 5.604l-.53-.53zM4.338 6.871l-.75.003a.75.75 0 0 0 .746.747zm2.542.762a.75.75 0 1 0 .007-1.5zM5.075 4.321a.75.75 0 1 0-1.5.008zm-1.248 6.464a.75.75 0 1 0-1.486-.204zm15.035-5.647c-3.82-3.82-9.993-3.86-13.788-.064l1.06 1.06c3.2-3.199 8.423-3.18 11.668.064zM5.138 18.862c3.82 3.82 9.993 3.86 13.788.064l-1.06-1.06c-3.2 3.199-8.423 3.18-11.668-.064zm13.788.064c3.795-3.795 3.756-9.968-.064-13.788l-1.06 1.06c3.244 3.245 3.263 8.468.064 11.668zM5.074 5.074L3.807 6.34L4.868 7.4l1.266-1.266zm-.74 2.547l2.546.012l.007-1.5l-2.545-.012zm.754-.754L5.075 4.32l-1.5.008l.013 2.545zM2.34 10.58a9.81 9.81 0 0 0 2.797 8.281l1.06-1.06a8.31 8.31 0 0 1-2.371-7.017z"/></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff"><path stroke="#ffffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v4l2.5 2.5"/><path fill="#ffffff" d="m5.604 5.604l-.53-.53zM4.338 6.871l-.75.003a.75.75 0 0 0 .746.747zm2.542.762a.75.75 0 1 0 .007-1.5zM5.075 4.321a.75.75 0 1 0-1.5.008zm-1.248 6.464a.75.75 0 1 0-1.486-.204zm15.035-5.647c-3.82-3.82-9.993-3.86-13.788-.064l1.06 1.06c3.2-3.199 8.423-3.18 11.668.064zM5.138 18.862c3.82 3.82 9.993 3.86 13.788.064l-1.06-1.06c-3.2 3.199-8.423 3.18-11.668-.064zm13.788.064c3.795-3.795 3.756-9.968-.064-13.788l-1.06 1.06c3.244 3.245 3.263 8.468.064 11.668zM5.074 5.074L3.807 6.34L4.868 7.4l1.266-1.266zm-.74 2.547l2.546.012l.007-1.5l-2.545-.012zm.754-.754L5.075 4.32l-1.5.008l.013 2.545zM2.34 10.58a9.81 9.81 0 0 0 2.797 8.281l1.06-1.06a8.31 8.31 0 0 1-2.371-7.017z"/></g></svg>

Before

Width:  |  Height:  |  Size: 910 B

After

Width:  |  Height:  |  Size: 905 B

Before After
Before After

View file

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><path d="M8 9.5c0-.466 0-.699.076-.883a1 1 0 0 1 .541-.54C8.801 8 9.034 8 9.5 8s.699 0 .883.076a1 1 0 0 1 .54.541c.077.184.077.417.077.883v5c0 .466 0 .699-.076.883a1 1 0 0 1-.541.54C10.199 16 9.966 16 9.5 16s-.699 0-.883-.076a1 1 0 0 1-.54-.541C8 15.199 8 14.966 8 14.5zm5 0c0-.466 0-.699.076-.883a1 1 0 0 1 .541-.54C13.801 8 14.034 8 14.5 8s.699 0 .883.076a1 1 0 0 1 .54.541c.077.184.077.417.077.883v5c0 .466 0 .699-.076.883a1 1 0 0 1-.541.54c-.184.077-.417.077-.883.077s-.699 0-.883-.076a1 1 0 0 1-.54-.541C13 15.199 13 14.966 13 14.5z"/></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="#ffffff" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><path d="M8 9.5c0-.466 0-.699.076-.883a1 1 0 0 1 .541-.54C8.801 8 9.034 8 9.5 8s.699 0 .883.076a1 1 0 0 1 .54.541c.077.184.077.417.077.883v5c0 .466 0 .699-.076.883a1 1 0 0 1-.541.54C10.199 16 9.966 16 9.5 16s-.699 0-.883-.076a1 1 0 0 1-.54-.541C8 15.199 8 14.966 8 14.5zm5 0c0-.466 0-.699.076-.883a1 1 0 0 1 .541-.54C13.801 8 14.034 8 14.5 8s.699 0 .883.076a1 1 0 0 1 .54.541c.077.184.077.417.077.883v5c0 .466 0 .699-.076.883a1 1 0 0 1-.541.54c-.184.077-.417.077-.883.077s-.699 0-.883-.076a1 1 0 0 1-.54-.541C13 15.199 13 14.966 13 14.5z"/></g></svg>

Before

Width:  |  Height:  |  Size: 726 B

After

Width:  |  Height:  |  Size: 721 B

Before After
Before After

View file

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" stroke="currentColor" stroke-width="1.5" d="m14.36 4.079l.927-.927a3.932 3.932 0 0 1 5.561 5.561l-.927.927m-5.56-5.561s.115 1.97 1.853 3.707C17.952 9.524 19.92 9.64 19.92 9.64m-5.56-5.561l-8.522 8.52c-.577.578-.866.867-1.114 1.185a6.6 6.6 0 0 0-.749 1.211c-.173.364-.302.752-.56 1.526l-1.094 3.281m17.6-10.162L11.4 18.16c-.577.577-.866.866-1.184 1.114a6.6 6.6 0 0 1-1.211.749c-.364.173-.751.302-1.526.56l-3.281 1.094m0 0l-.802.268a1.06 1.06 0 0 1-1.342-1.342l.268-.802m1.876 1.876l-1.876-1.876"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" stroke="#ffffff" stroke-width="1.5" d="m14.36 4.079l.927-.927a3.932 3.932 0 0 1 5.561 5.561l-.927.927m-5.56-5.561s.115 1.97 1.853 3.707C17.952 9.524 19.92 9.64 19.92 9.64m-5.56-5.561l-8.522 8.52c-.577.578-.866.867-1.114 1.185a6.6 6.6 0 0 0-.749 1.211c-.173.364-.302.752-.56 1.526l-1.094 3.281m17.6-10.162L11.4 18.16c-.577.577-.866.866-1.184 1.114a6.6 6.6 0 0 1-1.211.749c-.364.173-.751.302-1.526.56l-3.281 1.094m0 0l-.802.268a1.06 1.06 0 0 1-1.342-1.342l.268-.802m1.876 1.876l-1.876-1.876"/></svg>

Before

Width:  |  Height:  |  Size: 608 B

After

Width:  |  Height:  |  Size: 603 B

Before After
Before After

View file

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><path d="M15.414 10.941c.781.462.781 1.656 0 2.118l-4.72 2.787C9.934 16.294 9 15.71 9 14.786V9.214c0-.924.934-1.507 1.694-1.059z"/></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="#ffffff" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><path d="M15.414 10.941c.781.462.781 1.656 0 2.118l-4.72 2.787C9.934 16.294 9 15.71 9 14.786V9.214c0-.924.934-1.507 1.694-1.059z"/></g></svg>

Before

Width:  |  Height:  |  Size: 317 B

After

Width:  |  Height:  |  Size: 312 B

Before After
Before After

View file

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="currentColor" stroke-width="1.5"><path d="M2 11c0-3.771 0-5.657 1.172-6.828S6.229 3 10 3h4c3.771 0 5.657 0 6.828 1.172S22 7.229 22 11v2c0 3.771 0 5.657-1.172 6.828S17.771 21 14 21h-4c-3.771 0-5.657 0-6.828-1.172S2 16.771 2 13zm0 1h20"/><path stroke-linecap="round" d="M13.5 16.5H18m-4.5-9H18m-12 10v-2m0-7v-2m3 11v-2m0-7v-2"/></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="#ffffff" stroke-width="1.5"><path d="M2 11c0-3.771 0-5.657 1.172-6.828S6.229 3 10 3h4c3.771 0 5.657 0 6.828 1.172S22 7.229 22 11v2c0 3.771 0 5.657-1.172 6.828S17.771 21 14 21h-4c-3.771 0-5.657 0-6.828-1.172S2 16.771 2 13zm0 1h20"/><path stroke-linecap="round" d="M13.5 16.5H18m-4.5-9H18m-12 10v-2m0-7v-2m3 11v-2m0-7v-2"/></g></svg>

Before

Width:  |  Height:  |  Size: 447 B

After

Width:  |  Height:  |  Size: 442 B

Before After
Before After

View file

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff"><path stroke="currentColor" stroke-linecap="round" stroke-width="1.5" d="M19 17v1m-2.5-1v1M14 17v1m-2.5-1v1"/><path fill="#ffffff" d="m19 5.118l.72-.21l-.002-.007l-.003-.009zm-14 0l-.715-.226l-.003.009l-.002.008zM2.889 20.643l.433-.612zm-.552-.584l.634-.4zm19.326 0l-.634-.4zm-.552.584l-.433-.612zm0-6.698l-.433.612zm.552.584l-.634.4zm-18.774-.584l.433.612zm-.552.584l.634.4zM7.5 3.75h9v-1.5h-9zm9 0c.428 0 .753.1 1.016.305c.269.21.549.59.769 1.288l1.43-.45c-.28-.89-.698-1.57-1.277-2.021c-.587-.457-1.261-.622-1.938-.622zm-9-1.5c-.676 0-1.351.165-1.938.622c-.58.451-.997 1.13-1.277 2.02l1.43.45c.22-.697.5-1.077.77-1.287c.262-.205.587-.305 1.015-.305zm10.78 3.076l2.851 9.839l1.44-.418l-2.85-9.838zm-14-.417l-2.852 9.838l1.44.418L5.72 5.326zm1.22 9.43h13v-1.5h-13zm13 5.911h-13v1.5h13zm-13 0c-.719 0-1.198-.001-1.563-.04c-.35-.038-.509-.104-.615-.18l-.866 1.225c.398.282.842.395 1.32.446c.464.05 1.038.049 1.724.049zm-4.25-2.956c0 .729 0 1.329.046 1.812c.047.493.15.945.407 1.353l1.268-.8c-.08-.126-.145-.313-.182-.697c-.038-.394-.039-.91-.039-1.668zm2.072 2.737a1.3 1.3 0 0 1-.35-.372l-1.27.8c.198.313.453.584.754.796zm17.928-2.737c0 .758 0 1.274-.039 1.668c-.037.384-.103.57-.182.697l1.268.8c.258-.408.36-.86.407-1.353c.047-.483.046-1.083.046-1.812zM18.5 21.75c.686 0 1.26.001 1.723-.049c.479-.052.923-.164 1.321-.446l-.866-1.224c-.106.075-.265.141-.615.179c-.365.039-.844.04-1.563.04zm2.529-2.091a1.3 1.3 0 0 1-.351.372l.866 1.224c.3-.212.556-.483.753-.796zm-2.529-5.32c.719 0 1.198 0 1.563.04c.35.037.509.103.615.178l.866-1.224c-.398-.282-.842-.394-1.32-.446c-.464-.05-1.038-.049-1.724-.049zm4.25 2.955c0-.729 0-1.329-.046-1.811c-.047-.494-.15-.946-.407-1.354l-1.268.8c.08.127.145.313.182.697c.038.394.039.91.039 1.668zm-2.072-2.737c.136.097.256.223.35.373l1.27-.8a2.8 2.8 0 0 0-.754-.797zM5.5 12.838c-.686 0-1.26 0-1.723.05c-.479.05-.923.163-1.321.445l.866 1.224c.106-.075.265-.14.615-.178c.365-.04.844-.04 1.563-.04zm-2.75 4.456c0-.758 0-1.274.039-1.668c.037-.384.103-.57.182-.696l-1.268-.8c-.258.407-.36.86-.407 1.353c-.047.482-.046 1.082-.046 1.811zm-.294-3.961c-.3.212-.556.484-.753.796l1.268.8a1.3 1.3 0 0 1 .351-.372z"/></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff"><path stroke="#ffffff" stroke-linecap="round" stroke-width="1.5" d="M19 17v1m-2.5-1v1M14 17v1m-2.5-1v1"/><path fill="#ffffff" d="m19 5.118l.72-.21l-.002-.007l-.003-.009zm-14 0l-.715-.226l-.003.009l-.002.008zM2.889 20.643l.433-.612zm-.552-.584l.634-.4zm19.326 0l-.634-.4zm-.552.584l-.433-.612zm0-6.698l-.433.612zm.552.584l-.634.4zm-18.774-.584l.433.612zm-.552.584l.634.4zM7.5 3.75h9v-1.5h-9zm9 0c.428 0 .753.1 1.016.305c.269.21.549.59.769 1.288l1.43-.45c-.28-.89-.698-1.57-1.277-2.021c-.587-.457-1.261-.622-1.938-.622zm-9-1.5c-.676 0-1.351.165-1.938.622c-.58.451-.997 1.13-1.277 2.02l1.43.45c.22-.697.5-1.077.77-1.287c.262-.205.587-.305 1.015-.305zm10.78 3.076l2.851 9.839l1.44-.418l-2.85-9.838zm-14-.417l-2.852 9.838l1.44.418L5.72 5.326zm1.22 9.43h13v-1.5h-13zm13 5.911h-13v1.5h13zm-13 0c-.719 0-1.198-.001-1.563-.04c-.35-.038-.509-.104-.615-.18l-.866 1.225c.398.282.842.395 1.32.446c.464.05 1.038.049 1.724.049zm-4.25-2.956c0 .729 0 1.329.046 1.812c.047.493.15.945.407 1.353l1.268-.8c-.08-.126-.145-.313-.182-.697c-.038-.394-.039-.91-.039-1.668zm2.072 2.737a1.3 1.3 0 0 1-.35-.372l-1.27.8c.198.313.453.584.754.796zm17.928-2.737c0 .758 0 1.274-.039 1.668c-.037.384-.103.57-.182.697l1.268.8c.258-.408.36-.86.407-1.353c.047-.483.046-1.083.046-1.812zM18.5 21.75c.686 0 1.26.001 1.723-.049c.479-.052.923-.164 1.321-.446l-.866-1.224c-.106.075-.265.141-.615.179c-.365.039-.844.04-1.563.04zm2.529-2.091a1.3 1.3 0 0 1-.351.372l.866 1.224c.3-.212.556-.483.753-.796zm-2.529-5.32c.719 0 1.198 0 1.563.04c.35.037.509.103.615.178l.866-1.224c-.398-.282-.842-.394-1.32-.446c-.464-.05-1.038-.049-1.724-.049zm4.25 2.955c0-.729 0-1.329-.046-1.811c-.047-.494-.15-.946-.407-1.354l-1.268.8c.08.127.145.313.182.697c.038.394.039.91.039 1.668zm-2.072-2.737c.136.097.256.223.35.373l1.27-.8a2.8 2.8 0 0 0-.754-.797zM5.5 12.838c-.686 0-1.26 0-1.723.05c-.479.05-.923.163-1.321.445l.866 1.224c.106-.075.265-.14.615-.178c.365-.04.844-.04 1.563-.04zm-2.75 4.456c0-.758 0-1.274.039-1.668c.037-.384.103-.57.182-.696l-1.268-.8c-.258.407-.36.86-.407 1.353c-.047.482-.046 1.082-.046 1.811zm-.294-3.961c-.3.212-.556.484-.753.796l1.268.8a1.3 1.3 0 0 1 .351-.372z"/></g></svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Before After
Before After

View file

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" stroke="currentColor" stroke-linecap="round" stroke-width="1.5" d="M9.17 4a3.001 3.001 0 0 1 5.66 0m5.67 2h-17m15.333 2.5l-.46 6.9c-.177 2.654-.265 3.981-1.13 4.79s-2.196.81-4.856.81h-.774c-2.66 0-3.991 0-4.856-.81c-.865-.809-.954-2.136-1.13-4.79l-.46-6.9M9.5 11l.5 5m4.5-5l-.5 5"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" stroke="#ffffff" stroke-linecap="round" stroke-width="1.5" d="M9.17 4a3.001 3.001 0 0 1 5.66 0m5.67 2h-17m15.333 2.5l-.46 6.9c-.177 2.654-.265 3.981-1.13 4.79s-2.196.81-4.856.81h-.774c-2.66 0-3.991 0-4.856-.81c-.865-.809-.954-2.136-1.13-4.79l-.46-6.9M9.5 11l.5 5m4.5-5l-.5 5"/></svg>

Before

Width:  |  Height:  |  Size: 394 B

After

Width:  |  Height:  |  Size: 389 B

Before After
Before After

View file

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="currentColor" stroke-linecap="round" stroke-width="1.5"><path d="M17 9.002c2.175.012 3.353.109 4.121.877C22 10.758 22 12.172 22 15v1c0 2.829 0 4.243-.879 5.122C20.243 22 18.828 22 16 22H8c-2.828 0-4.243 0-5.121-.878C2 20.242 2 18.829 2 16v-1c0-2.828 0-4.242.879-5.121c.768-.768 1.946-.865 4.121-.877"/><path stroke-linejoin="round" d="M12 15V2m0 0l3 3.5M12 2L9 5.5"/></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="#ffffff" stroke-linecap="round" stroke-width="1.5"><path d="M17 9.002c2.175.012 3.353.109 4.121.877C22 10.758 22 12.172 22 15v1c0 2.829 0 4.243-.879 5.122C20.243 22 18.828 22 16 22H8c-2.828 0-4.243 0-5.121-.878C2 20.242 2 18.829 2 16v-1c0-2.828 0-4.242.879-5.121c.768-.768 1.946-.865 4.121-.877"/><path stroke-linejoin="round" d="M12 15V2m0 0l3 3.5M12 2L9 5.5"/></g></svg>

Before

Width:  |  Height:  |  Size: 488 B

After

Width:  |  Height:  |  Size: 483 B

Before After
Before After

View file

@ -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<Option<[muda::CheckMenuItem; 4]>> =
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"))]

View file

@ -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;

View file

@ -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<u64>) -> 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<i64>) -> 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<String>,
pub clicked: Option<String>,
/// Payload, перетащенный на папку (или "..") в этой таблице — (целевая папка, что бросили).
pub dropped_on_dir: Option<(FileEntry, std::sync::Arc<DragPayload>)>,
/// Действие из контекстного меню (ПКМ) — (строка, действие).
pub context_action: Option<(FileEntry, RowAction)>,
}
pub fn file_table<F, C>(
ui: &mut Ui,
id: &str,
entries: &[FileEntry],
selected: &mut Option<String>,
make_drag_payload: F,
context_menu: C,
) -> FileTableResponse
where
F: Fn(&FileEntry) -> Option<DragPayload>,
C: FnMut(&mut Ui, &FileEntry) -> Option<RowAction>,
{
ui.push_id(id, |ui| {
file_table_inner(ui, id, entries, selected, make_drag_payload, context_menu)
})
.inner
}
fn file_table_inner<F, C>(
ui: &mut Ui,
id: &str,
entries: &[FileEntry],
selected: &mut Option<String>,
make_drag_payload: F,
mut context_menu: C,
) -> FileTableResponse
where
F: Fn(&FileEntry) -> Option<DragPayload>,
C: FnMut(&mut Ui, &FileEntry) -> Option<RowAction>,
{
// Сортировка — читаем из 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<String> = None;
let mut clicked: Option<String> = None;
let mut new_sort: Option<(SortCol, SortDir)> = None;
let mut dropped_on_dir: Option<(FileEntry, std::sync::Arc<DragPayload>)> = 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::<DragPayload>(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::<DragPayload>()
{
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)
}
}

View file

@ -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<u64>) -> 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<i64>) -> 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<String>,
pub clicked: Option<String>,
/// Payload, перетащенный на папку (или "..") в этой таблице — (целевая папка, что бросили).
pub dropped_on_dir: Option<(FileEntry, std::sync::Arc<DragPayload>)>,
/// Действие из контекстного меню (ПКМ) — (строка, действие).
pub context_action: Option<(FileEntry, RowAction)>,
}
pub fn file_table<F, C>(
ui: &mut Ui,
id: &str,
entries: &[FileEntry],
selected: &mut Option<String>,
make_drag_payload: F,
context_menu: C,
) -> FileTableResponse
where
F: Fn(&FileEntry) -> Option<DragPayload>,
C: FnMut(&mut Ui, &FileEntry) -> Option<RowAction>,
{
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()
}

View file

@ -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<F>(ui: &mut Ui, entry: &FileEntry, row_id: Id, make_drag_payload: &F)
where
F: Fn(&FileEntry) -> Option<DragPayload>,
{
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);
});
}

View file

@ -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<FileEntry> {
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)
}
}

View file

@ -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<F, C>(
ui: &mut Ui,
id: &str,
entries: &[FileEntry],
selected: &mut Option<String>,
make_drag_payload: F,
mut context_menu: C,
) -> FileTableResponse
where
F: Fn(&FileEntry) -> Option<DragPayload>,
C: FnMut(&mut Ui, &FileEntry) -> Option<RowAction>,
{
// Сортировка — читаем из 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<String> = None;
let mut clicked: Option<String> = None;
let mut new_sort: Option<(SortCol, SortDir)> = None;
let mut dropped_on_dir: Option<(FileEntry, std::sync::Arc<DragPayload>)> = 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::<DragPayload>(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::<DragPayload>()
{
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,
}
}

View file

@ -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<RemoteRegistry>,
_rt_handle: &tokio::runtime::Handle,
db: &Arc<Mutex<rusqlite::Connection>>,
) {
let frame = egui::Frame::none()
.fill(BG_CONTENT)
.inner_margin(egui::Margin::same(0.0));
let (_inner, dropped) = ui.dnd_drop_zone::<DragPayload, _>(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<RemoteRegistry>,
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<RemoteRegistry>,
db: &Arc<Mutex<rusqlite::Connection>>,
) {
// Хлебные крошки + путь
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<RowAction> {
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<Mutex<rusqlite::Connection>>,
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<Mutex<rusqlite::Connection>>,
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<Mutex<rusqlite::Connection>>) {
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);
}
}
}

View file

@ -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<RemoteRegistry>,
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<RowAction> {
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<Mutex<rusqlite::Connection>>,
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<Mutex<rusqlite::Connection>>,
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())])
}
}
}

View file

@ -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<Mutex<rusqlite::Connection>>,
) {
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);
}
});
});
});
}

View file

@ -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<RemoteRegistry>,
_rt_handle: &tokio::runtime::Handle,
db: &Arc<Mutex<rusqlite::Connection>>,
) {
let frame = egui::Frame::none()
.fill(BG_CONTENT)
.inner_margin(egui::Margin::same(0.0));
let (_inner, dropped) = ui.dnd_drop_zone::<DragPayload, _>(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<RemoteRegistry>,
db: &Arc<Mutex<rusqlite::Connection>>,
) {
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())]);
}
}
}

View file

@ -4,5 +4,4 @@ pub mod queue;
pub mod remote_pane;
pub mod sidebar;
pub mod status_bar;
pub mod tabs;
pub mod toolbar;

View file

@ -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);

View file

@ -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<RemoteRegistry>,
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::<DragPayload, _>(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<RemoteRegistry>,
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::<Result<(), String>>));
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<RemoteRegistry>,
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<RowAction> {
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<RemoteRegistry>,
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<RemoteRegistry>,
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<RemoteRegistry>,
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<RemoteRegistry>,
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<RemoteRegistry>,
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::<Result<Vec<FileEntry>, 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 });
}

View file

@ -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<RemoteRegistry>,
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::<Result<(), String>>));
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<RowAction> {
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<RemoteRegistry>,
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<RemoteRegistry>,
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 => {}
}
}

View file

@ -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<RemoteRegistry>,
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<RemoteRegistry>,
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<RemoteRegistry>,
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<RemoteRegistry>,
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);
}
});
});
});
}

View file

@ -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<RemoteRegistry>,
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::<DragPayload, _>(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<RemoteRegistry>,
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<RemoteRegistry>,
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::<Result<Vec<FileEntry>, 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 });
}

View file

@ -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<Mutex<rusqlite::Connec
.inner_margin(egui::Margin::symmetric(0.0, 0.0));
frame.show(ui, |ui| {
ui.set_width(SIDEBAR_W);
egui::ScrollArea::vertical()
.auto_shrink([false; 2])
.show(ui, |ui| {
ui.add_space(8.0);
section_header(ui, "LOCAL");
section_header(ui, t("panels.local_label"));
ui.add_space(4.0);
for qa in &state.quick_access.clone() {
let is_active = state.local_path == qa.path;
let icon = quick_access_icon(&qa.name);
sidebar_item(ui, icon, &qa.name, is_active, || {
sidebar_item(ui, icon, quick_access_label(&qa.name), is_active, || {
state.local_path = qa.path.clone();
refresh_local(state);
});
@ -35,15 +35,21 @@ pub fn render(ui: &mut Ui, state: &mut AppState, db: &Arc<Mutex<rusqlite::Connec
section_divider(ui);
ui.add_space(6.0);
section_header(ui, "DRIVES");
section_header(ui, t("panels.sidebar.drives"));
ui.add_space(4.0);
// Root + data drives
let root_active = state.local_path == "/";
sidebar_item(ui, Icon::Ssd, "/ (root)", root_active, || {
state.local_path = "/".into();
refresh_local(state);
});
sidebar_item(
ui,
Icon::Ssd,
t("panels.sidebar.root_label"),
root_active,
|| {
state.local_path = "/".into();
refresh_local(state);
},
);
// попытаемся найти /media и /mnt точки монтирования
for mount_path in find_mounts() {
@ -58,34 +64,23 @@ pub fn render(ui: &mut Ui, state: &mut AppState, db: &Arc<Mutex<rusqlite::Connec
});
}
ui.add_space(10.0);
section_divider(ui);
ui.add_space(6.0);
if !state.bookmarks.is_empty() {
ui.add_space(10.0);
section_divider(ui);
ui.add_space(6.0);
section_header(ui, "BOOKMARKS");
ui.add_space(4.0);
section_header(ui, t("panels.sidebar.bookmarks"));
ui.add_space(4.0);
if state.bookmarks.is_empty() {
ui.label(
RichText::new(" No bookmarks yet")
.color(TEXT_HINT)
.size(11.0),
);
ui.label(
RichText::new(" Use ★ in file list")
.color(TEXT_HINT)
.size(10.0),
);
} else {
let mut to_remove: Option<i64> = 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<String> {
let mut result = Vec::new();
for prefix in ["/media", "/mnt"] {

View file

@ -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),
);
}
});
},

View file

@ -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<usize> = 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);
}

View file

@ -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);
}

View file

@ -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)]);
}

View file

@ -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);
}

View file

@ -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;
}
}

View file

@ -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<String> {
if on_remote {
state
.active_tab_ref()
.and_then(|t| t.remote_selected.clone())
} else {
state.local_selected.clone()
}
}

View file

@ -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<FileEntry>,
pub remote_selected: Option<String>,
pub loading: bool,
}
/// Какая панель последней принимала клик — используется тулбаром,
/// чтобы New Folder/Rename/Delete применялись к нужной стороне (local/remote).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Pane {
Local,
Remote,
}
type PendingResult<T> = Arc<std::sync::Mutex<Option<Result<T, String>>>>;
pub struct PendingConnect {
pub result: PendingResult<(ConnectionParams, Vec<FileEntry>)>,
}
pub struct PendingRemoteList {
pub tab_idx: usize,
pub result: PendingResult<Vec<FileEntry>>,
}
#[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<String>,
}
pub struct AppState {
pub tabs: Vec<ConnectionTab>,
pub active_tab: usize,
pub local_path: String,
pub local_entries: Vec<FileEntry>,
pub local_selected: Option<String>,
/// Панель, в которой был последний клик — 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<QuickAccess>,
/// Закладки, добавленные пользователем — хранятся в БД, можно удалять.
pub bookmarks: Vec<Bookmark>,
pub history: Vec<HistoryEntry>,
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<TransferTask>,
pub status_message: String,
pub connected_count: usize,
// action flags — выставляются тулбаром, обрабатываются в app.rs
pub pending_refresh: bool,
/// Клик по записи истории — переподключиться (новая вкладка, без диалога).
pub pending_history_reconnect: Option<HistoryEntry>,
/// ПКМ → "Save" на записи истории — сохранить как постоянный Site.
pub pending_history_save: Option<HistoryEntry>,
// диалоги операций над удалённой ФС
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<PendingResult<()>>,
pub pending_delete_result: Option<PendingResult<()>>,
pub pending_rename_result: Option<PendingResult<()>>,
pub pending_connect: Option<PendingConnect>,
pub pending_remote_list: Vec<PendingRemoteList>,
}
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<std::sync::Mutex<rusqlite::Connection>>) {
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();
}
}
}

88
src/ui/state/defaults.rs Normal file
View file

@ -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<QuickAccess> {
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)),
]
}

134
src/ui/state/mod.rs Normal file
View file

@ -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<ConnectionTab>,
pub active_tab: usize,
pub local_path: String,
pub local_entries: Vec<FileEntry>,
pub local_selected: Option<String>,
/// Панель, в которой был последний клик — 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<std::sync::atomic::AtomicU32>,
/// Инлайн-подтверждение "Clear All History" в разделе Settings → History.
pub history_clear_confirm: bool,
/// Фиксированные пункты быстрого доступа (Home/Desktop/...) — не удаляются, не хранятся в БД.
pub quick_access: Vec<QuickAccess>,
/// Закладки, добавленные пользователем — хранятся в БД, можно удалять.
pub bookmarks: Vec<Bookmark>,
pub history: Vec<HistoryEntry>,
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<TransferTask>,
pub status_message: String,
// action flags — выставляются тулбаром, обрабатываются в app.rs
pub pending_refresh: bool,
/// Клик по записи истории — переподключиться (новая вкладка, без диалога).
pub pending_history_reconnect: Option<HistoryEntry>,
/// ПКМ → "Save" на записи истории — сохранить как постоянный Site.
pub pending_history_save: Option<HistoryEntry>,
// диалоги операций над удалённой ФС
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<PendingResult<()>>,
pub pending_delete_result: Option<PendingResult<()>>,
pub pending_rename_result: Option<PendingResult<()>>,
pub pending_connect: Option<PendingConnect>,
pub pending_remote_list: Vec<PendingRemoteList>,
}
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<std::sync::Mutex<rusqlite::Connection>>) {
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();
}
}
}

74
src/ui/state/types.rs Normal file
View file

@ -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<FileEntry>,
pub remote_selected: Option<String>,
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<T> = Arc<std::sync::Mutex<Option<Result<T, String>>>>;
pub struct PendingConnect {
pub result: PendingResult<(ConnectionParams, Vec<FileEntry>)>,
}
pub struct PendingRemoteList {
pub tab_idx: usize,
pub result: PendingResult<Vec<FileEntry>>,
}
#[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<String>,
}

80
src/ui/widgets/button.rs Normal file
View file

@ -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)
}
}

View file

@ -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<Pos2> = 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::<bool>(force_close_id))
.unwrap_or(false);
if should_close {
ui.ctx().data_mut(|d| d.remove::<bool>(force_close_id));
ui.close_menu();
return;
}
add_contents(ui);
});
}

8
src/ui/widgets/mod.rs Normal file
View file

@ -0,0 +1,8 @@
//! Переиспользуемые UI-примитивы: кнопки с обратной связью, кликабельные строки,
//! модальные подложки. Вынесены сюда, чтобы отклик на действия был единообразным
//! во всём интерфейсе (см. правило про мгновенный визуальный отклик) и без копипасты.
pub mod button;
pub mod context_menu;
pub mod modal;
pub mod overlay;
pub mod row;

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