Хуйня не на tauri
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,3 @@
|
|||
use super::Entry;
|
||||
|
||||
pub const ENTRIES: &[Entry] = &[];
|
||||
23
src/i18n/strings/mod.rs
Normal 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
|
|
@ -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",
|
||||
],
|
||||
),
|
||||
];
|
||||
831
src/i18n/strings/settings.rs
Normal 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
|
|
@ -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…",
|
||||
],
|
||||
),
|
||||
];
|
||||
3
src/i18n/strings/welcome.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
use super::Entry;
|
||||
|
||||
pub const ENTRIES: &[Entry] = &[];
|
||||
15
src/lib.rs
|
|
@ -1,5 +1,6 @@
|
|||
pub mod domain;
|
||||
pub mod fs;
|
||||
pub mod i18n;
|
||||
pub mod protocols;
|
||||
pub mod storage;
|
||||
pub mod transfer;
|
||||
|
|
@ -9,6 +10,7 @@ use crate::fs::remote::RemoteRegistry;
|
|||
use crate::transfer::queue::TransferQueue;
|
||||
use crate::ui::app::FileManagerApp;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicU32;
|
||||
|
||||
pub fn run() {
|
||||
tracing_subscriber::fmt().with_target(false).init();
|
||||
|
|
@ -35,9 +37,18 @@ pub fn run() {
|
|||
let registry = Arc::new(RemoteRegistry::default());
|
||||
let queue = TransferQueue::default();
|
||||
|
||||
transfer::worker::spawn_worker(queue.clone(), registry.clone(), rt_handle);
|
||||
// Реальное значение подтягивается из БД внутри FileManagerApp::new —
|
||||
// здесь только общий на воркер и UI атомик, который Settings → Transfers
|
||||
// меняет на лету.
|
||||
let max_concurrent = Arc::new(AtomicU32::new(2));
|
||||
transfer::worker::spawn_worker(
|
||||
queue.clone(),
|
||||
registry.clone(),
|
||||
rt_handle,
|
||||
max_concurrent.clone(),
|
||||
);
|
||||
|
||||
let app = FileManagerApp::new(registry, queue, rt, db, sites);
|
||||
let app = FileManagerApp::new(registry, queue, rt, db, sites, max_concurrent);
|
||||
|
||||
let icon = load_app_icon();
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
101
src/protocols/ftp/transfer.rs
Normal 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(())
|
||||
}
|
||||
|
|
@ -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))?
|
||||
}
|
||||
}
|
||||
66
src/protocols/sftp/hostkey.rs
Normal 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
|
|
@ -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
|
|
@ -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))?
|
||||
}
|
||||
}
|
||||
100
src/protocols/sftp/transfer.rs
Normal 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))?
|
||||
}
|
||||
|
|
@ -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(())
|
||||
}
|
||||
25
src/storage/db/bookmarks.rs
Normal 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
|
|
@ -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
|
|
@ -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(())
|
||||
}
|
||||
43
src/storage/db/settings.rs
Normal 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
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1215
src/ui/app.rs
164
src/ui/app/dialogs.rs
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,143 @@
|
|||
//! Разбор результатов подключения и листинга удалённой директории.
|
||||
use super::FileManagerApp;
|
||||
use crate::domain::connection::{ConnectionParams, ConnectionStatus};
|
||||
use crate::domain::file_entry::{EntryKind, FileEntry};
|
||||
use crate::ui::dock::PaneTab;
|
||||
use crate::ui::panels::remote_pane;
|
||||
use crate::ui::state::ConnectionTab;
|
||||
|
||||
impl FileManagerApp {
|
||||
// --- Connect result ---
|
||||
pub(super) fn poll_connect(&mut self) {
|
||||
let opt = self.state.pending_connect.take();
|
||||
let Some(pc) = opt.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let mut guard = pc.result.lock().unwrap();
|
||||
let Some(res) = guard.take() else {
|
||||
drop(guard);
|
||||
self.state.pending_connect = opt;
|
||||
return;
|
||||
};
|
||||
drop(guard);
|
||||
|
||||
match res {
|
||||
Ok((params, entries)) => {
|
||||
let path = "/".to_string();
|
||||
let mut list = vec![FileEntry {
|
||||
name: "..".into(),
|
||||
path: path.clone(),
|
||||
kind: EntryKind::Dir,
|
||||
size: None,
|
||||
modified: None,
|
||||
permissions: None,
|
||||
}];
|
||||
list.extend(entries);
|
||||
|
||||
let tab = ConnectionTab {
|
||||
id: params.id.clone(),
|
||||
label: params.label.clone(),
|
||||
params: params.clone(),
|
||||
status: ConnectionStatus::Connected,
|
||||
remote_path: path,
|
||||
remote_entries: list,
|
||||
remote_selected: None,
|
||||
loading: false,
|
||||
};
|
||||
self.state.tabs.push(tab);
|
||||
self.state.active_tab = self.state.tabs.len() - 1;
|
||||
self.dock_state
|
||||
.push_to_focused_leaf(PaneTab::Remote(params.id.clone()));
|
||||
self.state.status_message = format!("Connected to {}", params.host);
|
||||
self.state.connect_loading = false;
|
||||
self.state.show_connect_dialog = false;
|
||||
|
||||
self.persist_connection(¶ms);
|
||||
self.state.reload_history(&self.db);
|
||||
}
|
||||
Err(e) => {
|
||||
self.state.status_message = format!("Connection failed: {}", e);
|
||||
self.state.connect_error = e;
|
||||
self.state.connect_loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Один и тот же host/port/username всегда должен использовать один conn_id —
|
||||
/// под ним лежит пароль в keychain, иначе повторное подключение через
|
||||
/// «New Connection» развело бы историю и keychain.
|
||||
fn persist_connection(&self, params: &ConnectionParams) {
|
||||
if let Ok(conn) = self.db.lock() {
|
||||
let canonical_id = crate::storage::db::find_history_conn_id(
|
||||
&conn,
|
||||
¶ms.host,
|
||||
params.port,
|
||||
¶ms.username,
|
||||
)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| params.id.clone());
|
||||
|
||||
if let Some(password) = ¶ms.password {
|
||||
let _ = crate::storage::keychain::store_password(&canonical_id, password);
|
||||
}
|
||||
|
||||
let _ = crate::storage::db::add_history_entry(
|
||||
&conn,
|
||||
¶ms.host,
|
||||
params.port,
|
||||
¶ms.username,
|
||||
&canonical_id,
|
||||
¶ms.protocol,
|
||||
params.key_path.as_deref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Remote list results ---
|
||||
pub(super) fn poll_remote_list(&mut self) {
|
||||
let mut done: Vec<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
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
36
src/ui/app/settings/about.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
192
src/ui/app/settings/connections.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
157
src/ui/app/settings/general.rs
Normal 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(¤t)
|
||||
.color(TEXT_DIM)
|
||||
.monospace()
|
||||
.size(11.0),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
let use_current = egui::Button::new(
|
||||
RichText::new(t("settings.general.use_current_folder"))
|
||||
.color(TEXT_PRIMARY)
|
||||
.size(11.5),
|
||||
)
|
||||
.rounding(RADIUS_MD)
|
||||
.min_size(egui::vec2(0.0, 28.0));
|
||||
if button::ghost(ui, use_current).clicked() {
|
||||
let path = self.state.local_path.clone();
|
||||
self.state.default_local_folder = path.clone();
|
||||
self.save_pref_str(KEY_DEFAULT_LOCAL_FOLDER, &path);
|
||||
}
|
||||
|
||||
ui.add_space(8.0);
|
||||
let reset = egui::Button::new(
|
||||
RichText::new(t("settings.general.reset_to_home"))
|
||||
.color(TEXT_DIM)
|
||||
.size(11.5),
|
||||
)
|
||||
.rounding(RADIUS_MD)
|
||||
.min_size(egui::vec2(0.0, 28.0));
|
||||
if button::ghost(ui, reset).clicked() {
|
||||
self.state.default_local_folder.clear();
|
||||
self.save_pref_str(KEY_DEFAULT_LOCAL_FOLDER, "");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn render_appearance(&mut self, ui: &mut Ui) {
|
||||
section_title(ui, t("settings.nav.appearance"));
|
||||
hint(ui, t("settings.appearance.hint"));
|
||||
ui.add_space(10.0);
|
||||
|
||||
appearance_checkbox(
|
||||
ui,
|
||||
t("settings.appearance.toolbar"),
|
||||
&mut self.state.show_toolbar,
|
||||
);
|
||||
appearance_checkbox(
|
||||
ui,
|
||||
t("settings.appearance.sidebar"),
|
||||
&mut self.state.show_sidebar,
|
||||
);
|
||||
appearance_checkbox(
|
||||
ui,
|
||||
t("settings.appearance.status_bar"),
|
||||
&mut self.state.show_status_bar,
|
||||
);
|
||||
appearance_checkbox(
|
||||
ui,
|
||||
t("settings.appearance.transfer_queue"),
|
||||
&mut self.state.show_queue_panel,
|
||||
);
|
||||
}
|
||||
|
||||
/// Сохраняет выбор языка и сразу переключает `t()`/шрифты — эффект виден
|
||||
/// в этом же кадре, без перезапуска (см. правило про мгновенный отклик).
|
||||
fn set_language(&self, ui: &Ui, lang: Lang) {
|
||||
crate::i18n::set_lang(lang);
|
||||
self.refresh_fonts(ui.ctx());
|
||||
self.save_pref_str(KEY_LANGUAGE, lang.code());
|
||||
}
|
||||
}
|
||||
|
||||
fn appearance_checkbox(ui: &mut Ui, label: &str, value: &mut bool) {
|
||||
ui.checkbox(value, RichText::new(label).color(TEXT_PRIMARY).size(12.5));
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
|
||||
/// Выпадающий список из всех 12 языков — названия на них самих (Español,
|
||||
/// 日本語...), чтобы найти свой язык, даже не понимая текущего. Возвращает
|
||||
/// выбранный язык, если пользователь только что кликнул по другому пункту.
|
||||
fn language_picker(ui: &mut Ui) -> Option<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
|
|
@ -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
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
67
src/ui/app/settings/prefs.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
59
src/ui/app/settings/transfers.rs
Normal 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
|
|
@ -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
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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(®istry, ¶ms_clone).await;
|
||||
*result_clone.lock().unwrap() = Some(r);
|
||||
});
|
||||
|
||||
state.pending_connect = Some(PendingConnect { result });
|
||||
}
|
||||
|
||||
/// Переподключение по клику на запись истории — пароль (если есть) уже лежит
|
||||
/// в keychain под `entry.conn_id`, поэтому диалог не нужен.
|
||||
pub fn reconnect_from_history(
|
||||
state: &mut AppState,
|
||||
registry: &Arc<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) = ¶ms.password {
|
||||
p.clone()
|
||||
} else {
|
||||
keychain::get_password(¶ms.id).map_err(|e| e.to_string())?
|
||||
};
|
||||
|
||||
let fs: Arc<dyn RemoteFs> = match params.protocol {
|
||||
Protocol::Sftp => {
|
||||
if let Some(key_path) = ¶ms.key_path {
|
||||
Arc::new(
|
||||
SftpClient::connect_key(¶ms.host, params.port, ¶ms.username, key_path)
|
||||
.map_err(|e| e.to_string())?,
|
||||
)
|
||||
} else {
|
||||
Arc::new(
|
||||
SftpClient::connect_password(
|
||||
¶ms.host,
|
||||
params.port,
|
||||
¶ms.username,
|
||||
&password,
|
||||
)
|
||||
.map_err(|e| e.to_string())?,
|
||||
)
|
||||
}
|
||||
}
|
||||
Protocol::Ftp => Arc::new(
|
||||
FtpClient::connect(¶ms.host, params.port, ¶ms.username, &password)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?,
|
||||
),
|
||||
Protocol::Ftps => return Err("FTPS not yet implemented".into()),
|
||||
};
|
||||
|
||||
registry.insert(params.id.clone(), fs.clone());
|
||||
let entries = fs.list("/").await.map_err(|e| e.to_string())?;
|
||||
Ok((params.clone(), entries))
|
||||
}
|
||||
159
src/ui/dialogs/connection/actions.rs
Normal 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(®istry, ¶ms_clone).await;
|
||||
*result_clone.lock().unwrap() = Some(r);
|
||||
});
|
||||
|
||||
state.pending_connect = Some(PendingConnect { result });
|
||||
}
|
||||
|
||||
async fn connect_async(
|
||||
registry: &RemoteRegistry,
|
||||
params: &ConnectionParams,
|
||||
) -> Result<(ConnectionParams, Vec<FileEntry>), String> {
|
||||
// Пароль резолвим лениво по месту: для ключевой/автоматической SSH-аутен-
|
||||
// тификации он не нужен, и обязательный поход в keychain раньше валил
|
||||
// подключение по ключу с пустым паролем ("нет пароля в keychain").
|
||||
let stored_password = || -> Option<String> {
|
||||
params
|
||||
.password
|
||||
.clone()
|
||||
.or_else(|| keychain::get_password(¶ms.id).ok())
|
||||
};
|
||||
|
||||
let fs: Arc<dyn RemoteFs> = match params.protocol {
|
||||
Protocol::Sftp => {
|
||||
if let Some(key_path) = ¶ms.key_path {
|
||||
// Явно выбранный ключ; пароль из формы (если есть) — passphrase.
|
||||
Arc::new(
|
||||
SftpClient::connect_key(
|
||||
¶ms.host,
|
||||
params.port,
|
||||
¶ms.username,
|
||||
key_path,
|
||||
params.password.as_deref(),
|
||||
)
|
||||
.map_err(|e| e.to_string())?,
|
||||
)
|
||||
} else if let Some(password) = stored_password() {
|
||||
Arc::new(
|
||||
SftpClient::connect_password(
|
||||
¶ms.host,
|
||||
params.port,
|
||||
¶ms.username,
|
||||
&password,
|
||||
)
|
||||
.map_err(|e| e.to_string())?,
|
||||
)
|
||||
} else {
|
||||
// Пароль пуст и ключ не выбран — ищем ключ сами (агент, ~/.ssh).
|
||||
Arc::new(
|
||||
SftpClient::connect_auto(¶ms.host, params.port, ¶ms.username)
|
||||
.map_err(|e| e.to_string())?,
|
||||
)
|
||||
}
|
||||
}
|
||||
Protocol::Ftp => {
|
||||
let password = stored_password().ok_or("Password is required for FTP connections.")?;
|
||||
Arc::new(
|
||||
FtpClient::connect(¶ms.host, params.port, ¶ms.username, &password)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?,
|
||||
)
|
||||
}
|
||||
Protocol::Ftps => return Err("FTPS not yet implemented".into()),
|
||||
};
|
||||
|
||||
registry.insert(params.id.clone(), fs.clone());
|
||||
let entries = fs.list("/").await.map_err(|e| e.to_string())?;
|
||||
Ok((params.clone(), entries))
|
||||
}
|
||||
173
src/ui/dialogs/connection/form.rs
Normal 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);
|
||||
});
|
||||
}
|
||||
82
src/ui/dialogs/connection/history.rs
Normal 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(())
|
||||
}
|
||||
9
src/ui/dialogs/connection/mod.rs
Normal 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;
|
||||
137
src/ui/dialogs/connection/view.rs
Normal 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
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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] {
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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"))]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
93
src/ui/panels/file_pane/mod.rs
Normal 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()
|
||||
}
|
||||
120
src/ui/panels/file_pane/row.rs
Normal 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);
|
||||
});
|
||||
}
|
||||
81
src/ui/panels/file_pane/sort.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
150
src/ui/panels/file_pane/table.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
199
src/ui/panels/local_pane/actions.rs
Normal 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())])
|
||||
}
|
||||
}
|
||||
}
|
||||
137
src/ui/panels/local_pane/bars.rs
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
131
src/ui/panels/local_pane/mod.rs
Normal 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())]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,5 +4,4 @@ pub mod queue;
|
|||
pub mod remote_pane;
|
||||
pub mod sidebar;
|
||||
pub mod status_bar;
|
||||
pub mod tabs;
|
||||
pub mod toolbar;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
189
src/ui/panels/remote_pane/actions.rs
Normal 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 => {}
|
||||
}
|
||||
}
|
||||
170
src/ui/panels/remote_pane/bars.rs
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
183
src/ui/panels/remote_pane/mod.rs
Normal 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 });
|
||||
}
|
||||
|
|
@ -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"] {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
52
src/ui/panels/toolbar/actions.rs
Normal 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)]);
|
||||
}
|
||||
72
src/ui/panels/toolbar/buttons.rs
Normal 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);
|
||||
}
|
||||
101
src/ui/panels/toolbar/history.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
189
src/ui/panels/toolbar/mod.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
252
src/ui/state.rs
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
47
src/ui/widgets/context_menu.rs
Normal 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
|
|
@ -0,0 +1,8 @@
|
|||
//! Переиспользуемые UI-примитивы: кнопки с обратной связью, кликабельные строки,
|
||||
//! модальные подложки. Вынесены сюда, чтобы отклик на действия был единообразным
|
||||
//! во всём интерфейсе (см. правило про мгновенный визуальный отклик) и без копипасты.
|
||||
pub mod button;
|
||||
pub mod context_menu;
|
||||
pub mod modal;
|
||||
pub mod overlay;
|
||||
pub mod row;
|
||||