Хуйня не на tauri
This commit is contained in:
parent
e895fe3237
commit
403d7086d4
104 changed files with 8941 additions and 4772 deletions
|
|
@ -1,398 +0,0 @@
|
|||
//! Переиспользуемая таблица файлов с нормальными колонками
|
||||
use crate::domain::file_entry::{EntryKind, FileEntry};
|
||||
use crate::ui::drag::DragPayload;
|
||||
use crate::ui::icons::{self, Icon, file_icon_for};
|
||||
use crate::ui::theme::*;
|
||||
use egui::{Color32, Id, RichText, Ui};
|
||||
|
||||
pub fn format_size(bytes: Option<u64>) -> String {
|
||||
match bytes {
|
||||
None => String::new(),
|
||||
Some(b) if b < 1024 => format!("{} B", b),
|
||||
Some(b) if b < 1024 * 1024 => format!("{:.1} KB", b as f64 / 1024.0),
|
||||
Some(b) if b < 1024 * 1024 * 1024 => format!("{:.1} MB", b as f64 / (1024.0 * 1024.0)),
|
||||
Some(b) => format!("{:.2} GB", b as f64 / (1024.0 * 1024.0 * 1024.0)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_time(ts: Option<i64>) -> String {
|
||||
match ts.and_then(|t| chrono::DateTime::from_timestamp(t, 0)) {
|
||||
Some(dt) => dt.format("%d.%m.%Y %H:%M").to_string(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum SortCol {
|
||||
Name,
|
||||
Size,
|
||||
Kind,
|
||||
Modified,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum SortDir {
|
||||
Asc,
|
||||
Desc,
|
||||
}
|
||||
|
||||
/// Действие, запрошенное через контекстное меню (ПКМ) на строке.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum RowAction {
|
||||
Open,
|
||||
Rename,
|
||||
Delete,
|
||||
Bookmark,
|
||||
CopyPath,
|
||||
/// Загрузка/выгрузка этой конкретной строки — направление решает вызывающая сторона.
|
||||
Transfer,
|
||||
}
|
||||
|
||||
pub struct FileTableResponse {
|
||||
pub double_clicked: Option<String>,
|
||||
pub clicked: Option<String>,
|
||||
/// Payload, перетащенный на папку (или "..") в этой таблице — (целевая папка, что бросили).
|
||||
pub dropped_on_dir: Option<(FileEntry, std::sync::Arc<DragPayload>)>,
|
||||
/// Действие из контекстного меню (ПКМ) — (строка, действие).
|
||||
pub context_action: Option<(FileEntry, RowAction)>,
|
||||
}
|
||||
|
||||
pub fn file_table<F, C>(
|
||||
ui: &mut Ui,
|
||||
id: &str,
|
||||
entries: &[FileEntry],
|
||||
selected: &mut Option<String>,
|
||||
make_drag_payload: F,
|
||||
context_menu: C,
|
||||
) -> FileTableResponse
|
||||
where
|
||||
F: Fn(&FileEntry) -> Option<DragPayload>,
|
||||
C: FnMut(&mut Ui, &FileEntry) -> Option<RowAction>,
|
||||
{
|
||||
ui.push_id(id, |ui| {
|
||||
file_table_inner(ui, id, entries, selected, make_drag_payload, context_menu)
|
||||
})
|
||||
.inner
|
||||
}
|
||||
|
||||
fn file_table_inner<F, C>(
|
||||
ui: &mut Ui,
|
||||
id: &str,
|
||||
entries: &[FileEntry],
|
||||
selected: &mut Option<String>,
|
||||
make_drag_payload: F,
|
||||
mut context_menu: C,
|
||||
) -> FileTableResponse
|
||||
where
|
||||
F: Fn(&FileEntry) -> Option<DragPayload>,
|
||||
C: FnMut(&mut Ui, &FileEntry) -> Option<RowAction>,
|
||||
{
|
||||
// Сортировка — читаем из egui memory
|
||||
let sort_id = Id::new((id, "sort"));
|
||||
let (sort_col, sort_dir) = ui
|
||||
.ctx()
|
||||
.data_mut(|d| *d.get_temp_mut_or(sort_id, (SortCol::Name, SortDir::Asc)));
|
||||
|
||||
let mut sorted = entries.to_vec();
|
||||
let dotdot_entry = sorted.iter().position(|e| e.name == "..");
|
||||
let dotdot = dotdot_entry.map(|i| sorted.remove(i));
|
||||
|
||||
sorted.sort_by(|a, b| {
|
||||
// папки всегда выше
|
||||
let dir_cmp = match (&b.kind, &a.kind) {
|
||||
(EntryKind::Dir, EntryKind::Dir) => std::cmp::Ordering::Equal,
|
||||
(EntryKind::Dir, _) => std::cmp::Ordering::Greater,
|
||||
(_, EntryKind::Dir) => std::cmp::Ordering::Less,
|
||||
_ => std::cmp::Ordering::Equal,
|
||||
};
|
||||
if dir_cmp != std::cmp::Ordering::Equal {
|
||||
return dir_cmp;
|
||||
}
|
||||
|
||||
let ord = match sort_col {
|
||||
SortCol::Name => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
||||
SortCol::Size => a.size.unwrap_or(0).cmp(&b.size.unwrap_or(0)),
|
||||
SortCol::Kind => format!("{:?}", a.kind).cmp(&format!("{:?}", b.kind)),
|
||||
SortCol::Modified => a.modified.unwrap_or(0).cmp(&b.modified.unwrap_or(0)),
|
||||
};
|
||||
if sort_dir == SortDir::Desc {
|
||||
ord.reverse()
|
||||
} else {
|
||||
ord
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(dd) = dotdot {
|
||||
sorted.insert(0, dd);
|
||||
}
|
||||
|
||||
let mut double_clicked: Option<String> = None;
|
||||
let mut clicked: Option<String> = None;
|
||||
let mut new_sort: Option<(SortCol, SortDir)> = None;
|
||||
let mut dropped_on_dir: Option<(FileEntry, std::sync::Arc<DragPayload>)> = None;
|
||||
let mut context_action: Option<(FileEntry, RowAction)> = None;
|
||||
|
||||
// Заголовки колонок
|
||||
let header_frame = egui::Frame::none()
|
||||
.fill(BG_HEADER)
|
||||
.inner_margin(egui::Margin::symmetric(12.0, 0.0));
|
||||
|
||||
header_frame.show(ui, |ui| {
|
||||
let width = ui.available_width();
|
||||
ui.allocate_ui_with_layout(
|
||||
egui::vec2(width, 24.0),
|
||||
egui::Layout::left_to_right(egui::Align::Center),
|
||||
|ui| {
|
||||
let name_r = sort_header(ui, "Name", 0.0, sort_col == SortCol::Name, sort_dir);
|
||||
if name_r {
|
||||
new_sort = Some(toggle_sort(SortCol::Name, sort_col, sort_dir));
|
||||
}
|
||||
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let mod_r = sort_header(
|
||||
ui,
|
||||
"Modified",
|
||||
140.0,
|
||||
sort_col == SortCol::Modified,
|
||||
sort_dir,
|
||||
);
|
||||
if mod_r {
|
||||
new_sort = Some(toggle_sort(SortCol::Modified, sort_col, sort_dir));
|
||||
}
|
||||
|
||||
let type_r = sort_header(ui, "Type", 40.0, sort_col == SortCol::Kind, sort_dir);
|
||||
if type_r {
|
||||
new_sort = Some(toggle_sort(SortCol::Kind, sort_col, sort_dir));
|
||||
}
|
||||
|
||||
let size_r = sort_header(ui, "Size", 70.0, sort_col == SortCol::Size, sort_dir);
|
||||
if size_r {
|
||||
new_sort = Some(toggle_sort(SortCol::Size, sort_col, sort_dir));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
if let Some(s) = new_sort {
|
||||
ui.ctx().data_mut(|d| {
|
||||
d.insert_temp(sort_id, s);
|
||||
});
|
||||
}
|
||||
|
||||
// разделитель
|
||||
let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0));
|
||||
ui.painter().rect_filled(sep, 0.0, BORDER);
|
||||
|
||||
// Список файлов
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt((id, "scroll"))
|
||||
.auto_shrink([false; 2])
|
||||
.show(ui, |ui| {
|
||||
for entry in &sorted {
|
||||
let is_selected = selected.as_deref() == Some(&entry.name);
|
||||
let row_id = Id::new((id, "row", &entry.name));
|
||||
|
||||
// Строка, которую сейчас тащат — её содержимое рисуется в
|
||||
// плавающем слое поверх курсора (см. dnd_drag_source), поэтому
|
||||
// сама она не может быть целью для дропа, а на исходном месте
|
||||
// не должно оставаться подсветки выбора/ховера — там пусто.
|
||||
let is_being_dragged = ui.ctx().is_being_dragged(row_id);
|
||||
|
||||
let row_rect = egui::Rect::from_min_size(
|
||||
ui.cursor().min,
|
||||
egui::vec2(ui.available_width(), ROW_H),
|
||||
);
|
||||
let is_hovered = !is_being_dragged && ui.rect_contains_pointer(row_rect);
|
||||
let is_drop_target = !is_being_dragged
|
||||
&& entry.kind == EntryKind::Dir
|
||||
&& egui::DragAndDrop::has_payload_of_type::<DragPayload>(ui.ctx());
|
||||
let is_drop_hover = is_drop_target && is_hovered;
|
||||
|
||||
let bg = if is_being_dragged {
|
||||
Color32::TRANSPARENT
|
||||
} else if is_drop_hover {
|
||||
ACCENT_DIM
|
||||
} else if is_selected {
|
||||
BG_ROW_SEL
|
||||
} else if is_hovered {
|
||||
BG_ROW_HOVER
|
||||
} else {
|
||||
Color32::TRANSPARENT
|
||||
};
|
||||
let stroke = if is_drop_hover {
|
||||
egui::Stroke::new(1.5, ACCENT)
|
||||
} else {
|
||||
egui::Stroke::NONE
|
||||
};
|
||||
|
||||
let row_width = ui.available_width();
|
||||
let response = egui::Frame::none()
|
||||
.fill(bg)
|
||||
.stroke(stroke)
|
||||
.rounding(RADIUS_SM)
|
||||
.inner_margin(egui::Margin::symmetric(12.0, 0.0))
|
||||
.show(ui, |ui| {
|
||||
ui.allocate_ui_with_layout(
|
||||
egui::vec2(row_width - 24.0, ROW_H),
|
||||
egui::Layout::left_to_right(egui::Align::Center),
|
||||
|ui| {
|
||||
// иконка (цвет по типу) + имя (единый цвет, как в спеке)
|
||||
let (icon, icon_col) = if entry.name == ".." {
|
||||
(Icon::ArrowUp, TEXT_HINT)
|
||||
} else {
|
||||
match entry.kind {
|
||||
EntryKind::Dir => (Icon::Folder, YELLOW),
|
||||
EntryKind::Symlink => (Icon::Link, YELLOW),
|
||||
EntryKind::File => {
|
||||
let ext = entry
|
||||
.name
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
file_icon_for(&ext)
|
||||
}
|
||||
}
|
||||
};
|
||||
let name_col = if entry.name == ".." {
|
||||
TEXT_HINT
|
||||
} else {
|
||||
TEXT_PRIMARY
|
||||
};
|
||||
|
||||
let draw_row = |ui: &mut Ui| {
|
||||
icons::icon(ui, icon, 14.0, icon_col);
|
||||
ui.add_space(6.0);
|
||||
ui.label(RichText::new(&entry.name).color(name_col).size(12.5));
|
||||
};
|
||||
|
||||
if let Some(payload) = make_drag_payload(entry) {
|
||||
ui.dnd_drag_source(row_id, payload, |ui| {
|
||||
ui.horizontal(draw_row);
|
||||
});
|
||||
} else {
|
||||
draw_row(ui);
|
||||
}
|
||||
|
||||
// правые колонки
|
||||
ui.with_layout(
|
||||
egui::Layout::right_to_left(egui::Align::Center),
|
||||
|ui| {
|
||||
ui.label(
|
||||
RichText::new(format_time(entry.modified))
|
||||
.color(TEXT_HINT)
|
||||
.monospace()
|
||||
.size(10.5),
|
||||
);
|
||||
ui.add_space(6.0);
|
||||
|
||||
let (type_str, type_col) = match entry.kind {
|
||||
EntryKind::Dir => ("Dir", TEXT_DIM),
|
||||
EntryKind::File => ("File", TEXT_HINT),
|
||||
EntryKind::Symlink => ("Link", YELLOW),
|
||||
};
|
||||
let type_label = egui::Label::new(
|
||||
RichText::new(type_str).color(type_col).size(11.0),
|
||||
)
|
||||
.truncate();
|
||||
ui.add_sized([38.0, ROW_H], type_label);
|
||||
|
||||
ui.label(
|
||||
RichText::new(format_size(entry.size))
|
||||
.color(TEXT_DIM)
|
||||
.monospace()
|
||||
.size(11.0),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
})
|
||||
.response;
|
||||
|
||||
// Frame::show() only senses hover, not clicks — add a real click
|
||||
// sense on the full row rect (separate id from the drag source,
|
||||
// which only senses drag over the icon+name sub-area). Without
|
||||
// this, every press was interpreted as a drag start and plain
|
||||
// clicks/double-clicks never fired.
|
||||
let click_response =
|
||||
ui.interact(response.rect, row_id.with("click"), egui::Sense::click());
|
||||
let response = response | click_response;
|
||||
|
||||
if response.clicked() {
|
||||
*selected = Some(entry.name.clone());
|
||||
clicked = Some(entry.name.clone());
|
||||
}
|
||||
if response.double_clicked() {
|
||||
double_clicked = Some(entry.name.clone());
|
||||
}
|
||||
if is_drop_target
|
||||
&& let Some(payload) = response.dnd_release_payload::<DragPayload>()
|
||||
{
|
||||
dropped_on_dir = Some((entry.clone(), payload));
|
||||
}
|
||||
|
||||
if entry.name != ".." {
|
||||
// Правый клик выбирает строку — иначе меню открывается
|
||||
// над предыдущим выделением, что сбивает с толку.
|
||||
if response.secondary_clicked() {
|
||||
*selected = Some(entry.name.clone());
|
||||
}
|
||||
response.context_menu(|ui| {
|
||||
if let Some(action) = context_menu(ui, entry) {
|
||||
context_action = Some((entry.clone(), action));
|
||||
ui.close_menu();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
FileTableResponse {
|
||||
double_clicked,
|
||||
clicked,
|
||||
dropped_on_dir,
|
||||
context_action,
|
||||
}
|
||||
}
|
||||
|
||||
/// Единообразная кнопка-пункт для контекстных меню (ПКМ на строке).
|
||||
pub fn context_menu_item(ui: &mut Ui, icon: Icon, label: &str, danger: bool) -> bool {
|
||||
let color = if danger { RED } else { TEXT_PRIMARY };
|
||||
let btn = egui::Button::image_and_text(
|
||||
icons::image(icon, 13.0, color),
|
||||
RichText::new(label).size(12.0).color(color),
|
||||
)
|
||||
.fill(Color32::TRANSPARENT)
|
||||
.min_size(egui::vec2(170.0, 26.0));
|
||||
ui.add(btn).clicked()
|
||||
}
|
||||
|
||||
fn sort_header(ui: &mut Ui, label: &str, min_w: f32, active: bool, dir: SortDir) -> bool {
|
||||
let arrow = if active {
|
||||
if dir == SortDir::Asc { " ↑" } else { " ↓" }
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let full = format!("{}{}", label, arrow);
|
||||
let col = if active { ACCENT } else { TEXT_DIM };
|
||||
let btn = egui::Button::new(RichText::new(full).color(col).size(10.5).strong())
|
||||
.fill(Color32::TRANSPARENT)
|
||||
.min_size(egui::vec2(min_w, 20.0));
|
||||
ui.add(btn).clicked()
|
||||
}
|
||||
|
||||
fn toggle_sort(col: SortCol, cur_col: SortCol, cur_dir: SortDir) -> (SortCol, SortDir) {
|
||||
if col == cur_col {
|
||||
let new_dir = if cur_dir == SortDir::Asc {
|
||||
SortDir::Desc
|
||||
} else {
|
||||
SortDir::Asc
|
||||
};
|
||||
(col, new_dir)
|
||||
} else {
|
||||
(col, SortDir::Asc)
|
||||
}
|
||||
}
|
||||
93
src/ui/panels/file_pane/mod.rs
Normal file
93
src/ui/panels/file_pane/mod.rs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
//! Переиспользуемая таблица файлов: публичный API, типы и форматтеры.
|
||||
//! Рендер строк — в [`table`], сортировка — в [`sort`].
|
||||
use crate::domain::file_entry::FileEntry;
|
||||
use crate::ui::drag::DragPayload;
|
||||
use crate::ui::icons::{self, Icon};
|
||||
use crate::ui::theme::*;
|
||||
use crate::ui::widgets::button;
|
||||
use egui::{RichText, Ui};
|
||||
|
||||
mod row;
|
||||
mod sort;
|
||||
mod table;
|
||||
|
||||
pub fn format_size(bytes: Option<u64>) -> String {
|
||||
match bytes {
|
||||
None => String::new(),
|
||||
Some(b) if b < 1024 => format!("{} B", b),
|
||||
Some(b) if b < 1024 * 1024 => format!("{:.1} KB", b as f64 / 1024.0),
|
||||
Some(b) if b < 1024 * 1024 * 1024 => format!("{:.1} MB", b as f64 / (1024.0 * 1024.0)),
|
||||
Some(b) => format!("{:.2} GB", b as f64 / (1024.0 * 1024.0 * 1024.0)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_time(ts: Option<i64>) -> String {
|
||||
match ts.and_then(|t| chrono::DateTime::from_timestamp(t, 0)) {
|
||||
Some(dt) => dt.format("%d.%m.%Y %H:%M").to_string(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum SortCol {
|
||||
Name,
|
||||
Size,
|
||||
Kind,
|
||||
Modified,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum SortDir {
|
||||
Asc,
|
||||
Desc,
|
||||
}
|
||||
|
||||
/// Действие, запрошенное через контекстное меню (ПКМ) на строке.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum RowAction {
|
||||
Open,
|
||||
Rename,
|
||||
Delete,
|
||||
Bookmark,
|
||||
CopyPath,
|
||||
/// Загрузка/выгрузка этой конкретной строки — направление решает вызывающая сторона.
|
||||
Transfer,
|
||||
}
|
||||
|
||||
pub struct FileTableResponse {
|
||||
pub double_clicked: Option<String>,
|
||||
pub clicked: Option<String>,
|
||||
/// Payload, перетащенный на папку (или "..") в этой таблице — (целевая папка, что бросили).
|
||||
pub dropped_on_dir: Option<(FileEntry, std::sync::Arc<DragPayload>)>,
|
||||
/// Действие из контекстного меню (ПКМ) — (строка, действие).
|
||||
pub context_action: Option<(FileEntry, RowAction)>,
|
||||
}
|
||||
|
||||
pub fn file_table<F, C>(
|
||||
ui: &mut Ui,
|
||||
id: &str,
|
||||
entries: &[FileEntry],
|
||||
selected: &mut Option<String>,
|
||||
make_drag_payload: F,
|
||||
context_menu: C,
|
||||
) -> FileTableResponse
|
||||
where
|
||||
F: Fn(&FileEntry) -> Option<DragPayload>,
|
||||
C: FnMut(&mut Ui, &FileEntry) -> Option<RowAction>,
|
||||
{
|
||||
ui.push_id(id, |ui| {
|
||||
table::file_table_inner(ui, id, entries, selected, make_drag_payload, context_menu)
|
||||
})
|
||||
.inner
|
||||
}
|
||||
|
||||
/// Единообразный пункт для контекстных меню (ПКМ на строке) с подсветкой на hover.
|
||||
pub fn context_menu_item(ui: &mut Ui, icon: Icon, label: &str, danger: bool) -> bool {
|
||||
let color = if danger { RED } else { TEXT_PRIMARY };
|
||||
let btn = egui::Button::image_and_text(
|
||||
icons::image(icon, 13.0, color),
|
||||
RichText::new(label).size(12.0).color(color),
|
||||
)
|
||||
.min_size(egui::vec2(170.0, 26.0));
|
||||
button::ghost(ui, btn).clicked()
|
||||
}
|
||||
120
src/ui/panels/file_pane/row.rs
Normal file
120
src/ui/panels/file_pane/row.rs
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
//! Отрисовка шапки колонок и содержимого одной строки таблицы файлов.
|
||||
use egui::{Id, RichText, Ui};
|
||||
|
||||
use super::sort::{sort_header, toggle_sort};
|
||||
use super::{SortCol, SortDir, format_size, format_time};
|
||||
use crate::domain::file_entry::{EntryKind, FileEntry};
|
||||
use crate::ui::drag::DragPayload;
|
||||
use crate::ui::icons::{self, Icon, file_icon_for};
|
||||
use crate::ui::theme::*;
|
||||
|
||||
/// Шапка с кликабельными заголовками колонок.
|
||||
pub(super) fn render_header(
|
||||
ui: &mut Ui,
|
||||
sort_col: SortCol,
|
||||
sort_dir: SortDir,
|
||||
new_sort: &mut Option<(SortCol, SortDir)>,
|
||||
) {
|
||||
let header_frame = egui::Frame::none()
|
||||
.fill(BG_HEADER)
|
||||
.inner_margin(egui::Margin::symmetric(12.0, 0.0));
|
||||
|
||||
header_frame.show(ui, |ui| {
|
||||
let width = ui.available_width();
|
||||
ui.allocate_ui_with_layout(
|
||||
egui::vec2(width, 24.0),
|
||||
egui::Layout::left_to_right(egui::Align::Center),
|
||||
|ui| {
|
||||
use crate::i18n::t;
|
||||
if sort_header(ui, t("panels.col_name"), 0.0, sort_col == SortCol::Name, sort_dir)
|
||||
{
|
||||
*new_sort = Some(toggle_sort(SortCol::Name, sort_col, sort_dir));
|
||||
}
|
||||
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if sort_header(
|
||||
ui,
|
||||
t("panels.col_modified"),
|
||||
140.0,
|
||||
sort_col == SortCol::Modified,
|
||||
sort_dir,
|
||||
) {
|
||||
*new_sort = Some(toggle_sort(SortCol::Modified, sort_col, sort_dir));
|
||||
}
|
||||
if sort_header(ui, t("panels.col_type"), 40.0, sort_col == SortCol::Kind, sort_dir) {
|
||||
*new_sort = Some(toggle_sort(SortCol::Kind, sort_col, sort_dir));
|
||||
}
|
||||
if sort_header(ui, t("panels.col_size"), 70.0, sort_col == SortCol::Size, sort_dir) {
|
||||
*new_sort = Some(toggle_sort(SortCol::Size, sort_col, sort_dir));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Содержимое одной строки: иконка+имя (drag source) слева, метаданные справа.
|
||||
pub(super) fn draw_row_content<F>(ui: &mut Ui, entry: &FileEntry, row_id: Id, make_drag_payload: &F)
|
||||
where
|
||||
F: Fn(&FileEntry) -> Option<DragPayload>,
|
||||
{
|
||||
let (icon, icon_col) = if entry.name == ".." {
|
||||
(Icon::ArrowUp, TEXT_HINT)
|
||||
} else {
|
||||
match entry.kind {
|
||||
EntryKind::Dir => (Icon::Folder, YELLOW),
|
||||
EntryKind::Symlink => (Icon::Link, YELLOW),
|
||||
EntryKind::File => {
|
||||
let ext = entry.name.rsplit('.').next().unwrap_or("").to_lowercase();
|
||||
file_icon_for(&ext)
|
||||
}
|
||||
}
|
||||
};
|
||||
let name_col = if entry.name == ".." {
|
||||
TEXT_HINT
|
||||
} else {
|
||||
TEXT_PRIMARY
|
||||
};
|
||||
|
||||
let draw = |ui: &mut Ui| {
|
||||
icons::icon(ui, icon, 14.0, icon_col);
|
||||
ui.add_space(6.0);
|
||||
ui.label(RichText::new(&entry.name).color(name_col).size(12.5));
|
||||
};
|
||||
|
||||
if let Some(payload) = make_drag_payload(entry) {
|
||||
ui.dnd_drag_source(row_id, payload, |ui| {
|
||||
ui.horizontal(draw);
|
||||
});
|
||||
} else {
|
||||
draw(ui);
|
||||
}
|
||||
|
||||
// правые колонки
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label(
|
||||
RichText::new(format_time(entry.modified))
|
||||
.color(TEXT_HINT)
|
||||
.monospace()
|
||||
.size(10.5),
|
||||
);
|
||||
ui.add_space(6.0);
|
||||
|
||||
let (type_str, type_col) = match entry.kind {
|
||||
EntryKind::Dir => (crate::i18n::t("panels.type_dir"), TEXT_DIM),
|
||||
EntryKind::File => (crate::i18n::t("panels.type_file"), TEXT_HINT),
|
||||
EntryKind::Symlink => (crate::i18n::t("panels.type_link"), YELLOW),
|
||||
};
|
||||
let type_label =
|
||||
egui::Label::new(RichText::new(type_str).color(type_col).size(11.0)).truncate();
|
||||
ui.add_sized([38.0, ROW_H], type_label);
|
||||
|
||||
ui.label(
|
||||
RichText::new(format_size(entry.size))
|
||||
.color(TEXT_DIM)
|
||||
.monospace()
|
||||
.size(11.0),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
});
|
||||
}
|
||||
81
src/ui/panels/file_pane/sort.rs
Normal file
81
src/ui/panels/file_pane/sort.rs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
//! Сортировка таблицы файлов: заголовки-переключатели и сам компаратор.
|
||||
use egui::{RichText, Ui};
|
||||
|
||||
use super::{SortCol, SortDir};
|
||||
use crate::domain::file_entry::{EntryKind, FileEntry};
|
||||
use crate::ui::theme::*;
|
||||
use crate::ui::widgets::button;
|
||||
|
||||
/// Сортирует записи по колонке/направлению, держа папки выше файлов, а ".." — первым.
|
||||
pub(super) fn sort_entries(
|
||||
entries: &[FileEntry],
|
||||
sort_col: SortCol,
|
||||
sort_dir: SortDir,
|
||||
) -> Vec<FileEntry> {
|
||||
let mut sorted = entries.to_vec();
|
||||
let dotdot_pos = sorted.iter().position(|e| e.name == "..");
|
||||
let dotdot = dotdot_pos.map(|i| sorted.remove(i));
|
||||
|
||||
sorted.sort_by(|a, b| {
|
||||
// папки всегда выше
|
||||
let dir_cmp = match (&b.kind, &a.kind) {
|
||||
(EntryKind::Dir, EntryKind::Dir) => std::cmp::Ordering::Equal,
|
||||
(EntryKind::Dir, _) => std::cmp::Ordering::Greater,
|
||||
(_, EntryKind::Dir) => std::cmp::Ordering::Less,
|
||||
_ => std::cmp::Ordering::Equal,
|
||||
};
|
||||
if dir_cmp != std::cmp::Ordering::Equal {
|
||||
return dir_cmp;
|
||||
}
|
||||
|
||||
let ord = match sort_col {
|
||||
SortCol::Name => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
||||
SortCol::Size => a.size.unwrap_or(0).cmp(&b.size.unwrap_or(0)),
|
||||
SortCol::Kind => format!("{:?}", a.kind).cmp(&format!("{:?}", b.kind)),
|
||||
SortCol::Modified => a.modified.unwrap_or(0).cmp(&b.modified.unwrap_or(0)),
|
||||
};
|
||||
if sort_dir == SortDir::Desc {
|
||||
ord.reverse()
|
||||
} else {
|
||||
ord
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(dd) = dotdot {
|
||||
sorted.insert(0, dd);
|
||||
}
|
||||
sorted
|
||||
}
|
||||
|
||||
/// Кликабельный заголовок колонки со стрелкой активной сортировки.
|
||||
pub(super) fn sort_header(
|
||||
ui: &mut Ui,
|
||||
label: &str,
|
||||
min_w: f32,
|
||||
active: bool,
|
||||
dir: SortDir,
|
||||
) -> bool {
|
||||
let arrow = if active {
|
||||
if dir == SortDir::Asc { " ↑" } else { " ↓" }
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let full = format!("{}{}", label, arrow);
|
||||
let col = if active { ACCENT } else { TEXT_DIM };
|
||||
let btn = egui::Button::new(RichText::new(full).color(col).size(10.5).strong())
|
||||
.min_size(egui::vec2(min_w, 20.0));
|
||||
button::ghost(ui, btn).clicked()
|
||||
}
|
||||
|
||||
pub(super) fn toggle_sort(col: SortCol, cur_col: SortCol, cur_dir: SortDir) -> (SortCol, SortDir) {
|
||||
if col == cur_col {
|
||||
let new_dir = if cur_dir == SortDir::Asc {
|
||||
SortDir::Desc
|
||||
} else {
|
||||
SortDir::Asc
|
||||
};
|
||||
(col, new_dir)
|
||||
} else {
|
||||
(col, SortDir::Asc)
|
||||
}
|
||||
}
|
||||
150
src/ui/panels/file_pane/table.rs
Normal file
150
src/ui/panels/file_pane/table.rs
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
//! Рендер строк таблицы файлов (шапка, список, drag&drop, контекстное меню).
|
||||
use egui::{Color32, Id, Ui};
|
||||
|
||||
use super::row::{draw_row_content, render_header};
|
||||
use super::sort::sort_entries;
|
||||
use super::{FileTableResponse, RowAction, SortCol, SortDir};
|
||||
use crate::domain::file_entry::{EntryKind, FileEntry};
|
||||
use crate::ui::drag::DragPayload;
|
||||
use crate::ui::theme::*;
|
||||
|
||||
pub(super) fn file_table_inner<F, C>(
|
||||
ui: &mut Ui,
|
||||
id: &str,
|
||||
entries: &[FileEntry],
|
||||
selected: &mut Option<String>,
|
||||
make_drag_payload: F,
|
||||
mut context_menu: C,
|
||||
) -> FileTableResponse
|
||||
where
|
||||
F: Fn(&FileEntry) -> Option<DragPayload>,
|
||||
C: FnMut(&mut Ui, &FileEntry) -> Option<RowAction>,
|
||||
{
|
||||
// Сортировка — читаем из egui memory
|
||||
let sort_id = Id::new((id, "sort"));
|
||||
let (sort_col, sort_dir) = ui
|
||||
.ctx()
|
||||
.data_mut(|d| *d.get_temp_mut_or(sort_id, (SortCol::Name, SortDir::Asc)));
|
||||
|
||||
let sorted = sort_entries(entries, sort_col, sort_dir);
|
||||
|
||||
let mut double_clicked: Option<String> = None;
|
||||
let mut clicked: Option<String> = None;
|
||||
let mut new_sort: Option<(SortCol, SortDir)> = None;
|
||||
let mut dropped_on_dir: Option<(FileEntry, std::sync::Arc<DragPayload>)> = None;
|
||||
let mut context_action: Option<(FileEntry, RowAction)> = None;
|
||||
|
||||
render_header(ui, sort_col, sort_dir, &mut new_sort);
|
||||
|
||||
if let Some(s) = new_sort {
|
||||
ui.ctx().data_mut(|d| {
|
||||
d.insert_temp(sort_id, s);
|
||||
});
|
||||
}
|
||||
|
||||
// разделитель
|
||||
let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0));
|
||||
ui.painter().rect_filled(sep, 0.0, BORDER);
|
||||
|
||||
// Список файлов
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt((id, "scroll"))
|
||||
.auto_shrink([false; 2])
|
||||
.show(ui, |ui| {
|
||||
for entry in &sorted {
|
||||
let is_selected = selected.as_deref() == Some(&entry.name);
|
||||
let row_id = Id::new((id, "row", &entry.name));
|
||||
|
||||
// Строка, которую сейчас тащат — её содержимое рисуется в
|
||||
// плавающем слое поверх курсора (см. dnd_drag_source), поэтому
|
||||
// сама она не может быть целью для дропа, а на исходном месте
|
||||
// не должно оставаться подсветки выбора/ховера — там пусто.
|
||||
let is_being_dragged = ui.ctx().is_being_dragged(row_id);
|
||||
|
||||
let row_rect = egui::Rect::from_min_size(
|
||||
ui.cursor().min,
|
||||
egui::vec2(ui.available_width(), ROW_H),
|
||||
);
|
||||
let is_hovered = !is_being_dragged && ui.rect_contains_pointer(row_rect);
|
||||
let is_drop_target = !is_being_dragged
|
||||
&& entry.kind == EntryKind::Dir
|
||||
&& egui::DragAndDrop::has_payload_of_type::<DragPayload>(ui.ctx());
|
||||
let is_drop_hover = is_drop_target && is_hovered;
|
||||
|
||||
let bg = if is_being_dragged {
|
||||
Color32::TRANSPARENT
|
||||
} else if is_drop_hover {
|
||||
ACCENT_DIM
|
||||
} else if is_selected {
|
||||
BG_ROW_SEL
|
||||
} else if is_hovered {
|
||||
BG_ROW_HOVER
|
||||
} else {
|
||||
Color32::TRANSPARENT
|
||||
};
|
||||
let stroke = if is_drop_hover {
|
||||
egui::Stroke::new(1.5, ACCENT)
|
||||
} else {
|
||||
egui::Stroke::NONE
|
||||
};
|
||||
|
||||
let row_width = ui.available_width();
|
||||
let response = egui::Frame::none()
|
||||
.fill(bg)
|
||||
.stroke(stroke)
|
||||
.rounding(RADIUS_SM)
|
||||
.inner_margin(egui::Margin::symmetric(12.0, 0.0))
|
||||
.show(ui, |ui| {
|
||||
ui.allocate_ui_with_layout(
|
||||
egui::vec2(row_width - 24.0, ROW_H),
|
||||
egui::Layout::left_to_right(egui::Align::Center),
|
||||
|ui| draw_row_content(ui, entry, row_id, &make_drag_payload),
|
||||
);
|
||||
})
|
||||
.response;
|
||||
|
||||
// Frame::show() only senses hover, not clicks — add a real click
|
||||
// sense on the full row rect (separate id from the drag source,
|
||||
// which only senses drag over the icon+name sub-area). Without
|
||||
// this, every press was interpreted as a drag start and plain
|
||||
// clicks/double-clicks never fired.
|
||||
let click_response =
|
||||
ui.interact(response.rect, row_id.with("click"), egui::Sense::click());
|
||||
let response = response | click_response;
|
||||
|
||||
if response.clicked() {
|
||||
*selected = Some(entry.name.clone());
|
||||
clicked = Some(entry.name.clone());
|
||||
}
|
||||
if response.double_clicked() {
|
||||
double_clicked = Some(entry.name.clone());
|
||||
}
|
||||
if is_drop_target
|
||||
&& let Some(payload) = response.dnd_release_payload::<DragPayload>()
|
||||
{
|
||||
dropped_on_dir = Some((entry.clone(), payload));
|
||||
}
|
||||
|
||||
if entry.name != ".." {
|
||||
// Правый клик выбирает строку — иначе меню открывается
|
||||
// над предыдущим выделением, что сбивает с толку.
|
||||
if response.secondary_clicked() {
|
||||
*selected = Some(entry.name.clone());
|
||||
}
|
||||
crate::ui::widgets::context_menu::context_menu(&response, |ui| {
|
||||
if let Some(action) = context_menu(ui, entry) {
|
||||
context_action = Some((entry.clone(), action));
|
||||
ui.close_menu();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
FileTableResponse {
|
||||
double_clicked,
|
||||
clicked,
|
||||
dropped_on_dir,
|
||||
context_action,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,414 +0,0 @@
|
|||
use crate::domain::connection::ConnectionStatus;
|
||||
use crate::domain::file_entry::{EntryKind, FileEntry};
|
||||
use crate::domain::transfer::{TransferKind, TransferTask};
|
||||
use crate::fs::local;
|
||||
use crate::fs::remote::RemoteRegistry;
|
||||
use crate::transfer::queue::TransferQueue;
|
||||
use crate::ui::drag::{DragPayload, make_local_payload};
|
||||
use crate::ui::icons::{self, Icon};
|
||||
use crate::ui::panels::file_pane::{
|
||||
FileTableResponse, RowAction, context_menu_item, file_table, format_size,
|
||||
};
|
||||
use crate::ui::state::{AppState, Pane};
|
||||
use crate::ui::theme::*;
|
||||
use egui::{RichText, Ui};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
fn parent_path(path: &str) -> String {
|
||||
std::path::Path::new(path)
|
||||
.parent()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| path.to_string())
|
||||
}
|
||||
|
||||
fn make_parent_entry(path: &str) -> FileEntry {
|
||||
FileEntry {
|
||||
name: "..".into(),
|
||||
path: parent_path(path),
|
||||
kind: EntryKind::Dir,
|
||||
size: None,
|
||||
modified: None,
|
||||
permissions: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(
|
||||
ui: &mut Ui,
|
||||
state: &mut AppState,
|
||||
queue: &TransferQueue,
|
||||
registry: &Arc<RemoteRegistry>,
|
||||
_rt_handle: &tokio::runtime::Handle,
|
||||
db: &Arc<Mutex<rusqlite::Connection>>,
|
||||
) {
|
||||
let frame = egui::Frame::none()
|
||||
.fill(BG_CONTENT)
|
||||
.inner_margin(egui::Margin::same(0.0));
|
||||
|
||||
let (_inner, dropped) = ui.dnd_drop_zone::<DragPayload, _>(frame, |ui| {
|
||||
render_inner(ui, state, queue, registry, db);
|
||||
});
|
||||
|
||||
if let Some(payload_arc) = dropped {
|
||||
let dest_dir = state.local_path.clone();
|
||||
handle_drop(state, queue, registry, &payload_arc, &dest_dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// Обрабатывает бросок файла в текущую директорию (dest_dir) — общий код
|
||||
/// для дропа на весь пейн и дропа на конкретную папку в списке.
|
||||
fn handle_drop(
|
||||
state: &mut AppState,
|
||||
queue: &TransferQueue,
|
||||
registry: &Arc<RemoteRegistry>,
|
||||
payload: &DragPayload,
|
||||
dest_dir: &str,
|
||||
) {
|
||||
match payload {
|
||||
DragPayload::RemoteFile(remote_path, file_name, conn_id) => {
|
||||
if registry.get(conn_id).is_some() {
|
||||
let local_path_str = format!("{}/{}", dest_dir.trim_end_matches('/'), file_name);
|
||||
let task = TransferTask::new(
|
||||
TransferKind::Download,
|
||||
conn_id.clone(),
|
||||
local_path_str,
|
||||
remote_path.clone(),
|
||||
file_name.clone(),
|
||||
0,
|
||||
);
|
||||
queue.push(task);
|
||||
state.status_message = format!("Download queued: {}", file_name);
|
||||
} else {
|
||||
state.status_message = "Connection no longer active".into();
|
||||
}
|
||||
}
|
||||
DragPayload::LocalFile(src_path, file_name) => {
|
||||
if std::path::Path::new(dest_dir)
|
||||
== std::path::Path::new(src_path)
|
||||
.parent()
|
||||
.unwrap_or(std::path::Path::new(""))
|
||||
{
|
||||
return; // бросили туда же, откуда взяли
|
||||
}
|
||||
match local::move_into(src_path, dest_dir) {
|
||||
Ok(()) => {
|
||||
state.status_message = format!("Moved {}", file_name);
|
||||
refresh_local(state);
|
||||
}
|
||||
Err(e) => state.status_message = format!("Move failed: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_inner(
|
||||
ui: &mut Ui,
|
||||
state: &mut AppState,
|
||||
queue: &TransferQueue,
|
||||
registry: &Arc<RemoteRegistry>,
|
||||
db: &Arc<Mutex<rusqlite::Connection>>,
|
||||
) {
|
||||
// Хлебные крошки + путь
|
||||
render_path_bar(ui, state);
|
||||
|
||||
// разделитель
|
||||
let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0));
|
||||
ui.painter().rect_filled(sep, 0.0, BORDER);
|
||||
|
||||
// Таблица
|
||||
let entries = state.local_entries.clone();
|
||||
let mut selected = state.local_selected.clone();
|
||||
|
||||
let has_connection = state
|
||||
.active_tab_ref()
|
||||
.map(|t| t.status == ConnectionStatus::Connected)
|
||||
.unwrap_or(false);
|
||||
|
||||
let FileTableResponse {
|
||||
double_clicked,
|
||||
clicked,
|
||||
dropped_on_dir,
|
||||
context_action,
|
||||
} = file_table(
|
||||
ui,
|
||||
"local_table",
|
||||
&entries,
|
||||
&mut selected,
|
||||
|entry| {
|
||||
if has_connection {
|
||||
make_local_payload(entry)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
|ui, entry| build_context_menu(ui, entry, has_connection),
|
||||
);
|
||||
|
||||
state.local_selected = selected;
|
||||
if clicked.is_some() {
|
||||
state.active_pane = Pane::Local;
|
||||
}
|
||||
|
||||
if let Some((target_entry, payload)) = dropped_on_dir {
|
||||
handle_drop(state, queue, registry, &payload, &target_entry.path);
|
||||
}
|
||||
|
||||
if let Some(name) = double_clicked {
|
||||
open_entry(state, &name);
|
||||
}
|
||||
|
||||
if let Some((entry, action)) = context_action {
|
||||
handle_row_action(ui.ctx(), state, queue, db, &entry, action);
|
||||
}
|
||||
|
||||
// Нижняя полоска
|
||||
render_footer(ui, state, db);
|
||||
}
|
||||
|
||||
fn build_context_menu(ui: &mut Ui, entry: &FileEntry, has_connection: bool) -> Option<RowAction> {
|
||||
if context_menu_item(ui, Icon::Folder, "Open", false) {
|
||||
return Some(RowAction::Open);
|
||||
}
|
||||
if entry.name != ".." {
|
||||
if context_menu_item(ui, Icon::Pen, "Rename", false) {
|
||||
return Some(RowAction::Rename);
|
||||
}
|
||||
if context_menu_item(ui, Icon::Trash, "Delete", true) {
|
||||
return Some(RowAction::Delete);
|
||||
}
|
||||
ui.separator();
|
||||
if entry.kind == EntryKind::Dir
|
||||
&& context_menu_item(ui, Icon::Star, "Add to Bookmarks", false)
|
||||
{
|
||||
return Some(RowAction::Bookmark);
|
||||
}
|
||||
if context_menu_item(ui, Icon::Document, "Copy Path", false) {
|
||||
return Some(RowAction::CopyPath);
|
||||
}
|
||||
if entry.kind == EntryKind::File && has_connection {
|
||||
ui.separator();
|
||||
if context_menu_item(ui, Icon::Upload, "Upload to Server", false) {
|
||||
return Some(RowAction::Transfer);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn open_entry(state: &mut AppState, name: &str) {
|
||||
if name == ".." {
|
||||
state.local_path = parent_path(&state.local_path);
|
||||
refresh_local(state);
|
||||
} else if let Some(entry) = state.local_entries.iter().find(|e| e.name == name) {
|
||||
if entry.kind == EntryKind::Dir {
|
||||
state.local_path = entry.path.clone();
|
||||
refresh_local(state);
|
||||
} else {
|
||||
match local::open(&entry.path) {
|
||||
Ok(()) => state.status_message = format!("Opening {}", entry.name),
|
||||
Err(e) => state.status_message = format!("Open failed: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_row_action(
|
||||
ctx: &egui::Context,
|
||||
state: &mut AppState,
|
||||
queue: &TransferQueue,
|
||||
db: &Arc<Mutex<rusqlite::Connection>>,
|
||||
entry: &FileEntry,
|
||||
action: RowAction,
|
||||
) {
|
||||
match action {
|
||||
RowAction::Open => open_entry(state, &entry.name),
|
||||
RowAction::Rename => {
|
||||
state.op_target = Pane::Local;
|
||||
state.show_rename_dialog = true;
|
||||
state.rename_old_name = entry.name.clone();
|
||||
state.rename_new_name = entry.name.clone();
|
||||
}
|
||||
RowAction::Delete => {
|
||||
state.op_target = Pane::Local;
|
||||
state.show_delete_dialog = true;
|
||||
state.delete_name = entry.name.clone();
|
||||
}
|
||||
RowAction::Bookmark => add_bookmark(state, db, &entry.name, &entry.path),
|
||||
RowAction::CopyPath => {
|
||||
ctx.copy_text(entry.path.clone());
|
||||
state.status_message = "Path copied".into();
|
||||
}
|
||||
RowAction::Transfer => {
|
||||
if let Some(tab) = state.active_tab_ref() {
|
||||
let connection_id = tab.id.clone();
|
||||
let remote_path =
|
||||
format!("{}/{}", tab.remote_path.trim_end_matches('/'), entry.name);
|
||||
let task = TransferTask::new(
|
||||
TransferKind::Upload,
|
||||
connection_id,
|
||||
entry.path.clone(),
|
||||
remote_path,
|
||||
entry.name.clone(),
|
||||
0,
|
||||
);
|
||||
queue.push(task);
|
||||
state.status_message = format!("Upload queued: {}", entry.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn add_bookmark(
|
||||
state: &mut AppState,
|
||||
db: &Arc<Mutex<rusqlite::Connection>>,
|
||||
name: &str,
|
||||
path: &str,
|
||||
) {
|
||||
if state.bookmarks.iter().any(|b| b.path == path) {
|
||||
state.status_message = "Already bookmarked".into();
|
||||
return;
|
||||
}
|
||||
let Ok(conn) = db.lock() else {
|
||||
return;
|
||||
};
|
||||
match crate::storage::db::add_bookmark(&conn, name, path) {
|
||||
Ok(id) => {
|
||||
state.bookmarks.push(crate::ui::state::Bookmark {
|
||||
id,
|
||||
name: name.to_string(),
|
||||
path: path.to_string(),
|
||||
});
|
||||
state.status_message = "Bookmark added".into();
|
||||
}
|
||||
Err(e) => state.status_message = format!("Bookmark failed: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_path_bar(ui: &mut Ui, state: &mut AppState) {
|
||||
let frame = egui::Frame::none()
|
||||
.fill(BG_BASE)
|
||||
.inner_margin(egui::Margin::symmetric(12.0, 0.0));
|
||||
|
||||
frame.show(ui, |ui| {
|
||||
let width = ui.available_width();
|
||||
ui.allocate_ui_with_layout(
|
||||
egui::vec2(width, 30.0),
|
||||
egui::Layout::left_to_right(egui::Align::Center),
|
||||
|ui| {
|
||||
ui.label(RichText::new("LOCAL").color(TEXT_DIM).size(10.5).strong());
|
||||
ui.add_space(10.0);
|
||||
|
||||
// иконка home
|
||||
let home = dirs::home_dir()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let is_home = state.local_path == home;
|
||||
let home_col = if is_home { ACCENT } else { TEXT_DIM };
|
||||
let home_btn = egui::Button::image(icons::image(Icon::Home, 13.0, home_col))
|
||||
.fill(if is_home {
|
||||
BG_ROW_SEL
|
||||
} else {
|
||||
egui::Color32::TRANSPARENT
|
||||
})
|
||||
.rounding(RADIUS_SM)
|
||||
.min_size(egui::vec2(22.0, 22.0));
|
||||
if ui.add(home_btn).clicked() {
|
||||
state.local_path = home;
|
||||
refresh_local(state);
|
||||
}
|
||||
|
||||
ui.add_space(4.0);
|
||||
|
||||
// Крошки
|
||||
let path_clone = state.local_path.clone();
|
||||
let parts: Vec<&str> = path_clone.split('/').filter(|s| !s.is_empty()).collect();
|
||||
let mut acc = String::new();
|
||||
|
||||
for (i, part) in parts.iter().enumerate() {
|
||||
acc.push('/');
|
||||
acc.push_str(part);
|
||||
let is_last = i == parts.len() - 1;
|
||||
|
||||
if is_last {
|
||||
ui.label(RichText::new(*part).color(TEXT_PRIMARY).size(12.0).strong());
|
||||
} else {
|
||||
let path_snap = acc.clone();
|
||||
let link =
|
||||
egui::Label::new(RichText::new(*part).color(TEXT_DIM).size(12.0))
|
||||
.sense(egui::Sense::click());
|
||||
if ui.add(link).clicked() {
|
||||
state.local_path = path_snap;
|
||||
refresh_local(state);
|
||||
}
|
||||
ui.label(RichText::new("/").color(TEXT_HINT).size(12.0));
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn render_footer(ui: &mut Ui, state: &mut AppState, db: &Arc<Mutex<rusqlite::Connection>>) {
|
||||
let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0));
|
||||
ui.painter().rect_filled(sep, 0.0, BORDER);
|
||||
|
||||
let frame = egui::Frame::none()
|
||||
.fill(BG_PANEL)
|
||||
.inner_margin(egui::Margin::symmetric(8.0, 3.0));
|
||||
|
||||
frame.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let count = state
|
||||
.local_entries
|
||||
.iter()
|
||||
.filter(|e| e.name != "..")
|
||||
.count();
|
||||
let total_size: u64 = state.local_entries.iter().filter_map(|e| e.size).sum();
|
||||
|
||||
ui.label(
|
||||
RichText::new(format!("{} items", count))
|
||||
.color(TEXT_DIM)
|
||||
.size(11.0),
|
||||
);
|
||||
|
||||
if total_size > 0 {
|
||||
ui.label(
|
||||
RichText::new(format!("({})", format_size(Some(total_size))))
|
||||
.color(TEXT_HINT)
|
||||
.size(11.0),
|
||||
);
|
||||
}
|
||||
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
// кнопка добавить закладку
|
||||
let star = egui::Button::image(icons::image(Icon::Star, 12.0, YELLOW))
|
||||
.fill(egui::Color32::TRANSPARENT)
|
||||
.min_size(egui::vec2(20.0, 20.0));
|
||||
if ui.add(star).on_hover_text("Bookmark this folder").clicked() {
|
||||
let name = std::path::Path::new(&state.local_path)
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "Bookmark".into());
|
||||
let path = state.local_path.clone();
|
||||
add_bookmark(state, db, &name, &path);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
pub fn refresh_local(state: &mut AppState) {
|
||||
let mut entries = Vec::new();
|
||||
if state.local_path != "/" {
|
||||
entries.push(make_parent_entry(&state.local_path));
|
||||
}
|
||||
match local::list(&state.local_path) {
|
||||
Ok(mut list) => {
|
||||
entries.append(&mut list);
|
||||
state.local_entries = entries;
|
||||
}
|
||||
Err(e) => {
|
||||
state.status_message = format!("Local error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
199
src/ui/panels/local_pane/actions.rs
Normal file
199
src/ui/panels/local_pane/actions.rs
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
//! Действия над локальной ФС: drop, открытие, контекстное меню, закладки.
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use super::{parent_path, refresh_local};
|
||||
use crate::domain::file_entry::EntryKind;
|
||||
use crate::domain::file_entry::FileEntry;
|
||||
use crate::domain::transfer::{TransferKind, TransferTask};
|
||||
use crate::fs::local;
|
||||
use crate::fs::remote::RemoteRegistry;
|
||||
use crate::transfer::queue::TransferQueue;
|
||||
use crate::ui::drag::DragPayload;
|
||||
use crate::ui::icons::Icon;
|
||||
use crate::ui::panels::file_pane::{RowAction, context_menu_item};
|
||||
use crate::ui::state::{AppState, Pane};
|
||||
|
||||
/// Обрабатывает бросок файла в директорию `dest_dir` — общий код для дропа
|
||||
/// на весь пейн и дропа на конкретную папку в списке.
|
||||
pub(super) fn handle_drop(
|
||||
state: &mut AppState,
|
||||
queue: &TransferQueue,
|
||||
registry: &Arc<RemoteRegistry>,
|
||||
payload: &DragPayload,
|
||||
dest_dir: &str,
|
||||
) {
|
||||
match payload {
|
||||
DragPayload::RemoteFile(remote_path, file_name, conn_id) => {
|
||||
if registry.get(conn_id).is_some() {
|
||||
let local_path_str = format!("{}/{}", dest_dir.trim_end_matches('/'), file_name);
|
||||
let task = TransferTask::new(
|
||||
TransferKind::Download,
|
||||
conn_id.clone(),
|
||||
local_path_str,
|
||||
remote_path.clone(),
|
||||
file_name.clone(),
|
||||
0,
|
||||
);
|
||||
queue.push(task);
|
||||
state.status_message =
|
||||
crate::i18n::tf("toolbar.download_queued", &[("{name}", file_name)]);
|
||||
} else {
|
||||
state.status_message = crate::i18n::t("panels.local.conn_inactive").into();
|
||||
}
|
||||
}
|
||||
DragPayload::LocalFile(src_path, file_name) => {
|
||||
if std::path::Path::new(dest_dir)
|
||||
== std::path::Path::new(src_path)
|
||||
.parent()
|
||||
.unwrap_or(std::path::Path::new(""))
|
||||
{
|
||||
return; // бросили туда же, откуда взяли
|
||||
}
|
||||
match local::move_into(src_path, dest_dir) {
|
||||
Ok(()) => {
|
||||
state.status_message =
|
||||
crate::i18n::tf("panels.local.moved", &[("{name}", file_name)]);
|
||||
refresh_local(state);
|
||||
}
|
||||
Err(e) => {
|
||||
state.status_message =
|
||||
crate::i18n::tf("panels.local.move_failed", &[("{err}", &e.to_string())])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_context_menu(
|
||||
ui: &mut egui::Ui,
|
||||
entry: &FileEntry,
|
||||
has_connection: bool,
|
||||
) -> Option<RowAction> {
|
||||
use crate::i18n::t;
|
||||
if context_menu_item(ui, Icon::Folder, t("common.open"), false) {
|
||||
return Some(RowAction::Open);
|
||||
}
|
||||
if entry.name != ".." {
|
||||
if context_menu_item(ui, Icon::Pen, t("common.rename"), false) {
|
||||
return Some(RowAction::Rename);
|
||||
}
|
||||
if context_menu_item(ui, Icon::Trash, t("common.delete"), true) {
|
||||
return Some(RowAction::Delete);
|
||||
}
|
||||
ui.separator();
|
||||
if entry.kind == EntryKind::Dir
|
||||
&& context_menu_item(ui, Icon::Star, t("panels.local.add_to_bookmarks"), false)
|
||||
{
|
||||
return Some(RowAction::Bookmark);
|
||||
}
|
||||
if context_menu_item(ui, Icon::Document, t("common.copy_path"), false) {
|
||||
return Some(RowAction::CopyPath);
|
||||
}
|
||||
if entry.kind == EntryKind::File && has_connection {
|
||||
ui.separator();
|
||||
if context_menu_item(ui, Icon::Upload, t("panels.local.upload_to_server"), false) {
|
||||
return Some(RowAction::Transfer);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(super) fn open_entry(state: &mut AppState, name: &str) {
|
||||
if name == ".." {
|
||||
state.local_path = parent_path(&state.local_path);
|
||||
refresh_local(state);
|
||||
} else if let Some(entry) = state.local_entries.iter().find(|e| e.name == name) {
|
||||
if entry.kind == EntryKind::Dir {
|
||||
state.local_path = entry.path.clone();
|
||||
refresh_local(state);
|
||||
} else {
|
||||
match local::open(&entry.path) {
|
||||
Ok(()) => {
|
||||
state.status_message =
|
||||
crate::i18n::tf("panels.local.opening", &[("{name}", &entry.name)])
|
||||
}
|
||||
Err(e) => {
|
||||
state.status_message =
|
||||
crate::i18n::tf("panels.local.open_failed", &[("{err}", &e.to_string())])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn handle_row_action(
|
||||
ctx: &egui::Context,
|
||||
state: &mut AppState,
|
||||
queue: &TransferQueue,
|
||||
db: &Arc<Mutex<rusqlite::Connection>>,
|
||||
entry: &FileEntry,
|
||||
action: RowAction,
|
||||
) {
|
||||
match action {
|
||||
RowAction::Open => open_entry(state, &entry.name),
|
||||
RowAction::Rename => {
|
||||
state.op_target = Pane::Local;
|
||||
state.show_rename_dialog = true;
|
||||
state.rename_old_name = entry.name.clone();
|
||||
state.rename_new_name = entry.name.clone();
|
||||
}
|
||||
RowAction::Delete => {
|
||||
state.op_target = Pane::Local;
|
||||
state.show_delete_dialog = true;
|
||||
state.delete_name = entry.name.clone();
|
||||
}
|
||||
RowAction::Bookmark => add_bookmark(state, db, &entry.name, &entry.path),
|
||||
RowAction::CopyPath => {
|
||||
ctx.copy_text(entry.path.clone());
|
||||
state.status_message = crate::i18n::t("panels.path_copied").into();
|
||||
}
|
||||
RowAction::Transfer => {
|
||||
if let Some(tab) = state.active_tab_ref() {
|
||||
let connection_id = tab.id.clone();
|
||||
let remote_path =
|
||||
format!("{}/{}", tab.remote_path.trim_end_matches('/'), entry.name);
|
||||
let task = TransferTask::new(
|
||||
TransferKind::Upload,
|
||||
connection_id,
|
||||
entry.path.clone(),
|
||||
remote_path,
|
||||
entry.name.clone(),
|
||||
0,
|
||||
);
|
||||
queue.push(task);
|
||||
state.status_message =
|
||||
crate::i18n::tf("toolbar.upload_queued", &[("{name}", &entry.name)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn add_bookmark(
|
||||
state: &mut AppState,
|
||||
db: &Arc<Mutex<rusqlite::Connection>>,
|
||||
name: &str,
|
||||
path: &str,
|
||||
) {
|
||||
if state.bookmarks.iter().any(|b| b.path == path) {
|
||||
state.status_message = crate::i18n::t("panels.local.already_bookmarked").into();
|
||||
return;
|
||||
}
|
||||
let Ok(conn) = db.lock() else {
|
||||
return;
|
||||
};
|
||||
match crate::storage::db::add_bookmark(&conn, name, path) {
|
||||
Ok(id) => {
|
||||
state.bookmarks.push(crate::ui::state::Bookmark {
|
||||
id,
|
||||
name: name.to_string(),
|
||||
path: path.to_string(),
|
||||
});
|
||||
state.status_message = crate::i18n::t("panels.local.bookmark_added").into();
|
||||
}
|
||||
Err(e) => {
|
||||
state.status_message =
|
||||
crate::i18n::tf("panels.local.bookmark_failed", &[("{err}", &e.to_string())])
|
||||
}
|
||||
}
|
||||
}
|
||||
137
src/ui/panels/local_pane/bars.rs
Normal file
137
src/ui/panels/local_pane/bars.rs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
//! Верхняя (путь + home) и нижняя (счётчик + закладка) полоски локального пейна.
|
||||
use egui::{RichText, Ui};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use super::actions::add_bookmark;
|
||||
use super::refresh_local;
|
||||
use crate::ui::icons::{self, Icon};
|
||||
use crate::ui::panels::file_pane::format_size;
|
||||
use crate::ui::state::AppState;
|
||||
use crate::ui::theme::*;
|
||||
use crate::ui::widgets::button;
|
||||
|
||||
pub(super) fn render_path_bar(ui: &mut Ui, state: &mut AppState) {
|
||||
let frame = egui::Frame::none()
|
||||
.fill(BG_BASE)
|
||||
.inner_margin(egui::Margin::symmetric(12.0, 0.0));
|
||||
|
||||
frame.show(ui, |ui| {
|
||||
let width = ui.available_width();
|
||||
ui.allocate_ui_with_layout(
|
||||
egui::vec2(width, 30.0),
|
||||
egui::Layout::left_to_right(egui::Align::Center),
|
||||
|ui| {
|
||||
ui.label(
|
||||
RichText::new(crate::i18n::t("panels.local_label"))
|
||||
.color(TEXT_DIM)
|
||||
.size(10.5)
|
||||
.strong(),
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
|
||||
// иконка home
|
||||
let home = dirs::home_dir()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let is_home = state.local_path == home;
|
||||
let home_col = if is_home { ACCENT } else { TEXT_DIM };
|
||||
let home_btn = egui::Button::image(icons::image(Icon::Home, 13.0, home_col))
|
||||
.rounding(RADIUS_SM)
|
||||
.min_size(egui::vec2(22.0, 22.0));
|
||||
if button::toggle(ui, home_btn, is_home).clicked() {
|
||||
state.local_path = home;
|
||||
refresh_local(state);
|
||||
}
|
||||
|
||||
ui.add_space(4.0);
|
||||
|
||||
breadcrumbs(ui, state);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Хлебные крошки локального пути — каждый сегмент ghost-кнопка (hover + курсор-рука).
|
||||
fn breadcrumbs(ui: &mut Ui, state: &mut AppState) {
|
||||
let path_clone = state.local_path.clone();
|
||||
let parts: Vec<&str> = path_clone.split('/').filter(|s| !s.is_empty()).collect();
|
||||
let mut acc = String::new();
|
||||
|
||||
for (i, part) in parts.iter().enumerate() {
|
||||
acc.push('/');
|
||||
acc.push_str(part);
|
||||
let is_last = i == parts.len() - 1;
|
||||
|
||||
if is_last {
|
||||
ui.label(RichText::new(*part).color(TEXT_PRIMARY).size(12.0).strong());
|
||||
} else {
|
||||
let path_snap = acc.clone();
|
||||
let seg = egui::Button::new(RichText::new(*part).color(TEXT_DIM).size(12.0))
|
||||
.min_size(egui::vec2(0.0, 22.0));
|
||||
if button::ghost(ui, seg).clicked() {
|
||||
state.local_path = path_snap;
|
||||
refresh_local(state);
|
||||
}
|
||||
ui.label(RichText::new("/").color(TEXT_HINT).size(12.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn render_footer(
|
||||
ui: &mut Ui,
|
||||
state: &mut AppState,
|
||||
db: &Arc<Mutex<rusqlite::Connection>>,
|
||||
) {
|
||||
let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0));
|
||||
ui.painter().rect_filled(sep, 0.0, BORDER);
|
||||
|
||||
let frame = egui::Frame::none()
|
||||
.fill(BG_PANEL)
|
||||
.inner_margin(egui::Margin::symmetric(8.0, 3.0));
|
||||
|
||||
frame.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let count = state
|
||||
.local_entries
|
||||
.iter()
|
||||
.filter(|e| e.name != "..")
|
||||
.count();
|
||||
let total_size: u64 = state.local_entries.iter().filter_map(|e| e.size).sum();
|
||||
|
||||
ui.label(
|
||||
RichText::new(crate::i18n::tf(
|
||||
"panels.count_items",
|
||||
&[("{n}", &count.to_string())],
|
||||
))
|
||||
.color(TEXT_DIM)
|
||||
.size(11.0),
|
||||
);
|
||||
|
||||
if total_size > 0 {
|
||||
ui.label(
|
||||
RichText::new(format!("({})", format_size(Some(total_size))))
|
||||
.color(TEXT_HINT)
|
||||
.size(11.0),
|
||||
);
|
||||
}
|
||||
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
// кнопка добавить закладку
|
||||
let star = egui::Button::image(icons::image(Icon::Star, 12.0, YELLOW))
|
||||
.min_size(egui::vec2(22.0, 22.0));
|
||||
if button::ghost(ui, star)
|
||||
.on_hover_text(crate::i18n::t("panels.sidebar.bookmark_this_folder"))
|
||||
.clicked()
|
||||
{
|
||||
let name = std::path::Path::new(&state.local_path)
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "Bookmark".into());
|
||||
let path = state.local_path.clone();
|
||||
add_bookmark(state, db, &name, &path);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
131
src/ui/panels/local_pane/mod.rs
Normal file
131
src/ui/panels/local_pane/mod.rs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
use crate::domain::connection::ConnectionStatus;
|
||||
use crate::domain::file_entry::{EntryKind, FileEntry};
|
||||
use crate::fs::local;
|
||||
use crate::fs::remote::RemoteRegistry;
|
||||
use crate::transfer::queue::TransferQueue;
|
||||
use crate::ui::drag::{DragPayload, make_local_payload};
|
||||
use crate::ui::panels::file_pane::{FileTableResponse, file_table};
|
||||
use crate::ui::state::{AppState, Pane};
|
||||
use crate::ui::theme::*;
|
||||
use egui::Ui;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
mod actions;
|
||||
mod bars;
|
||||
|
||||
pub(super) fn parent_path(path: &str) -> String {
|
||||
std::path::Path::new(path)
|
||||
.parent()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| path.to_string())
|
||||
}
|
||||
|
||||
fn make_parent_entry(path: &str) -> FileEntry {
|
||||
FileEntry {
|
||||
name: "..".into(),
|
||||
path: parent_path(path),
|
||||
kind: EntryKind::Dir,
|
||||
size: None,
|
||||
modified: None,
|
||||
permissions: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(
|
||||
ui: &mut Ui,
|
||||
state: &mut AppState,
|
||||
queue: &TransferQueue,
|
||||
registry: &Arc<RemoteRegistry>,
|
||||
_rt_handle: &tokio::runtime::Handle,
|
||||
db: &Arc<Mutex<rusqlite::Connection>>,
|
||||
) {
|
||||
let frame = egui::Frame::none()
|
||||
.fill(BG_CONTENT)
|
||||
.inner_margin(egui::Margin::same(0.0));
|
||||
|
||||
let (_inner, dropped) = ui.dnd_drop_zone::<DragPayload, _>(frame, |ui| {
|
||||
render_inner(ui, state, queue, registry, db);
|
||||
});
|
||||
|
||||
if let Some(payload_arc) = dropped {
|
||||
let dest_dir = state.local_path.clone();
|
||||
actions::handle_drop(state, queue, registry, &payload_arc, &dest_dir);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_inner(
|
||||
ui: &mut Ui,
|
||||
state: &mut AppState,
|
||||
queue: &TransferQueue,
|
||||
registry: &Arc<RemoteRegistry>,
|
||||
db: &Arc<Mutex<rusqlite::Connection>>,
|
||||
) {
|
||||
bars::render_path_bar(ui, state);
|
||||
|
||||
let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0));
|
||||
ui.painter().rect_filled(sep, 0.0, BORDER);
|
||||
|
||||
let entries = state.local_entries.clone();
|
||||
let mut selected = state.local_selected.clone();
|
||||
|
||||
let has_connection = state
|
||||
.active_tab_ref()
|
||||
.map(|t| t.status == ConnectionStatus::Connected)
|
||||
.unwrap_or(false);
|
||||
|
||||
let FileTableResponse {
|
||||
double_clicked,
|
||||
clicked,
|
||||
dropped_on_dir,
|
||||
context_action,
|
||||
} = file_table(
|
||||
ui,
|
||||
"local_table",
|
||||
&entries,
|
||||
&mut selected,
|
||||
|entry| {
|
||||
if has_connection {
|
||||
make_local_payload(entry)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
|ui, entry| actions::build_context_menu(ui, entry, has_connection),
|
||||
);
|
||||
|
||||
state.local_selected = selected;
|
||||
if clicked.is_some() {
|
||||
state.active_pane = Pane::Local;
|
||||
}
|
||||
|
||||
if let Some((target_entry, payload)) = dropped_on_dir {
|
||||
actions::handle_drop(state, queue, registry, &payload, &target_entry.path);
|
||||
}
|
||||
|
||||
if let Some(name) = double_clicked {
|
||||
actions::open_entry(state, &name);
|
||||
}
|
||||
|
||||
if let Some((entry, action)) = context_action {
|
||||
actions::handle_row_action(ui.ctx(), state, queue, db, &entry, action);
|
||||
}
|
||||
|
||||
bars::render_footer(ui, state, db);
|
||||
}
|
||||
|
||||
pub fn refresh_local(state: &mut AppState) {
|
||||
let mut entries = Vec::new();
|
||||
if state.local_path != "/" {
|
||||
entries.push(make_parent_entry(&state.local_path));
|
||||
}
|
||||
match local::list(&state.local_path) {
|
||||
Ok(mut list) => {
|
||||
entries.append(&mut list);
|
||||
state.local_entries = entries;
|
||||
}
|
||||
Err(e) => {
|
||||
state.status_message =
|
||||
crate::i18n::tf("panels.local.error", &[("{err}", &e.to_string())]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,5 +4,4 @@ pub mod queue;
|
|||
pub mod remote_pane;
|
||||
pub mod sidebar;
|
||||
pub mod status_bar;
|
||||
pub mod tabs;
|
||||
pub mod toolbar;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ use crate::ui::icons::{self, Icon};
|
|||
use crate::ui::panels::file_pane::format_size;
|
||||
use crate::ui::state::AppState;
|
||||
use crate::ui::theme::*;
|
||||
use egui::{Color32, RichText, Ui};
|
||||
use crate::ui::widgets::button;
|
||||
use egui::{RichText, Ui};
|
||||
|
||||
pub fn render(ui: &mut Ui, state: &mut AppState, tasks: &[TransferTask]) {
|
||||
// Заголовок очереди (всегда виден)
|
||||
|
|
@ -28,11 +29,8 @@ pub fn render(ui: &mut Ui, state: &mut AppState, tasks: &[TransferTask]) {
|
|||
.filter(|t| matches!(t.state, TaskState::Running | TaskState::Queued))
|
||||
.count();
|
||||
|
||||
let title = if n_active > 0 {
|
||||
format!("Transfer Queue ({})", n_active)
|
||||
} else {
|
||||
format!("Transfer Queue ({})", tasks.len())
|
||||
};
|
||||
let n = if n_active > 0 { n_active } else { tasks.len() };
|
||||
let title = crate::i18n::tf("queue.title", &[("{n}", &n.to_string())]);
|
||||
|
||||
let btn = egui::Button::image_and_text(
|
||||
icons::image(chevron, 13.0, TEXT_DIM),
|
||||
|
|
@ -41,10 +39,9 @@ pub fn render(ui: &mut Ui, state: &mut AppState, tasks: &[TransferTask]) {
|
|||
.size(11.5)
|
||||
.strong(),
|
||||
)
|
||||
.fill(Color32::TRANSPARENT)
|
||||
.min_size(egui::vec2(0.0, QUEUE_COLLAPSED_H));
|
||||
|
||||
if ui.add(btn).clicked() {
|
||||
if button::ghost(ui, btn).clicked() {
|
||||
state.show_queue = !state.show_queue;
|
||||
}
|
||||
|
||||
|
|
@ -72,16 +69,22 @@ pub fn render(ui: &mut Ui, state: &mut AppState, tasks: &[TransferTask]) {
|
|||
.filter(|t| matches!(t.state, TaskState::Failed(_)))
|
||||
.count();
|
||||
ui.label(
|
||||
RichText::new(format!("{}/{} done", done, tasks.len()))
|
||||
.color(TEXT_DIM)
|
||||
.size(11.0),
|
||||
RichText::new(crate::i18n::tf(
|
||||
"queue.done_suffix",
|
||||
&[("{done}", &done.to_string()), ("{total}", &tasks.len().to_string())],
|
||||
))
|
||||
.color(TEXT_DIM)
|
||||
.size(11.0),
|
||||
);
|
||||
if failed > 0 {
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
RichText::new(format!("{} failed", failed))
|
||||
.color(RED)
|
||||
.size(11.0),
|
||||
RichText::new(crate::i18n::tf(
|
||||
"queue.failed_suffix",
|
||||
&[("{n}", &failed.to_string())],
|
||||
))
|
||||
.color(RED)
|
||||
.size(11.0),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -147,6 +150,7 @@ fn render_task(ui: &mut Ui, task: &TransferTask) {
|
|||
};
|
||||
let bar = egui::ProgressBar::new(pct)
|
||||
.desired_width(ui.available_width() - 130.0)
|
||||
.animate(matches!(task.state, TaskState::Running))
|
||||
.fill(bar_col);
|
||||
ui.add(bar);
|
||||
ui.add_space(10.0);
|
||||
|
|
@ -160,16 +164,34 @@ fn render_task(ui: &mut Ui, task: &TransferTask) {
|
|||
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let (state_str, state_col, state_icon) = match &task.state {
|
||||
TaskState::Queued => ("Queued".to_string(), TEXT_HINT, None),
|
||||
TaskState::Queued => (crate::i18n::t("queue.state_queued").to_string(), TEXT_HINT, None),
|
||||
TaskState::Running => match task.speed {
|
||||
Some(s) if s > 0 => (format!("{}/s", format_size(Some(s))), GREEN, None),
|
||||
_ => ("Uploading".to_string(), ACCENT, None),
|
||||
_ => {
|
||||
let verb = match task.kind {
|
||||
TransferKind::Upload => "queue.state_uploading",
|
||||
TransferKind::Download => "queue.state_downloading",
|
||||
};
|
||||
(crate::i18n::t(verb).to_string(), ACCENT, None)
|
||||
}
|
||||
},
|
||||
TaskState::Completed => ("Complete".to_string(), GREEN, Some(Icon::CheckCircle)),
|
||||
TaskState::Failed(_) => ("Failed".to_string(), RED, None),
|
||||
TaskState::Paused => ("Paused".to_string(), YELLOW, Some(Icon::PlayCircle)),
|
||||
TaskState::Cancelled => ("Cancelled".to_string(), TEXT_HINT, None),
|
||||
TaskState::Retrying(_) => ("Retrying".to_string(), YELLOW, None),
|
||||
TaskState::Completed => (
|
||||
crate::i18n::t("queue.state_complete").to_string(),
|
||||
GREEN,
|
||||
Some(Icon::CheckCircle),
|
||||
),
|
||||
TaskState::Failed(_) => (crate::i18n::t("queue.state_failed").to_string(), RED, None),
|
||||
TaskState::Paused => (
|
||||
crate::i18n::t("queue.state_paused").to_string(),
|
||||
YELLOW,
|
||||
Some(Icon::PlayCircle),
|
||||
),
|
||||
TaskState::Cancelled => {
|
||||
(crate::i18n::t("queue.state_cancelled").to_string(), TEXT_HINT, None)
|
||||
}
|
||||
TaskState::Retrying(_) => {
|
||||
(crate::i18n::t("queue.state_retrying").to_string(), YELLOW, None)
|
||||
}
|
||||
};
|
||||
if let Some(ic) = state_icon {
|
||||
icons::icon(ui, ic, 13.0, state_col);
|
||||
|
|
|
|||
|
|
@ -1,496 +0,0 @@
|
|||
use egui::{RichText, Ui};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::domain::connection::ConnectionStatus;
|
||||
use crate::domain::file_entry::{EntryKind, FileEntry};
|
||||
use crate::domain::transfer::{TransferKind, TransferTask};
|
||||
use crate::fs::remote::RemoteRegistry;
|
||||
use crate::transfer::queue::TransferQueue;
|
||||
use crate::ui::drag::{DragPayload, make_remote_payload};
|
||||
use crate::ui::icons::{self, Icon};
|
||||
use crate::ui::panels::file_pane::{
|
||||
FileTableResponse, RowAction, context_menu_item, file_table, format_size,
|
||||
};
|
||||
use crate::ui::state::{AppState, Pane, PendingRemoteList};
|
||||
use crate::ui::theme::*;
|
||||
|
||||
pub fn remote_parent(path: &str) -> String {
|
||||
let trimmed = path.trim_end_matches('/');
|
||||
if trimmed.is_empty() || trimmed == "/" {
|
||||
return "/".into();
|
||||
}
|
||||
if let Some(idx) = trimmed.rfind('/') {
|
||||
let p = &trimmed[..idx];
|
||||
if p.is_empty() {
|
||||
"/".into()
|
||||
} else {
|
||||
p.to_string()
|
||||
}
|
||||
} else {
|
||||
"/".into()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(
|
||||
ui: &mut Ui,
|
||||
state: &mut AppState,
|
||||
tab_idx: usize,
|
||||
registry: &Arc<RemoteRegistry>,
|
||||
rt_handle: &tokio::runtime::Handle,
|
||||
queue: &TransferQueue,
|
||||
) {
|
||||
let frame = egui::Frame::none()
|
||||
.fill(BG_CONTENT)
|
||||
.inner_margin(egui::Margin::same(0.0));
|
||||
|
||||
let (_inner, dropped) = ui.dnd_drop_zone::<DragPayload, _>(frame, |ui| {
|
||||
render_content(ui, state, tab_idx, registry, rt_handle, queue);
|
||||
});
|
||||
|
||||
let remote_path = state.tabs[tab_idx].remote_path.clone();
|
||||
|
||||
if let Some(payload_arc) = dropped {
|
||||
handle_drop(
|
||||
state,
|
||||
tab_idx,
|
||||
queue,
|
||||
registry,
|
||||
rt_handle,
|
||||
&payload_arc,
|
||||
&remote_path,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Обрабатывает бросок файла в директорию dest_dir на удалённой стороне —
|
||||
/// общий код для дропа на весь пейн и дропа на конкретную папку в списке.
|
||||
fn handle_drop(
|
||||
state: &mut AppState,
|
||||
tab_idx: usize,
|
||||
queue: &TransferQueue,
|
||||
registry: &Arc<RemoteRegistry>,
|
||||
rt_handle: &tokio::runtime::Handle,
|
||||
payload: &DragPayload,
|
||||
dest_dir: &str,
|
||||
) {
|
||||
let is_connected = state.tabs[tab_idx].status == ConnectionStatus::Connected;
|
||||
let connection_id = state.tabs[tab_idx].id.clone();
|
||||
|
||||
match payload {
|
||||
DragPayload::LocalFile(local_path, file_name) => {
|
||||
if is_connected {
|
||||
let dest = format!("{}/{}", dest_dir.trim_end_matches('/'), file_name);
|
||||
let task = TransferTask::new(
|
||||
TransferKind::Upload,
|
||||
connection_id,
|
||||
local_path.clone(),
|
||||
dest,
|
||||
file_name.clone(),
|
||||
0,
|
||||
);
|
||||
queue.push(task);
|
||||
state.status_message = format!("Upload queued: {}", file_name);
|
||||
} else {
|
||||
state.status_message = "No active connection".into();
|
||||
}
|
||||
}
|
||||
DragPayload::RemoteFile(remote_path, file_name, conn_id) => {
|
||||
if *conn_id != connection_id {
|
||||
state.status_message = "Cannot move between different connections".into();
|
||||
return;
|
||||
}
|
||||
let dest = format!("{}/{}", dest_dir.trim_end_matches('/'), file_name);
|
||||
if dest == *remote_path {
|
||||
return;
|
||||
}
|
||||
let registry = registry.clone();
|
||||
let from = remote_path.clone();
|
||||
let conn_id_clone = conn_id.clone();
|
||||
let result = Arc::new(std::sync::Mutex::new(None::<Result<(), String>>));
|
||||
let result_clone = result.clone();
|
||||
rt_handle.spawn(async move {
|
||||
let fs = match registry.get(&conn_id_clone) {
|
||||
Some(fs) => fs,
|
||||
None => {
|
||||
*result_clone.lock().unwrap() = Some(Err("connection not found".into()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let r = fs.rename(&from, &dest).await.map_err(|e| e.to_string());
|
||||
*result_clone.lock().unwrap() = Some(r);
|
||||
});
|
||||
state.pending_rename_result = Some(result);
|
||||
state.status_message = format!("Moving {}…", file_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_content(
|
||||
ui: &mut Ui,
|
||||
state: &mut AppState,
|
||||
tab_idx: usize,
|
||||
registry: &Arc<RemoteRegistry>,
|
||||
rt_handle: &tokio::runtime::Handle,
|
||||
queue: &TransferQueue,
|
||||
) {
|
||||
// Path bar
|
||||
render_path_bar(ui, state, tab_idx, registry, rt_handle);
|
||||
|
||||
let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0));
|
||||
ui.painter().rect_filled(sep, 0.0, BORDER);
|
||||
|
||||
// Loading overlay hint
|
||||
if state.tabs[tab_idx].loading {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(8.0);
|
||||
ui.spinner();
|
||||
ui.label(RichText::new("Loading...").color(TEXT_DIM).size(11.0));
|
||||
});
|
||||
}
|
||||
|
||||
// Table
|
||||
let entries;
|
||||
let mut selected;
|
||||
{
|
||||
let tab = &state.tabs[tab_idx];
|
||||
entries = tab.remote_entries.clone();
|
||||
selected = tab.remote_selected.clone();
|
||||
}
|
||||
|
||||
let is_connected = state.tabs[tab_idx].status == ConnectionStatus::Connected;
|
||||
let conn_id = state.tabs[tab_idx].id.clone();
|
||||
|
||||
let table_id = format!("remote_table_{}", tab_idx);
|
||||
let conn_id_for_drag = conn_id.clone();
|
||||
let FileTableResponse {
|
||||
double_clicked,
|
||||
clicked,
|
||||
dropped_on_dir,
|
||||
context_action,
|
||||
} = file_table(
|
||||
ui,
|
||||
&table_id,
|
||||
&entries,
|
||||
&mut selected,
|
||||
move |entry| {
|
||||
if is_connected {
|
||||
make_remote_payload(entry, &conn_id_for_drag)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
build_context_menu,
|
||||
);
|
||||
|
||||
state.tabs[tab_idx].remote_selected = selected;
|
||||
if clicked.is_some() {
|
||||
state.active_pane = Pane::Remote;
|
||||
}
|
||||
|
||||
if let Some((target_entry, payload)) = dropped_on_dir {
|
||||
handle_drop(
|
||||
state,
|
||||
tab_idx,
|
||||
queue,
|
||||
registry,
|
||||
rt_handle,
|
||||
&payload,
|
||||
&target_entry.path,
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(name) = double_clicked {
|
||||
open_entry(state, tab_idx, registry, rt_handle, queue, &name);
|
||||
}
|
||||
|
||||
if let Some((entry, action)) = context_action {
|
||||
handle_row_action(
|
||||
ui.ctx(),
|
||||
state,
|
||||
tab_idx,
|
||||
registry,
|
||||
rt_handle,
|
||||
queue,
|
||||
&entry,
|
||||
action,
|
||||
);
|
||||
}
|
||||
|
||||
// Footer
|
||||
render_footer(ui, state, tab_idx, registry, rt_handle);
|
||||
}
|
||||
|
||||
fn build_context_menu(ui: &mut Ui, entry: &FileEntry) -> Option<RowAction> {
|
||||
if context_menu_item(ui, Icon::Folder, "Open", false) {
|
||||
return Some(RowAction::Open);
|
||||
}
|
||||
if entry.name != ".." {
|
||||
if context_menu_item(ui, Icon::Pen, "Rename", false) {
|
||||
return Some(RowAction::Rename);
|
||||
}
|
||||
if context_menu_item(ui, Icon::Trash, "Delete", true) {
|
||||
return Some(RowAction::Delete);
|
||||
}
|
||||
ui.separator();
|
||||
if context_menu_item(ui, Icon::Document, "Copy Path", false) {
|
||||
return Some(RowAction::CopyPath);
|
||||
}
|
||||
if entry.kind == EntryKind::File
|
||||
&& context_menu_item(ui, Icon::Download, "Download to Local", false)
|
||||
{
|
||||
return Some(RowAction::Transfer);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn open_entry(
|
||||
state: &mut AppState,
|
||||
tab_idx: usize,
|
||||
registry: &Arc<RemoteRegistry>,
|
||||
rt_handle: &tokio::runtime::Handle,
|
||||
queue: &TransferQueue,
|
||||
name: &str,
|
||||
) {
|
||||
let (nav_path, is_dir) = if name == ".." {
|
||||
let parent = remote_parent(&state.tabs[tab_idx].remote_path);
|
||||
(parent, true)
|
||||
} else {
|
||||
let entry = state.tabs[tab_idx]
|
||||
.remote_entries
|
||||
.iter()
|
||||
.find(|e| e.name == name);
|
||||
match entry {
|
||||
Some(e) if e.kind == EntryKind::Dir => (e.path.clone(), true),
|
||||
_ => (state.tabs[tab_idx].remote_path.clone(), false),
|
||||
}
|
||||
};
|
||||
|
||||
if is_dir {
|
||||
state.tabs[tab_idx].remote_path = nav_path;
|
||||
if !state.tabs[tab_idx].loading {
|
||||
trigger_list(state, tab_idx, registry, rt_handle);
|
||||
}
|
||||
} else if let Some(entry) = state.tabs[tab_idx]
|
||||
.remote_entries
|
||||
.iter()
|
||||
.find(|e| e.name == name)
|
||||
.cloned()
|
||||
{
|
||||
queue_download(state, tab_idx, queue, &entry);
|
||||
}
|
||||
}
|
||||
|
||||
fn queue_download(state: &mut AppState, tab_idx: usize, queue: &TransferQueue, entry: &FileEntry) {
|
||||
let connection_id = state.tabs[tab_idx].id.clone();
|
||||
let task = TransferTask::new(
|
||||
TransferKind::Download,
|
||||
connection_id,
|
||||
format!("{}/{}", state.local_path.trim_end_matches('/'), entry.name),
|
||||
entry.path.clone(),
|
||||
entry.name.clone(),
|
||||
0,
|
||||
);
|
||||
queue.push(task);
|
||||
state.status_message = format!("Download queued: {}", entry.name);
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_row_action(
|
||||
ctx: &egui::Context,
|
||||
state: &mut AppState,
|
||||
tab_idx: usize,
|
||||
registry: &Arc<RemoteRegistry>,
|
||||
rt_handle: &tokio::runtime::Handle,
|
||||
queue: &TransferQueue,
|
||||
entry: &FileEntry,
|
||||
action: RowAction,
|
||||
) {
|
||||
match action {
|
||||
RowAction::Open => open_entry(state, tab_idx, registry, rt_handle, queue, &entry.name),
|
||||
RowAction::Rename => {
|
||||
state.op_target = Pane::Remote;
|
||||
state.show_rename_dialog = true;
|
||||
state.rename_old_name = entry.name.clone();
|
||||
state.rename_new_name = entry.name.clone();
|
||||
}
|
||||
RowAction::Delete => {
|
||||
state.op_target = Pane::Remote;
|
||||
state.show_delete_dialog = true;
|
||||
state.delete_name = entry.name.clone();
|
||||
}
|
||||
RowAction::CopyPath => {
|
||||
ctx.copy_text(entry.path.clone());
|
||||
state.status_message = "Path copied".into();
|
||||
}
|
||||
RowAction::Transfer => queue_download(state, tab_idx, queue, entry),
|
||||
RowAction::Bookmark => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_path_bar(
|
||||
ui: &mut Ui,
|
||||
state: &mut AppState,
|
||||
tab_idx: usize,
|
||||
registry: &Arc<RemoteRegistry>,
|
||||
rt_handle: &tokio::runtime::Handle,
|
||||
) {
|
||||
let frame = egui::Frame::none()
|
||||
.fill(BG_BASE)
|
||||
.inner_margin(egui::Margin::symmetric(12.0, 0.0));
|
||||
|
||||
frame.show(ui, |ui| {
|
||||
let width = ui.available_width();
|
||||
ui.allocate_ui_with_layout(
|
||||
egui::vec2(width, 30.0),
|
||||
egui::Layout::left_to_right(egui::Align::Center),
|
||||
|ui| {
|
||||
let host = state.tabs[tab_idx].params.host.clone();
|
||||
ui.label(
|
||||
RichText::new(format!("REMOTE · {}", host))
|
||||
.color(TEXT_DIM)
|
||||
.size(10.5)
|
||||
.strong(),
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
|
||||
// статус точка
|
||||
let (dot, dot_col) = match state.tabs[tab_idx].status {
|
||||
ConnectionStatus::Connected => ("●", GREEN),
|
||||
ConnectionStatus::Disconnected => ("○", TEXT_HINT),
|
||||
ConnectionStatus::Connecting => ("◐", YELLOW),
|
||||
ConnectionStatus::Error(_) => ("×", RED),
|
||||
};
|
||||
ui.label(RichText::new(dot).color(dot_col).size(10.0));
|
||||
ui.add_space(4.0);
|
||||
|
||||
// Хлебные крошки для remote path
|
||||
let path_clone = state.tabs[tab_idx].remote_path.clone();
|
||||
let parts: Vec<&str> = path_clone.split('/').filter(|s| !s.is_empty()).collect();
|
||||
let mut acc = String::new();
|
||||
|
||||
// Root
|
||||
let root_btn = egui::Button::new(RichText::new("/").color(TEXT_DIM).size(12.0))
|
||||
.fill(egui::Color32::TRANSPARENT)
|
||||
.min_size(egui::vec2(12.0, 22.0));
|
||||
if ui.add(root_btn).clicked() {
|
||||
state.tabs[tab_idx].remote_path = "/".into();
|
||||
if !state.tabs[tab_idx].loading {
|
||||
trigger_list(state, tab_idx, registry, rt_handle);
|
||||
}
|
||||
}
|
||||
|
||||
for (i, part) in parts.iter().enumerate() {
|
||||
acc.push('/');
|
||||
acc.push_str(part);
|
||||
let is_last = i == parts.len() - 1;
|
||||
let path_snap = acc.clone();
|
||||
|
||||
if is_last {
|
||||
ui.label(RichText::new(*part).color(TEXT_PRIMARY).size(12.0).strong());
|
||||
} else {
|
||||
let link =
|
||||
egui::Label::new(RichText::new(*part).color(TEXT_DIM).size(12.0))
|
||||
.sense(egui::Sense::click());
|
||||
if ui.add(link).clicked() {
|
||||
state.tabs[tab_idx].remote_path = path_snap;
|
||||
if !state.tabs[tab_idx].loading {
|
||||
trigger_list(state, tab_idx, registry, rt_handle);
|
||||
}
|
||||
}
|
||||
ui.label(RichText::new("/").color(TEXT_HINT).size(12.0));
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn render_footer(
|
||||
ui: &mut Ui,
|
||||
state: &mut AppState,
|
||||
tab_idx: usize,
|
||||
registry: &Arc<RemoteRegistry>,
|
||||
rt_handle: &tokio::runtime::Handle,
|
||||
) {
|
||||
let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0));
|
||||
ui.painter().rect_filled(sep, 0.0, BORDER);
|
||||
|
||||
let frame = egui::Frame::none()
|
||||
.fill(BG_PANEL)
|
||||
.inner_margin(egui::Margin::symmetric(8.0, 3.0));
|
||||
|
||||
frame.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let tab = &state.tabs[tab_idx];
|
||||
let count = tab.remote_entries.iter().filter(|e| e.name != "..").count();
|
||||
let total_size: u64 = tab.remote_entries.iter().filter_map(|e| e.size).sum();
|
||||
|
||||
let label = if tab.loading {
|
||||
"Loading…".to_string()
|
||||
} else {
|
||||
format!("{} items", count)
|
||||
};
|
||||
|
||||
ui.label(RichText::new(label).color(TEXT_DIM).size(11.0));
|
||||
|
||||
if total_size > 0 {
|
||||
ui.label(
|
||||
RichText::new(format!("({})", format_size(Some(total_size))))
|
||||
.color(TEXT_HINT)
|
||||
.size(11.0),
|
||||
);
|
||||
}
|
||||
|
||||
// Refresh кнопка
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let refresh = egui::Button::image(icons::image(Icon::Refresh, 13.0, ACCENT))
|
||||
.fill(egui::Color32::TRANSPARENT)
|
||||
.min_size(egui::vec2(20.0, 20.0));
|
||||
if ui.add(refresh).on_hover_text("Refresh").clicked()
|
||||
&& !state.tabs[tab_idx].loading
|
||||
{
|
||||
trigger_list(state, tab_idx, registry, rt_handle);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
pub fn trigger_list(
|
||||
state: &mut AppState,
|
||||
tab_idx: usize,
|
||||
registry: &Arc<RemoteRegistry>,
|
||||
rt_handle: &tokio::runtime::Handle,
|
||||
) {
|
||||
if state.tabs[tab_idx].status != ConnectionStatus::Connected {
|
||||
state.status_message = "Not connected".into();
|
||||
return;
|
||||
}
|
||||
state.tabs[tab_idx].loading = true;
|
||||
|
||||
let connection_id = state.tabs[tab_idx].id.clone();
|
||||
let path = state.tabs[tab_idx].remote_path.clone();
|
||||
let registry = registry.clone();
|
||||
|
||||
let result = Arc::new(std::sync::Mutex::new(
|
||||
None::<Result<Vec<FileEntry>, String>>,
|
||||
));
|
||||
let result_clone = result.clone();
|
||||
|
||||
rt_handle.spawn(async move {
|
||||
let fs = match registry.get(&connection_id) {
|
||||
Some(fs) => fs,
|
||||
None => {
|
||||
*result_clone.lock().unwrap() = Some(Err("connection not found".into()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let r = fs.list(&path).await.map_err(|e| e.to_string());
|
||||
*result_clone.lock().unwrap() = Some(r);
|
||||
});
|
||||
|
||||
state
|
||||
.pending_remote_list
|
||||
.push(PendingRemoteList { tab_idx, result });
|
||||
}
|
||||
189
src/ui/panels/remote_pane/actions.rs
Normal file
189
src/ui/panels/remote_pane/actions.rs
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
//! Действия над удалённой ФС: drop-обработка, открытие записей, контекстное меню.
|
||||
use egui::Ui;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::{remote_parent, trigger_list};
|
||||
use crate::domain::connection::ConnectionStatus;
|
||||
use crate::domain::file_entry::{EntryKind, FileEntry};
|
||||
use crate::domain::transfer::{TransferKind, TransferTask};
|
||||
use crate::fs::remote::RemoteRegistry;
|
||||
use crate::transfer::queue::TransferQueue;
|
||||
use crate::ui::drag::DragPayload;
|
||||
use crate::ui::icons::Icon;
|
||||
use crate::ui::panels::file_pane::{RowAction, context_menu_item};
|
||||
use crate::ui::state::{AppState, Pane};
|
||||
|
||||
/// Обрабатывает бросок файла в директорию `dest_dir` на удалённой стороне —
|
||||
/// общий код для дропа на весь пейн и дропа на конкретную папку в списке.
|
||||
pub(super) fn handle_drop(
|
||||
state: &mut AppState,
|
||||
tab_idx: usize,
|
||||
queue: &TransferQueue,
|
||||
registry: &Arc<RemoteRegistry>,
|
||||
rt_handle: &tokio::runtime::Handle,
|
||||
payload: &DragPayload,
|
||||
dest_dir: &str,
|
||||
) {
|
||||
let is_connected = state.tabs[tab_idx].status == ConnectionStatus::Connected;
|
||||
let connection_id = state.tabs[tab_idx].id.clone();
|
||||
|
||||
match payload {
|
||||
DragPayload::LocalFile(local_path, file_name) => {
|
||||
if is_connected {
|
||||
let dest = format!("{}/{}", dest_dir.trim_end_matches('/'), file_name);
|
||||
let task = TransferTask::new(
|
||||
TransferKind::Upload,
|
||||
connection_id,
|
||||
local_path.clone(),
|
||||
dest,
|
||||
file_name.clone(),
|
||||
0,
|
||||
);
|
||||
queue.push(task);
|
||||
state.status_message =
|
||||
crate::i18n::tf("toolbar.upload_queued", &[("{name}", file_name)]);
|
||||
} else {
|
||||
state.status_message = crate::i18n::t("panels.remote.no_active_connection").into();
|
||||
}
|
||||
}
|
||||
DragPayload::RemoteFile(remote_path, file_name, conn_id) => {
|
||||
if *conn_id != connection_id {
|
||||
state.status_message =
|
||||
crate::i18n::t("panels.remote.cannot_move_cross_connection").into();
|
||||
return;
|
||||
}
|
||||
let dest = format!("{}/{}", dest_dir.trim_end_matches('/'), file_name);
|
||||
if dest == *remote_path {
|
||||
return;
|
||||
}
|
||||
let registry = registry.clone();
|
||||
let from = remote_path.clone();
|
||||
let conn_id_clone = conn_id.clone();
|
||||
let result = Arc::new(std::sync::Mutex::new(None::<Result<(), String>>));
|
||||
let result_clone = result.clone();
|
||||
rt_handle.spawn(async move {
|
||||
let fs = match registry.get(&conn_id_clone) {
|
||||
Some(fs) => fs,
|
||||
None => {
|
||||
*result_clone.lock().unwrap() = Some(Err("connection not found".into()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let r = fs.rename(&from, &dest).await.map_err(|e| e.to_string());
|
||||
*result_clone.lock().unwrap() = Some(r);
|
||||
});
|
||||
state.pending_rename_result = Some(result);
|
||||
state.status_message =
|
||||
crate::i18n::tf("panels.remote.moving", &[("{name}", file_name)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_context_menu(ui: &mut Ui, entry: &FileEntry) -> Option<RowAction> {
|
||||
use crate::i18n::t;
|
||||
if context_menu_item(ui, Icon::Folder, t("common.open"), false) {
|
||||
return Some(RowAction::Open);
|
||||
}
|
||||
if entry.name != ".." {
|
||||
if context_menu_item(ui, Icon::Pen, t("common.rename"), false) {
|
||||
return Some(RowAction::Rename);
|
||||
}
|
||||
if context_menu_item(ui, Icon::Trash, t("common.delete"), true) {
|
||||
return Some(RowAction::Delete);
|
||||
}
|
||||
ui.separator();
|
||||
if context_menu_item(ui, Icon::Document, t("common.copy_path"), false) {
|
||||
return Some(RowAction::CopyPath);
|
||||
}
|
||||
if entry.kind == EntryKind::File
|
||||
&& context_menu_item(ui, Icon::Download, t("panels.remote.download_to_local"), false)
|
||||
{
|
||||
return Some(RowAction::Transfer);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(super) fn open_entry(
|
||||
state: &mut AppState,
|
||||
tab_idx: usize,
|
||||
registry: &Arc<RemoteRegistry>,
|
||||
rt_handle: &tokio::runtime::Handle,
|
||||
queue: &TransferQueue,
|
||||
name: &str,
|
||||
) {
|
||||
let (nav_path, is_dir) = if name == ".." {
|
||||
let parent = remote_parent(&state.tabs[tab_idx].remote_path);
|
||||
(parent, true)
|
||||
} else {
|
||||
let entry = state.tabs[tab_idx]
|
||||
.remote_entries
|
||||
.iter()
|
||||
.find(|e| e.name == name);
|
||||
match entry {
|
||||
Some(e) if e.kind == EntryKind::Dir => (e.path.clone(), true),
|
||||
_ => (state.tabs[tab_idx].remote_path.clone(), false),
|
||||
}
|
||||
};
|
||||
|
||||
if is_dir {
|
||||
state.tabs[tab_idx].remote_path = nav_path;
|
||||
if !state.tabs[tab_idx].loading {
|
||||
trigger_list(state, tab_idx, registry, rt_handle);
|
||||
}
|
||||
} else if let Some(entry) = state.tabs[tab_idx]
|
||||
.remote_entries
|
||||
.iter()
|
||||
.find(|e| e.name == name)
|
||||
.cloned()
|
||||
{
|
||||
queue_download(state, tab_idx, queue, &entry);
|
||||
}
|
||||
}
|
||||
|
||||
fn queue_download(state: &mut AppState, tab_idx: usize, queue: &TransferQueue, entry: &FileEntry) {
|
||||
let connection_id = state.tabs[tab_idx].id.clone();
|
||||
let task = TransferTask::new(
|
||||
TransferKind::Download,
|
||||
connection_id,
|
||||
format!("{}/{}", state.local_path.trim_end_matches('/'), entry.name),
|
||||
entry.path.clone(),
|
||||
entry.name.clone(),
|
||||
0,
|
||||
);
|
||||
queue.push(task);
|
||||
state.status_message = crate::i18n::tf("toolbar.download_queued", &[("{name}", &entry.name)]);
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn handle_row_action(
|
||||
ctx: &egui::Context,
|
||||
state: &mut AppState,
|
||||
tab_idx: usize,
|
||||
registry: &Arc<RemoteRegistry>,
|
||||
rt_handle: &tokio::runtime::Handle,
|
||||
queue: &TransferQueue,
|
||||
entry: &FileEntry,
|
||||
action: RowAction,
|
||||
) {
|
||||
match action {
|
||||
RowAction::Open => open_entry(state, tab_idx, registry, rt_handle, queue, &entry.name),
|
||||
RowAction::Rename => {
|
||||
state.op_target = Pane::Remote;
|
||||
state.show_rename_dialog = true;
|
||||
state.rename_old_name = entry.name.clone();
|
||||
state.rename_new_name = entry.name.clone();
|
||||
}
|
||||
RowAction::Delete => {
|
||||
state.op_target = Pane::Remote;
|
||||
state.show_delete_dialog = true;
|
||||
state.delete_name = entry.name.clone();
|
||||
}
|
||||
RowAction::CopyPath => {
|
||||
ctx.copy_text(entry.path.clone());
|
||||
state.status_message = crate::i18n::t("panels.path_copied").into();
|
||||
}
|
||||
RowAction::Transfer => queue_download(state, tab_idx, queue, entry),
|
||||
RowAction::Bookmark => {}
|
||||
}
|
||||
}
|
||||
170
src/ui/panels/remote_pane/bars.rs
Normal file
170
src/ui/panels/remote_pane/bars.rs
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
//! Верхняя (путь + статус) и нижняя (счётчик + refresh) полоски удалённого пейна.
|
||||
use egui::{RichText, Ui};
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::trigger_list;
|
||||
use crate::domain::connection::ConnectionStatus;
|
||||
use crate::fs::remote::RemoteRegistry;
|
||||
use crate::ui::icons::{self, Icon};
|
||||
use crate::ui::panels::file_pane::format_size;
|
||||
use crate::ui::state::AppState;
|
||||
use crate::ui::theme::*;
|
||||
use crate::ui::widgets::button;
|
||||
|
||||
pub(super) fn render_path_bar(
|
||||
ui: &mut Ui,
|
||||
state: &mut AppState,
|
||||
tab_idx: usize,
|
||||
registry: &Arc<RemoteRegistry>,
|
||||
rt_handle: &tokio::runtime::Handle,
|
||||
) {
|
||||
let frame = egui::Frame::none()
|
||||
.fill(BG_BASE)
|
||||
.inner_margin(egui::Margin::symmetric(12.0, 0.0));
|
||||
|
||||
frame.show(ui, |ui| {
|
||||
let width = ui.available_width();
|
||||
ui.allocate_ui_with_layout(
|
||||
egui::vec2(width, 30.0),
|
||||
egui::Layout::left_to_right(egui::Align::Center),
|
||||
|ui| {
|
||||
let host = state.tabs[tab_idx].params.host.clone();
|
||||
ui.label(
|
||||
RichText::new(crate::i18n::tf("panels.remote_label", &[("{host}", &host)]))
|
||||
.color(TEXT_DIM)
|
||||
.size(10.5)
|
||||
.strong(),
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
|
||||
// статус точка
|
||||
let (dot, dot_col) = match state.tabs[tab_idx].status {
|
||||
ConnectionStatus::Connected => ("●", GREEN),
|
||||
ConnectionStatus::Disconnected => ("○", TEXT_HINT),
|
||||
ConnectionStatus::Connecting => ("◐", YELLOW),
|
||||
ConnectionStatus::Error(_) => ("×", RED),
|
||||
};
|
||||
ui.label(RichText::new(dot).color(dot_col).size(10.0));
|
||||
ui.add_space(4.0);
|
||||
|
||||
breadcrumbs(ui, state, tab_idx, registry, rt_handle);
|
||||
|
||||
// Индикатор загрузки — справа, не сдвигает таблицу под баром
|
||||
if state.tabs[tab_idx].loading {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label(
|
||||
RichText::new(crate::i18n::t("panels.loading"))
|
||||
.color(TEXT_HINT)
|
||||
.size(10.5),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
ui.spinner();
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Хлебные крошки: root + сегменты пути. Каждый сегмент — ghost-кнопка с
|
||||
/// подсветкой и курсором-рукой, чтобы кликабельность читалась сразу.
|
||||
fn breadcrumbs(
|
||||
ui: &mut Ui,
|
||||
state: &mut AppState,
|
||||
tab_idx: usize,
|
||||
registry: &Arc<RemoteRegistry>,
|
||||
rt_handle: &tokio::runtime::Handle,
|
||||
) {
|
||||
let path_clone = state.tabs[tab_idx].remote_path.clone();
|
||||
let parts: Vec<&str> = path_clone.split('/').filter(|s| !s.is_empty()).collect();
|
||||
let mut acc = String::new();
|
||||
|
||||
let root = egui::Button::new(RichText::new("/").color(TEXT_DIM).size(12.0))
|
||||
.min_size(egui::vec2(12.0, 22.0));
|
||||
if button::ghost(ui, root).clicked() {
|
||||
navigate(state, tab_idx, "/".into(), registry, rt_handle);
|
||||
}
|
||||
|
||||
for (i, part) in parts.iter().enumerate() {
|
||||
acc.push('/');
|
||||
acc.push_str(part);
|
||||
let is_last = i == parts.len() - 1;
|
||||
|
||||
if is_last {
|
||||
ui.label(RichText::new(*part).color(TEXT_PRIMARY).size(12.0).strong());
|
||||
} else {
|
||||
let path_snap = acc.clone();
|
||||
let seg = egui::Button::new(RichText::new(*part).color(TEXT_DIM).size(12.0))
|
||||
.min_size(egui::vec2(0.0, 22.0));
|
||||
if button::ghost(ui, seg).clicked() {
|
||||
navigate(state, tab_idx, path_snap, registry, rt_handle);
|
||||
}
|
||||
ui.label(RichText::new("/").color(TEXT_HINT).size(12.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn navigate(
|
||||
state: &mut AppState,
|
||||
tab_idx: usize,
|
||||
path: String,
|
||||
registry: &Arc<RemoteRegistry>,
|
||||
rt_handle: &tokio::runtime::Handle,
|
||||
) {
|
||||
state.tabs[tab_idx].remote_path = path;
|
||||
if !state.tabs[tab_idx].loading {
|
||||
trigger_list(state, tab_idx, registry, rt_handle);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn render_footer(
|
||||
ui: &mut Ui,
|
||||
state: &mut AppState,
|
||||
tab_idx: usize,
|
||||
registry: &Arc<RemoteRegistry>,
|
||||
rt_handle: &tokio::runtime::Handle,
|
||||
) {
|
||||
let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0));
|
||||
ui.painter().rect_filled(sep, 0.0, BORDER);
|
||||
|
||||
let frame = egui::Frame::none()
|
||||
.fill(BG_PANEL)
|
||||
.inner_margin(egui::Margin::symmetric(8.0, 3.0));
|
||||
|
||||
frame.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let tab = &state.tabs[tab_idx];
|
||||
let count = tab.remote_entries.iter().filter(|e| e.name != "..").count();
|
||||
let total_size: u64 = tab.remote_entries.iter().filter_map(|e| e.size).sum();
|
||||
|
||||
let label = if tab.loading {
|
||||
crate::i18n::t("panels.loading").to_string()
|
||||
} else {
|
||||
crate::i18n::tf("panels.count_items", &[("{n}", &count.to_string())])
|
||||
};
|
||||
|
||||
ui.label(RichText::new(label).color(TEXT_DIM).size(11.0));
|
||||
|
||||
if total_size > 0 {
|
||||
ui.label(
|
||||
RichText::new(format!("({})", format_size(Some(total_size))))
|
||||
.color(TEXT_HINT)
|
||||
.size(11.0),
|
||||
);
|
||||
}
|
||||
|
||||
// Refresh — тот же серый, что и в тулбаре (единый стиль)
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let refresh = egui::Button::image(icons::image(Icon::Refresh, 13.0, TEXT_DIM))
|
||||
.min_size(egui::vec2(22.0, 22.0));
|
||||
if button::ghost(ui, refresh)
|
||||
.on_hover_text(crate::i18n::t("common.refresh"))
|
||||
.clicked()
|
||||
&& !state.tabs[tab_idx].loading
|
||||
{
|
||||
trigger_list(state, tab_idx, registry, rt_handle);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
183
src/ui/panels/remote_pane/mod.rs
Normal file
183
src/ui/panels/remote_pane/mod.rs
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
use egui::Ui;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::domain::connection::ConnectionStatus;
|
||||
use crate::domain::file_entry::FileEntry;
|
||||
use crate::fs::remote::RemoteRegistry;
|
||||
use crate::transfer::queue::TransferQueue;
|
||||
use crate::ui::drag::{DragPayload, make_remote_payload};
|
||||
use crate::ui::panels::file_pane::{FileTableResponse, file_table};
|
||||
use crate::ui::state::{AppState, Pane, PendingRemoteList};
|
||||
|
||||
mod actions;
|
||||
mod bars;
|
||||
|
||||
pub fn remote_parent(path: &str) -> String {
|
||||
let trimmed = path.trim_end_matches('/');
|
||||
if trimmed.is_empty() || trimmed == "/" {
|
||||
return "/".into();
|
||||
}
|
||||
if let Some(idx) = trimmed.rfind('/') {
|
||||
let p = &trimmed[..idx];
|
||||
if p.is_empty() {
|
||||
"/".into()
|
||||
} else {
|
||||
p.to_string()
|
||||
}
|
||||
} else {
|
||||
"/".into()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(
|
||||
ui: &mut Ui,
|
||||
state: &mut AppState,
|
||||
tab_idx: usize,
|
||||
registry: &Arc<RemoteRegistry>,
|
||||
rt_handle: &tokio::runtime::Handle,
|
||||
queue: &TransferQueue,
|
||||
) {
|
||||
let frame = egui::Frame::none()
|
||||
.fill(crate::ui::theme::BG_CONTENT)
|
||||
.inner_margin(egui::Margin::same(0.0));
|
||||
|
||||
let (_inner, dropped) = ui.dnd_drop_zone::<DragPayload, _>(frame, |ui| {
|
||||
render_content(ui, state, tab_idx, registry, rt_handle, queue);
|
||||
});
|
||||
|
||||
let remote_path = state.tabs[tab_idx].remote_path.clone();
|
||||
|
||||
if let Some(payload_arc) = dropped {
|
||||
actions::handle_drop(
|
||||
state,
|
||||
tab_idx,
|
||||
queue,
|
||||
registry,
|
||||
rt_handle,
|
||||
&payload_arc,
|
||||
&remote_path,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_content(
|
||||
ui: &mut Ui,
|
||||
state: &mut AppState,
|
||||
tab_idx: usize,
|
||||
registry: &Arc<RemoteRegistry>,
|
||||
rt_handle: &tokio::runtime::Handle,
|
||||
queue: &TransferQueue,
|
||||
) {
|
||||
bars::render_path_bar(ui, state, tab_idx, registry, rt_handle);
|
||||
|
||||
let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0));
|
||||
ui.painter().rect_filled(sep, 0.0, crate::ui::theme::BORDER);
|
||||
|
||||
// Table
|
||||
let entries;
|
||||
let mut selected;
|
||||
{
|
||||
let tab = &state.tabs[tab_idx];
|
||||
entries = tab.remote_entries.clone();
|
||||
selected = tab.remote_selected.clone();
|
||||
}
|
||||
|
||||
let is_connected = state.tabs[tab_idx].status == ConnectionStatus::Connected;
|
||||
let conn_id = state.tabs[tab_idx].id.clone();
|
||||
|
||||
let table_id = format!("remote_table_{}", tab_idx);
|
||||
let conn_id_for_drag = conn_id.clone();
|
||||
let FileTableResponse {
|
||||
double_clicked,
|
||||
clicked,
|
||||
dropped_on_dir,
|
||||
context_action,
|
||||
} = file_table(
|
||||
ui,
|
||||
&table_id,
|
||||
&entries,
|
||||
&mut selected,
|
||||
move |entry| {
|
||||
if is_connected {
|
||||
make_remote_payload(entry, &conn_id_for_drag)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
actions::build_context_menu,
|
||||
);
|
||||
|
||||
state.tabs[tab_idx].remote_selected = selected;
|
||||
if clicked.is_some() {
|
||||
state.active_pane = Pane::Remote;
|
||||
}
|
||||
|
||||
if let Some((target_entry, payload)) = dropped_on_dir {
|
||||
actions::handle_drop(
|
||||
state,
|
||||
tab_idx,
|
||||
queue,
|
||||
registry,
|
||||
rt_handle,
|
||||
&payload,
|
||||
&target_entry.path,
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(name) = double_clicked {
|
||||
actions::open_entry(state, tab_idx, registry, rt_handle, queue, &name);
|
||||
}
|
||||
|
||||
if let Some((entry, action)) = context_action {
|
||||
actions::handle_row_action(
|
||||
ui.ctx(),
|
||||
state,
|
||||
tab_idx,
|
||||
registry,
|
||||
rt_handle,
|
||||
queue,
|
||||
&entry,
|
||||
action,
|
||||
);
|
||||
}
|
||||
|
||||
bars::render_footer(ui, state, tab_idx, registry, rt_handle);
|
||||
}
|
||||
|
||||
pub fn trigger_list(
|
||||
state: &mut AppState,
|
||||
tab_idx: usize,
|
||||
registry: &Arc<RemoteRegistry>,
|
||||
rt_handle: &tokio::runtime::Handle,
|
||||
) {
|
||||
if state.tabs[tab_idx].status != ConnectionStatus::Connected {
|
||||
state.status_message = crate::i18n::t("status.not_connected").into();
|
||||
return;
|
||||
}
|
||||
state.tabs[tab_idx].loading = true;
|
||||
|
||||
let connection_id = state.tabs[tab_idx].id.clone();
|
||||
let path = state.tabs[tab_idx].remote_path.clone();
|
||||
let registry = registry.clone();
|
||||
|
||||
let result = Arc::new(std::sync::Mutex::new(
|
||||
None::<Result<Vec<FileEntry>, String>>,
|
||||
));
|
||||
let result_clone = result.clone();
|
||||
|
||||
rt_handle.spawn(async move {
|
||||
let fs = match registry.get(&connection_id) {
|
||||
Some(fs) => fs,
|
||||
None => {
|
||||
*result_clone.lock().unwrap() = Some(Err("connection not found".into()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let r = fs.list(&path).await.map_err(|e| e.to_string());
|
||||
*result_clone.lock().unwrap() = Some(r);
|
||||
});
|
||||
|
||||
state
|
||||
.pending_remote_list
|
||||
.push(PendingRemoteList { tab_idx, result });
|
||||
}
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
//! Левая боковая панель — bookmarks + места
|
||||
use crate::i18n::t;
|
||||
use crate::ui::icons::{self, Icon};
|
||||
use crate::ui::panels::local_pane::refresh_local;
|
||||
use crate::ui::state::AppState;
|
||||
use crate::ui::theme::*;
|
||||
use crate::ui::widgets::row::clickable_row;
|
||||
use egui::{RichText, Ui};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
|
|
@ -12,20 +14,18 @@ pub fn render(ui: &mut Ui, state: &mut AppState, db: &Arc<Mutex<rusqlite::Connec
|
|||
.inner_margin(egui::Margin::symmetric(0.0, 0.0));
|
||||
|
||||
frame.show(ui, |ui| {
|
||||
ui.set_width(SIDEBAR_W);
|
||||
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false; 2])
|
||||
.show(ui, |ui| {
|
||||
ui.add_space(8.0);
|
||||
|
||||
section_header(ui, "LOCAL");
|
||||
section_header(ui, t("panels.local_label"));
|
||||
ui.add_space(4.0);
|
||||
|
||||
for qa in &state.quick_access.clone() {
|
||||
let is_active = state.local_path == qa.path;
|
||||
let icon = quick_access_icon(&qa.name);
|
||||
sidebar_item(ui, icon, &qa.name, is_active, || {
|
||||
sidebar_item(ui, icon, quick_access_label(&qa.name), is_active, || {
|
||||
state.local_path = qa.path.clone();
|
||||
refresh_local(state);
|
||||
});
|
||||
|
|
@ -35,15 +35,21 @@ pub fn render(ui: &mut Ui, state: &mut AppState, db: &Arc<Mutex<rusqlite::Connec
|
|||
section_divider(ui);
|
||||
ui.add_space(6.0);
|
||||
|
||||
section_header(ui, "DRIVES");
|
||||
section_header(ui, t("panels.sidebar.drives"));
|
||||
ui.add_space(4.0);
|
||||
|
||||
// Root + data drives
|
||||
let root_active = state.local_path == "/";
|
||||
sidebar_item(ui, Icon::Ssd, "/ (root)", root_active, || {
|
||||
state.local_path = "/".into();
|
||||
refresh_local(state);
|
||||
});
|
||||
sidebar_item(
|
||||
ui,
|
||||
Icon::Ssd,
|
||||
t("panels.sidebar.root_label"),
|
||||
root_active,
|
||||
|| {
|
||||
state.local_path = "/".into();
|
||||
refresh_local(state);
|
||||
},
|
||||
);
|
||||
|
||||
// попытаемся найти /media и /mnt точки монтирования
|
||||
for mount_path in find_mounts() {
|
||||
|
|
@ -58,34 +64,23 @@ pub fn render(ui: &mut Ui, state: &mut AppState, db: &Arc<Mutex<rusqlite::Connec
|
|||
});
|
||||
}
|
||||
|
||||
ui.add_space(10.0);
|
||||
section_divider(ui);
|
||||
ui.add_space(6.0);
|
||||
if !state.bookmarks.is_empty() {
|
||||
ui.add_space(10.0);
|
||||
section_divider(ui);
|
||||
ui.add_space(6.0);
|
||||
|
||||
section_header(ui, "BOOKMARKS");
|
||||
ui.add_space(4.0);
|
||||
section_header(ui, t("panels.sidebar.bookmarks"));
|
||||
ui.add_space(4.0);
|
||||
|
||||
if state.bookmarks.is_empty() {
|
||||
ui.label(
|
||||
RichText::new(" No bookmarks yet")
|
||||
.color(TEXT_HINT)
|
||||
.size(11.0),
|
||||
);
|
||||
ui.label(
|
||||
RichText::new(" Use ★ in file list")
|
||||
.color(TEXT_HINT)
|
||||
.size(10.0),
|
||||
);
|
||||
} else {
|
||||
let mut to_remove: Option<i64> = None;
|
||||
for bm in &state.bookmarks.clone() {
|
||||
let is_active = state.local_path == bm.path;
|
||||
sidebar_item(ui, Icon::Star, &bm.name, is_active, || {
|
||||
let resp = sidebar_item(ui, Icon::Star, &bm.name, is_active, || {
|
||||
state.local_path = bm.path.clone();
|
||||
refresh_local(state);
|
||||
})
|
||||
.context_menu(|ui| {
|
||||
if ui.button("Remove bookmark").clicked() {
|
||||
});
|
||||
crate::ui::widgets::context_menu::context_menu(&resp, |ui| {
|
||||
if ui.button(t("panels.sidebar.remove_bookmark")).clicked() {
|
||||
to_remove = Some(bm.id);
|
||||
ui.close_menu();
|
||||
}
|
||||
|
|
@ -123,35 +118,16 @@ fn sidebar_item(
|
|||
active: bool,
|
||||
mut on_click: impl FnMut(),
|
||||
) -> egui::Response {
|
||||
let bg = if active {
|
||||
BG_ROW_SEL
|
||||
} else {
|
||||
egui::Color32::TRANSPARENT
|
||||
};
|
||||
let text_col = TEXT_PRIMARY;
|
||||
let icon_col = if active { ACCENT } else { TEXT_DIM };
|
||||
|
||||
let resp = egui::Frame::none()
|
||||
.fill(bg)
|
||||
.rounding(RADIUS_SM)
|
||||
.inner_margin(egui::Margin::symmetric(8.0, 0.0))
|
||||
.show(ui, |ui| {
|
||||
ui.allocate_ui_with_layout(
|
||||
egui::vec2(SIDEBAR_W - 32.0, 28.0),
|
||||
egui::Layout::left_to_right(egui::Align::Center),
|
||||
|ui| {
|
||||
icons::icon(ui, icon, 14.0, icon_col);
|
||||
ui.add_space(8.0);
|
||||
ui.label(RichText::new(label).color(text_col).size(12.5));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
let response = resp.response.interact(egui::Sense::click());
|
||||
if response.clicked() {
|
||||
let resp = clickable_row(ui, active, 28.0, |ui| {
|
||||
icons::icon(ui, icon, 14.0, icon_col);
|
||||
ui.add_space(8.0);
|
||||
ui.label(RichText::new(label).color(TEXT_PRIMARY).size(12.5));
|
||||
});
|
||||
if resp.clicked() {
|
||||
on_click();
|
||||
}
|
||||
response
|
||||
resp
|
||||
}
|
||||
|
||||
fn quick_access_icon(name: &str) -> Icon {
|
||||
|
|
@ -167,6 +143,22 @@ fn quick_access_icon(name: &str) -> Icon {
|
|||
}
|
||||
}
|
||||
|
||||
/// `state.quick_access[].name` — стабильный английский идентификатор (нужен
|
||||
/// для матчинга иконки в [`quick_access_icon`] и не должен зависеть от языка);
|
||||
/// здесь он превращается в переведённую подпись для показа в сайдбаре.
|
||||
fn quick_access_label(name: &str) -> &'static str {
|
||||
match name {
|
||||
"Home" => t("panels.sidebar.qa_home"),
|
||||
"Desktop" => t("panels.sidebar.qa_desktop"),
|
||||
"Documents" => t("panels.sidebar.qa_documents"),
|
||||
"Downloads" => t("panels.sidebar.qa_downloads"),
|
||||
"Pictures" => t("panels.sidebar.qa_pictures"),
|
||||
"Music" => t("panels.sidebar.qa_music"),
|
||||
"Videos" => t("panels.sidebar.qa_videos"),
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
fn find_mounts() -> Vec<String> {
|
||||
let mut result = Vec::new();
|
||||
for prefix in ["/media", "/mnt"] {
|
||||
|
|
|
|||
|
|
@ -62,7 +62,11 @@ pub fn render(ui: &mut egui::Ui, state: &AppState) {
|
|||
.size(11.0),
|
||||
);
|
||||
} else {
|
||||
ui.label(RichText::new("Not connected").color(TEXT_HINT).size(11.0));
|
||||
ui.label(
|
||||
RichText::new(crate::i18n::t("status.not_connected"))
|
||||
.color(TEXT_HINT)
|
||||
.size(11.0),
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,112 +0,0 @@
|
|||
use crate::domain::connection::ConnectionStatus;
|
||||
use crate::ui::state::AppState;
|
||||
use crate::ui::theme::*;
|
||||
use egui::{Color32, RichText, Ui};
|
||||
|
||||
pub fn render(ui: &mut Ui, state: &mut AppState) {
|
||||
let frame = egui::Frame::none()
|
||||
.fill(BG_BASE)
|
||||
.inner_margin(egui::Margin {
|
||||
left: 6.0,
|
||||
right: 6.0,
|
||||
top: 0.0,
|
||||
bottom: 0.0,
|
||||
});
|
||||
|
||||
frame.show(ui, |ui| {
|
||||
let width = ui.available_width();
|
||||
ui.allocate_ui_with_layout(
|
||||
egui::vec2(width, TABBAR_H),
|
||||
egui::Layout::left_to_right(egui::Align::Center),
|
||||
|ui| {
|
||||
let mut to_close: Option<usize> = None;
|
||||
|
||||
if state.tabs.is_empty() {
|
||||
ui.label(RichText::new("No connections").color(TEXT_HINT).size(11.0));
|
||||
}
|
||||
|
||||
for (i, tab) in state.tabs.iter().enumerate() {
|
||||
let is_active = i == state.active_tab;
|
||||
|
||||
let (dot_col, dot) = match &tab.status {
|
||||
ConnectionStatus::Connected => (GREEN, "●"),
|
||||
ConnectionStatus::Disconnected => (TEXT_HINT, "○"),
|
||||
ConnectionStatus::Connecting => (YELLOW, "◐"),
|
||||
ConnectionStatus::Error(_) => (RED, "×"),
|
||||
};
|
||||
|
||||
let bg = if is_active {
|
||||
BG_TAB_ACTIVE
|
||||
} else {
|
||||
BG_TAB_IDLE
|
||||
};
|
||||
let tcol = if is_active { TEXT_PRIMARY } else { TEXT_DIM };
|
||||
|
||||
let tab_response = egui::Frame::none()
|
||||
.fill(bg)
|
||||
.rounding(RADIUS_MD)
|
||||
.inner_margin(egui::Margin {
|
||||
left: 10.0,
|
||||
right: 4.0,
|
||||
top: 0.0,
|
||||
bottom: 0.0,
|
||||
})
|
||||
.show(ui, |ui| {
|
||||
ui.allocate_ui_with_layout(
|
||||
egui::vec2(ui.available_width().min(220.0), 24.0),
|
||||
egui::Layout::left_to_right(egui::Align::Center),
|
||||
|ui| {
|
||||
// цветная точка статуса
|
||||
ui.label(RichText::new(dot).color(dot_col).size(9.0));
|
||||
ui.add_space(4.0);
|
||||
// название вкладки
|
||||
let lbl = egui::Label::new(
|
||||
RichText::new(&tab.label).color(tcol).size(12.0),
|
||||
)
|
||||
.sense(egui::Sense::click());
|
||||
if ui.add(lbl).clicked() {
|
||||
// обрабатывается ниже
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
// кнопка закрытия
|
||||
let close = egui::Label::new(
|
||||
RichText::new("×").color(TEXT_HINT).size(14.0),
|
||||
)
|
||||
.sense(egui::Sense::click());
|
||||
if ui.add(close).clicked() {
|
||||
to_close = Some(i);
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
if tab_response.response.clicked() {
|
||||
state.active_tab = i;
|
||||
}
|
||||
}
|
||||
|
||||
// кнопка "+"
|
||||
ui.add_space(4.0);
|
||||
let plus = egui::Button::new(RichText::new("+").color(TEXT_DIM).size(15.0))
|
||||
.fill(Color32::TRANSPARENT)
|
||||
.rounding(RADIUS_MD)
|
||||
.min_size(egui::vec2(24.0, 24.0));
|
||||
if ui.add(plus).clicked() {
|
||||
state.show_connect_dialog = true;
|
||||
}
|
||||
|
||||
if let Some(idx) = to_close {
|
||||
state.tabs.remove(idx);
|
||||
if !state.tabs.is_empty() && state.active_tab >= state.tabs.len() {
|
||||
state.active_tab = state.tabs.len() - 1;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// нижняя граница таббара
|
||||
let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0));
|
||||
ui.painter().rect_filled(sep, 0.0, BORDER);
|
||||
}
|
||||
|
|
@ -1,355 +0,0 @@
|
|||
use crate::domain::transfer::{TransferKind, TransferTask};
|
||||
use crate::transfer::queue::TransferQueue;
|
||||
use crate::ui::icons::{self, Icon};
|
||||
use crate::ui::state::{AppState, Pane};
|
||||
use crate::ui::theme::*;
|
||||
use egui::{Align, Layout, RichText, Ui};
|
||||
|
||||
pub fn render(ui: &mut Ui, state: &mut AppState, queue: &TransferQueue) {
|
||||
let frame = egui::Frame::none()
|
||||
.fill(BG_TOOLBAR)
|
||||
.inner_margin(egui::Margin::symmetric(8.0, 0.0));
|
||||
|
||||
frame.show(ui, |ui| {
|
||||
let width = ui.available_width();
|
||||
ui.allocate_ui_with_layout(
|
||||
egui::vec2(width, TOOLBAR_H),
|
||||
Layout::left_to_right(Align::Center),
|
||||
|ui| {
|
||||
// Логотип
|
||||
icons::icon(ui, Icon::Folder, 17.0, ACCENT);
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
RichText::new("LoFlum")
|
||||
.color(TEXT_PRIMARY)
|
||||
.size(14.0)
|
||||
.strong(),
|
||||
);
|
||||
|
||||
ui.add_space(8.0);
|
||||
separator_v(ui);
|
||||
ui.add_space(8.0);
|
||||
|
||||
// Новое подключение — единственная акцентная кнопка в тулбаре
|
||||
let connect_btn = egui::Button::image_and_text(
|
||||
icons::image(Icon::AddCircleLinear, 15.0, TEXT_PRIMARY),
|
||||
RichText::new("New Connection")
|
||||
.color(TEXT_PRIMARY)
|
||||
.size(12.5),
|
||||
)
|
||||
.fill(ACCENT_DIM)
|
||||
.rounding(RADIUS_MD)
|
||||
.min_size(egui::vec2(0.0, 30.0));
|
||||
if ui.add(connect_btn).clicked() {
|
||||
state.show_connect_dialog = true;
|
||||
}
|
||||
|
||||
separator_v(ui);
|
||||
|
||||
// Кнопки работы с файлами
|
||||
let has_conn = state
|
||||
.active_tab_ref()
|
||||
.map(|t| t.status == crate::domain::connection::ConnectionStatus::Connected)
|
||||
.unwrap_or(false);
|
||||
|
||||
let has_local_sel = state.local_selected.is_some();
|
||||
let has_remote_sel = state
|
||||
.active_tab_ref()
|
||||
.and_then(|t| t.remote_selected.as_ref())
|
||||
.is_some();
|
||||
|
||||
toolbar_btn_enabled(
|
||||
ui,
|
||||
Icon::Upload,
|
||||
"Upload",
|
||||
has_conn && has_local_sel,
|
||||
|| {
|
||||
queue_selected_upload(state, queue);
|
||||
},
|
||||
);
|
||||
toolbar_btn_enabled(
|
||||
ui,
|
||||
Icon::Download,
|
||||
"Download",
|
||||
has_conn && has_remote_sel,
|
||||
|| {
|
||||
queue_selected_download(state, queue);
|
||||
},
|
||||
);
|
||||
|
||||
separator_v(ui);
|
||||
|
||||
// Очередь — с счётчиком
|
||||
let q_count = state.queue_tasks.len();
|
||||
let q_label = if q_count > 0 {
|
||||
format!("Queue {}", q_count)
|
||||
} else {
|
||||
"Queue".to_string()
|
||||
};
|
||||
let q_active = state.show_queue;
|
||||
toolbar_btn(ui, Icon::Clock, &q_label, q_active, || {
|
||||
state.show_queue = !state.show_queue;
|
||||
});
|
||||
|
||||
separator_v(ui);
|
||||
|
||||
let on_remote = state.active_pane == Pane::Remote;
|
||||
let can_new_folder = if on_remote { has_conn } else { true };
|
||||
let can_delete_rename = if on_remote {
|
||||
has_conn && has_remote_sel
|
||||
} else {
|
||||
has_local_sel
|
||||
};
|
||||
|
||||
toolbar_icon_btn(ui, Icon::Refresh, has_conn, "Refresh", || {
|
||||
// refresh remote — вызывается снаружи через state флаг
|
||||
state.pending_refresh = true;
|
||||
});
|
||||
toolbar_icon_btn(
|
||||
ui,
|
||||
Icon::FolderWithFiles,
|
||||
can_new_folder,
|
||||
"New Folder",
|
||||
|| {
|
||||
state.op_target = state.active_pane;
|
||||
state.show_mkdir_dialog = true;
|
||||
state.mkdir_name.clear();
|
||||
},
|
||||
);
|
||||
toolbar_icon_btn(ui, Icon::Trash, can_delete_rename, "Delete", || {
|
||||
let name = if on_remote {
|
||||
state
|
||||
.active_tab_ref()
|
||||
.and_then(|t| t.remote_selected.clone())
|
||||
} else {
|
||||
state.local_selected.clone()
|
||||
};
|
||||
if let Some(name) = name {
|
||||
state.op_target = state.active_pane;
|
||||
state.show_delete_dialog = true;
|
||||
state.delete_name = name;
|
||||
}
|
||||
});
|
||||
toolbar_icon_btn(ui, Icon::Pen, can_delete_rename, "Rename", || {
|
||||
let name = if on_remote {
|
||||
state
|
||||
.active_tab_ref()
|
||||
.and_then(|t| t.remote_selected.clone())
|
||||
} else {
|
||||
state.local_selected.clone()
|
||||
};
|
||||
if let Some(name) = name {
|
||||
state.op_target = state.active_pane;
|
||||
state.show_rename_dialog = true;
|
||||
state.rename_old_name = name.clone();
|
||||
state.rename_new_name = name;
|
||||
}
|
||||
});
|
||||
|
||||
// Правая часть — History / Bookmarks / Settings
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
// На macOS кнопка настроек живёт в нативном верхнем меню
|
||||
// (LoFlum → Settings…), здесь она не нужна.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
toolbar_icon_btn(ui, Icon::Settings, true, "Settings", || {
|
||||
state.show_settings_dialog = true;
|
||||
});
|
||||
toolbar_btn(ui, Icon::History, "History", state.show_history, || {
|
||||
state.show_history = !state.show_history;
|
||||
state.show_bookmarks = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// История поверх
|
||||
if state.show_history {
|
||||
render_history_popup(ui, state);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_history_popup(ui: &mut Ui, state: &mut AppState) {
|
||||
let available = ui.clip_rect();
|
||||
let popup_rect = egui::Rect::from_min_size(
|
||||
egui::pos2(available.max.x - 280.0, TOOLBAR_H),
|
||||
egui::vec2(270.0, 200.0),
|
||||
);
|
||||
|
||||
egui::Area::new(egui::Id::new("history_popup_area"))
|
||||
.fixed_pos(popup_rect.min)
|
||||
.order(egui::Order::Foreground)
|
||||
.show(ui.ctx(), |ui| {
|
||||
egui::Frame::none()
|
||||
.fill(BG_PANEL)
|
||||
.stroke(egui::Stroke::new(1.0, BORDER))
|
||||
.rounding(RADIUS_LG)
|
||||
.inner_margin(egui::Margin::same(10.0))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(260.0);
|
||||
ui.horizontal(|ui| {
|
||||
icons::icon(ui, Icon::ServerSquare, 12.0, TEXT_DIM);
|
||||
ui.add_space(6.0);
|
||||
ui.label(
|
||||
RichText::new("Recent connections")
|
||||
.color(TEXT_DIM)
|
||||
.size(11.0)
|
||||
.strong(),
|
||||
);
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
if state.history.is_empty() {
|
||||
ui.label(RichText::new("No history yet").color(TEXT_HINT));
|
||||
} else {
|
||||
egui::ScrollArea::vertical()
|
||||
.max_height(160.0)
|
||||
.show(ui, |ui| {
|
||||
for entry in &state.history.clone() {
|
||||
let label =
|
||||
format!("{}@{}:{}", entry.user, entry.host, entry.port);
|
||||
let time =
|
||||
RichText::new(&entry.time).color(TEXT_HINT).size(10.0);
|
||||
let btn = ui.add(
|
||||
egui::Button::new(
|
||||
RichText::new(&label).color(TEXT_PRIMARY).size(12.0),
|
||||
)
|
||||
.fill(egui::Color32::TRANSPARENT)
|
||||
.min_size(egui::vec2(240.0, 24.0)),
|
||||
);
|
||||
if btn.clicked() {
|
||||
state.pending_history_reconnect = Some(entry.clone());
|
||||
state.show_history = false;
|
||||
}
|
||||
btn.context_menu(|ui| {
|
||||
if ui.button("Edit").clicked() {
|
||||
crate::ui::dialogs::connection::edit_history_entry(
|
||||
state, entry,
|
||||
);
|
||||
state.show_history = false;
|
||||
ui.close_menu();
|
||||
}
|
||||
if ui.button("Save").clicked() {
|
||||
state.pending_history_save = Some(entry.clone());
|
||||
ui.close_menu();
|
||||
}
|
||||
});
|
||||
ui.label(time);
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(RichText::new("Close").color(TEXT_DIM).size(11.0))
|
||||
.fill(egui::Color32::TRANSPARENT),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
state.show_history = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn toolbar_btn(ui: &mut Ui, icon: Icon, label: &str, active: bool, mut on_click: impl FnMut()) {
|
||||
let fill = if active {
|
||||
BG_TAB_ACTIVE
|
||||
} else {
|
||||
egui::Color32::TRANSPARENT
|
||||
};
|
||||
let text_col = if active { ACCENT } else { TEXT_PRIMARY };
|
||||
let btn = egui::Button::image_and_text(
|
||||
icons::image(icon, 15.0, text_col),
|
||||
RichText::new(label).color(text_col).size(12.5),
|
||||
)
|
||||
.fill(fill)
|
||||
.rounding(RADIUS_MD)
|
||||
.min_size(egui::vec2(0.0, 30.0));
|
||||
if ui.add(btn).clicked() {
|
||||
on_click();
|
||||
}
|
||||
}
|
||||
|
||||
fn toolbar_btn_enabled(
|
||||
ui: &mut Ui,
|
||||
icon: Icon,
|
||||
label: &str,
|
||||
enabled: bool,
|
||||
on_click: impl FnMut(),
|
||||
) {
|
||||
ui.add_enabled_ui(enabled, |ui| {
|
||||
toolbar_btn(ui, icon, label, false, on_click);
|
||||
});
|
||||
}
|
||||
|
||||
fn toolbar_icon_btn(
|
||||
ui: &mut Ui,
|
||||
icon: Icon,
|
||||
enabled: bool,
|
||||
hover: &str,
|
||||
mut on_click: impl FnMut(),
|
||||
) {
|
||||
ui.add_enabled_ui(enabled, |ui| {
|
||||
let col = if enabled { TEXT_DIM } else { TEXT_HINT };
|
||||
let btn = egui::Button::image(icons::image(icon, 16.0, col))
|
||||
.fill(egui::Color32::TRANSPARENT)
|
||||
.rounding(RADIUS_MD)
|
||||
.min_size(egui::vec2(30.0, 30.0));
|
||||
if ui.add(btn).on_hover_text(hover).clicked() {
|
||||
on_click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn separator_v(ui: &mut Ui) {
|
||||
let (_, rect) = ui.allocate_space(egui::vec2(1.0, 20.0));
|
||||
ui.painter().rect_filled(rect, 0.0, BORDER);
|
||||
}
|
||||
|
||||
fn queue_selected_upload(state: &mut AppState, queue: &TransferQueue) {
|
||||
let Some(name) = state.local_selected.clone() else {
|
||||
return;
|
||||
};
|
||||
let Some(tab) = state.active_tab_ref() else {
|
||||
return;
|
||||
};
|
||||
let connection_id = tab.id.clone();
|
||||
let remote_dir = tab.remote_path.clone();
|
||||
let local_path = format!("{}/{}", state.local_path.trim_end_matches('/'), name);
|
||||
let remote_path = format!("{}/{}", remote_dir.trim_end_matches('/'), name);
|
||||
let task = TransferTask::new(
|
||||
TransferKind::Upload,
|
||||
connection_id,
|
||||
local_path,
|
||||
remote_path,
|
||||
name.clone(),
|
||||
0,
|
||||
);
|
||||
queue.push(task);
|
||||
state.status_message = format!("Upload queued: {}", name);
|
||||
}
|
||||
|
||||
fn queue_selected_download(state: &mut AppState, queue: &TransferQueue) {
|
||||
let Some(tab) = state.active_tab_ref() else {
|
||||
return;
|
||||
};
|
||||
let Some(name) = tab.remote_selected.clone() else {
|
||||
return;
|
||||
};
|
||||
let Some(entry) = tab.remote_entries.iter().find(|e| e.name == name) else {
|
||||
return;
|
||||
};
|
||||
let connection_id = tab.id.clone();
|
||||
let remote_path = entry.path.clone();
|
||||
let local_path = format!("{}/{}", state.local_path.trim_end_matches('/'), name);
|
||||
let task = TransferTask::new(
|
||||
TransferKind::Download,
|
||||
connection_id,
|
||||
local_path,
|
||||
remote_path,
|
||||
name.clone(),
|
||||
0,
|
||||
);
|
||||
queue.push(task);
|
||||
state.status_message = format!("Download queued: {}", name);
|
||||
}
|
||||
52
src/ui/panels/toolbar/actions.rs
Normal file
52
src/ui/panels/toolbar/actions.rs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
//! Постановка выбранного файла в очередь из тулбара (Upload/Download).
|
||||
use crate::domain::transfer::{TransferKind, TransferTask};
|
||||
use crate::transfer::queue::TransferQueue;
|
||||
use crate::ui::state::AppState;
|
||||
|
||||
pub fn queue_selected_upload(state: &mut AppState, queue: &TransferQueue) {
|
||||
let Some(name) = state.local_selected.clone() else {
|
||||
return;
|
||||
};
|
||||
let Some(tab) = state.active_tab_ref() else {
|
||||
return;
|
||||
};
|
||||
let connection_id = tab.id.clone();
|
||||
let remote_dir = tab.remote_path.clone();
|
||||
let local_path = format!("{}/{}", state.local_path.trim_end_matches('/'), name);
|
||||
let remote_path = format!("{}/{}", remote_dir.trim_end_matches('/'), name);
|
||||
let task = TransferTask::new(
|
||||
TransferKind::Upload,
|
||||
connection_id,
|
||||
local_path,
|
||||
remote_path,
|
||||
name.clone(),
|
||||
0,
|
||||
);
|
||||
queue.push(task);
|
||||
state.status_message = crate::i18n::tf("toolbar.upload_queued", &[("{name}", &name)]);
|
||||
}
|
||||
|
||||
pub fn queue_selected_download(state: &mut AppState, queue: &TransferQueue) {
|
||||
let Some(tab) = state.active_tab_ref() else {
|
||||
return;
|
||||
};
|
||||
let Some(name) = tab.remote_selected.clone() else {
|
||||
return;
|
||||
};
|
||||
let Some(entry) = tab.remote_entries.iter().find(|e| e.name == name) else {
|
||||
return;
|
||||
};
|
||||
let connection_id = tab.id.clone();
|
||||
let remote_path = entry.path.clone();
|
||||
let local_path = format!("{}/{}", state.local_path.trim_end_matches('/'), name);
|
||||
let task = TransferTask::new(
|
||||
TransferKind::Download,
|
||||
connection_id,
|
||||
local_path,
|
||||
remote_path,
|
||||
name.clone(),
|
||||
0,
|
||||
);
|
||||
queue.push(task);
|
||||
state.status_message = crate::i18n::tf("toolbar.download_queued", &[("{name}", &name)]);
|
||||
}
|
||||
72
src/ui/panels/toolbar/buttons.rs
Normal file
72
src/ui/panels/toolbar/buttons.rs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
//! Кнопки тулбара. Все построены поверх `widgets::button`, поэтому мгновенно
|
||||
//! реагируют на наведение и нажатие (заливка по состояниям), а не только по клику.
|
||||
use egui::{RichText, Ui, Vec2};
|
||||
|
||||
use crate::ui::icons::{self, Icon};
|
||||
use crate::ui::theme::*;
|
||||
use crate::ui::widgets::button;
|
||||
|
||||
/// Кнопка с иконкой и текстом; `active` — «включённое» состояние (акцентный текст + подложка).
|
||||
pub fn text_btn(ui: &mut Ui, icon: Icon, label: &str, active: bool, mut on_click: impl FnMut()) {
|
||||
let text_col = if active { ACCENT } else { TEXT_PRIMARY };
|
||||
let btn = egui::Button::image_and_text(
|
||||
icons::image(icon, 15.0, text_col),
|
||||
RichText::new(label).color(text_col).size(12.5),
|
||||
)
|
||||
.rounding(RADIUS_MD)
|
||||
.min_size(Vec2::new(0.0, 30.0));
|
||||
if button::toggle(ui, btn, active).clicked() {
|
||||
on_click();
|
||||
}
|
||||
}
|
||||
|
||||
/// Иконка-переключатель без подписи (History): подсказка по наведению вместо
|
||||
/// текста рядом — компактнее в узком тулбаре, поведение как у `text_btn`.
|
||||
pub fn icon_toggle_btn(
|
||||
ui: &mut Ui,
|
||||
icon: Icon,
|
||||
active: bool,
|
||||
hover: &str,
|
||||
mut on_click: impl FnMut(),
|
||||
) {
|
||||
let col = if active { ACCENT } else { TEXT_DIM };
|
||||
let btn = egui::Button::image(icons::image(icon, 16.0, col))
|
||||
.rounding(RADIUS_MD)
|
||||
.min_size(Vec2::new(30.0, 30.0));
|
||||
if button::toggle(ui, btn, active)
|
||||
.on_hover_text(hover)
|
||||
.clicked()
|
||||
{
|
||||
on_click();
|
||||
}
|
||||
}
|
||||
|
||||
/// Кнопка с иконкой и текстом, которую можно отключить (Upload/Download).
|
||||
pub fn text_btn_enabled(
|
||||
ui: &mut Ui,
|
||||
icon: Icon,
|
||||
label: &str,
|
||||
enabled: bool,
|
||||
on_click: impl FnMut(),
|
||||
) {
|
||||
ui.add_enabled_ui(enabled, |ui| text_btn(ui, icon, label, false, on_click));
|
||||
}
|
||||
|
||||
/// Иконочная кнопка с подсказкой; серая при disabled.
|
||||
pub fn icon_btn(ui: &mut Ui, icon: Icon, enabled: bool, hover: &str, mut on_click: impl FnMut()) {
|
||||
ui.add_enabled_ui(enabled, |ui| {
|
||||
let col = if enabled { TEXT_DIM } else { TEXT_HINT };
|
||||
let btn = egui::Button::image(icons::image(icon, 16.0, col))
|
||||
.rounding(RADIUS_MD)
|
||||
.min_size(Vec2::new(30.0, 30.0));
|
||||
if button::ghost(ui, btn).on_hover_text(hover).clicked() {
|
||||
on_click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Вертикальный разделитель между группами кнопок.
|
||||
pub fn separator_v(ui: &mut Ui) {
|
||||
let (_, rect) = ui.allocate_space(Vec2::new(1.0, 20.0));
|
||||
ui.painter().rect_filled(rect, 0.0, BORDER);
|
||||
}
|
||||
101
src/ui/panels/toolbar/history.rs
Normal file
101
src/ui/panels/toolbar/history.rs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
//! Всплывающий список недавних подключений (кнопка History в тулбаре).
|
||||
use egui::{RichText, Ui};
|
||||
|
||||
// Алиас `tr`: параметр `t` в этом файле — анимационная «открытость» (f32),
|
||||
// а не перевод, так что `crate::i18n::t` импортирован под другим именем.
|
||||
use crate::i18n::t as tr;
|
||||
use crate::ui::icons::{self, Icon};
|
||||
use crate::ui::state::AppState;
|
||||
use crate::ui::theme::*;
|
||||
use crate::ui::widgets::{button, overlay};
|
||||
|
||||
/// `t` — анимированная «открытость» (0 = скрыт, 1 = открыт): короткий fade
|
||||
/// вместе с лёгким смещением вниз, тот же язык анимации, что и у диалогов.
|
||||
pub fn render_history_popup(ui: &mut Ui, state: &mut AppState, t: f32) {
|
||||
let available = ui.clip_rect();
|
||||
let slide = 6.0 * (1.0 - overlay::ease_out(t));
|
||||
let popup_rect = egui::Rect::from_min_size(
|
||||
egui::pos2(available.max.x - 280.0, TOOLBAR_H + slide),
|
||||
egui::vec2(270.0, 200.0),
|
||||
);
|
||||
|
||||
let area = egui::Area::new(egui::Id::new("history_popup_area"))
|
||||
.fixed_pos(popup_rect.min)
|
||||
.order(egui::Order::Foreground)
|
||||
.interactable(t >= 1.0)
|
||||
.show(ui.ctx(), |ui| {
|
||||
ui.set_opacity(overlay::ease_out(t));
|
||||
egui::Frame::none()
|
||||
.fill(BG_PANEL)
|
||||
.stroke(egui::Stroke::new(1.0, BORDER))
|
||||
.rounding(RADIUS_LG)
|
||||
.inner_margin(egui::Margin::same(10.0))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(260.0);
|
||||
ui.horizontal(|ui| {
|
||||
icons::icon(ui, Icon::ServerSquare, 12.0, TEXT_DIM);
|
||||
ui.add_space(6.0);
|
||||
ui.label(
|
||||
RichText::new(tr("history.recent_connections"))
|
||||
.color(TEXT_DIM)
|
||||
.size(11.0)
|
||||
.strong(),
|
||||
);
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
if state.history.is_empty() {
|
||||
ui.label(RichText::new(tr("history.no_history")).color(TEXT_HINT));
|
||||
} else {
|
||||
egui::ScrollArea::vertical()
|
||||
.max_height(160.0)
|
||||
.show(ui, |ui| {
|
||||
for entry in &state.history.clone() {
|
||||
let label =
|
||||
format!("{}@{}:{}", entry.user, entry.host, entry.port);
|
||||
let time =
|
||||
RichText::new(&entry.time).color(TEXT_HINT).size(10.0);
|
||||
let btn = button::ghost(
|
||||
ui,
|
||||
egui::Button::new(
|
||||
RichText::new(&label).color(TEXT_PRIMARY).size(12.0),
|
||||
)
|
||||
.min_size(egui::vec2(240.0, 24.0)),
|
||||
);
|
||||
if btn.clicked() {
|
||||
state.pending_history_reconnect = Some(entry.clone());
|
||||
state.show_history = false;
|
||||
}
|
||||
crate::ui::widgets::context_menu::context_menu(&btn, |ui| {
|
||||
if ui.button(tr("common.edit")).clicked() {
|
||||
crate::ui::dialogs::connection::edit_history_entry(
|
||||
state, entry,
|
||||
);
|
||||
state.show_history = false;
|
||||
ui.close_menu();
|
||||
}
|
||||
if ui.button(tr("common.save")).clicked() {
|
||||
state.pending_history_save = Some(entry.clone());
|
||||
ui.close_menu();
|
||||
}
|
||||
});
|
||||
ui.label(time);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Клик по пустому пространству (где угодно за пределами попапа) закрывает
|
||||
// его — единственный способ убрать попап теперь, что кнопки Close нет.
|
||||
// Гейт по t>=1.0 (как и interactable выше) не даёт клику, которым попап
|
||||
// только открыли, тем же кадром его закрыть.
|
||||
if t >= 1.0
|
||||
&& ui.ctx().input(|i| i.pointer.any_click())
|
||||
&& ui
|
||||
.ctx()
|
||||
.input(|i| i.pointer.interact_pos())
|
||||
.is_some_and(|pos| !area.response.rect.contains(pos))
|
||||
{
|
||||
state.show_history = false;
|
||||
}
|
||||
}
|
||||
189
src/ui/panels/toolbar/mod.rs
Normal file
189
src/ui/panels/toolbar/mod.rs
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
use crate::domain::connection::ConnectionStatus;
|
||||
use crate::domain::transfer::TaskState;
|
||||
use crate::i18n::t;
|
||||
use crate::transfer::queue::TransferQueue;
|
||||
use crate::ui::icons::{self, Icon};
|
||||
use crate::ui::state::{AppState, Pane};
|
||||
use crate::ui::theme::*;
|
||||
use crate::ui::widgets::{button, overlay};
|
||||
use egui::{Align, Layout, RichText, Ui};
|
||||
|
||||
mod actions;
|
||||
mod buttons;
|
||||
mod history;
|
||||
|
||||
use buttons::{icon_btn, icon_toggle_btn, separator_v, text_btn, text_btn_enabled};
|
||||
|
||||
pub fn render(ui: &mut Ui, state: &mut AppState, queue: &TransferQueue) {
|
||||
let frame = egui::Frame::none()
|
||||
.fill(BG_TOOLBAR)
|
||||
.inner_margin(egui::Margin::symmetric(8.0, 0.0));
|
||||
|
||||
frame.show(ui, |ui| {
|
||||
let width = ui.available_width();
|
||||
ui.allocate_ui_with_layout(
|
||||
egui::vec2(width, TOOLBAR_H),
|
||||
Layout::left_to_right(Align::Center),
|
||||
|ui| {
|
||||
// Логотип
|
||||
icons::icon(ui, Icon::Folder, 17.0, ACCENT);
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
RichText::new("LoFlum")
|
||||
.color(TEXT_PRIMARY)
|
||||
.size(14.0)
|
||||
.strong(),
|
||||
);
|
||||
|
||||
ui.add_space(8.0);
|
||||
separator_v(ui);
|
||||
ui.add_space(8.0);
|
||||
|
||||
// Новое подключение — единственная акцентная кнопка в тулбаре
|
||||
let connect_btn = egui::Button::image_and_text(
|
||||
icons::image(Icon::AddCircleLinear, 15.0, TEXT_PRIMARY),
|
||||
RichText::new(t("toolbar.new_connection"))
|
||||
.color(TEXT_PRIMARY)
|
||||
.size(12.5),
|
||||
)
|
||||
.rounding(RADIUS_MD)
|
||||
.min_size(egui::vec2(0.0, 30.0));
|
||||
if button::accent_dim(ui, connect_btn).clicked() {
|
||||
state.show_connect_dialog = true;
|
||||
}
|
||||
|
||||
separator_v(ui);
|
||||
|
||||
// Кнопки работы с файлами
|
||||
let has_conn = state
|
||||
.active_tab_ref()
|
||||
.map(|t| t.status == ConnectionStatus::Connected)
|
||||
.unwrap_or(false);
|
||||
|
||||
let has_local_sel = state.local_selected.is_some();
|
||||
let has_remote_sel = state
|
||||
.active_tab_ref()
|
||||
.and_then(|t| t.remote_selected.as_ref())
|
||||
.is_some();
|
||||
|
||||
text_btn_enabled(
|
||||
ui,
|
||||
Icon::Upload,
|
||||
t("common.upload"),
|
||||
has_conn && has_local_sel,
|
||||
|| {
|
||||
actions::queue_selected_upload(state, queue);
|
||||
},
|
||||
);
|
||||
text_btn_enabled(
|
||||
ui,
|
||||
Icon::Download,
|
||||
t("common.download"),
|
||||
has_conn && has_remote_sel,
|
||||
|| {
|
||||
actions::queue_selected_download(state, queue);
|
||||
},
|
||||
);
|
||||
|
||||
separator_v(ui);
|
||||
|
||||
// Очередь — счётчик активных (в согласии с заголовком панели очереди)
|
||||
let q_pending = state
|
||||
.queue_tasks
|
||||
.iter()
|
||||
.filter(|t| matches!(t.state, TaskState::Running | TaskState::Queued))
|
||||
.count();
|
||||
let q_label = if q_pending > 0 {
|
||||
format!("{} {}", t("toolbar.queue_label"), q_pending)
|
||||
} else {
|
||||
t("toolbar.queue_label").to_string()
|
||||
};
|
||||
let q_active = state.show_queue;
|
||||
text_btn(ui, Icon::Clock, &q_label, q_active, || {
|
||||
state.show_queue = !state.show_queue;
|
||||
});
|
||||
|
||||
separator_v(ui);
|
||||
|
||||
let on_remote = state.active_pane == Pane::Remote;
|
||||
let can_new_folder = if on_remote { has_conn } else { true };
|
||||
let can_delete_rename = if on_remote {
|
||||
has_conn && has_remote_sel
|
||||
} else {
|
||||
has_local_sel
|
||||
};
|
||||
|
||||
icon_btn(ui, Icon::Refresh, has_conn, t("common.refresh"), || {
|
||||
// refresh remote — вызывается снаружи через state флаг
|
||||
state.pending_refresh = true;
|
||||
});
|
||||
icon_btn(
|
||||
ui,
|
||||
Icon::FolderWithFiles,
|
||||
can_new_folder,
|
||||
t("common.new_folder"),
|
||||
|| {
|
||||
state.op_target = state.active_pane;
|
||||
state.show_mkdir_dialog = true;
|
||||
state.mkdir_name.clear();
|
||||
},
|
||||
);
|
||||
icon_btn(ui, Icon::Trash, can_delete_rename, t("common.delete"), || {
|
||||
let name = selected_name(state, on_remote);
|
||||
if let Some(name) = name {
|
||||
state.op_target = state.active_pane;
|
||||
state.show_delete_dialog = true;
|
||||
state.delete_name = name;
|
||||
}
|
||||
});
|
||||
icon_btn(ui, Icon::Pen, can_delete_rename, t("common.rename"), || {
|
||||
let name = selected_name(state, on_remote);
|
||||
if let Some(name) = name {
|
||||
state.op_target = state.active_pane;
|
||||
state.show_rename_dialog = true;
|
||||
state.rename_old_name = name.clone();
|
||||
state.rename_new_name = name;
|
||||
}
|
||||
});
|
||||
|
||||
// Правая часть — History / Settings
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
// На macOS кнопка настроек живёт в нативном верхнем меню
|
||||
// (LoFlum → Settings…), здесь она не нужна.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
icon_btn(ui, Icon::Settings, true, t("common.settings"), || {
|
||||
state.show_settings_dialog = true;
|
||||
});
|
||||
icon_toggle_btn(
|
||||
ui,
|
||||
Icon::History,
|
||||
state.show_history,
|
||||
t("common.history"),
|
||||
|| {
|
||||
state.show_history = !state.show_history;
|
||||
state.show_bookmarks = false;
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// «Открытость» тикает каждый кадр (в т.ч. пока скрыто), иначе первое
|
||||
// открытие происходит без анимации — см. overlay::openness.
|
||||
let t_history = overlay::openness(ui.ctx(), "history_popup", state.show_history);
|
||||
if t_history > 0.0 {
|
||||
history::render_history_popup(ui, state, t_history);
|
||||
}
|
||||
}
|
||||
|
||||
/// Имя выбранного элемента в активной панели (remote/local).
|
||||
fn selected_name(state: &AppState, on_remote: bool) -> Option<String> {
|
||||
if on_remote {
|
||||
state
|
||||
.active_tab_ref()
|
||||
.and_then(|t| t.remote_selected.clone())
|
||||
} else {
|
||||
state.local_selected.clone()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue