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:
parent
9cb8e47a97
commit
f983f12bec
16 changed files with 1672 additions and 789 deletions
|
|
@ -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,65 +42,222 @@ 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_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" => "☕",
|
||||
_ => "📄",
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
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<RemoteRegistry>,
|
||||
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<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
156
src/ui/panels/sidebar.rs
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<usize> = 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<usize> = 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue