fix: warnings and icon rendering

- Remove unused imports (HashMap, TextEdit)
- Remove unnecessary mut on fonts
- Replace ✕ (U+2715) with × (U+00D7) for close/cross icons
- Replace + (U+FF0B) with + (U+002B) for New Connection button
- Replace ▲/▼ (U+25B2/U+25BC) with ↑/↓ (U+2191/U+2193) for sort arrows
This commit is contained in:
loki5512344 2026-06-19 16:53:51 +02:00
parent 9cb8e47a97
commit f983f12bec
16 changed files with 1672 additions and 789 deletions

View file

@ -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(&params.host, params.port, &params.username);
@ -100,6 +97,7 @@ impl FileManagerApp {
}
}
// --- Remote list results ---
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();
@ -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<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
}
}
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,28 +204,74 @@ impl eframe::App for FileManagerApp {
.filter(|t| t.status == ConnectionStatus::Connected)
.count();
TopBottomPanel::top("toolbar").show(ctx, |ui| {
// ── Toolbar ──────────────────────────────────────────────────────────
TopBottomPanel::top("toolbar")
.frame(egui::Frame::none().fill(BG_TOOLBAR))
.show(ctx, |ui| {
toolbar::render(ui, &mut self.state);
});
TopBottomPanel::top("tabs").show(ctx, |ui| {
// ── Tab bar ──────────────────────────────────────────────────────────
TopBottomPanel::top("tabbar")
.frame(egui::Frame::none().fill(BG_BASE))
.show(ctx, |ui| {
tabs::render(ui, &mut self.state);
});
SidePanel::left("tree_panel")
.resizable(true)
.min_width(180.0)
// ── Status bar ───────────────────────────────────────────────────────
TopBottomPanel::bottom("status_bar")
.frame(egui::Frame::none().fill(BG_BASE))
.exact_height(STATUS_H)
.show(ctx, |ui| {
tree::render(ui, &mut self.state);
status_bar::render(ui, &self.state);
});
let has_connection = self.state.active_tab_ref().is_some()
&& self.state.active_tab_ref().unwrap().status == ConnectionStatus::Connected;
// ── Queue panel ───────────────────────────────────────────────────────
let tasks = self.state.queue_tasks.clone();
let q_h = if self.state.show_queue && !tasks.is_empty() {
QUEUE_EXPANDED_H
} else {
QUEUE_COLLAPSED_H
};
TopBottomPanel::bottom("queue_panel")
.frame(egui::Frame::none().fill(BG_QUEUE).stroke(egui::Stroke::new(1.0, BORDER)))
.resizable(false)
.exact_height(q_h)
.show(ctx, |ui| {
queue::render(ui, &mut self.state, &tasks);
});
// ── Sidebar ───────────────────────────────────────────────────────────
SidePanel::left("sidebar")
.frame(egui::Frame::none()
.fill(BG_PANEL)
.stroke(egui::Stroke::new(1.0, BORDER)))
.resizable(true)
.default_width(SIDEBAR_W)
.width_range(120.0..=260.0)
.show(ctx, |ui| {
sidebar::render(ui, &mut self.state);
});
// ── 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);
if has_connection {
CentralPanel::default().show(ctx, |ui| {
ui.columns(2, |columns| {
columns[0].vertical(|ui| {
local_pane::render(
ui,
&mut self.state,
@ -196,10 +279,28 @@ impl eframe::App for FileManagerApp {
&self.registry,
self.rt.handle(),
);
});
},
);
columns[1].vertical(|ui| {
// разделитель
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,
@ -208,47 +309,75 @@ impl eframe::App for FileManagerApp {
self.rt.handle(),
&self.queue,
);
});
});
});
} else {
CentralPanel::default().show(ctx, |ui| {
local_pane::render(
ui,
&mut self.state,
&self.queue,
&self.registry,
self.rt.handle(),
// 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;
}
});
},
);
}
},
);
});
}
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)
.show(ctx, |ui| {
transfer_area::render(ui, &mut self.state);
});
TopBottomPanel::bottom("status_bar").show(ctx, |ui| {
status_bar::render(ui, &mut self.state);
});
// ── 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);
}

