diff --git a/src/ui/app.rs b/src/ui/app.rs index d7d6932..a8bdb7d 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use egui::{CentralPanel, SidePanel, Style, TopBottomPanel, Visuals}; +use egui::{Color32, CentralPanel, RichText, SidePanel, TopBottomPanel, Visuals}; use crate::domain::connection::ConnectionStatus; use crate::domain::file_entry::{EntryKind, FileEntry}; @@ -8,14 +8,10 @@ use crate::domain::site::Site; use crate::fs::remote::RemoteRegistry; use crate::transfer::queue::TransferQueue; use crate::ui::dialogs::connection; -use crate::ui::panels::local_pane; -use crate::ui::panels::queue; -use crate::ui::panels::remote_pane; -use crate::ui::panels::status_bar; -use crate::ui::panels::tabs; -use crate::ui::panels::toolbar; -use crate::ui::panels::tree; -use crate::ui::panels::transfer_area; +use crate::ui::panels::{ + local_pane, queue, remote_pane, sidebar, status_bar, tabs, toolbar, +}; +use crate::ui::theme::*; pub struct FileManagerApp { pub state: crate::ui::state::AppState, @@ -53,6 +49,7 @@ impl FileManagerApp { } fn poll_pending(&mut self) { + // --- Connect result --- let opt = self.state.pending_connect.take(); if let Some(pc) = opt.as_ref() { let mut guard = pc.result.lock().unwrap(); @@ -83,7 +80,7 @@ impl FileManagerApp { }; self.state.tabs.push(tab); self.state.active_tab = self.state.tabs.len() - 1; - self.state.status_message = "Connected".into(); + self.state.status_message = format!("Connected to {}", params.host); self.state.connect_loading = false; self.state.show_connect_dialog = false; self.state.add_history(¶ms.host, params.port, ¶ms.username); @@ -100,6 +97,7 @@ impl FileManagerApp { } } + // --- Remote list results --- let mut done: Vec = Vec::new(); for (i, pending) in self.state.pending_remote_list.iter().enumerate() { let mut guard = pending.result.lock().unwrap(); @@ -143,6 +141,49 @@ impl FileManagerApp { for i in done.into_iter().rev() { self.state.pending_remote_list.remove(i); } + + // --- Toolbar action flags --- + if self.state.pending_refresh { + self.state.pending_refresh = false; + if let Some(idx) = self.active_tab_idx() { + remote_pane::trigger_list(&mut self.state, idx, &self.registry, self.rt.handle()); + } + } + } + + fn active_tab_idx(&self) -> Option { + if self.state.tabs.is_empty() { return None; } + let idx = self.state.active_tab.min(self.state.tabs.len() - 1); + if self.state.tabs[idx].status == ConnectionStatus::Connected { + Some(idx) + } else { + None + } + } + + fn 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 = Color32::from_rgb(36, 36, 40); + 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); + + let fonts = egui::FontDefinitions::default(); + ctx.set_fonts(fonts); + + let mut style = ctx.style().as_ref().clone(); + style.spacing.item_spacing = egui::vec2(4.0, 2.0); + style.spacing.button_padding = egui::vec2(8.0, 4.0); + ctx.set_style(style); } } @@ -150,11 +191,7 @@ impl eframe::App for FileManagerApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { if self.first_frame { self.first_frame = false; - let style = Style { - visuals: Visuals::dark(), - ..Default::default() - }; - ctx.set_style(style); + self.apply_visuals(ctx); ctx.request_repaint(); } @@ -167,88 +204,180 @@ impl eframe::App for FileManagerApp { .filter(|t| t.status == ConnectionStatus::Connected) .count(); - TopBottomPanel::top("toolbar").show(ctx, |ui| { - toolbar::render(ui, &mut self.state); - }); - - TopBottomPanel::top("tabs").show(ctx, |ui| { - tabs::render(ui, &mut self.state); - }); - - SidePanel::left("tree_panel") - .resizable(true) - .min_width(180.0) + // ── Toolbar ────────────────────────────────────────────────────────── + TopBottomPanel::top("toolbar") + .frame(egui::Frame::none().fill(BG_TOOLBAR)) .show(ctx, |ui| { - tree::render(ui, &mut self.state); + toolbar::render(ui, &mut self.state); }); - let has_connection = self.state.active_tab_ref().is_some() - && self.state.active_tab_ref().unwrap().status == ConnectionStatus::Connected; - - if has_connection { - CentralPanel::default().show(ctx, |ui| { - ui.columns(2, |columns| { - columns[0].vertical(|ui| { - local_pane::render( - ui, - &mut self.state, - &self.queue, - &self.registry, - self.rt.handle(), - ); - }); - - columns[1].vertical(|ui| { - let idx = self.state.active_tab.min(self.state.tabs.len() - 1); - remote_pane::render( - ui, - &mut self.state, - idx, - &self.registry, - self.rt.handle(), - &self.queue, - ); - }); - }); + // ── Tab bar ────────────────────────────────────────────────────────── + TopBottomPanel::top("tabbar") + .frame(egui::Frame::none().fill(BG_BASE)) + .show(ctx, |ui| { + tabs::render(ui, &mut self.state); }); - } else { - CentralPanel::default().show(ctx, |ui| { - local_pane::render( - ui, - &mut self.state, - &self.queue, - &self.registry, - self.rt.handle(), - ); - }); - } + // ── Status bar ─────────────────────────────────────────────────────── + TopBottomPanel::bottom("status_bar") + .frame(egui::Frame::none().fill(BG_BASE)) + .exact_height(STATUS_H) + .show(ctx, |ui| { + status_bar::render(ui, &self.state); + }); + + // ── Queue panel ─────────────────────────────────────────────────────── let tasks = self.state.queue_tasks.clone(); - if self.state.show_queue || !tasks.is_empty() { - TopBottomPanel::bottom("queue_panel") - .resizable(true) - .default_height(130.0) - .min_height(60.0) - .show(ctx, |ui| { - queue::render(ui, &mut self.state, &tasks); - }); - } - - TopBottomPanel::bottom("transfer_area") - .default_height(40.0) - .min_height(30.0) + let q_h = if self.state.show_queue && !tasks.is_empty() { + QUEUE_EXPANDED_H + } else { + QUEUE_COLLAPSED_H + }; + TopBottomPanel::bottom("queue_panel") + .frame(egui::Frame::none().fill(BG_QUEUE).stroke(egui::Stroke::new(1.0, BORDER))) + .resizable(false) + .exact_height(q_h) .show(ctx, |ui| { - transfer_area::render(ui, &mut self.state); + queue::render(ui, &mut self.state, &tasks); }); - TopBottomPanel::bottom("status_bar").show(ctx, |ui| { - status_bar::render(ui, &mut self.state); - }); + // ── Sidebar ─────────────────────────────────────────────────────────── + SidePanel::left("sidebar") + .frame(egui::Frame::none() + .fill(BG_PANEL) + .stroke(egui::Stroke::new(1.0, BORDER))) + .resizable(true) + .default_width(SIDEBAR_W) + .width_range(120.0..=260.0) + .show(ctx, |ui| { + sidebar::render(ui, &mut self.state); + }); + // ── Main content: Local | Remote ───────────────────────────────────── + CentralPanel::default() + .frame(egui::Frame::none().fill(BG_CONTENT)) + .show(ctx, |ui| { + let has_connection = self.active_tab_idx().is_some(); + let total_w = ui.available_width(); + let total_h = ui.available_height(); + let sep_w = 1.0; + let half = (total_w - sep_w) * 0.5; + + ui.horizontal(|ui| { + // ── LOCAL PANE ────────────────────────────────────────── + ui.allocate_ui_with_layout( + egui::vec2(half, total_h), + egui::Layout::top_down(egui::Align::LEFT), + |ui| { + // Хедер панели + pane_header(ui, "LOCAL", half); + + local_pane::render( + ui, + &mut self.state, + &self.queue, + &self.registry, + self.rt.handle(), + ); + }, + ); + + // разделитель + let (_, sep_rect) = ui.allocate_space(egui::vec2(sep_w, total_h)); + ui.painter().rect_filled(sep_rect, 0.0, BORDER); + + // ── REMOTE PANE ───────────────────────────────────────── + ui.allocate_ui_with_layout( + egui::vec2(half, total_h), + egui::Layout::top_down(egui::Align::LEFT), + |ui| { + if has_connection { + let idx = self.state.active_tab.min(self.state.tabs.len() - 1); + + // Хедер панели + let label = self.state.tabs[idx] + .params + .host + .clone(); + pane_header(ui, &format!("REMOTE · {}", label), half); + + remote_pane::render( + ui, + &mut self.state, + idx, + &self.registry, + self.rt.handle(), + &self.queue, + ); + } else { + // Empty state + pane_header(ui, "REMOTE", half); + ui.with_layout( + egui::Layout::centered_and_justified( + egui::Direction::TopDown, + ), + |ui| { + ui.vertical_centered(|ui| { + ui.add_space(ui.available_height() * 0.3); + ui.label( + RichText::new("Remote") + .color(TEXT_HINT) + .size(14.0) + .strong(), + ); + ui.add_space(4.0); + ui.label( + RichText::new("Connect to a server to browse files") + .color(TEXT_HINT) + .size(12.0), + ); + ui.add_space(16.0); + let btn = egui::Button::new( + RichText::new("+ New Connection") + .color(Color32::WHITE) + .size(12.0), + ) + .fill(ACCENT) + .rounding(6.0) + .min_size(egui::vec2(150.0, 34.0)); + if ui.add(btn).clicked() { + self.state.show_connect_dialog = true; + } + }); + }, + ); + } + }, + ); + }); + }); + + // ── Connection dialog ──────────────────────────────────────────────── if self.state.show_connect_dialog { connection::render(ctx, &mut self.state, &self.registry, self.rt.handle()); } + // 200ms repaint для обновления прогресса ctx.request_repaint_after(std::time::Duration::from_millis(200)); } } + +fn pane_header(ui: &mut egui::Ui, label: &str, width: f32) { + egui::Frame::none() + .fill(BG_BASE) + .inner_margin(egui::Margin { left: 10.0, right: 6.0, top: 4.0, bottom: 4.0 }) + .show(ui, |ui| { + ui.set_width(width); + ui.horizontal(|ui| { + ui.label( + RichText::new(label) + .color(TEXT_DIM) + .size(10.0) + .strong(), + ); + }); + }); + + let (_, sep) = ui.allocate_space(egui::vec2(width, 1.0)); + ui.painter().rect_filled(sep, 0.0, BORDER); +} diff --git a/src/ui/dialogs/connection.rs b/src/ui/dialogs/connection.rs index d0e2ddf..9128fd6 100644 --- a/src/ui/dialogs/connection.rs +++ b/src/ui/dialogs/connection.rs @@ -1,10 +1,13 @@ +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::protocols::{ftp::FtpClient, sftp::SftpClient, RemoteFs}; use crate::storage::keychain; use crate::ui::state::{AppState, PendingConnect}; -use std::sync::Arc; +use crate::ui::theme::*; pub fn render( ctx: &egui::Context, @@ -12,149 +15,226 @@ pub fn render( registry: &Arc, rt_handle: &tokio::runtime::Handle, ) { - let mut open = true; - egui::Window::new("New Connection") - .open(&mut open) - .resizable(false) + let protocols = ["SFTP", "FTP", "FTPS"]; + let default_ports: [u16; 3] = [22, 21, 990]; + + // Центрируем окно + let screen = ctx.screen_rect(); + let win_size = egui::vec2(420.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| { - ui.horizontal(|ui| { - ui.label("Protocol:"); - let protocols = ["SFTP", "FTP", "FTPS"]; - egui::ComboBox::from_id_source("protocol") - .selected_text(protocols[state.connect_protocol]) - .show_ui(ui, |ui| { - for (i, p) in protocols.iter().enumerate() { - ui.selectable_value(&mut state.connect_protocol, i, *p); - } - }); - }); - - ui.horizontal(|ui| { - ui.label("Label:"); - ui.add( - egui::TextEdit::singleline(&mut state.connect_label) - .id("label".into()) - .desired_width(200.0), - ); - }); - - ui.horizontal(|ui| { - ui.label("Host:"); - ui.add( - egui::TextEdit::singleline(&mut state.connect_host) - .id("host".into()) - .desired_width(200.0), - ); - }); - - ui.horizontal(|ui| { - ui.label("Port:"); - ui.add( - egui::TextEdit::singleline(&mut state.connect_port) - .id("port".into()) - .desired_width(60.0), - ); - }); - - ui.horizontal(|ui| { - ui.label("Username:"); - ui.add( - egui::TextEdit::singleline(&mut state.connect_user) - .id("user".into()) - .desired_width(200.0), - ); - }); - - ui.horizontal(|ui| { - ui.label("Password:"); - ui.add( - egui::TextEdit::singleline(&mut state.connect_pass) - .password(true) - .id("pass".into()) - .desired_width(200.0), - ); - }); - - if state.connect_protocol == 0 { - ui.horizontal(|ui| { - ui.label("Key path:"); - ui.add( - egui::TextEdit::singleline(&mut state.connect_key_path) - .id("key".into()) - .desired_width(200.0), - ); - }); - } - - if !state.connect_error.is_empty() { - ui.colored_label(egui::Color32::RED, &state.connect_error); - } - - ui.add_enabled_ui(!state.connect_loading, |ui| { - if ui.button("Connect").clicked() { - state.connect_error.clear(); - - 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() { - state.connect_host.clone() - } 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()) - }, - }; - - 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 = do_connect(®istry, ¶ms_clone).await; - *result_clone.lock().unwrap() = Some(r); - }); - - state.pending_connect = Some(PendingConnect { result }); - } - }); - - if state.connect_loading { - ui.label("Connecting..."); + 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; } }); - if !open { - 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(Color32::from_rgb(30, 30, 34)) + .stroke(egui::Stroke::new(1.0, BORDER)) + .rounding(10.0) + .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 } else { Color32::from_rgb(40, 40, 44) }; + let tc = if active { Color32::WHITE } else { TEXT_DIM }; + let btn = egui::Button::new(RichText::new(*p).color(tc).size(12.0)) + .fill(bg) + .rounding(4.0) + .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); + + // Поля ввода + let field_w = ui.available_width(); + field(ui, "Label (optional)", &mut state.connect_label, field_w, false); + ui.add_space(8.0); + + ui.horizontal(|ui| { + let host_w = field_w - 80.0 - 8.0; + field(ui, "Host", &mut state.connect_host, host_w, false); + ui.add_space(8.0); + field(ui, "Port", &mut state.connect_port, 80.0, false); + }); + ui.add_space(8.0); + + field(ui, "Username", &mut state.connect_user, field_w, 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", &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(Color32::from_rgb(40, 40, 44)) + .min_size(egui::vec2(72.0, 32.0)) + .rounding(4.0); + ui.add_enabled(false, browse); + }); + } + + // Ошибка + if !state.connect_error.is_empty() { + ui.add_space(10.0); + egui::Frame::none() + .fill(Color32::from_rgb(60, 25, 25)) + .rounding(4.0) + .inner_margin(egui::Margin::symmetric(8.0, 6.0)) + .show(ui, |ui| { + 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.0)) + .fill(Color32::from_rgb(40, 40, 44)) + .rounding(6.0) + .min_size(egui::vec2(90.0, 34.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(Color32::WHITE).size(12.0).strong() + ) + .fill(ACCENT) + .rounding(6.0) + .min_size(egui::vec2(110.0, 34.0)); + + if ui.add(connect_btn).clicked() { + do_connect(state, registry, rt_handle); + } + }); + + if state.connect_loading { + ui.add_space(8.0); + ui.spinner(); + } + }); + }); + }); + }); } -async fn do_connect( +fn field(ui: &mut Ui, label: &str, value: &mut String, width: f32, password: bool) { + ui.label(RichText::new(label).color(TEXT_DIM).size(11.0)); + let te = TextEdit::singleline(value) + .password(password) + .desired_width(width) + .margin(egui::Margin::symmetric(8.0, 6.0)) + .font(egui::TextStyle::Body); + ui.add(te); +} + +fn do_connect( + state: &mut AppState, + registry: &Arc, + rt_handle: &tokio::runtime::Handle, +) { + state.connect_error.clear(); + + 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()) }, + }; + + let registry = registry.clone(); + let params_clone = params.clone(); + let result = Arc::new(std::sync::Mutex::new(None)); + let result_clone = result.clone(); + + state.connect_loading = true; + rt_handle.spawn(async move { + let r = connect_async(®istry, ¶ms_clone).await; + *result_clone.lock().unwrap() = Some(r); + }); + + state.pending_connect = Some(PendingConnect { result }); +} + +async fn connect_async( registry: &RemoteRegistry, params: &ConnectionParams, ) -> Result<(ConnectionParams, Vec), String> { @@ -173,13 +253,8 @@ async fn do_connect( ) } else { Arc::new( - SftpClient::connect_password( - ¶ms.host, - params.port, - ¶ms.username, - &password, - ) - .map_err(|e| e.to_string())?, + SftpClient::connect_password(¶ms.host, params.port, ¶ms.username, &password) + .map_err(|e| e.to_string())?, ) } } @@ -192,7 +267,6 @@ async fn do_connect( }; registry.insert(params.id.clone(), fs.clone()); - let entries = fs.list("/").await.map_err(|e| e.to_string())?; Ok((params.clone(), entries)) } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 33d7818..0f1b6a6 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -3,3 +3,4 @@ pub mod dialogs; pub mod drag; pub mod panels; pub mod state; +pub mod theme; diff --git a/src/ui/panels/file_pane.rs b/src/ui/panels/file_pane.rs index 283b98b..065ab38 100644 --- a/src/ui/panels/file_pane.rs +++ b/src/ui/panels/file_pane.rs @@ -1,33 +1,39 @@ +//! Переиспользуемая таблица файлов с нормальными колонками +use egui::{Color32, Id, RichText, Ui}; use crate::domain::file_entry::{EntryKind, FileEntry}; use crate::ui::drag::DragPayload; -use egui::{Grid, Id}; +use crate::ui::theme::*; pub fn format_size(bytes: Option) -> String { match bytes { None => String::new(), Some(b) if b < 1024 => format!("{} B", b), Some(b) if b < 1024 * 1024 => format!("{:.1} KB", b as f64 / 1024.0), - Some(b) if b < 1024 * 1024 * 1024 => { - format!("{:.1} MB", b as f64 / (1024.0 * 1024.0)) - } + Some(b) if b < 1024 * 1024 * 1024 => format!("{:.1} MB", b as f64 / (1024.0 * 1024.0)), Some(b) => format!("{:.2} GB", b as f64 / (1024.0 * 1024.0 * 1024.0)), } } pub fn format_time(ts: Option) -> String { match ts.and_then(|t| chrono::DateTime::from_timestamp(t, 0)) { - Some(dt) => dt.format("%Y-%m-%d %H:%M").to_string(), + 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 } + pub struct FileTableResponse { pub double_clicked: Option, pub clicked: Option, } pub fn file_table( - ui: &mut egui::Ui, + ui: &mut Ui, id: &str, entries: &[FileEntry], selected: &mut Option, @@ -36,65 +42,222 @@ pub fn file_table( where F: Fn(&FileEntry) -> Option, { - let mut double_clicked: Option = None; - let mut clicked: Option = None; - - let mut sorted = entries.to_vec(); - sorted.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), + // Сортировка — читаем из egui memory + let sort_id = Id::new((id, "sort")); + let (sort_col, sort_dir) = ui.ctx().data_mut(|d| { + *d.get_temp_mut_or(sort_id, (SortCol::Name, SortDir::Asc)) }); + let mut sorted = entries.to_vec(); + let dotdot_entry = sorted.iter().position(|e| e.name == ".."); + let dotdot = dotdot_entry.map(|i| sorted.remove(i)); + + sorted.sort_by(|a, b| { + // папки всегда выше + let dir_cmp = match (&b.kind, &a.kind) { + (EntryKind::Dir, EntryKind::Dir) => std::cmp::Ordering::Equal, + (EntryKind::Dir, _) => std::cmp::Ordering::Greater, + (_, EntryKind::Dir) => std::cmp::Ordering::Less, + _ => std::cmp::Ordering::Equal, + }; + if dir_cmp != std::cmp::Ordering::Equal { return dir_cmp; } + + let ord = match sort_col { + SortCol::Name => a.name.to_lowercase().cmp(&b.name.to_lowercase()), + SortCol::Size => a.size.unwrap_or(0).cmp(&b.size.unwrap_or(0)), + SortCol::Kind => format!("{:?}", a.kind).cmp(&format!("{:?}", b.kind)), + SortCol::Modified => a.modified.unwrap_or(0).cmp(&b.modified.unwrap_or(0)), + }; + if sort_dir == SortDir::Desc { ord.reverse() } else { ord } + }); + + if let Some(dd) = dotdot { + sorted.insert(0, dd); + } + + let mut double_clicked: Option = None; + let mut clicked: Option = None; + let mut new_sort: Option<(SortCol, SortDir)> = None; + + // Заголовки колонок + let header_frame = egui::Frame::none() + .fill(BG_HEADER) + .inner_margin(egui::Margin { left: 6.0, right: 6.0, top: 0.0, bottom: 0.0 }); + + header_frame.show(ui, |ui| { + ui.set_min_height(22.0); + ui.horizontal(|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() .auto_shrink([false; 2]) .show(ui, |ui| { - Grid::new(id) - .striped(true) - .min_col_width(60.0) - .show(ui, |ui| { - ui.strong("Name"); - ui.strong("Size"); - ui.strong("Type"); - ui.strong("Modified"); - ui.end_row(); + for entry in &sorted { + let is_selected = selected.as_deref() == Some(&entry.name); + let row_id = Id::new((id, "row", &entry.name)); - for entry in &sorted { - let is_selected = selected.as_ref() == Some(&entry.name); + let row_rect = egui::Rect::from_min_size( + ui.cursor().min, + egui::vec2(ui.available_width(), ROW_H), + ); + let is_hovered = ui.rect_contains_pointer(row_rect); - let response = if let Some(payload) = make_drag_payload(entry) { - let drag_id = Id::new(("drag", id, &entry.name)); - ui.dnd_drag_source(drag_id, payload, |ui| { - ui.selectable_label(is_selected, &entry.name) - }) - .response - } else { - ui.selectable_label(is_selected, &entry.name) - }; + let bg = if is_selected { + BG_ROW_SEL + } else if is_hovered { + BG_ROW_HOVER + } else { + Color32::TRANSPARENT + }; - if response.clicked() { - *selected = Some(entry.name.clone()); - clicked = Some(entry.name.clone()); - } - if response.double_clicked() { - double_clicked = Some(entry.name.clone()); - } + let response = egui::Frame::none() + .fill(bg) + .inner_margin(egui::Margin { left: 6.0, right: 6.0, top: 0.0, bottom: 0.0 }) + .show(ui, |ui| { + ui.set_min_height(ROW_H); + ui.horizontal_centered(|ui| { + // иконка + имя + let name_text = if entry.name == ".." { + format!("⬆ ..") + } else { + match entry.kind { + EntryKind::Dir => format!("📁 {}", entry.name), + EntryKind::Symlink => format!("🔗 {}", entry.name), + EntryKind::File => { + let icon = file_icon(&entry.name); + format!("{} {}", icon, entry.name) + } + } + }; - ui.label(format_size(entry.size)); - ui.label(match entry.kind { - EntryKind::Dir => "Dir", - EntryKind::File => "File", - EntryKind::Symlink => "Link", + let name_col = if entry.name == ".." { + TEXT_HINT + } else if entry.kind == EntryKind::Dir { + ACCENT + } else { + TEXT_PRIMARY + }; + + if let Some(payload) = make_drag_payload(entry) { + ui.dnd_drag_source(row_id, payload, |ui| { + ui.label( + RichText::new(&name_text).color(name_col).size(12.0) + ); + }); + } else { + ui.label(RichText::new(&name_text).color(name_col).size(12.0)); + } + + // правые колонки + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + ui.label( + RichText::new(format_time(entry.modified)) + .color(TEXT_DIM) + .size(11.0), + ); + 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) + .size(11.0), + ); + ui.add_space(4.0); + }, + ); }); - ui.label(format_time(entry.modified)); - ui.end_row(); - } - }); + }) + .response; + + if response.clicked() { + *selected = Some(entry.name.clone()); + clicked = Some(entry.name.clone()); + } + if response.double_clicked() { + double_clicked = Some(entry.name.clone()); + } + } }); - FileTableResponse { - double_clicked, - clicked, + FileTableResponse { double_clicked, 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(11.0).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) + } +} + +fn file_icon(name: &str) -> &'static str { + let ext = name.rsplit('.').next().unwrap_or("").to_lowercase(); + match ext.as_str() { + "rs" => "🦀", + "toml" | "yaml" | "yml" | "json" => "⚙", + "md" | "txt" | "log" => "📝", + "png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" => "🖼", + "mp4" | "mkv" | "avi" | "mov" => "🎬", + "mp3" | "ogg" | "flac" | "wav" => "🎵", + "zip" | "tar" | "gz" | "bz2" | "xz" | "7z" => "📦", + "pdf" => "📕", + "sh" | "bash" | "fish" | "zsh" => "⚡", + "html" | "css" | "js" | "ts" => "🌐", + "py" => "🐍", + "java" | "class" | "jar" => "☕", + _ => "📄", } } diff --git a/src/ui/panels/local_pane.rs b/src/ui/panels/local_pane.rs index 85a96bc..042b416 100644 --- a/src/ui/panels/local_pane.rs +++ b/src/ui/panels/local_pane.rs @@ -1,3 +1,4 @@ +use egui::{RichText, Ui}; use crate::domain::connection::ConnectionStatus; use crate::domain::file_entry::{EntryKind, FileEntry}; use crate::domain::transfer::{TransferKind, TransferTask}; @@ -5,14 +6,14 @@ use crate::fs::local; use crate::fs::remote::RemoteRegistry; use crate::transfer::queue::TransferQueue; use crate::ui::drag::{make_local_payload, DragPayload}; -use crate::ui::panels::file_pane::{file_table, FileTableResponse}; +use crate::ui::panels::file_pane::{file_table, FileTableResponse, format_size}; use crate::ui::state::AppState; -use egui::TextEdit; +use crate::ui::theme::*; use std::sync::Arc; fn parent_path(path: &str) -> String { - let p = std::path::Path::new(path); - p.parent() + std::path::Path::new(path) + .parent() .map(|p| p.to_string_lossy().to_string()) .unwrap_or_else(|| path.to_string()) } @@ -29,106 +30,193 @@ fn make_parent_entry(path: &str) -> FileEntry { } pub fn render( - ui: &mut egui::Ui, + ui: &mut Ui, state: &mut AppState, queue: &TransferQueue, registry: &Arc, _rt_handle: &tokio::runtime::Handle, ) { - let frame = egui::Frame::none().inner_margin(egui::Margin::symmetric(4.0, 2.0)); + let frame = egui::Frame::none() + .fill(BG_CONTENT) + .inner_margin(egui::Margin::same(0.0)); + let (_inner, dropped) = ui.dnd_drop_zone::(frame, |ui| { - render_content(ui, state); + render_inner(ui, state); }); if let Some(payload_arc) = dropped { - let payload = &*payload_arc; - match payload { + match &*payload_arc { DragPayload::RemoteFile(remote_path, file_name, conn_id) => { - if registry.get(conn_id).is_none() { + if registry.get(conn_id).is_some() { + let local_path_str = + format!("{}/{}", state.local_path.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(); - return; } - let local_path_str = - format!("{}/{}", state.local_path.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 = "Download queued via drag & drop".into(); } DragPayload::LocalFile(_, _) => { - state.status_message = "Cannot drop local file onto local pane".into(); + state.status_message = "Cannot drop local onto local".into(); } } } } -fn render_content(ui: &mut egui::Ui, state: &mut AppState) { - ui.horizontal(|ui| { - ui.label("\u{1F4C1}"); - let mut path = state.local_path.clone(); - ui.add( - TextEdit::singleline(&mut path) - .id("local_path".into()) - .desired_width(f32::INFINITY), - ); - if path != state.local_path { - state.local_path = path; - refresh_local(state); - } - }); +fn render_inner(ui: &mut Ui, state: &mut AppState) { + // Хлебные крошки + путь + render_path_bar(ui, state); - ui.separator(); + // разделитель + 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 FileTableResponse { double_clicked, .. } = - file_table(ui, "local_table", &entries, &mut selected, |entry| { - let has_connection = state - .active_tab_ref() - .map(|t| t.status == ConnectionStatus::Connected) - .unwrap_or(false); + let has_connection = state + .active_tab_ref() + .map(|t| t.status == ConnectionStatus::Connected) + .unwrap_or(false); + + let FileTableResponse { double_clicked, .. } = file_table( + ui, + "local_table", + &entries, + &mut selected, + |entry| { if has_connection { make_local_payload(entry) } else { None } - }); + }, + ); state.local_selected = selected; if let Some(name) = double_clicked { if name == ".." { state.local_path = parent_path(&state.local_path); - } else if let Some(entry) = state.local_entries.iter().find(|e| e.name == name) - && entry.kind == EntryKind::Dir - { - state.local_path = entry.path.clone(); + } 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); } - ui.separator(); - ui.horizontal(|ui| { - ui.label(format!("{} items", state.local_entries.len())); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.small_button("\u{2B50}").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()); - state.bookmarks.push(crate::ui::state::Bookmark { - name, - path: state.local_path.clone(), - }); - state.status_message = "Bookmark added".into(); + // Нижняя полоска + render_footer(ui, state); +} + +fn render_path_bar(ui: &mut Ui, state: &mut AppState) { + let frame = egui::Frame::none() + .fill(BG_PANEL) + .inner_margin(egui::Margin { left: 8.0, right: 8.0, top: 4.0, bottom: 4.0 }); + + frame.show(ui, |ui| { + ui.horizontal(|ui| { + // иконка home + let home = dirs::home_dir() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + let is_home = state.local_path == home; + let home_btn = egui::Button::new( + RichText::new("🏠").size(13.0) + ) + .fill(if is_home { BG_ROW_SEL } else { egui::Color32::TRANSPARENT }) + .rounding(4.0) + .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) { + 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::new(RichText::new("★").color(YELLOW).size(12.0)) + .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()); + state.bookmarks.push(crate::ui::state::Bookmark { + name, + path: state.local_path.clone(), + }); + state.status_message = "Bookmark added".into(); + } + }); }); }); } @@ -144,7 +232,7 @@ pub fn refresh_local(state: &mut AppState) { state.local_entries = entries; } Err(e) => { - state.status_message = format!("Local list error: {}", e); + state.status_message = format!("Local error: {}", e); } } } diff --git a/src/ui/panels/mod.rs b/src/ui/panels/mod.rs index 0cc33c5..be63324 100644 --- a/src/ui/panels/mod.rs +++ b/src/ui/panels/mod.rs @@ -2,8 +2,7 @@ pub mod file_pane; pub mod local_pane; pub mod queue; pub mod remote_pane; +pub mod sidebar; pub mod status_bar; pub mod tabs; pub mod toolbar; -pub mod transfer_area; -pub mod tree; diff --git a/src/ui/panels/queue.rs b/src/ui/panels/queue.rs index 8aec11e..53d9faa 100644 --- a/src/ui/panels/queue.rs +++ b/src/ui/panels/queue.rs @@ -1,82 +1,179 @@ +use egui::{Color32, RichText, Ui}; +use crate::domain::transfer::{TaskState, TransferKind}; use crate::domain::transfer::TransferTask; use crate::ui::panels::file_pane::format_size; use crate::ui::state::AppState; +use crate::ui::theme::*; -pub fn render(ui: &mut egui::Ui, _state: &mut AppState, tasks: &[TransferTask]) { - ui.horizontal(|ui| { - ui.strong("Transfer Queue"); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.label(format!("{} tasks", tasks.len())); +pub fn render(ui: &mut Ui, state: &mut AppState, tasks: &[TransferTask]) { + // Заголовок очереди (всегда виден) + let header_frame = egui::Frame::none() + .fill(BG_QUEUE) + .inner_margin(egui::Margin::symmetric(8.0, 0.0)); + + header_frame.show(ui, |ui| { + ui.set_min_height(QUEUE_COLLAPSED_H); + ui.horizontal_centered(|ui| { + let arrow = if state.show_queue { "▼" } else { "▲" }; + let n_active = tasks.iter().filter(|t| matches!(t.state, TaskState::Running | TaskState::Queued)).count(); + + let title = if n_active > 0 { + format!("{} Transfer Queue ({})", arrow, n_active) + } else { + format!("{} Transfer Queue ({})", arrow, tasks.len()) + }; + + let btn = egui::Button::new( + RichText::new(&title).color(TEXT_PRIMARY).size(12.0).strong() + ) + .fill(Color32::TRANSPARENT) + .min_size(egui::vec2(0.0, QUEUE_COLLAPSED_H)); + + if ui.add(btn).clicked() { + state.show_queue = !state.show_queue; + } + + // Аггрегированная скорость справа + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let agg_speed: u64 = tasks.iter() + .filter(|t| t.state == TaskState::Running) + .filter_map(|t| t.speed) + .sum(); + + if agg_speed > 0 { + ui.label( + RichText::new(format!("{}/s", format_size(Some(agg_speed)))) + .color(GREEN) + .size(11.0) + ); + } else if !tasks.is_empty() { + let done = tasks.iter().filter(|t| t.state == TaskState::Completed).count(); + let failed = tasks.iter().filter(|t| matches!(t.state, TaskState::Failed(_))).count(); + ui.label( + RichText::new(format!("{}/{} done", done, tasks.len())) + .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)); + } + } + }); }); }); - ui.separator(); - - if tasks.is_empty() { - ui.label("No pending transfers"); + if !state.show_queue || tasks.is_empty() { return; } + let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0)); + ui.painter().rect_filled(sep, 0.0, BORDER); + egui::ScrollArea::vertical() .auto_shrink([false; 2]) - .max_height(200.0) + .max_height(QUEUE_EXPANDED_H - QUEUE_COLLAPSED_H - 4.0) .show(ui, |ui| { for task in tasks { - let bg = match &task.state { - crate::domain::transfer::TaskState::Completed => { - egui::Color32::from_rgb(220, 255, 220) - } - crate::domain::transfer::TaskState::Failed(_) => { - egui::Color32::from_rgb(255, 220, 220) - } - crate::domain::transfer::TaskState::Running => { - egui::Color32::from_rgb(220, 220, 255) - } - _ => egui::Color32::TRANSPARENT, - }; - - egui::Frame::none() - .fill(bg) - .inner_margin(egui::Margin::symmetric(4.0, 2.0)) - .show(ui, |ui| { - ui.horizontal(|ui| { - let arrow = match &task.kind { - crate::domain::transfer::TransferKind::Upload => "\u{2191}", - crate::domain::transfer::TransferKind::Download => "\u{2193}", - }; - ui.label(arrow); - ui.label(&task.file_name); - ui.with_layout( - egui::Layout::right_to_left(egui::Align::Center), - |ui| { - ui.label(task.state.to_string()); - }, - ); - }); - - ui.horizontal(|ui| { - let pct = task.progress_pct(); - let pb = egui::ProgressBar::new(pct as f32 / 100.0) - .text(format!("{:.1}%", pct)) - .desired_width(ui.available_width() * 0.6); - ui.add(pb); - - ui.label(format!( - "{}/{}", - format_size(Some(task.transferred_bytes)), - format_size(Some(task.total_bytes)) - )); - }); - - ui.horizontal(|ui| { - if let Some(speed) = task.speed { - ui.label(format!("{}/s", format_size(Some(speed)))); - } - if let Some(eta) = task.eta_secs { - ui.label(format!("ETA: {}s", eta)); - } - }); - }); + render_task(ui, task); } }); } + +fn render_task(ui: &mut Ui, task: &TransferTask) { + let bg = match &task.state { + TaskState::Completed => Color32::from_rgb(22, 38, 24), + TaskState::Failed(_) => Color32::from_rgb(38, 22, 22), + TaskState::Running => Color32::from_rgb(22, 28, 42), + TaskState::Queued => BG_QUEUE, + _ => BG_QUEUE, + }; + + egui::Frame::none() + .fill(bg) + .inner_margin(egui::Margin { left: 10.0, right: 10.0, top: 4.0, bottom: 4.0 }) + .show(ui, |ui| { + ui.horizontal(|ui| { + // стрелка вида + let (arrow, arrow_col) = match task.kind { + TransferKind::Upload => ("↑", ACCENT), + TransferKind::Download => ("↓", GREEN), + }; + ui.label(RichText::new(arrow).color(arrow_col).size(13.0).strong()); + ui.add_space(6.0); + + // имя файла + ui.label( + RichText::new(&task.file_name).color(TEXT_PRIMARY).size(12.0) + ); + + // статус справа + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let (state_str, state_col) = match &task.state { + TaskState::Queued => ("Queued", TEXT_HINT), + TaskState::Running => ("Uploading", ACCENT), + TaskState::Completed => ("✓ Done", GREEN), + TaskState::Failed(_) => ("× Failed", RED), + TaskState::Paused => ("Paused", YELLOW), + TaskState::Cancelled => ("Cancelled", TEXT_HINT), + TaskState::Retrying(_) => ("Retrying", YELLOW), + }; + ui.label(RichText::new(state_str).color(state_col).size(11.0)); + }); + }); + + // прогресс + ui.horizontal(|ui| { + let pct = task.progress_pct() as f32 / 100.0; + let bar_col = match &task.state { + TaskState::Completed => GREEN, + TaskState::Failed(_) => RED, + _ => ACCENT, + }; + + let bar = egui::ProgressBar::new(pct) + .text(format!("{:.0}%", task.progress_pct())) + .desired_width(ui.available_width() * 0.55) + .fill(bar_col); + ui.add(bar); + + ui.label( + RichText::new(format!( + " {}/{}", + format_size(Some(task.transferred_bytes)), + format_size(Some(task.total_bytes)) + )) + .color(TEXT_DIM) + .size(11.0) + ); + + if let Some(speed) = task.speed { + if speed > 0 { + ui.label( + RichText::new(format!(" {}/s", format_size(Some(speed)))) + .color(GREEN) + .size(11.0) + ); + } + } + if let Some(eta) = task.eta_secs { + if eta > 0 { + ui.label( + RichText::new(format!(" ETA {}s", eta)) + .color(TEXT_HINT) + .size(10.0) + ); + } + } + }); + + // ошибка если есть + if let TaskState::Failed(err) = &task.state { + ui.label(RichText::new(err).color(RED).size(10.0)); + } + }); + + // разделитель + let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0)); + ui.painter().rect_filled(sep, 0.0, SEPARATOR); +} diff --git a/src/ui/panels/remote_pane.rs b/src/ui/panels/remote_pane.rs index e874a6b..68f2c7e 100644 --- a/src/ui/panels/remote_pane.rs +++ b/src/ui/panels/remote_pane.rs @@ -1,44 +1,39 @@ +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::{make_remote_payload, DragPayload}; -use crate::ui::panels::file_pane::{file_table, FileTableResponse}; +use crate::ui::panels::file_pane::{file_table, FileTableResponse, format_size}; use crate::ui::state::{AppState, PendingRemoteList}; -use egui::TextEdit; -use std::sync::Arc; +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 trimmed.is_empty() || trimmed == "/" { return "/".into(); } if let Some(idx) = trimmed.rfind('/') { - let parent = &trimmed[..idx]; - if parent.is_empty() { - "/".into() - } else { - parent.to_string() - } + let p = &trimmed[..idx]; + if p.is_empty() { "/".into() } else { p.to_string() } } else { "/".into() } } pub fn render( - ui: &mut egui::Ui, + ui: &mut Ui, state: &mut AppState, tab_idx: usize, registry: &Arc, rt_handle: &tokio::runtime::Handle, queue: &TransferQueue, ) { - ui.horizontal(|ui| { - ui.heading("\u{1F310} Remote"); - }); + let frame = egui::Frame::none() + .fill(BG_CONTENT) + .inner_margin(egui::Margin::same(0.0)); - let frame = egui::Frame::none().inner_margin(egui::Margin::symmetric(4.0, 2.0)); let (_inner, dropped) = ui.dnd_drop_zone::(frame, |ui| { render_content(ui, state, tab_idx, registry, rt_handle); }); @@ -48,74 +43,54 @@ pub fn render( let remote_path = state.tabs[tab_idx].remote_path.clone(); if let Some(payload_arc) = dropped { - let payload = &*payload_arc; - match payload { + match &*payload_arc { DragPayload::LocalFile(local_path, file_name) => { - if !is_connected { + if is_connected { + let dest = format!("{}/{}", remote_path.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(); - return; } - let remote_path_str = - format!("{}/{}", remote_path.trim_end_matches('/'), file_name); - let task = TransferTask::new( - TransferKind::Upload, - connection_id.clone(), - local_path.clone(), - remote_path_str, - file_name.clone(), - 0, - ); - queue.push(task); - state.status_message = "Upload queued via drag & drop".into(); } DragPayload::RemoteFile(_, _, _) => { - state.status_message = "Cannot drop remote file onto remote pane".into(); + state.status_message = "Cannot drop remote onto remote".into(); } } } } fn render_content( - ui: &mut egui::Ui, + ui: &mut Ui, state: &mut AppState, tab_idx: usize, registry: &Arc, rt_handle: &tokio::runtime::Handle, ) { - let label = state.tabs[tab_idx].label.clone(); - ui.horizontal(|ui| { - ui.label(format!("\u{25CF} {}", label)); - if state.tabs[tab_idx].loading { - ui.label("loading..."); - } - }); + // Path bar + render_path_bar(ui, state, tab_idx, registry, rt_handle); - let path_changed; - { - let tab = &state.tabs[tab_idx]; - let mut path = tab.remote_path.clone(); + 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( - TextEdit::singleline(&mut path) - .id("remote_path".into()) - .desired_width(f32::INFINITY), - ); + ui.add_space(8.0); + ui.spinner(); + ui.label(RichText::new("Loading...").color(TEXT_DIM).size(11.0)); }); - path_changed = path != tab.remote_path; - if path_changed { - state.tabs[tab_idx].remote_path = path; - } } - if path_changed { - let loading = state.tabs[tab_idx].loading; - if !loading { - trigger_list(state, tab_idx, registry, rt_handle); - } - } - - ui.separator(); - + // Table let entries; let mut selected; { @@ -140,41 +115,145 @@ fn render_content( state.tabs[tab_idx].remote_selected = selected; if let Some(name) = double_clicked { - let (nav_path, is_dir) = { - if name == ".." { - let parent = remote_parent(state.tabs[tab_idx].remote_path.as_str()); - (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), - } + 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; - let loading = state.tabs[tab_idx].loading; - if !loading { + if !state.tabs[tab_idx].loading { trigger_list(state, tab_idx, registry, rt_handle); } } } - ui.separator(); - { - let tab = &state.tabs[tab_idx]; + // Footer + render_footer(ui, state, tab_idx, registry, rt_handle); +} + +fn render_path_bar( + ui: &mut Ui, + state: &mut AppState, + tab_idx: usize, + registry: &Arc, + rt_handle: &tokio::runtime::Handle, +) { + let frame = egui::Frame::none() + .fill(BG_PANEL) + .inner_margin(egui::Margin { left: 8.0, right: 8.0, top: 4.0, bottom: 4.0 }); + + frame.show(ui, |ui| { ui.horizontal(|ui| { - ui.label(format!("{} items", tab.remote_entries.len())); - if tab.loading { - ui.label(" loading..."); + // статус точка + let (dot, dot_col) = match state.tabs[tab_idx].status { + ConnectionStatus::Connected => ("●", GREEN), + ConnectionStatus::Disconnected => ("○", TEXT_HINT), + ConnectionStatus::Connecting => ("◐", YELLOW), + ConnectionStatus::Error(_) => ("×", RED), + }; + ui.label(RichText::new(dot).color(dot_col).size(10.0)); + ui.add_space(4.0); + + // Хлебные крошки для remote path + let path_clone = state.tabs[tab_idx].remote_path.clone(); + let parts: Vec<&str> = path_clone.split('/').filter(|s| !s.is_empty()).collect(); + let mut acc = String::new(); + + // Root + let root_btn = egui::Button::new(RichText::new("/").color(TEXT_DIM).size(12.0)) + .fill(egui::Color32::TRANSPARENT) + .min_size(egui::vec2(12.0, 22.0)); + if ui.add(root_btn).clicked() { + state.tabs[tab_idx].remote_path = "/".into(); + if !state.tabs[tab_idx].loading { + trigger_list(state, tab_idx, registry, rt_handle); + } + } + + for (i, part) in parts.iter().enumerate() { + acc.push('/'); + acc.push_str(part); + let is_last = i == parts.len() - 1; + let path_snap = acc.clone(); + + if is_last { + ui.label(RichText::new(*part).color(TEXT_PRIMARY).size(12.0).strong()); + } else { + let link = egui::Label::new( + RichText::new(*part).color(TEXT_DIM).size(12.0) + ).sense(egui::Sense::click()); + if ui.add(link).clicked() { + state.tabs[tab_idx].remote_path = path_snap; + if !state.tabs[tab_idx].loading { + trigger_list(state, tab_idx, registry, rt_handle); + } + } + ui.label(RichText::new("/").color(TEXT_HINT).size(12.0)); + } } }); - } + }); +} + +fn render_footer( + ui: &mut Ui, + state: &mut AppState, + tab_idx: usize, + registry: &Arc, + rt_handle: &tokio::runtime::Handle, +) { + let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0)); + ui.painter().rect_filled(sep, 0.0, BORDER); + + let frame = egui::Frame::none() + .fill(BG_PANEL) + .inner_margin(egui::Margin::symmetric(8.0, 3.0)); + + frame.show(ui, |ui| { + ui.horizontal(|ui| { + let tab = &state.tabs[tab_idx]; + let count = tab.remote_entries.iter().filter(|e| e.name != "..").count(); + let total_size: u64 = tab.remote_entries.iter().filter_map(|e| e.size).sum(); + + let label = if tab.loading { + "Loading…".to_string() + } else { + format!("{} items", count) + }; + + ui.label(RichText::new(label).color(TEXT_DIM).size(11.0)); + + if total_size > 0 { + ui.label( + RichText::new(format!("({})", format_size(Some(total_size)))) + .color(TEXT_HINT) + .size(11.0) + ); + } + + // Refresh кнопка + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let refresh = egui::Button::new(RichText::new("⟳").color(ACCENT).size(14.0)) + .fill(egui::Color32::TRANSPARENT) + .min_size(egui::vec2(20.0, 20.0)); + if ui.add(refresh).on_hover_text("Refresh").clicked() { + if !state.tabs[tab_idx].loading { + trigger_list(state, tab_idx, registry, rt_handle); + } + } + }); + }); + }); } pub fn trigger_list( @@ -210,7 +289,5 @@ pub fn trigger_list( *result_clone.lock().unwrap() = Some(r); }); - state - .pending_remote_list - .push(PendingRemoteList { tab_idx, result }); + state.pending_remote_list.push(PendingRemoteList { tab_idx, result }); } diff --git a/src/ui/panels/sidebar.rs b/src/ui/panels/sidebar.rs new file mode 100644 index 0000000..9f06916 --- /dev/null +++ b/src/ui/panels/sidebar.rs @@ -0,0 +1,156 @@ +//! Левая боковая панель — bookmarks + места +use egui::{RichText, Ui}; +use crate::ui::state::AppState; +use crate::ui::theme::*; +use crate::ui::panels::local_pane::refresh_local; + +pub fn render(ui: &mut Ui, state: &mut AppState) { + let frame = egui::Frame::none() + .fill(BG_PANEL) + .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"); + ui.add_space(4.0); + + for bm in &state.bookmarks.clone() { + let is_active = state.local_path == bm.path; + let icon = bookmark_icon(&bm.name); + sidebar_item(ui, icon, &bm.name, is_active, || { + state.local_path = bm.path.clone(); + refresh_local(state); + }); + } + + ui.add_space(10.0); + section_divider(ui); + ui.add_space(6.0); + + section_header(ui, "DRIVES"); + ui.add_space(4.0); + + // Root + data drives + let root_active = state.local_path == "/"; + sidebar_item(ui, "🖥", "/ (root)", root_active, || { + state.local_path = "/".into(); + refresh_local(state); + }); + + // попытаемся найти /media и /mnt точки монтирования + for mount_path in find_mounts() { + let label = std::path::Path::new(&mount_path) + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| mount_path.clone()); + let is_active = state.local_path == mount_path; + sidebar_item(ui, "💾", &label, is_active, || { + state.local_path = mount_path.clone(); + refresh_local(state); + }); + } + + ui.add_space(10.0); + section_divider(ui); + ui.add_space(6.0); + + section_header(ui, "BOOKMARKS"); + ui.add_space(4.0); + + // Пользовательские закладки (после 3 дефолтных) + let custom: Vec<_> = state.bookmarks.iter().skip(3).cloned().collect(); + if custom.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 { + for bm in &custom { + let is_active = state.local_path == bm.path; + sidebar_item(ui, "📌", &bm.name, is_active, || { + state.local_path = bm.path.clone(); + refresh_local(state); + }); + } + } + + ui.add_space(8.0); + }); + }); +} + +fn section_header(ui: &mut Ui, text: &str) { + ui.horizontal(|ui| { + ui.add_space(10.0); + ui.label(RichText::new(text).color(TEXT_HINT).size(10.0).strong()); + }); +} + +fn section_divider(ui: &mut Ui) { + let (_, rect) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0)); + ui.painter().rect_filled(rect, 0.0, SEPARATOR); +} + +fn sidebar_item(ui: &mut Ui, icon: &str, label: &str, active: bool, mut on_click: impl FnMut()) { + let bg = if active { BG_ROW_SEL } else { egui::Color32::TRANSPARENT }; + let text_col = if active { egui::Color32::WHITE } else { TEXT_PRIMARY }; + + let resp = egui::Frame::none() + .fill(bg) + .rounding(4.0) + .inner_margin(egui::Margin { left: 10.0, right: 6.0, top: 2.0, bottom: 2.0 }) + .show(ui, |ui| { + ui.set_min_width(SIDEBAR_W - 8.0); + ui.horizontal(|ui| { + ui.label(RichText::new(icon).size(12.0)); + ui.add_space(6.0); + ui.label( + RichText::new(label) + .color(text_col) + .size(12.0) + ); + }); + }); + + if resp.response.interact(egui::Sense::click()).clicked() { + on_click(); + } +} + +fn bookmark_icon(name: &str) -> &'static str { + match name { + "Home" => "🏠", + "Desktop" => "🖥", + "Documents" => "📄", + "Downloads" => "⬇", + "Pictures" => "🖼", + "Music" => "🎵", + "Videos" => "🎬", + _ => "📁", + } +} + +fn find_mounts() -> Vec { + let mut result = Vec::new(); + for prefix in ["/media", "/mnt"] { + if let Ok(rd) = std::fs::read_dir(prefix) { + for e in rd.flatten() { + let p = e.path().to_string_lossy().to_string(); + result.push(p); + } + } + } + result +} diff --git a/src/ui/panels/status_bar.rs b/src/ui/panels/status_bar.rs index d08af5e..dc0cda8 100644 --- a/src/ui/panels/status_bar.rs +++ b/src/ui/panels/status_bar.rs @@ -1,27 +1,61 @@ +use egui::RichText; use crate::domain::connection::ConnectionStatus; +use crate::ui::panels::file_pane::format_size; use crate::ui::state::AppState; +use crate::ui::theme::*; -pub fn render(ui: &mut egui::Ui, state: &mut AppState) { - ui.horizontal(|ui| { - ui.label(&state.status_message); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let queued = state.queue_tasks.len(); - let queue_label = if state.show_queue { - format!("Queue ({}) \u{25BC}", queued) - } else { - format!("Queue ({}) \u{25B2}", queued) - }; - if ui.button(queue_label).clicked() { - state.show_queue = !state.show_queue; - } - ui.separator(); - let connected = state - .tabs - .iter() - .filter(|t| t.status == ConnectionStatus::Connected) - .count(); - let total = state.tabs.len(); - ui.label(format!("{}/{}", connected, total)); +pub fn render(ui: &mut egui::Ui, state: &AppState) { + let frame = egui::Frame::none() + .fill(BG_BASE) + .inner_margin(egui::Margin::symmetric(10.0, 2.0)); + + frame.show(ui, |ui| { + ui.set_min_height(STATUS_H); + ui.horizontal_centered(|ui| { + // статус сообщение + ui.label( + RichText::new(&state.status_message) + .color(TEXT_DIM) + .size(11.0) + ); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // аггрегированная скорость + let agg: u64 = state.queue_tasks.iter() + .filter(|t| t.state == crate::domain::transfer::TaskState::Running) + .filter_map(|t| t.speed) + .sum(); + + if agg > 0 { + ui.label( + RichText::new(format!("⬆⬇ {}/s", format_size(Some(agg)))) + .color(GREEN) + .size(11.0) + ); + ui.add_space(10.0); + } + + // соединения + let connected = state.tabs.iter() + .filter(|t| t.status == ConnectionStatus::Connected) + .count(); + let total = state.tabs.len(); + + if total > 0 { + let dot_col = if connected > 0 { GREEN } else { TEXT_HINT }; + ui.label( + RichText::new(format!("● {}/{}", connected, total)) + .color(dot_col) + .size(11.0) + ); + } else { + ui.label( + RichText::new("○ Not connected") + .color(TEXT_HINT) + .size(11.0) + ); + } + }); }); }); } diff --git a/src/ui/panels/tabs.rs b/src/ui/panels/tabs.rs index 8d00d42..cb0f0d1 100644 --- a/src/ui/panels/tabs.rs +++ b/src/ui/panels/tabs.rs @@ -1,45 +1,91 @@ +use egui::{Color32, RichText, Ui}; use crate::domain::connection::ConnectionStatus; use crate::ui::state::AppState; +use crate::ui::theme::*; -pub fn render(ui: &mut egui::Ui, state: &mut AppState) { - ui.horizontal(|ui| { - let mut to_close: Option = None; +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 }); - for (i, tab) in state.tabs.iter().enumerate() { - let is_active = i == state.active_tab; + frame.show(ui, |ui| { + ui.set_min_height(TABBAR_H); + ui.horizontal(|ui| { + let mut to_close: Option = None; - let dot = match &tab.status { - ConnectionStatus::Connected => "\u{25CF}", - ConnectionStatus::Disconnected => "\u{25CB}", - ConnectionStatus::Connecting => "\u{25D0}", - ConnectionStatus::Error(_) => "\u{2716}", - }; - - let label = format!("{} {} ", dot, tab.label); - - let mut tab_btn = egui::Button::new(&label) - .min_size(egui::vec2(60.0, 28.0)); - if is_active { - tab_btn = tab_btn.fill(egui::Color32::from_rgb(60, 60, 70)); - } - if ui.add(tab_btn).clicked() { - state.active_tab = i; + if state.tabs.is_empty() { + ui.label(RichText::new("No connections").color(TEXT_HINT).size(11.0)); } - if ui.small_button("x").clicked() { - to_close = Some(i); - } - } + for (i, tab) in state.tabs.iter().enumerate() { + let is_active = i == state.active_tab; - if let Some(idx) = to_close { - state.tabs.remove(idx); - if state.active_tab >= state.tabs.len() && !state.tabs.is_empty() { - state.active_tab = state.tabs.len() - 1; - } - } + let (dot_col, dot) = match &tab.status { + ConnectionStatus::Connected => (GREEN, "●"), + ConnectionStatus::Disconnected => (TEXT_HINT, "○"), + ConnectionStatus::Connecting => (YELLOW, "◐"), + ConnectionStatus::Error(_) => (RED, "×"), + }; - if ui.button("+").clicked() { - state.show_connect_dialog = true; - } + 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(egui::Rounding { nw: 4.0, ne: 4.0, sw: 0.0, se: 0.0 }) + .inner_margin(egui::Margin { left: 10.0, right: 4.0, top: 0.0, bottom: 0.0 }) + .show(ui, |ui| { + ui.set_min_height(TABBAR_H); + ui.horizontal_centered(|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(16.0)) + .fill(Color32::TRANSPARENT) + .rounding(4.0) + .min_size(egui::vec2(28.0, TABBAR_H)); + if ui.add(plus).clicked() { + state.show_connect_dialog = true; + } + + if let Some(idx) = to_close { + state.tabs.remove(idx); + if !state.tabs.is_empty() && state.active_tab >= state.tabs.len() { + state.active_tab = state.tabs.len() - 1; + } + } + }); }); + + // нижняя граница таббара + let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0)); + ui.painter().rect_filled(sep, 0.0, BORDER); } diff --git a/src/ui/panels/toolbar.rs b/src/ui/panels/toolbar.rs index c9878f3..5204989 100644 --- a/src/ui/panels/toolbar.rs +++ b/src/ui/panels/toolbar.rs @@ -1,65 +1,179 @@ +use egui::{Align, Layout, RichText, Ui}; use crate::ui::state::AppState; +use crate::ui::theme::*; -pub fn render(ui: &mut egui::Ui, state: &mut AppState) { - ui.horizontal(|ui| { - if ui.button("+ New Connection").clicked() { - state.show_connect_dialog = true; - } +pub fn render(ui: &mut Ui, state: &mut AppState) { + let frame = egui::Frame::none() + .fill(BG_TOOLBAR) + .inner_margin(egui::Margin::symmetric(8.0, 0.0)); - ui.separator(); + frame.show(ui, |ui| { + ui.set_min_height(TOOLBAR_H); + ui.horizontal_centered(|ui| { + // Логотип + ui.label( + RichText::new("LoFlum") + .color(TEXT_PRIMARY) + .size(14.0) + .strong(), + ); - if ui.button("Bookmarks").clicked() { - state.show_bookmarks = !state.show_bookmarks; - state.show_history = false; - } + ui.add_space(8.0); + separator_v(ui); + ui.add_space(8.0); - if ui.button("History").clicked() { - state.show_history = !state.show_history; - state.show_bookmarks = false; - } + // Новое подключение + toolbar_btn(ui, "+ New Connection", false, || { + state.show_connect_dialog = true; + }); - if state.show_bookmarks { - egui::ComboBox::from_id_source("bookmarks_menu") - .selected_text("Bookmarks") - .width(150.0) - .show_ui(ui, |ui| { - for bm in &state.bookmarks.clone() { - if ui.button(&bm.name).clicked() { - state.local_path = bm.path.clone(); - crate::ui::panels::local_pane::refresh_local(state); - state.show_bookmarks = false; - } - } + 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, "⬆ Upload", has_conn && has_local_sel, || { + state.status_message = "Upload: select file and use drag & drop".into(); + }); + toolbar_btn_enabled(ui, "⬇ Download", has_conn && has_remote_sel, || { + state.status_message = "Download: select file and use drag & drop".into(); + }); + + 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, &q_label, q_active, || { + state.show_queue = !state.show_queue; + }); + + separator_v(ui); + + toolbar_btn_enabled(ui, "⟳ Refresh", has_conn, || { + // refresh remote — вызывается снаружи через state флаг + state.pending_refresh = true; + }); + toolbar_btn_enabled(ui, "📁 New Folder", has_conn, || { + state.pending_mkdir = true; + }); + toolbar_btn_enabled(ui, "× Delete", has_conn && has_remote_sel, || { + state.pending_delete = true; + }); + toolbar_btn_enabled(ui, "✎ Rename", has_conn && has_remote_sel, || { + state.pending_rename = true; + }); + + // Правая часть — History / Bookmarks + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + toolbar_btn(ui, "⏱ History", state.show_history, || { + state.show_history = !state.show_history; + state.show_bookmarks = false; }); - } - - if state.show_history { - egui::ComboBox::from_id_source("history_menu") - .selected_text("History") - .width(200.0) - .show_ui(ui, |ui| { - if state.history.is_empty() { - ui.label("No history yet"); - } else { - for entry in &state.history.clone() { - let label = format!("{}@{}:{} [{}]", entry.user, entry.host, entry.port, entry.time); - if ui.button(&label).clicked() { - state.connect_host = entry.host.clone(); - state.connect_user = entry.user.clone(); - state.connect_port = entry.port.to_string(); - state.show_connect_dialog = true; - state.show_history = false; - } - } - } - }); - } - - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let queued = state.queue_tasks.len(); - if queued > 0 { - ui.label(format!("Queue: {}", queued)); - } + }); }); }); + + // История поверх + 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(egui::Color32::from_rgb(32, 32, 36)) + .stroke(egui::Stroke::new(1.0, BORDER)) + .rounding(6.0) + .inner_margin(egui::Margin::same(8.0)) + .show(ui, |ui| { + ui.set_width(260.0); + ui.label(RichText::new("Recent Connections").color(TEXT_DIM).size(11.0)); + 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); + if 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)), + ) + .clicked() + { + state.connect_host = entry.host.clone(); + state.connect_user = entry.user.clone(); + state.connect_port = entry.port.to_string(); + state.show_connect_dialog = true; + state.show_history = false; + } + 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, 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::new(RichText::new(label).color(text_col).size(12.0)) + .fill(fill) + .rounding(4.0) + .min_size(egui::vec2(0.0, 26.0)); + if ui.add(btn).clicked() { + on_click(); + } +} + +fn toolbar_btn_enabled(ui: &mut Ui, label: &str, enabled: bool, on_click: impl FnMut()) { + ui.add_enabled_ui(enabled, |ui| { + toolbar_btn(ui, label, false, 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); } diff --git a/src/ui/panels/transfer_area.rs b/src/ui/panels/transfer_area.rs deleted file mode 100644 index 375489d..0000000 --- a/src/ui/panels/transfer_area.rs +++ /dev/null @@ -1,13 +0,0 @@ -use crate::ui::state::AppState; - -pub fn render(ui: &mut egui::Ui, state: &mut AppState) { - ui.horizontal(|ui| { - ui.label("\u{1F4E4} Transfer area"); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let queued = state.queue_tasks.len(); - if queued > 0 { - ui.label(format!("{} files", queued)); - } - }); - }); -} diff --git a/src/ui/panels/tree.rs b/src/ui/panels/tree.rs deleted file mode 100644 index 056878f..0000000 --- a/src/ui/panels/tree.rs +++ /dev/null @@ -1,126 +0,0 @@ -use crate::domain::file_entry::{EntryKind, FileEntry}; -use crate::fs::local; -use crate::ui::state::AppState; - -pub fn refresh_tree(state: &mut AppState, path: &str) { - if state.tree_children.contains_key(path) { - return; - } - state.tree_loading.insert(path.to_string(), true); - let entries = local::list(path).unwrap_or_default(); - let dirs: Vec = entries - .into_iter() - .filter(|e| e.kind == EntryKind::Dir) - .collect(); - state.tree_children.insert(path.to_string(), dirs); - state.tree_loading.remove(path); -} - -pub fn render(ui: &mut egui::Ui, state: &mut AppState) { - egui::ScrollArea::vertical() - .auto_shrink([false; 2]) - .show(ui, |ui| { - ui.strong("Local Files"); - ui.separator(); - - let home = dirs::home_dir() - .unwrap_or_default() - .to_string_lossy() - .to_string(); - - let roots = [("/", "Root"), (&home, "Home"), ("/tmp", "tmp")]; - - for (root_path, label) in &roots { - render_tree_node(ui, state, root_path, label, 0); - } - - ui.separator(); - ui.strong("Bookmarks"); - for bm in &state.bookmarks.clone() { - let is_current = state.local_path == bm.path - || state.local_path.starts_with(&bm.path); - let mut btn = egui::Button::new(&bm.name).min_size(egui::vec2(140.0, 20.0)); - if is_current { - btn = btn.fill(egui::Color32::from_rgb(50, 55, 60)); - } - if ui.add(btn).clicked() { - state.local_path = bm.path.clone(); - crate::ui::panels::local_pane::refresh_local(state); - } - } - }); -} - -fn render_tree_node( - ui: &mut egui::Ui, - state: &mut AppState, - path: &str, - label: &str, - depth: usize, -) { - let is_current = state.local_path == path - || (path != "/" - && state.local_path.starts_with(path) - && state.local_path.as_str()[path.len()..].starts_with('/')); - - let expanded = state.tree_expanded.get(path).copied().unwrap_or(false); - let has_children = state.tree_children.contains_key(path); - - ui.horizontal(|ui| { - ui.add_space(depth as f32 * 16.0); - - let arrow = if has_children { - if expanded { - "\u{25BC}" - } else { - "\u{25B6}" - } - } else { - " " - }; - - let arrow_resp = ui.selectable_label(false, arrow); - if arrow_resp.clicked() && has_children { - if expanded { - state.tree_expanded.insert(path.to_string(), false); - } else { - state.tree_expanded.insert(path.to_string(), true); - if !state.tree_children.contains_key(path) { - refresh_tree(state, path); - } - } - } - - let icon = if expanded { - "\u{1F4C2}" - } else { - "\u{1F4C1}" - }; - let mut btn = egui::Button::new(format!("{} {}", icon, label)) - .min_size(egui::vec2(120.0, 20.0)); - if is_current { - btn = btn.fill(egui::Color32::from_rgb(50, 55, 60)); - } - if ui.add(btn).clicked() { - state.local_path = path.to_string(); - crate::ui::panels::local_pane::refresh_local(state); - } - }); - - if expanded { - let children = state.tree_children.get(path).cloned().unwrap_or_default(); - let mut dirs: Vec<_> = children.iter().collect(); - dirs.sort_by(|a, b| a.name.cmp(&b.name)); - - for child in &dirs { - let child_path = child.path.clone(); - let child_label = child.name.clone(); - - if !state.tree_children.contains_key(&child_path) { - refresh_tree(state, &child_path); - } - - render_tree_node(ui, state, &child_path, &child_label, depth + 1); - } - } -} diff --git a/src/ui/state.rs b/src/ui/state.rs index 97ebece..9b4e330 100644 --- a/src/ui/state.rs +++ b/src/ui/state.rs @@ -1,4 +1,3 @@ -use std::collections::HashMap; use std::sync::Arc; use crate::domain::connection::{ConnectionParams, ConnectionStatus}; @@ -45,17 +44,18 @@ pub struct HistoryEntry { pub struct AppState { pub tabs: Vec, pub active_tab: usize, + pub local_path: String, pub local_entries: Vec, pub local_selected: Option, + pub show_connect_dialog: bool, pub show_bookmarks: bool, pub show_history: bool, + pub bookmarks: Vec, pub history: Vec, - pub tree_expanded: HashMap, - pub tree_children: HashMap>, - pub tree_loading: HashMap, + pub connect_label: String, pub connect_host: String, pub connect_port: String, @@ -65,38 +65,50 @@ pub struct AppState { pub connect_protocol: usize, pub connect_error: String, pub connect_loading: bool, + pub show_queue: bool, pub queue_tasks: Vec, + pub status_message: String, - pub agg_speed: u64, pub connected_count: usize, + + // action flags — выставляются тулбаром, обрабатываются в app.rs + pub pending_refresh: bool, + pub pending_mkdir: bool, + pub pending_delete: bool, + pub pending_rename: bool, + pub pending_connect: Option, pub pending_remote_list: Vec, } impl Default for AppState { fn default() -> Self { + let home = dirs::home_dir() + .unwrap_or_default() + .to_string_lossy() + .to_string(); Self { tabs: Vec::new(), active_tab: 0, - local_path: dirs::home_dir() - .unwrap_or_default() - .to_string_lossy() - .to_string(), + local_path: home.clone(), local_entries: Vec::new(), local_selected: None, show_connect_dialog: false, show_bookmarks: false, show_history: false, bookmarks: vec![ - Bookmark { name: "Home".into(), path: dirs::home_dir().unwrap_or_default().to_string_lossy().to_string() }, - Bookmark { name: "Root".into(), path: "/".into() }, - Bookmark { name: "Downloads".into(), path: dirs::download_dir().map(|p| p.to_string_lossy().to_string()).unwrap_or_else(|| "/tmp".into()) }, + Bookmark { name: "Home".into(), path: home.clone() }, + Bookmark { name: "Desktop".into(), path: format!("{}/Desktop", home) }, + Bookmark { name: "Documents".into(), path: format!("{}/Documents", home) }, + Bookmark { name: "Downloads".into(), path: dirs::download_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| format!("{}/Downloads", home)) }, + Bookmark { name: "Pictures".into(), path: format!("{}/Pictures", home) }, + Bookmark { name: "Music".into(), path: format!("{}/Music", home) }, + Bookmark { name: "Videos".into(), path: format!("{}/Videos", home) }, ], history: Vec::new(), - tree_expanded: HashMap::new(), - tree_children: HashMap::new(), - tree_loading: HashMap::new(), connect_label: String::new(), connect_host: String::new(), connect_port: "22".into(), @@ -109,8 +121,11 @@ impl Default for AppState { show_queue: false, queue_tasks: Vec::new(), status_message: "Ready".into(), - agg_speed: 0, connected_count: 0, + pending_refresh: false, + pending_mkdir: false, + pending_delete: false, + pending_rename: false, pending_connect: None, pending_remote_list: Vec::new(), } @@ -119,34 +134,23 @@ impl Default for AppState { impl AppState { pub fn active_tab_mut(&mut self) -> Option<&mut ConnectionTab> { - if self.tabs.is_empty() { - None - } else { - let idx = self.active_tab.min(self.tabs.len() - 1); - Some(&mut self.tabs[idx]) - } + 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() { - None - } else { - let idx = self.active_tab.min(self.tabs.len() - 1); - Some(&self.tabs[idx]) - } + if self.tabs.is_empty() { return None; } + let idx = self.active_tab.min(self.tabs.len() - 1); + Some(&self.tabs[idx]) } pub fn add_history(&mut self, host: &str, port: u16, user: &str) { use chrono::Local; let now = Local::now().format("%H:%M %d.%m").to_string(); self.history.insert(0, HistoryEntry { - host: host.into(), - port, - user: user.into(), - time: now, + host: host.into(), port, user: user.into(), time: now, }); - if self.history.len() > 20 { - self.history.truncate(20); - } + if self.history.len() > 20 { self.history.truncate(20); } } } diff --git a/src/ui/theme.rs b/src/ui/theme.rs new file mode 100644 index 0000000..610ef26 --- /dev/null +++ b/src/ui/theme.rs @@ -0,0 +1,40 @@ +//! Цвета и константы темы LoFlum +use egui::Color32; + +// Backgrounds +pub const BG_BASE: Color32 = Color32::from_rgb(18, 18, 18); // самый тёмный фон +pub const BG_PANEL: Color32 = Color32::from_rgb(24, 24, 24); // боковая панель +pub const BG_CONTENT: Color32 = Color32::from_rgb(28, 28, 28); // основная область +pub const BG_TOOLBAR: Color32 = Color32::from_rgb(22, 22, 22); // тулбар +pub const BG_TAB_ACTIVE: Color32 = Color32::from_rgb(38, 38, 42); // активная вкладка +pub const BG_TAB_IDLE: Color32 = Color32::from_rgb(28, 28, 30); // неактивная +pub const BG_ROW_HOVER: Color32 = Color32::from_rgb(35, 35, 38); +pub const BG_ROW_SEL: Color32 = Color32::from_rgb(42, 64, 95); // выделение — синеватое +pub const BG_QUEUE: Color32 = Color32::from_rgb(20, 20, 22); +pub const BG_HEADER: Color32 = Color32::from_rgb(22, 22, 24); + +// Accents +pub const ACCENT: Color32 = Color32::from_rgb(64, 130, 220); // синий акцент +pub const ACCENT_DIM: Color32 = Color32::from_rgb(45, 95, 165); +pub const GREEN: Color32 = Color32::from_rgb(60, 180, 100); +pub const RED: Color32 = Color32::from_rgb(210, 70, 70); +pub const YELLOW: Color32 = Color32::from_rgb(210, 160, 50); +pub const ORANGE: Color32 = Color32::from_rgb(210, 120, 50); + +// Text +pub const TEXT_PRIMARY: Color32 = Color32::from_rgb(220, 220, 220); +pub const TEXT_DIM: Color32 = Color32::from_rgb(130, 130, 135); +pub const TEXT_HINT: Color32 = Color32::from_rgb(80, 80, 85); + +// Borders / separators +pub const BORDER: Color32 = Color32::from_rgb(40, 40, 44); +pub const SEPARATOR: Color32 = Color32::from_rgb(35, 35, 38); + +// Size constants +pub const SIDEBAR_W: f32 = 180.0; +pub const TOOLBAR_H: f32 = 40.0; +pub const TABBAR_H: f32 = 32.0; +pub const QUEUE_COLLAPSED_H: f32 = 32.0; +pub const QUEUE_EXPANDED_H: f32 = 160.0; +pub const STATUS_H: f32 = 24.0; +pub const ROW_H: f32 = 22.0;