This commit is contained in:
loki5512344 2026-07-04 13:38:39 +03:00
parent e0edd14fa5
commit e895fe3237
65 changed files with 4118 additions and 1427 deletions

View file

@ -1,6 +1,7 @@
//! Переиспользуемая таблица файлов с нормальными колонками
use crate::domain::file_entry::{EntryKind, FileEntry};
use crate::ui::drag::DragPayload;
use crate::ui::icons::{self, Icon, file_icon_for};
use crate::ui::theme::*;
use egui::{Color32, Id, RichText, Ui};
@ -35,20 +36,56 @@ pub enum SortDir {
Desc,
}
/// Действие, запрошенное через контекстное меню (ПКМ) на строке.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum RowAction {
Open,
Rename,
Delete,
Bookmark,
CopyPath,
/// Загрузка/выгрузка этой конкретной строки — направление решает вызывающая сторона.
Transfer,
}
pub struct FileTableResponse {
pub double_clicked: Option<String>,
pub clicked: Option<String>,
/// Payload, перетащенный на папку (или "..") в этой таблице — (целевая папка, что бросили).
pub dropped_on_dir: Option<(FileEntry, std::sync::Arc<DragPayload>)>,
/// Действие из контекстного меню (ПКМ) — (строка, действие).
pub context_action: Option<(FileEntry, RowAction)>,
}
pub fn file_table<F>(
pub fn file_table<F, C>(
ui: &mut Ui,
id: &str,
entries: &[FileEntry],
selected: &mut Option<String>,
make_drag_payload: F,
context_menu: C,
) -> FileTableResponse
where
F: Fn(&FileEntry) -> Option<DragPayload>,
C: FnMut(&mut Ui, &FileEntry) -> Option<RowAction>,
{
ui.push_id(id, |ui| {
file_table_inner(ui, id, entries, selected, make_drag_payload, context_menu)
})
.inner
}
fn file_table_inner<F, C>(
ui: &mut Ui,
id: &str,
entries: &[FileEntry],
selected: &mut Option<String>,
make_drag_payload: F,
mut context_menu: C,
) -> FileTableResponse
where
F: Fn(&FileEntry) -> Option<DragPayload>,
C: FnMut(&mut Ui, &FileEntry) -> Option<RowAction>,
{
// Сортировка — читаем из egui memory
let sort_id = Id::new((id, "sort"));
@ -92,48 +129,49 @@ where
let mut double_clicked: Option<String> = None;
let mut clicked: Option<String> = None;
let mut new_sort: Option<(SortCol, SortDir)> = None;
let mut dropped_on_dir: Option<(FileEntry, std::sync::Arc<DragPayload>)> = None;
let mut context_action: Option<(FileEntry, RowAction)> = None;
// Заголовки колонок
let header_frame = egui::Frame::none()
.fill(BG_HEADER)
.inner_margin(egui::Margin {
left: 6.0,
right: 6.0,
top: 0.0,
bottom: 0.0,
});
.inner_margin(egui::Margin::symmetric(12.0, 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 width = ui.available_width();
ui.allocate_ui_with_layout(
egui::vec2(width, 24.0),
egui::Layout::left_to_right(egui::Align::Center),
|ui| {
let name_r = sort_header(ui, "Name", 0.0, sort_col == SortCol::Name, sort_dir);
if name_r {
new_sort = Some(toggle_sort(SortCol::Name, sort_col, sort_dir));
}
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));
}
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 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));
}
});
});
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 {
@ -148,101 +186,141 @@ where
// Список файлов
egui::ScrollArea::vertical()
.id_salt((id, "scroll"))
.auto_shrink([false; 2])
.show(ui, |ui| {
for entry in &sorted {
let is_selected = selected.as_deref() == Some(&entry.name);
let row_id = Id::new((id, "row", &entry.name));
// Строка, которую сейчас тащат — её содержимое рисуется в
// плавающем слое поверх курсора (см. dnd_drag_source), поэтому
// сама она не может быть целью для дропа, а на исходном месте
// не должно оставаться подсветки выбора/ховера — там пусто.
let is_being_dragged = ui.ctx().is_being_dragged(row_id);
let row_rect = egui::Rect::from_min_size(
ui.cursor().min,
egui::vec2(ui.available_width(), ROW_H),
);
let is_hovered = ui.rect_contains_pointer(row_rect);
let is_hovered = !is_being_dragged && ui.rect_contains_pointer(row_rect);
let is_drop_target = !is_being_dragged
&& entry.kind == EntryKind::Dir
&& egui::DragAndDrop::has_payload_of_type::<DragPayload>(ui.ctx());
let is_drop_hover = is_drop_target && is_hovered;
let bg = if is_selected {
let bg = if is_being_dragged {
Color32::TRANSPARENT
} else if is_drop_hover {
ACCENT_DIM
} else if is_selected {
BG_ROW_SEL
} else if is_hovered {
BG_ROW_HOVER
} else {
Color32::TRANSPARENT
};
let stroke = if is_drop_hover {
egui::Stroke::new(1.5, ACCENT)
} else {
egui::Stroke::NONE
};
let row_width = ui.available_width();
let response = egui::Frame::none()
.fill(bg)
.inner_margin(egui::Margin {
left: 6.0,
right: 6.0,
top: 0.0,
bottom: 0.0,
})
.stroke(stroke)
.rounding(RADIUS_SM)
.inner_margin(egui::Margin::symmetric(12.0, 0.0))
.show(ui, |ui| {
ui.set_min_height(ROW_H);
ui.horizontal_centered(|ui| {
// иконка + имя
let name_text = if entry.name == ".." {
"⬆ ..".to_string()
} 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.allocate_ui_with_layout(
egui::vec2(row_width - 24.0, ROW_H),
egui::Layout::left_to_right(egui::Align::Center),
|ui| {
// иконка (цвет по типу) + имя (единый цвет, как в спеке)
let (icon, icon_col) = if entry.name == ".." {
(Icon::ArrowUp, TEXT_HINT)
} else {
match entry.kind {
EntryKind::Dir => (Icon::Folder, YELLOW),
EntryKind::Symlink => (Icon::Link, YELLOW),
EntryKind::File => {
let ext = entry
.name
.rsplit('.')
.next()
.unwrap_or("")
.to_lowercase();
file_icon_for(&ext)
}
}
}
};
};
let name_col = if entry.name == ".." {
TEXT_HINT
} else {
TEXT_PRIMARY
};
let 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),
);
let draw_row = |ui: &mut Ui| {
icons::icon(ui, icon, 14.0, icon_col);
ui.add_space(6.0);
ui.label(RichText::new(&entry.name).color(name_col).size(12.5));
};
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);
if let Some(payload) = make_drag_payload(entry) {
ui.dnd_drag_source(row_id, payload, |ui| {
ui.horizontal(draw_row);
});
} else {
draw_row(ui);
}
ui.label(
RichText::new(format_size(entry.size))
.color(TEXT_DIM)
.size(11.0),
);
ui.add_space(4.0);
},
);
});
// правые колонки
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
ui.label(
RichText::new(format_time(entry.modified))
.color(TEXT_HINT)
.monospace()
.size(10.5),
);
ui.add_space(6.0);
let (type_str, type_col) = match entry.kind {
EntryKind::Dir => ("Dir", TEXT_DIM),
EntryKind::File => ("File", TEXT_HINT),
EntryKind::Symlink => ("Link", YELLOW),
};
let type_label = egui::Label::new(
RichText::new(type_str).color(type_col).size(11.0),
)
.truncate();
ui.add_sized([38.0, ROW_H], type_label);
ui.label(
RichText::new(format_size(entry.size))
.color(TEXT_DIM)
.monospace()
.size(11.0),
);
ui.add_space(4.0);
},
);
},
);
})
.response;
// Frame::show() only senses hover, not clicks — add a real click
// sense on the full row rect (separate id from the drag source,
// which only senses drag over the icon+name sub-area). Without
// this, every press was interpreted as a drag start and plain
// clicks/double-clicks never fired.
let click_response =
ui.interact(response.rect, row_id.with("click"), egui::Sense::click());
let response = response | click_response;
if response.clicked() {
*selected = Some(entry.name.clone());
clicked = Some(entry.name.clone());
@ -250,15 +328,48 @@ where
if response.double_clicked() {
double_clicked = Some(entry.name.clone());
}
if is_drop_target
&& let Some(payload) = response.dnd_release_payload::<DragPayload>()
{
dropped_on_dir = Some((entry.clone(), payload));
}
if entry.name != ".." {
// Правый клик выбирает строку — иначе меню открывается
// над предыдущим выделением, что сбивает с толку.
if response.secondary_clicked() {
*selected = Some(entry.name.clone());
}
response.context_menu(|ui| {
if let Some(action) = context_menu(ui, entry) {
context_action = Some((entry.clone(), action));
ui.close_menu();
}
});
}
}
});
FileTableResponse {
double_clicked,
clicked,
dropped_on_dir,
context_action,
}
}
/// Единообразная кнопка-пункт для контекстных меню (ПКМ на строке).
pub fn context_menu_item(ui: &mut Ui, icon: Icon, label: &str, danger: bool) -> bool {
let color = if danger { RED } else { TEXT_PRIMARY };
let btn = egui::Button::image_and_text(
icons::image(icon, 13.0, color),
RichText::new(label).size(12.0).color(color),
)
.fill(Color32::TRANSPARENT)
.min_size(egui::vec2(170.0, 26.0));
ui.add(btn).clicked()
}
fn sort_header(ui: &mut Ui, label: &str, min_w: f32, active: bool, dir: SortDir) -> bool {
let arrow = if active {
if dir == SortDir::Asc { " ↑" } else { " ↓" }
@ -267,7 +378,7 @@ fn sort_header(ui: &mut Ui, label: &str, min_w: f32, active: bool, dir: SortDir)
};
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())
let btn = egui::Button::new(RichText::new(full).color(col).size(10.5).strong())
.fill(Color32::TRANSPARENT)
.min_size(egui::vec2(min_w, 20.0));
ui.add(btn).clicked()
@ -285,22 +396,3 @@ fn toggle_sort(col: SortCol, cur_col: SortCol, cur_dir: SortDir) -> (SortCol, So
(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

@ -5,11 +5,14 @@ use crate::fs::local;
use crate::fs::remote::RemoteRegistry;
use crate::transfer::queue::TransferQueue;
use crate::ui::drag::{DragPayload, make_local_payload};
use crate::ui::panels::file_pane::{FileTableResponse, file_table, format_size};
use crate::ui::state::AppState;
use crate::ui::icons::{self, Icon};
use crate::ui::panels::file_pane::{
FileTableResponse, RowAction, context_menu_item, file_table, format_size,
};
use crate::ui::state::{AppState, Pane};
use crate::ui::theme::*;
use egui::{RichText, Ui};
use std::sync::Arc;
use std::sync::{Arc, Mutex};
fn parent_path(path: &str) -> String {
std::path::Path::new(path)
@ -35,43 +38,75 @@ pub fn render(
queue: &TransferQueue,
registry: &Arc<RemoteRegistry>,
_rt_handle: &tokio::runtime::Handle,
db: &Arc<Mutex<rusqlite::Connection>>,
) {
let frame = egui::Frame::none()
.fill(BG_CONTENT)
.inner_margin(egui::Margin::same(0.0));
let (_inner, dropped) = ui.dnd_drop_zone::<DragPayload, _>(frame, |ui| {
render_inner(ui, state);
render_inner(ui, state, queue, registry, db);
});
if let Some(payload_arc) = dropped {
match &*payload_arc {
DragPayload::RemoteFile(remote_path, file_name, conn_id) => {
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();
}
let dest_dir = state.local_path.clone();
handle_drop(state, queue, registry, &payload_arc, &dest_dir);
}
}
/// Обрабатывает бросок файла в текущую директорию (dest_dir) — общий код
/// для дропа на весь пейн и дропа на конкретную папку в списке.
fn handle_drop(
state: &mut AppState,
queue: &TransferQueue,
registry: &Arc<RemoteRegistry>,
payload: &DragPayload,
dest_dir: &str,
) {
match payload {
DragPayload::RemoteFile(remote_path, file_name, conn_id) => {
if registry.get(conn_id).is_some() {
let local_path_str = format!("{}/{}", dest_dir.trim_end_matches('/'), file_name);
let task = TransferTask::new(
TransferKind::Download,
conn_id.clone(),
local_path_str,
remote_path.clone(),
file_name.clone(),
0,
);
queue.push(task);
state.status_message = format!("Download queued: {}", file_name);
} else {
state.status_message = "Connection no longer active".into();
}
DragPayload::LocalFile(_, _) => {
state.status_message = "Cannot drop local onto local".into();
}
DragPayload::LocalFile(src_path, file_name) => {
if std::path::Path::new(dest_dir)
== std::path::Path::new(src_path)
.parent()
.unwrap_or(std::path::Path::new(""))
{
return; // бросили туда же, откуда взяли
}
match local::move_into(src_path, dest_dir) {
Ok(()) => {
state.status_message = format!("Moved {}", file_name);
refresh_local(state);
}
Err(e) => state.status_message = format!("Move failed: {}", e),
}
}
}
}
fn render_inner(ui: &mut Ui, state: &mut AppState) {
fn render_inner(
ui: &mut Ui,
state: &mut AppState,
queue: &TransferQueue,
registry: &Arc<RemoteRegistry>,
db: &Arc<Mutex<rusqlite::Connection>>,
) {
// Хлебные крошки + путь
render_path_bar(ui, state);
@ -88,93 +123,232 @@ fn render_inner(ui: &mut Ui, state: &mut AppState) {
.map(|t| t.status == ConnectionStatus::Connected)
.unwrap_or(false);
let FileTableResponse { double_clicked, .. } =
file_table(ui, "local_table", &entries, &mut selected, |entry| {
let FileTableResponse {
double_clicked,
clicked,
dropped_on_dir,
context_action,
} = file_table(
ui,
"local_table",
&entries,
&mut selected,
|entry| {
if has_connection {
make_local_payload(entry)
} else {
None
}
});
},
|ui, entry| build_context_menu(ui, entry, has_connection),
);
state.local_selected = selected;
if clicked.is_some() {
state.active_pane = Pane::Local;
}
if let Some((target_entry, payload)) = dropped_on_dir {
handle_drop(state, queue, registry, &payload, &target_entry.path);
}
if let Some(name) = double_clicked {
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();
}
refresh_local(state);
open_entry(state, &name);
}
if let Some((entry, action)) = context_action {
handle_row_action(ui.ctx(), state, queue, db, &entry, action);
}
// Нижняя полоска
render_footer(ui, state);
render_footer(ui, state, db);
}
fn build_context_menu(ui: &mut Ui, entry: &FileEntry, has_connection: bool) -> Option<RowAction> {
if context_menu_item(ui, Icon::Folder, "Open", false) {
return Some(RowAction::Open);
}
if entry.name != ".." {
if context_menu_item(ui, Icon::Pen, "Rename", false) {
return Some(RowAction::Rename);
}
if context_menu_item(ui, Icon::Trash, "Delete", true) {
return Some(RowAction::Delete);
}
ui.separator();
if entry.kind == EntryKind::Dir
&& context_menu_item(ui, Icon::Star, "Add to Bookmarks", false)
{
return Some(RowAction::Bookmark);
}
if context_menu_item(ui, Icon::Document, "Copy Path", false) {
return Some(RowAction::CopyPath);
}
if entry.kind == EntryKind::File && has_connection {
ui.separator();
if context_menu_item(ui, Icon::Upload, "Upload to Server", false) {
return Some(RowAction::Transfer);
}
}
}
None
}
fn open_entry(state: &mut AppState, name: &str) {
if name == ".." {
state.local_path = parent_path(&state.local_path);
refresh_local(state);
} else if let Some(entry) = state.local_entries.iter().find(|e| e.name == name) {
if entry.kind == EntryKind::Dir {
state.local_path = entry.path.clone();
refresh_local(state);
} else {
match local::open(&entry.path) {
Ok(()) => state.status_message = format!("Opening {}", entry.name),
Err(e) => state.status_message = format!("Open failed: {}", e),
}
}
}
}
fn handle_row_action(
ctx: &egui::Context,
state: &mut AppState,
queue: &TransferQueue,
db: &Arc<Mutex<rusqlite::Connection>>,
entry: &FileEntry,
action: RowAction,
) {
match action {
RowAction::Open => open_entry(state, &entry.name),
RowAction::Rename => {
state.op_target = Pane::Local;
state.show_rename_dialog = true;
state.rename_old_name = entry.name.clone();
state.rename_new_name = entry.name.clone();
}
RowAction::Delete => {
state.op_target = Pane::Local;
state.show_delete_dialog = true;
state.delete_name = entry.name.clone();
}
RowAction::Bookmark => add_bookmark(state, db, &entry.name, &entry.path),
RowAction::CopyPath => {
ctx.copy_text(entry.path.clone());
state.status_message = "Path copied".into();
}
RowAction::Transfer => {
if let Some(tab) = state.active_tab_ref() {
let connection_id = tab.id.clone();
let remote_path =
format!("{}/{}", tab.remote_path.trim_end_matches('/'), entry.name);
let task = TransferTask::new(
TransferKind::Upload,
connection_id,
entry.path.clone(),
remote_path,
entry.name.clone(),
0,
);
queue.push(task);
state.status_message = format!("Upload queued: {}", entry.name);
}
}
}
}
fn add_bookmark(
state: &mut AppState,
db: &Arc<Mutex<rusqlite::Connection>>,
name: &str,
path: &str,
) {
if state.bookmarks.iter().any(|b| b.path == path) {
state.status_message = "Already bookmarked".into();
return;
}
let Ok(conn) = db.lock() else {
return;
};
match crate::storage::db::add_bookmark(&conn, name, path) {
Ok(id) => {
state.bookmarks.push(crate::ui::state::Bookmark {
id,
name: name.to_string(),
path: path.to_string(),
});
state.status_message = "Bookmark added".into();
}
Err(e) => state.status_message = format!("Bookmark failed: {}", e),
}
}
fn render_path_bar(ui: &mut Ui, state: &mut AppState) {
let frame = egui::Frame::none()
.fill(BG_PANEL)
.inner_margin(egui::Margin {
left: 8.0,
right: 8.0,
top: 4.0,
bottom: 4.0,
});
.fill(BG_BASE)
.inner_margin(egui::Margin::symmetric(12.0, 0.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);
}
let width = ui.available_width();
ui.allocate_ui_with_layout(
egui::vec2(width, 30.0),
egui::Layout::left_to_right(egui::Align::Center),
|ui| {
ui.label(RichText::new("LOCAL").color(TEXT_DIM).size(10.5).strong());
ui.add_space(10.0);
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));
// иконка home
let home = dirs::home_dir()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let is_home = state.local_path == home;
let home_col = if is_home { ACCENT } else { TEXT_DIM };
let home_btn = egui::Button::image(icons::image(Icon::Home, 13.0, home_col))
.fill(if is_home {
BG_ROW_SEL
} else {
egui::Color32::TRANSPARENT
})
.rounding(RADIUS_SM)
.min_size(egui::vec2(22.0, 22.0));
if ui.add(home_btn).clicked() {
state.local_path = home;
refresh_local(state);
}
}
});
ui.add_space(4.0);
// Крошки
let path_clone = state.local_path.clone();
let parts: Vec<&str> = path_clone.split('/').filter(|s| !s.is_empty()).collect();
let mut acc = String::new();
for (i, part) in parts.iter().enumerate() {
acc.push('/');
acc.push_str(part);
let is_last = i == parts.len() - 1;
if is_last {
ui.label(RichText::new(*part).color(TEXT_PRIMARY).size(12.0).strong());
} else {
let path_snap = acc.clone();
let link =
egui::Label::new(RichText::new(*part).color(TEXT_DIM).size(12.0))
.sense(egui::Sense::click());
if ui.add(link).clicked() {
state.local_path = path_snap;
refresh_local(state);
}
ui.label(RichText::new("/").color(TEXT_HINT).size(12.0));
}
}
},
);
});
}
fn render_footer(ui: &mut Ui, state: &mut AppState) {
fn render_footer(ui: &mut Ui, state: &mut AppState, db: &Arc<Mutex<rusqlite::Connection>>) {
let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0));
ui.painter().rect_filled(sep, 0.0, BORDER);
@ -207,7 +381,7 @@ fn render_footer(ui: &mut Ui, state: &mut AppState) {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
// кнопка добавить закладку
let star = egui::Button::new(RichText::new("★").color(YELLOW).size(12.0))
let star = egui::Button::image(icons::image(Icon::Star, 12.0, YELLOW))
.fill(egui::Color32::TRANSPARENT)
.min_size(egui::vec2(20.0, 20.0));
if ui.add(star).on_hover_text("Bookmark this folder").clicked() {
@ -215,11 +389,8 @@ fn render_footer(ui: &mut Ui, state: &mut AppState) {
.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();
let path = state.local_path.clone();
add_bookmark(state, db, &name, &path);
}
});
});

View file

@ -1,5 +1,6 @@
use crate::domain::transfer::TransferTask;
use crate::domain::transfer::{TaskState, TransferKind};
use crate::ui::icons::{self, Icon};
use crate::ui::panels::file_pane::format_size;
use crate::ui::state::AppState;
use crate::ui::theme::*;
@ -9,75 +10,84 @@ 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));
.inner_margin(egui::Margin::symmetric(12.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
let width = ui.available_width();
ui.allocate_ui_with_layout(
egui::vec2(width, QUEUE_COLLAPSED_H),
egui::Layout::left_to_right(egui::Align::Center),
|ui| {
let chevron = if state.show_queue {
Icon::ArrowDown
} else {
Icon::ArrowUp
};
let n_active = tasks
.iter()
.filter(|t| t.state == TaskState::Running)
.filter_map(|t| t.speed)
.sum();
.filter(|t| matches!(t.state, TaskState::Running | TaskState::Queued))
.count();
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
let title = if n_active > 0 {
format!("Transfer Queue ({})", n_active)
} else {
format!("Transfer Queue ({})", tasks.len())
};
let btn = egui::Button::image_and_text(
icons::image(chevron, 13.0, TEXT_DIM),
RichText::new(&title)
.color(TEXT_PRIMARY)
.size(11.5)
.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::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);
.filter(|t| t.state == TaskState::Running)
.filter_map(|t| t.speed)
.sum();
if agg_speed > 0 {
ui.label(
RichText::new(format!("{} failed", failed))
.color(RED)
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),
);
}
}
}
});
});
});
},
);
});
if !state.show_queue || tasks.is_empty() {
@ -91,113 +101,81 @@ pub fn render(ui: &mut Ui, state: &mut AppState, tasks: &[TransferTask]) {
.auto_shrink([false; 2])
.max_height(QUEUE_EXPANDED_H - QUEUE_COLLAPSED_H - 4.0)
.show(ui, |ui| {
for task in tasks {
render_task(ui, task);
ui.add_space(2.0);
for (i, task) in tasks.iter().enumerate() {
if i > 0 {
ui.add_space(6.0);
}
egui::Frame::none()
.inner_margin(egui::Margin::symmetric(12.0, 0.0))
.show(ui, |ui| 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,
};
// Плоская однострочная карточка, как в макете — без цветной подложки
ui.horizontal(|ui| {
ui.set_min_height(34.0);
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);
let (arrow, arrow_col) = match task.kind {
TransferKind::Upload => (Icon::Upload, ACCENT),
TransferKind::Download => (Icon::Download, GREEN),
};
icons::icon(ui, arrow, 15.0, arrow_col);
ui.add_space(10.0);
// имя файла
ui.label(
RichText::new(&task.file_name)
.color(TEXT_PRIMARY)
.size(12.0),
);
// имя файла — фиксированная ширина, как в спеке
let name_label = egui::Label::new(
RichText::new(&task.file_name)
.color(TEXT_PRIMARY)
.size(12.0),
)
.truncate();
let name_resp = ui.add_sized([150.0, 18.0], name_label);
if let TaskState::Failed(err) = &task.state {
name_resp.on_hover_text(err);
}
ui.add_space(10.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));
});
});
let pct = task.progress_pct() as f32 / 100.0;
let bar_col = match &task.state {
TaskState::Completed => GREEN,
TaskState::Failed(_) => RED,
TaskState::Paused => TEXT_HINT,
_ => ACCENT,
};
let bar = egui::ProgressBar::new(pct)
.desired_width(ui.available_width() - 130.0)
.fill(bar_col);
ui.add(bar);
ui.add_space(10.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,
};
ui.label(
RichText::new(format!("{:.0}%", task.progress_pct()))
.color(TEXT_DIM)
.monospace()
.size(11.0),
);
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
&& speed > 0
{
ui.label(
RichText::new(format!(" {}/s", format_size(Some(speed))))
.color(GREEN)
.size(11.0),
);
}
if let Some(eta) = task.eta_secs
&& 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));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let (state_str, state_col, state_icon) = match &task.state {
TaskState::Queued => ("Queued".to_string(), TEXT_HINT, None),
TaskState::Running => match task.speed {
Some(s) if s > 0 => (format!("{}/s", format_size(Some(s))), GREEN, None),
_ => ("Uploading".to_string(), ACCENT, None),
},
TaskState::Completed => ("Complete".to_string(), GREEN, Some(Icon::CheckCircle)),
TaskState::Failed(_) => ("Failed".to_string(), RED, None),
TaskState::Paused => ("Paused".to_string(), YELLOW, Some(Icon::PlayCircle)),
TaskState::Cancelled => ("Cancelled".to_string(), TEXT_HINT, None),
TaskState::Retrying(_) => ("Retrying".to_string(), YELLOW, None),
};
if let Some(ic) = state_icon {
icons::icon(ui, ic, 13.0, state_col);
ui.add_space(4.0);
}
ui.label(RichText::new(state_str).color(state_col).size(10.5));
});
// разделитель
let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0));
ui.painter().rect_filled(sep, 0.0, SEPARATOR);
});
}