View file

@ -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,86 +15,178 @@ pub fn render(
registry: &Arc<RemoteRegistry>,
rt_handle: &tokio::runtime::Handle,
) {
let mut open = true;
egui::Window::new("New Connection")
.open(&mut open)
.resizable(false)
.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);
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| {
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(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| {
ui.label("Label:");
ui.add(
egui::TextEdit::singleline(&mut state.connect_label)
.id("label".into())
.desired_width(200.0),
);
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.horizontal(|ui| {
ui.label("Host:");
ui.add(
egui::TextEdit::singleline(&mut state.connect_host)
.id("host".into())
.desired_width(200.0),
);
});
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| {
ui.label("Port:");
ui.add(
egui::TextEdit::singleline(&mut state.connect_port)
.id("port".into())
.desired_width(60.0),
);
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);
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),
);
});
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| {
ui.label("Key path:");
ui.add(
egui::TextEdit::singleline(&mut state.connect_key_path)
.id("key".into())
.desired_width(200.0),
);
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.colored_label(egui::Color32::RED, &state.connect_error);
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| {
if ui.button("Connect").clicked() {
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();
}
});
});
});
});
}
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();
let protocol = match state.connect_protocol {
@ -109,7 +204,11 @@ pub fn render(
let params = ConnectionParams {
id: uuid::Uuid::new_v4().to_string(),
label: if state.connect_label.is_empty() {
state.connect_host.clone()
format!("{} ({})", state.connect_host, match protocol {
Protocol::Sftp => "SFTP",
Protocol::Ftp => "FTP",
Protocol::Ftps => "FTPS",
})
} else {
state.connect_label.clone()
},
@ -117,16 +216,8 @@ pub fn render(
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())
},
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();
@ -136,25 +227,14 @@ pub fn render(
state.connect_loading = true;
rt_handle.spawn(async move {
let r = do_connect(&registry, &params_clone).await;
let r = connect_async(&registry, &params_clone).await;
*result_clone.lock().unwrap() = Some(r);
});
state.pending_connect = Some(PendingConnect { result });
}
});
if state.connect_loading {
ui.label("Connecting...");
}
});
if !open {
state.show_connect_dialog = false;
}
}
async fn do_connect(
async fn connect_async(
registry: &RemoteRegistry,
params: &ConnectionParams,
) -> Result<(ConnectionParams, Vec<FileEntry>), String> {
@ -173,12 +253,7 @@ async fn do_connect(
)
} else {
Arc::new(
SftpClient::connect_password(
&params.host,
params.port,
&params.username,
&password,
)
SftpClient::connect_password(&params.host, params.port, &params.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))
}

View file

@ -3,3 +3,4 @@ pub mod dialogs;
pub mod drag;
pub mod panels;
pub mod state;
pub mod theme;

View file

@ -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<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) 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("%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<String>,
pub clicked: Option<String>,
}
pub fn file_table<F>(
ui: &mut egui::Ui,
ui: &mut Ui,
id: &str,
entries: &[FileEntry],
selected: &mut Option<String>,
@ -36,43 +42,171 @@ pub fn file_table<F>(
where
F: Fn(&FileEntry) -> Option<DragPayload>,
{
let mut double_clicked: Option<String> = None;
let mut clicked: Option<String> = 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<String> = None;
let mut clicked: Option<String> = 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_ref() == Some(&entry.name);
let is_selected = selected.as_deref() == Some(&entry.name);
let row_id = Id::new((id, "row", &entry.name));
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
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 bg = if is_selected {
BG_ROW_SEL
} else if is_hovered {
BG_ROW_HOVER
} else {
ui.selectable_label(is_selected, &entry.name)
Color32::TRANSPARENT
};
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)
}
}
};
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);
},
);
});
})
.response;
if response.clicked() {
*selected = Some(entry.name.clone());
clicked = Some(entry.name.clone());
@ -80,21 +214,50 @@ where
if response.double_clicked() {
double_clicked = Some(entry.name.clone());
}
ui.label(format_size(entry.size));
ui.label(match entry.kind {
EntryKind::Dir => "Dir",
EntryKind::File => "File",
EntryKind::Symlink => "Link",
});
ui.label(format_time(entry.modified));
ui.end_row();
}
});
});
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" => "☕",
_ => "📄",
}
}

View file

@ -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,25 +30,24 @@ fn make_parent_entry(path: &str) -> FileEntry {
}
pub fn render(
ui: &mut egui::Ui,
ui: &mut Ui,
state: &mut AppState,
queue: &TransferQueue,
registry: &Arc<RemoteRegistry>,
_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::<DragPayload, _>(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() {
state.status_message = "Connection no longer active".into();
return;
}
if registry.get(conn_id).is_some() {
let local_path_str =
format!("{}/{}", state.local_path.trim_end_matches('/'), file_name);
let task = TransferTask::new(
@ -59,66 +59,153 @@ pub fn render(
0,
);
queue.push(task);
state.status_message = "Download queued via drag & drop".into();
state.status_message = format!("Download queued: {}", file_name);
} else {
state.status_message = "Connection no longer active".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 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
{
} 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();
// Нижняя полоска
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| {
ui.label(format!("{} items", state.local_entries.len()));
// иконка 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| {
if ui.small_button("\u{2B50}").clicked() {
// кнопка добавить закладку
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())
@ -131,6 +218,7 @@ fn render_content(ui: &mut egui::Ui, state: &mut AppState) {
}
});
});
});
}
pub fn refresh_local(state: &mut AppState) {
@ -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);
}
}
}

View file

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

View file

@ -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");
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| {
ui.label(format!("{} tasks", tasks.len()));
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 {
render_task(ui, task);
}
});
}
fn render_task(ui: &mut Ui, task: &TransferTask) {
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,
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::symmetric(4.0, 2.0))
.inner_margin(egui::Margin { left: 10.0, right: 10.0, top: 4.0, bottom: 4.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}",
// стрелка вида
let (arrow, arrow_col) = match task.kind {
TransferKind::Upload => ("↑", ACCENT),
TransferKind::Download => ("↓", GREEN),
};
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.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();
let pb = egui::ProgressBar::new(pct as f32 / 100.0)
.text(format!("{:.1}%", pct))
.desired_width(ui.available_width() * 0.6);
ui.add(pb);
let pct = task.progress_pct() as f32 / 100.0;
let bar_col = match &task.state {
TaskState::Completed => GREEN,
TaskState::Failed(_) => RED,
_ => ACCENT,
};
ui.label(format!(
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)
);
ui.horizontal(|ui| {
if let Some(speed) = task.speed {
ui.label(format!("{}/s", format_size(Some(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 {
ui.label(format!("ETA: {}s", eta));
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);
}

View file

@ -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<RemoteRegistry>,
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::<DragPayload, _>(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 {
state.status_message = "No active connection".into();
return;
}
let remote_path_str =
format!("{}/{}", remote_path.trim_end_matches('/'), file_name);
if is_connected {
let dest = format!("{}/{}", remote_path.trim_end_matches('/'), file_name);
let task = TransferTask::new(
TransferKind::Upload,
connection_id.clone(),
connection_id,
local_path.clone(),
remote_path_str,
dest,
file_name.clone(),
0,
);
queue.push(task);
state.status_message = "Upload queued via drag & drop".into();
state.status_message = format!("Upload queued: {}", file_name);
} else {
state.status_message = "No active connection".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<RemoteRegistry>,
rt_handle: &tokio::runtime::Handle,
) {
let label = state.tabs[tab_idx].label.clone();
ui.horizontal(|ui| {
ui.label(format!("\u{25CF} {}", label));
// 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.label("loading...");
}
});
let path_changed;
{
let tab = &state.tabs[tab_idx];
let mut path = tab.remote_path.clone();
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,9 +115,8 @@ 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());
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]
@ -153,28 +127,133 @@ fn render_content(
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<RemoteRegistry>,
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<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::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 });
}

156
src/ui/panels/sidebar.rs Normal file
View file

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

View file

@ -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: &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)
);
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;
// аггрегированная скорость
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);
}
ui.separator();
let connected = state
.tabs
.iter()
// соединения
let connected = state.tabs.iter()
.filter(|t| t.status == ConnectionStatus::Connected)
.count();
let total = state.tabs.len();
ui.label(format!("{}/{}", connected, total));
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)
);
}
});
});
});
}

View file

@ -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) {
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| {
ui.set_min_height(TABBAR_H);
ui.horizontal(|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 = match &tab.status {
ConnectionStatus::Connected => "\u{25CF}",
ConnectionStatus::Disconnected => "\u{25CB}",
ConnectionStatus::Connecting => "\u{25D0}",
ConnectionStatus::Error(_) => "\u{2716}",
let (dot_col, dot) = match &tab.status {
ConnectionStatus::Connected => (GREEN, "●"),
ConnectionStatus::Disconnected => (TEXT_HINT, "○"),
ConnectionStatus::Connecting => (YELLOW, "◐"),
ConnectionStatus::Error(_) => (RED, "×"),
};
let label = format!("{} {} ", dot, tab.label);
let bg = if is_active { BG_TAB_ACTIVE } else { BG_TAB_IDLE };
let tcol = if is_active { TEXT_PRIMARY } else { TEXT_DIM };
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));
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() {
// обрабатывается ниже
}
if ui.add(tab_btn).clicked() {
state.active_tab = i;
}
if ui.small_button("x").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.active_tab >= state.tabs.len() && !state.tabs.is_empty() {
if !state.tabs.is_empty() && state.active_tab >= state.tabs.len() {
state.active_tab = state.tabs.len() - 1;
}
}
if ui.button("+").clicked() {
state.show_connect_dialog = true;
}
});
});
// нижняя граница таббара
let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0));
ui.painter().rect_filled(sep, 0.0, BORDER);
}

View file

@ -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() {
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));
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(),
);
ui.add_space(8.0);
separator_v(ui);
ui.add_space(8.0);
// Новое подключение
toolbar_btn(ui, "+ New Connection", false, || {
state.show_connect_dialog = true;
}
});
ui.separator();
separator_v(ui);
if ui.button("Bookmarks").clicked() {
state.show_bookmarks = !state.show_bookmarks;
state.show_history = false;
}
// Кнопки работы с файлами
let has_conn = state
.active_tab_ref()
.map(|t| t.status == crate::domain::connection::ConnectionStatus::Connected)
.unwrap_or(false);
if ui.button("History").clicked() {
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_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;
}
}
});
});
});
});
// История поверх
if state.show_history {
render_history_popup(ui, state);
}
}
if state.show_history {
egui::ComboBox::from_id_source("history_menu")
.selected_text("History")
.width(200.0)
.show_ui(ui, |ui| {
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("No history yet");
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, entry.time);
if ui.button(&label).clicked() {
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;
}
});
});
}
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));
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);
}

View file

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

View file

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

View file

@ -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<ConnectionTab>,
pub active_tab: usize,
pub local_path: String,
pub local_entries: Vec<FileEntry>,
pub local_selected: Option<String>,
pub show_connect_dialog: bool,
pub show_bookmarks: bool,
pub show_history: bool,
pub bookmarks: Vec<Bookmark>,
pub history: Vec<HistoryEntry>,
pub tree_expanded: HashMap<String, bool>,
pub tree_children: HashMap<String, Vec<FileEntry>>,
pub tree_loading: HashMap<String, bool>,
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<TransferTask>,
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<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: 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 {
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 {
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); }
}
}

40
src/ui/theme.rs Normal file
View file

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