View file

@ -7,8 +7,11 @@ use crate::domain::transfer::{TransferKind, TransferTask};
use crate::fs::remote::RemoteRegistry;
use crate::transfer::queue::TransferQueue;
use crate::ui::drag::{DragPayload, make_remote_payload};
use crate::ui::panels::file_pane::{FileTableResponse, file_table, format_size};
use crate::ui::state::{AppState, PendingRemoteList};
use crate::ui::icons::{self, Icon};
use crate::ui::panels::file_pane::{
FileTableResponse, RowAction, context_menu_item, file_table, format_size,
};
use crate::ui::state::{AppState, Pane, PendingRemoteList};
use crate::ui::theme::*;
pub fn remote_parent(path: &str) -> String {
@ -41,35 +44,83 @@ pub fn render(
.inner_margin(egui::Margin::same(0.0));
let (_inner, dropped) = ui.dnd_drop_zone::<DragPayload, _>(frame, |ui| {
render_content(ui, state, tab_idx, registry, rt_handle);
render_content(ui, state, tab_idx, registry, rt_handle, queue);
});
let is_connected = state.tabs[tab_idx].status == ConnectionStatus::Connected;
let connection_id = state.tabs[tab_idx].id.clone();
let remote_path = state.tabs[tab_idx].remote_path.clone();
if let Some(payload_arc) = dropped {
match &*payload_arc {
DragPayload::LocalFile(local_path, file_name) => {
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();
}
handle_drop(
state,
tab_idx,
queue,
registry,
rt_handle,
&payload_arc,
&remote_path,
);
}
}
/// Обрабатывает бросок файла в директорию dest_dir на удалённой стороне —
/// общий код для дропа на весь пейн и дропа на конкретную папку в списке.
fn handle_drop(
state: &mut AppState,
tab_idx: usize,
queue: &TransferQueue,
registry: &Arc<RemoteRegistry>,
rt_handle: &tokio::runtime::Handle,
payload: &DragPayload,
dest_dir: &str,
) {
let is_connected = state.tabs[tab_idx].status == ConnectionStatus::Connected;
let connection_id = state.tabs[tab_idx].id.clone();
match payload {
DragPayload::LocalFile(local_path, file_name) => {
if is_connected {
let dest = format!("{}/{}", dest_dir.trim_end_matches('/'), file_name);
let task = TransferTask::new(
TransferKind::Upload,
connection_id,
local_path.clone(),
dest,
file_name.clone(),
0,
);
queue.push(task);
state.status_message = format!("Upload queued: {}", file_name);
} else {
state.status_message = "No active connection".into();
}
DragPayload::RemoteFile(_, _, _) => {
state.status_message = "Cannot drop remote onto remote".into();
}
DragPayload::RemoteFile(remote_path, file_name, conn_id) => {
if *conn_id != connection_id {
state.status_message = "Cannot move between different connections".into();
return;
}
let dest = format!("{}/{}", dest_dir.trim_end_matches('/'), file_name);
if dest == *remote_path {
return;
}
let registry = registry.clone();
let from = remote_path.clone();
let conn_id_clone = conn_id.clone();
let result = Arc::new(std::sync::Mutex::new(None::<Result<(), String>>));
let result_clone = result.clone();
rt_handle.spawn(async move {
let fs = match registry.get(&conn_id_clone) {
Some(fs) => fs,
None => {
*result_clone.lock().unwrap() = Some(Err("connection not found".into()));
return;
}
};
let r = fs.rename(&from, &dest).await.map_err(|e| e.to_string());
*result_clone.lock().unwrap() = Some(r);
});
state.pending_rename_result = Some(result);
state.status_message = format!("Moving {}…", file_name);
}
}
}
@ -80,6 +131,7 @@ fn render_content(
tab_idx: usize,
registry: &Arc<RemoteRegistry>,
rt_handle: &tokio::runtime::Handle,
queue: &TransferQueue,
) {
// Path bar
render_path_bar(ui, state, tab_idx, registry, rt_handle);
@ -109,44 +161,173 @@ fn render_content(
let conn_id = state.tabs[tab_idx].id.clone();
let table_id = format!("remote_table_{}", tab_idx);
let FileTableResponse { double_clicked, .. } =
file_table(ui, &table_id, &entries, &mut selected, move |entry| {
let conn_id_for_drag = conn_id.clone();
let FileTableResponse {
double_clicked,
clicked,
dropped_on_dir,
context_action,
} = file_table(
ui,
&table_id,
&entries,
&mut selected,
move |entry| {
if is_connected {
make_remote_payload(entry, &conn_id)
make_remote_payload(entry, &conn_id_for_drag)
} else {
None
}
});
},
build_context_menu,
);
state.tabs[tab_idx].remote_selected = selected;
if clicked.is_some() {
state.active_pane = Pane::Remote;
}
if let Some((target_entry, payload)) = dropped_on_dir {
handle_drop(
state,
tab_idx,
queue,
registry,
rt_handle,
&payload,
&target_entry.path,
);
}
if let Some(name) = double_clicked {
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),
}
};
open_entry(state, tab_idx, registry, rt_handle, queue, &name);
}
if is_dir {
state.tabs[tab_idx].remote_path = nav_path;
if !state.tabs[tab_idx].loading {
trigger_list(state, tab_idx, registry, rt_handle);
}
}
if let Some((entry, action)) = context_action {
handle_row_action(
ui.ctx(),
state,
tab_idx,
registry,
rt_handle,
queue,
&entry,
action,
);
}
// Footer
render_footer(ui, state, tab_idx, registry, rt_handle);
}
fn build_context_menu(ui: &mut Ui, entry: &FileEntry) -> Option<RowAction> {
if context_menu_item(ui, Icon::Folder, "Open", false) {
return Some(RowAction::Open);
}
if entry.name != ".." {
if context_menu_item(ui, Icon::Pen, "Rename", false) {
return Some(RowAction::Rename);
}
if context_menu_item(ui, Icon::Trash, "Delete", true) {
return Some(RowAction::Delete);
}
ui.separator();
if context_menu_item(ui, Icon::Document, "Copy Path", false) {
return Some(RowAction::CopyPath);
}
if entry.kind == EntryKind::File
&& context_menu_item(ui, Icon::Download, "Download to Local", false)
{
return Some(RowAction::Transfer);
}
}
None
}
fn open_entry(
state: &mut AppState,
tab_idx: usize,
registry: &Arc<RemoteRegistry>,
rt_handle: &tokio::runtime::Handle,
queue: &TransferQueue,
name: &str,
) {
let (nav_path, is_dir) = if name == ".." {
let parent = remote_parent(&state.tabs[tab_idx].remote_path);
(parent, true)
} else {
let entry = state.tabs[tab_idx]
.remote_entries
.iter()
.find(|e| e.name == name);
match entry {
Some(e) if e.kind == EntryKind::Dir => (e.path.clone(), true),
_ => (state.tabs[tab_idx].remote_path.clone(), false),
}
};
if is_dir {
state.tabs[tab_idx].remote_path = nav_path;
if !state.tabs[tab_idx].loading {
trigger_list(state, tab_idx, registry, rt_handle);
}
} else if let Some(entry) = state.tabs[tab_idx]
.remote_entries
.iter()
.find(|e| e.name == name)
.cloned()
{
queue_download(state, tab_idx, queue, &entry);
}
}
fn queue_download(state: &mut AppState, tab_idx: usize, queue: &TransferQueue, entry: &FileEntry) {
let connection_id = state.tabs[tab_idx].id.clone();
let task = TransferTask::new(
TransferKind::Download,
connection_id,
format!("{}/{}", state.local_path.trim_end_matches('/'), entry.name),
entry.path.clone(),
entry.name.clone(),
0,
);
queue.push(task);
state.status_message = format!("Download queued: {}", entry.name);
}
#[allow(clippy::too_many_arguments)]
fn handle_row_action(
ctx: &egui::Context,
state: &mut AppState,
tab_idx: usize,
registry: &Arc<RemoteRegistry>,
rt_handle: &tokio::runtime::Handle,
queue: &TransferQueue,
entry: &FileEntry,
action: RowAction,
) {
match action {
RowAction::Open => open_entry(state, tab_idx, registry, rt_handle, queue, &entry.name),
RowAction::Rename => {
state.op_target = Pane::Remote;
state.show_rename_dialog = true;
state.rename_old_name = entry.name.clone();
state.rename_new_name = entry.name.clone();
}
RowAction::Delete => {
state.op_target = Pane::Remote;
state.show_delete_dialog = true;
state.delete_name = entry.name.clone();
}
RowAction::CopyPath => {
ctx.copy_text(entry.path.clone());
state.status_message = "Path copied".into();
}
RowAction::Transfer => queue_download(state, tab_idx, queue, entry),
RowAction::Bookmark => {}
}
}
fn render_path_bar(
ui: &mut Ui,
state: &mut AppState,
@ -155,63 +336,73 @@ fn render_path_bar(
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,
});
.fill(BG_BASE)
.inner_margin(egui::Margin::symmetric(12.0, 0.0));
frame.show(ui, |ui| {
ui.horizontal(|ui| {
// статус точка
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);
let width = ui.available_width();
ui.allocate_ui_with_layout(
egui::vec2(width, 30.0),
egui::Layout::left_to_right(egui::Align::Center),
|ui| {
let host = state.tabs[tab_idx].params.host.clone();
ui.label(
RichText::new(format!("REMOTE · {}", host))
.color(TEXT_DIM)
.size(10.5)
.strong(),
);
ui.add_space(10.0);
// Хлебные крошки для 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();
// статус точка
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);
// 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);
}
}
// Хлебные крошки для 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();
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);
}
// 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);
}
ui.label(RichText::new("/").color(TEXT_HINT).size(12.0));
}
}
});
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));
}
}
},
);
});
}
@ -253,7 +444,7 @@ fn render_footer(
// 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))
let refresh = egui::Button::image(icons::image(Icon::Refresh, 13.0, ACCENT))
.fill(egui::Color32::TRANSPARENT)
.min_size(egui::vec2(20.0, 20.0));
if ui.add(refresh).on_hover_text("Refresh").clicked()

View file

@ -1,10 +1,12 @@
//! Левая боковая панель — bookmarks + места
use crate::ui::icons::{self, Icon};
use crate::ui::panels::local_pane::refresh_local;
use crate::ui::state::AppState;
use crate::ui::theme::*;
use egui::{RichText, Ui};
use std::sync::{Arc, Mutex};
pub fn render(ui: &mut Ui, state: &mut AppState) {
pub fn render(ui: &mut Ui, state: &mut AppState, db: &Arc<Mutex<rusqlite::Connection>>) {
let frame = egui::Frame::none()
.fill(BG_PANEL)
.inner_margin(egui::Margin::symmetric(0.0, 0.0));
@ -20,11 +22,11 @@ pub fn render(ui: &mut Ui, state: &mut AppState) {
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();
for qa in &state.quick_access.clone() {
let is_active = state.local_path == qa.path;
let icon = quick_access_icon(&qa.name);
sidebar_item(ui, icon, &qa.name, is_active, || {
state.local_path = qa.path.clone();
refresh_local(state);
});
}
@ -38,7 +40,7 @@ pub fn render(ui: &mut Ui, state: &mut AppState) {
// Root + data drives
let root_active = state.local_path == "/";
sidebar_item(ui, "🖥", "/ (root)", root_active, || {
sidebar_item(ui, Icon::Ssd, "/ (root)", root_active, || {
state.local_path = "/".into();
refresh_local(state);
});
@ -50,7 +52,7 @@ pub fn render(ui: &mut Ui, state: &mut AppState) {
.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, || {
sidebar_item(ui, Icon::Database, &label, is_active, || {
state.local_path = mount_path.clone();
refresh_local(state);
});
@ -63,9 +65,7 @@ pub fn render(ui: &mut Ui, state: &mut AppState) {
section_header(ui, "BOOKMARKS");
ui.add_space(4.0);
// Пользовательские закладки (после 3 дефолтных)
let custom: Vec<_> = state.bookmarks.iter().skip(3).cloned().collect();
if custom.is_empty() {
if state.bookmarks.is_empty() {
ui.label(
RichText::new(" No bookmarks yet")
.color(TEXT_HINT)
@ -77,13 +77,26 @@ pub fn render(ui: &mut Ui, state: &mut AppState) {
.size(10.0),
);
} else {
for bm in &custom {
let mut to_remove: Option<i64> = None;
for bm in &state.bookmarks.clone() {
let is_active = state.local_path == bm.path;
sidebar_item(ui, "📌", &bm.name, is_active, || {
sidebar_item(ui, Icon::Star, &bm.name, is_active, || {
state.local_path = bm.path.clone();
refresh_local(state);
})
.context_menu(|ui| {
if ui.button("Remove bookmark").clicked() {
to_remove = Some(bm.id);
ui.close_menu();
}
});
}
if let Some(id) = to_remove {
state.bookmarks.retain(|b| b.id != id);
if let Ok(conn) = db.lock() {
let _ = crate::storage::db::remove_bookmark(&conn, id);
}
}
}
ui.add_space(8.0);
@ -93,8 +106,8 @@ pub fn render(ui: &mut Ui, state: &mut AppState) {
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());
ui.add_space(14.0);
ui.label(RichText::new(text).color(TEXT_HINT).size(10.5).strong());
});
}
@ -103,51 +116,54 @@ fn section_divider(ui: &mut Ui) {
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()) {
fn sidebar_item(
ui: &mut Ui,
icon: Icon,
label: &str,
active: bool,
mut on_click: impl FnMut(),
) -> egui::Response {
let bg = if active {
BG_ROW_SEL
} else {
egui::Color32::TRANSPARENT
};
let text_col = if active {
egui::Color32::WHITE
} else {
TEXT_PRIMARY
};
let text_col = TEXT_PRIMARY;
let icon_col = if active { ACCENT } else { TEXT_DIM };
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,
})
.rounding(RADIUS_SM)
.inner_margin(egui::Margin::symmetric(8.0, 0.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));
});
ui.allocate_ui_with_layout(
egui::vec2(SIDEBAR_W - 32.0, 28.0),
egui::Layout::left_to_right(egui::Align::Center),
|ui| {
icons::icon(ui, icon, 14.0, icon_col);
ui.add_space(8.0);
ui.label(RichText::new(label).color(text_col).size(12.5));
},
);
});
if resp.response.interact(egui::Sense::click()).clicked() {
let response = resp.response.interact(egui::Sense::click());
if response.clicked() {
on_click();
}
response
}
fn bookmark_icon(name: &str) -> &'static str {
fn quick_access_icon(name: &str) -> Icon {
match name {
"Home" => "🏠",
"Desktop" => "🖥",
"Documents" => "📄",
"Downloads" => "⬇",
"Pictures" => "🖼",
"Music" => "🎵",
"Videos" => "🎬",
_ => "📁",
"Home" => Icon::Home,
"Desktop" => Icon::Monitor,
"Documents" => Icon::Document,
"Downloads" => Icon::DownloadSquare,
"Pictures" => Icon::Gallery,
"Music" => Icon::MusicNote,
"Videos" => Icon::Videocamera,
_ => Icon::Folder,
}
}

View file

@ -1,4 +1,5 @@
use crate::domain::connection::ConnectionStatus;
use crate::ui::icons::{self, Icon};
use crate::ui::panels::file_pane::format_size;
use crate::ui::state::AppState;
use crate::ui::theme::*;
@ -10,56 +11,61 @@ pub fn render(ui: &mut egui::Ui, state: &AppState) {
.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),
);
let width = ui.available_width();
ui.allocate_ui_with_layout(
egui::vec2(width, STATUS_H),
egui::Layout::left_to_right(egui::Align::Center),
|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();
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);
}
if agg > 0 {
ui.label(
RichText::new(format!("{}/s", format_size(Some(agg))))
.color(GREEN)
.monospace()
.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();
// соединения
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),
);
}
});
});
if total > 0 {
let dot_col = if connected > 0 { GREEN } else { TEXT_HINT };
if connected > 0 {
icons::icon(ui, Icon::LockPassword, 11.0, GREEN);
ui.add_space(4.0);
}
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

@ -14,92 +14,96 @@ pub fn render(ui: &mut Ui, state: &mut AppState) {
});
frame.show(ui, |ui| {
ui.set_min_height(TABBAR_H);
ui.horizontal(|ui| {
let mut to_close: Option<usize> = None;
let width = ui.available_width();
ui.allocate_ui_with_layout(
egui::vec2(width, TABBAR_H),
egui::Layout::left_to_right(egui::Align::Center),
|ui| {
let mut to_close: Option<usize> = None;
if state.tabs.is_empty() {
ui.label(RichText::new("No connections").color(TEXT_HINT).size(11.0));
}
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;
for (i, tab) in state.tabs.iter().enumerate() {
let is_active = i == state.active_tab;
let (dot_col, dot) = match &tab.status {
ConnectionStatus::Connected => (GREEN, "●"),
ConnectionStatus::Disconnected => (TEXT_HINT, "○"),
ConnectionStatus::Connecting => (YELLOW, "◐"),
ConnectionStatus::Error(_) => (RED, "×"),
};
let (dot_col, dot) = match &tab.status {
ConnectionStatus::Connected => (GREEN, "●"),
ConnectionStatus::Disconnected => (TEXT_HINT, "○"),
ConnectionStatus::Connecting => (YELLOW, "◐"),
ConnectionStatus::Error(_) => (RED, "×"),
};
let bg = if is_active {
BG_TAB_ACTIVE
} else {
BG_TAB_IDLE
};
let tcol = if is_active { TEXT_PRIMARY } else { TEXT_DIM };
let 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))
let tab_response = egui::Frame::none()
.fill(bg)
.rounding(RADIUS_MD)
.inner_margin(egui::Margin {
left: 10.0,
right: 4.0,
top: 0.0,
bottom: 0.0,
})
.show(ui, |ui| {
ui.allocate_ui_with_layout(
egui::vec2(ui.available_width().min(220.0), 24.0),
egui::Layout::left_to_right(egui::Align::Center),
|ui| {
// цветная точка статуса
ui.label(RichText::new(dot).color(dot_col).size(9.0));
ui.add_space(4.0);
// название вкладки
let lbl = egui::Label::new(
RichText::new(&tab.label).color(tcol).size(12.0),
)
.sense(egui::Sense::click());
if ui.add(lbl).clicked() {
// обрабатывается ниже
}
ui.add_space(6.0);
// кнопка закрытия
let close =
egui::Label::new(RichText::new("×").color(TEXT_HINT).size(14.0))
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 ui.add(close).clicked() {
to_close = Some(i);
}
ui.add_space(4.0);
},
);
});
});
if tab_response.response.clicked() {
state.active_tab = i;
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;
// кнопка "+"
ui.add_space(4.0);
let plus = egui::Button::new(RichText::new("+").color(TEXT_DIM).size(15.0))
.fill(Color32::TRANSPARENT)
.rounding(RADIUS_MD)
.min_size(egui::vec2(24.0, 24.0));
if ui.add(plus).clicked() {
state.show_connect_dialog = true;
}
}
});
if let Some(idx) = to_close {
state.tabs.remove(idx);
if !state.tabs.is_empty() && state.active_tab >= state.tabs.len() {
state.active_tab = state.tabs.len() - 1;
}
}
},
);
});
// нижняя граница таббара

View file

@ -1,105 +1,166 @@
use crate::ui::state::AppState;
use crate::domain::transfer::{TransferKind, TransferTask};
use crate::transfer::queue::TransferQueue;
use crate::ui::icons::{self, Icon};
use crate::ui::state::{AppState, Pane};
use crate::ui::theme::*;
use egui::{Align, Layout, RichText, Ui};
pub fn render(ui: &mut Ui, state: &mut AppState) {
pub fn render(ui: &mut Ui, state: &mut AppState, queue: &TransferQueue) {
let frame = egui::Frame::none()
.fill(BG_TOOLBAR)
.inner_margin(egui::Margin::symmetric(8.0, 0.0));
frame.show(ui, |ui| {
ui.set_min_height(TOOLBAR_H);
ui.horizontal_centered(|ui| {
// Логотип
ui.label(
RichText::new("LoFlum")
.color(TEXT_PRIMARY)
.size(14.0)
.strong(),
);
let width = ui.available_width();
ui.allocate_ui_with_layout(
egui::vec2(width, TOOLBAR_H),
Layout::left_to_right(Align::Center),
|ui| {
// Логотип
icons::icon(ui, Icon::Folder, 17.0, ACCENT);
ui.add_space(4.0);
ui.label(
RichText::new("LoFlum")
.color(TEXT_PRIMARY)
.size(14.0)
.strong(),
);
ui.add_space(8.0);
separator_v(ui);
ui.add_space(8.0);
ui.add_space(8.0);
separator_v(ui);
ui.add_space(8.0);
// Новое подключение
toolbar_btn(ui, "+ New Connection", false, || {
state.show_connect_dialog = true;
});
separator_v(ui);
// Кнопки работы с файлами
let has_conn = state
.active_tab_ref()
.map(|t| t.status == crate::domain::connection::ConnectionStatus::Connected)
.unwrap_or(false);
let has_local_sel = state.local_selected.is_some();
let has_remote_sel = state
.active_tab_ref()
.and_then(|t| t.remote_selected.as_ref())
.is_some();
toolbar_btn_enabled(ui, "⬆ 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.show_mkdir_dialog = true;
state.mkdir_name.clear();
});
toolbar_btn_enabled(ui, "× Delete", has_conn && has_remote_sel, || {
if let Some(name) = state
.active_tab_ref()
.and_then(|t| t.remote_selected.clone())
{
state.show_delete_dialog = true;
state.delete_name = name;
// Новое подключение — единственная акцентная кнопка в тулбаре
let connect_btn = egui::Button::image_and_text(
icons::image(Icon::AddCircleLinear, 15.0, TEXT_PRIMARY),
RichText::new("New Connection")
.color(TEXT_PRIMARY)
.size(12.5),
)
.fill(ACCENT_DIM)
.rounding(RADIUS_MD)
.min_size(egui::vec2(0.0, 30.0));
if ui.add(connect_btn).clicked() {
state.show_connect_dialog = true;
}
});
toolbar_btn_enabled(ui, "✎ Rename", has_conn && has_remote_sel, || {
if let Some(name) = state
.active_tab_ref()
.and_then(|t| t.remote_selected.clone())
{
state.show_rename_dialog = true;
state.rename_old_name = name.clone();
state.rename_new_name = name;
}
});
// Правая часть — 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;
separator_v(ui);
// Кнопки работы с файлами
let has_conn = state
.active_tab_ref()
.map(|t| t.status == crate::domain::connection::ConnectionStatus::Connected)
.unwrap_or(false);
let has_local_sel = state.local_selected.is_some();
let has_remote_sel = state
.active_tab_ref()
.and_then(|t| t.remote_selected.as_ref())
.is_some();
toolbar_btn_enabled(
ui,
Icon::Upload,
"Upload",
has_conn && has_local_sel,
|| {
queue_selected_upload(state, queue);
},
);
toolbar_btn_enabled(
ui,
Icon::Download,
"Download",
has_conn && has_remote_sel,
|| {
queue_selected_download(state, queue);
},
);
separator_v(ui);
// Очередь — с счётчиком
let q_count = state.queue_tasks.len();
let q_label = if q_count > 0 {
format!("Queue {}", q_count)
} else {
"Queue".to_string()
};
let q_active = state.show_queue;
toolbar_btn(ui, Icon::Clock, &q_label, q_active, || {
state.show_queue = !state.show_queue;
});
});
});
separator_v(ui);
let on_remote = state.active_pane == Pane::Remote;
let can_new_folder = if on_remote { has_conn } else { true };
let can_delete_rename = if on_remote {
has_conn && has_remote_sel
} else {
has_local_sel
};
toolbar_icon_btn(ui, Icon::Refresh, has_conn, "Refresh", || {
// refresh remote — вызывается снаружи через state флаг
state.pending_refresh = true;
});
toolbar_icon_btn(
ui,
Icon::FolderWithFiles,
can_new_folder,
"New Folder",
|| {
state.op_target = state.active_pane;
state.show_mkdir_dialog = true;
state.mkdir_name.clear();
},
);
toolbar_icon_btn(ui, Icon::Trash, can_delete_rename, "Delete", || {
let name = if on_remote {
state
.active_tab_ref()
.and_then(|t| t.remote_selected.clone())
} else {
state.local_selected.clone()
};
if let Some(name) = name {
state.op_target = state.active_pane;
state.show_delete_dialog = true;
state.delete_name = name;
}
});
toolbar_icon_btn(ui, Icon::Pen, can_delete_rename, "Rename", || {
let name = if on_remote {
state
.active_tab_ref()
.and_then(|t| t.remote_selected.clone())
} else {
state.local_selected.clone()
};
if let Some(name) = name {
state.op_target = state.active_pane;
state.show_rename_dialog = true;
state.rename_old_name = name.clone();
state.rename_new_name = name;
}
});
// Правая часть — History / Bookmarks / Settings
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
// На macOS кнопка настроек живёт в нативном верхнем меню
// (LoFlum → Settings…), здесь она не нужна.
#[cfg(not(target_os = "macos"))]
toolbar_icon_btn(ui, Icon::Settings, true, "Settings", || {
state.show_settings_dialog = true;
});
toolbar_btn(ui, Icon::History, "History", state.show_history, || {
state.show_history = !state.show_history;
state.show_bookmarks = false;
});
});
},
);
});
// История поверх
@ -120,17 +181,22 @@ fn render_history_popup(ui: &mut Ui, state: &mut AppState) {
.order(egui::Order::Foreground)
.show(ui.ctx(), |ui| {
egui::Frame::none()
.fill(egui::Color32::from_rgb(32, 32, 36))
.fill(BG_PANEL)
.stroke(egui::Stroke::new(1.0, BORDER))
.rounding(6.0)
.inner_margin(egui::Margin::same(8.0))
.rounding(RADIUS_LG)
.inner_margin(egui::Margin::same(10.0))
.show(ui, |ui| {
ui.set_width(260.0);
ui.label(
RichText::new("Recent Connections")
.color(TEXT_DIM)
.size(11.0),
);
ui.horizontal(|ui| {
icons::icon(ui, Icon::ServerSquare, 12.0, TEXT_DIM);
ui.add_space(6.0);
ui.label(
RichText::new("Recent connections")
.color(TEXT_DIM)
.size(11.0)
.strong(),
);
});
ui.add_space(4.0);
if state.history.is_empty() {
ui.label(RichText::new("No history yet").color(TEXT_HINT));
@ -143,24 +209,30 @@ fn render_history_popup(ui: &mut Ui, state: &mut AppState) {
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)),
let btn = ui.add(
egui::Button::new(
RichText::new(&label).color(TEXT_PRIMARY).size(12.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;
.fill(egui::Color32::TRANSPARENT)
.min_size(egui::vec2(240.0, 24.0)),
);
if btn.clicked() {
state.pending_history_reconnect = Some(entry.clone());
state.show_history = false;
}
btn.context_menu(|ui| {
if ui.button("Edit").clicked() {
crate::ui::dialogs::connection::edit_history_entry(
state, entry,
);
state.show_history = false;
ui.close_menu();
}
if ui.button("Save").clicked() {
state.pending_history_save = Some(entry.clone());
ui.close_menu();
}
});
ui.label(time);
}
});
@ -179,25 +251,53 @@ fn render_history_popup(ui: &mut Ui, state: &mut AppState) {
});
}
fn toolbar_btn(ui: &mut Ui, label: &str, active: bool, mut on_click: impl FnMut()) {
fn toolbar_btn(ui: &mut Ui, icon: Icon, label: &str, active: bool, mut on_click: impl FnMut()) {
let fill = if active {
BG_TAB_ACTIVE
} else {
egui::Color32::TRANSPARENT
};
let text_col = if active { ACCENT } else { TEXT_PRIMARY };
let btn = egui::Button::new(RichText::new(label).color(text_col).size(12.0))
.fill(fill)
.rounding(4.0)
.min_size(egui::vec2(0.0, 26.0));
let btn = egui::Button::image_and_text(
icons::image(icon, 15.0, text_col),
RichText::new(label).color(text_col).size(12.5),
)
.fill(fill)
.rounding(RADIUS_MD)
.min_size(egui::vec2(0.0, 30.0));
if ui.add(btn).clicked() {
on_click();
}
}
fn toolbar_btn_enabled(ui: &mut Ui, label: &str, enabled: bool, on_click: impl FnMut()) {
fn toolbar_btn_enabled(
ui: &mut Ui,
icon: Icon,
label: &str,
enabled: bool,
on_click: impl FnMut(),
) {
ui.add_enabled_ui(enabled, |ui| {
toolbar_btn(ui, label, false, on_click);
toolbar_btn(ui, icon, label, false, on_click);
});
}
fn toolbar_icon_btn(
ui: &mut Ui,
icon: Icon,
enabled: bool,
hover: &str,
mut on_click: impl FnMut(),
) {
ui.add_enabled_ui(enabled, |ui| {
let col = if enabled { TEXT_DIM } else { TEXT_HINT };
let btn = egui::Button::image(icons::image(icon, 16.0, col))
.fill(egui::Color32::TRANSPARENT)
.rounding(RADIUS_MD)
.min_size(egui::vec2(30.0, 30.0));
if ui.add(btn).on_hover_text(hover).clicked() {
on_click();
}
});
}
@ -205,3 +305,51 @@ fn separator_v(ui: &mut Ui) {
let (_, rect) = ui.allocate_space(egui::vec2(1.0, 20.0));
ui.painter().rect_filled(rect, 0.0, BORDER);
}
fn queue_selected_upload(state: &mut AppState, queue: &TransferQueue) {
let Some(name) = state.local_selected.clone() else {
return;
};
let Some(tab) = state.active_tab_ref() else {
return;
};
let connection_id = tab.id.clone();
let remote_dir = tab.remote_path.clone();
let local_path = format!("{}/{}", state.local_path.trim_end_matches('/'), name);
let remote_path = format!("{}/{}", remote_dir.trim_end_matches('/'), name);
let task = TransferTask::new(
TransferKind::Upload,
connection_id,
local_path,
remote_path,
name.clone(),
0,
);
queue.push(task);
state.status_message = format!("Upload queued: {}", name);
}
fn queue_selected_download(state: &mut AppState, queue: &TransferQueue) {
let Some(tab) = state.active_tab_ref() else {
return;
};
let Some(name) = tab.remote_selected.clone() else {
return;
};
let Some(entry) = tab.remote_entries.iter().find(|e| e.name == name) else {
return;
};
let connection_id = tab.id.clone();
let remote_path = entry.path.clone();
let local_path = format!("{}/{}", state.local_path.trim_end_matches('/'), name);
let task = TransferTask::new(
TransferKind::Download,
connection_id,
local_path,
remote_path,
name.clone(),
0,
);
queue.push(task);
state.status_message = format!("Download queued: {}", name);
}