redesign: file tree always left, transfer area bottom, bookmarks, history

This commit is contained in:
loki5512344 2026-06-15 21:43:10 +02:00
parent 4e637535f0
commit 9cb8e47a97
7 changed files with 287 additions and 151 deletions

View file

@ -1,6 +1,6 @@
use std::sync::Arc;
use egui::{CentralPanel, Style, TopBottomPanel, Visuals};
use egui::{CentralPanel, SidePanel, Style, TopBottomPanel, Visuals};
use crate::domain::connection::ConnectionStatus;
use crate::domain::file_entry::{EntryKind, FileEntry};
@ -14,6 +14,8 @@ use crate::ui::panels::remote_pane;
use crate::ui::panels::status_bar;
use crate::ui::panels::tabs;
use crate::ui::panels::toolbar;
use crate::ui::panels::tree;
use crate::ui::panels::transfer_area;
pub struct FileManagerApp {
pub state: crate::ui::state::AppState,
@ -72,7 +74,7 @@ impl FileManagerApp {
let tab = crate::ui::state::ConnectionTab {
id: params.id.clone(),
label: params.label.clone(),
params,
params: params.clone(),
status: ConnectionStatus::Connected,
remote_path: path,
remote_entries: list,
@ -84,10 +86,7 @@ impl FileManagerApp {
self.state.status_message = "Connected".into();
self.state.connect_loading = false;
self.state.show_connect_dialog = false;
self.state.onboarding_host.clear();
self.state.onboarding_user.clear();
self.state.onboarding_pass.clear();
self.state.onboarding_port.clear();
self.state.add_history(&params.host, params.port, &params.username);
}
Err(e) => {
self.state.status_message = format!("Connection failed: {}", e);
@ -151,13 +150,11 @@ impl eframe::App for FileManagerApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
if self.first_frame {
self.first_frame = false;
let style = Style {
visuals: Visuals::dark(),
..Default::default()
};
ctx.set_style(style);
ctx.request_repaint();
}
@ -170,13 +167,20 @@ impl eframe::App for FileManagerApp {
.filter(|t| t.status == ConnectionStatus::Connected)
.count();
TopBottomPanel::top("toolbar").show(ctx, |ui| {
toolbar::render(ui, &mut self.state);
});
TopBottomPanel::top("tabs").show(ctx, |ui| {
tabs::render(ui, &mut self.state);
});
TopBottomPanel::top("toolbar").show(ctx, |ui| {
toolbar::render(ui, &mut self.state);
});
SidePanel::left("tree_panel")
.resizable(true)
.min_width(180.0)
.show(ctx, |ui| {
tree::render(ui, &mut self.state);
});
let has_connection = self.state.active_tab_ref().is_some()
&& self.state.active_tab_ref().unwrap().status == ConnectionStatus::Connected;
@ -209,78 +213,13 @@ impl eframe::App for FileManagerApp {
});
} else {
CentralPanel::default().show(ctx, |ui| {
let available = ui.available_height();
ui.vertical_centered(|ui| {
ui.add_space(available * 0.15);
ui.heading("LoFlum");
ui.label("FTP/SFTP client");
ui.add_space(24.0);
let card_frame = egui::Frame::window(&ctx.style());
card_frame.show(ui, |ui| {
ui.set_min_width(320.0);
ui.vertical_centered(|ui| {
ui.label("Connect to a server");
ui.add_space(12.0);
ui.horizontal(|ui| {
ui.label("Host:");
ui.add(
egui::TextEdit::singleline(&mut self.state.onboarding_host)
.id("onb_host".into())
.desired_width(150.0)
.hint_text("hostname"),
local_pane::render(
ui,
&mut self.state,
&self.queue,
&self.registry,
self.rt.handle(),
);
ui.label("Port:");
ui.add(
egui::TextEdit::singleline(&mut self.state.onboarding_port)
.id("onb_port".into())
.desired_width(60.0)
.char_limit(5)
.hint_text("port"),
);
});
ui.horizontal(|ui| {
ui.label("User:");
ui.add(
egui::TextEdit::singleline(&mut self.state.onboarding_user)
.id("onb_user".into())
.desired_width(200.0)
.hint_text("username"),
);
});
ui.horizontal(|ui| {
ui.label("Pass:");
ui.add(
egui::TextEdit::singleline(&mut self.state.onboarding_pass)
.password(true)
.id("onb_pass".into())
.desired_width(200.0)
.hint_text("password"),
);
});
ui.add_space(12.0);
let conn_btn = egui::Button::new("Connect")
.min_size(egui::vec2(200.0, 32.0));
if ui.add(conn_btn).clicked() {
self.state.connect_host = self.state.onboarding_host.clone();
self.state.connect_user = self.state.onboarding_user.clone();
self.state.connect_pass = self.state.onboarding_pass.clone();
self.state.connect_port = if self.state.onboarding_port.is_empty() {
"22".into()
} else {
self.state.onboarding_port.clone()
};
self.state.connect_protocol = 0;
self.state.show_connect_dialog = true;
}
});
});
});
});
}
@ -295,6 +234,13 @@ impl eframe::App for FileManagerApp {
});
}
TopBottomPanel::bottom("transfer_area")
.default_height(40.0)
.min_height(30.0)
.show(ctx, |ui| {
transfer_area::render(ui, &mut self.state);
});
TopBottomPanel::bottom("status_bar").show(ctx, |ui| {
status_bar::render(ui, &mut self.state);
});

View file

@ -35,31 +35,9 @@ pub fn render(
registry: &Arc<RemoteRegistry>,
_rt_handle: &tokio::runtime::Handle,
) {
ui.horizontal(|ui| {
ui.heading("\u{1F4C1} Local");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.small_button("\u{1F5C4}").clicked() {
state.local_tree_open = !state.local_tree_open;
}
});
});
let frame = egui::Frame::none().inner_margin(egui::Margin::symmetric(4.0, 2.0));
let (_inner, dropped) = ui.dnd_drop_zone::<DragPayload, _>(frame, |ui| {
if state.local_tree_open {
ui.horizontal(|ui| {
ui.separator();
ui.vertical(|ui| {
render_tree(ui, state);
});
ui.separator();
ui.vertical(|ui| {
render_content(ui, state);
});
});
} else {
render_content(ui, state);
}
render_content(ui, state);
});
if let Some(payload_arc) = dropped {
@ -90,44 +68,9 @@ pub fn render(
}
}
fn render_tree(ui: &mut egui::Ui, state: &mut AppState) {
let home = dirs::home_dir()
.unwrap_or_default()
.to_string_lossy()
.to_string();
egui::ScrollArea::vertical()
.auto_shrink([false; 2])
.min_scrolled_width(120.0)
.show(ui, |ui| {
ui.strong("Folders");
ui.separator();
let roots = [
("/", "Root"),
(&home, "Home"),
("/tmp", "tmp"),
("/var", "var"),
("/etc", "etc"),
];
for (root_path, label) in &roots {
let is_current = state.local_path == *root_path
|| state.local_path.starts_with(root_path);
let mut btn = egui::Button::new(*label).min_size(egui::vec2(120.0, 20.0));
if is_current {
btn = btn.fill(egui::Color32::from_rgb(50, 60, 80));
}
if ui.add(btn).clicked() {
state.local_path = root_path.to_string();
refresh_local(state);
}
}
});
}
fn render_content(ui: &mut egui::Ui, state: &mut AppState) {
ui.horizontal(|ui| {
ui.label("\u{1F4C1}");
let mut path = state.local_path.clone();
ui.add(
TextEdit::singleline(&mut path)
@ -174,6 +117,19 @@ fn render_content(ui: &mut egui::Ui, state: &mut AppState) {
ui.separator();
ui.horizontal(|ui| {
ui.label(format!("{} items", state.local_entries.len()));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.small_button("\u{2B50}").clicked() {
let name = std::path::Path::new(&state.local_path)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "Bookmark".into());
state.bookmarks.push(crate::ui::state::Bookmark {
name,
path: state.local_path.clone(),
});
state.status_message = "Bookmark added".into();
}
});
});
}

View file

@ -5,3 +5,5 @@ pub mod remote_pane;
pub mod status_bar;
pub mod tabs;
pub mod toolbar;
pub mod transfer_area;
pub mod tree;

View file

@ -5,5 +5,61 @@ pub fn render(ui: &mut egui::Ui, state: &mut AppState) {
if ui.button("+ New Connection").clicked() {
state.show_connect_dialog = true;
}
ui.separator();
if ui.button("Bookmarks").clicked() {
state.show_bookmarks = !state.show_bookmarks;
state.show_history = false;
}
if ui.button("History").clicked() {
state.show_history = !state.show_history;
state.show_bookmarks = false;
}
if state.show_bookmarks {
egui::ComboBox::from_id_source("bookmarks_menu")
.selected_text("Bookmarks")
.width(150.0)
.show_ui(ui, |ui| {
for bm in &state.bookmarks.clone() {
if ui.button(&bm.name).clicked() {
state.local_path = bm.path.clone();
crate::ui::panels::local_pane::refresh_local(state);
state.show_bookmarks = false;
}
}
});
}
if state.show_history {
egui::ComboBox::from_id_source("history_menu")
.selected_text("History")
.width(200.0)
.show_ui(ui, |ui| {
if state.history.is_empty() {
ui.label("No history yet");
} else {
for entry in &state.history.clone() {
let label = format!("{}@{}:{} [{}]", entry.user, entry.host, entry.port, entry.time);
if ui.button(&label).clicked() {
state.connect_host = entry.host.clone();
state.connect_user = entry.user.clone();
state.connect_port = entry.port.to_string();
state.show_connect_dialog = true;
state.show_history = false;
}
}
}
});
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let queued = state.queue_tasks.len();
if queued > 0 {
ui.label(format!("Queue: {}", queued));
}
});
});
}

View file

@ -0,0 +1,13 @@
use crate::ui::state::AppState;
pub fn render(ui: &mut egui::Ui, state: &mut AppState) {
ui.horizontal(|ui| {
ui.label("\u{1F4E4} Transfer area");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let queued = state.queue_tasks.len();
if queued > 0 {
ui.label(format!("{} files", queued));
}
});
});
}

126
src/ui/panels/tree.rs Normal file
View file

@ -0,0 +1,126 @@
use crate::domain::file_entry::{EntryKind, FileEntry};
use crate::fs::local;
use crate::ui::state::AppState;
pub fn refresh_tree(state: &mut AppState, path: &str) {
if state.tree_children.contains_key(path) {
return;
}
state.tree_loading.insert(path.to_string(), true);
let entries = local::list(path).unwrap_or_default();
let dirs: Vec<FileEntry> = entries
.into_iter()
.filter(|e| e.kind == EntryKind::Dir)
.collect();
state.tree_children.insert(path.to_string(), dirs);
state.tree_loading.remove(path);
}
pub fn render(ui: &mut egui::Ui, state: &mut AppState) {
egui::ScrollArea::vertical()
.auto_shrink([false; 2])
.show(ui, |ui| {
ui.strong("Local Files");
ui.separator();
let home = dirs::home_dir()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let roots = [("/", "Root"), (&home, "Home"), ("/tmp", "tmp")];
for (root_path, label) in &roots {
render_tree_node(ui, state, root_path, label, 0);
}
ui.separator();
ui.strong("Bookmarks");
for bm in &state.bookmarks.clone() {
let is_current = state.local_path == bm.path
|| state.local_path.starts_with(&bm.path);
let mut btn = egui::Button::new(&bm.name).min_size(egui::vec2(140.0, 20.0));
if is_current {
btn = btn.fill(egui::Color32::from_rgb(50, 55, 60));
}
if ui.add(btn).clicked() {
state.local_path = bm.path.clone();
crate::ui::panels::local_pane::refresh_local(state);
}
}
});
}
fn render_tree_node(
ui: &mut egui::Ui,
state: &mut AppState,
path: &str,
label: &str,
depth: usize,
) {
let is_current = state.local_path == path
|| (path != "/"
&& state.local_path.starts_with(path)
&& state.local_path.as_str()[path.len()..].starts_with('/'));
let expanded = state.tree_expanded.get(path).copied().unwrap_or(false);
let has_children = state.tree_children.contains_key(path);
ui.horizontal(|ui| {
ui.add_space(depth as f32 * 16.0);
let arrow = if has_children {
if expanded {
"\u{25BC}"
} else {
"\u{25B6}"
}
} else {
" "
};
let arrow_resp = ui.selectable_label(false, arrow);
if arrow_resp.clicked() && has_children {
if expanded {
state.tree_expanded.insert(path.to_string(), false);
} else {
state.tree_expanded.insert(path.to_string(), true);
if !state.tree_children.contains_key(path) {
refresh_tree(state, path);
}
}
}
let icon = if expanded {
"\u{1F4C2}"
} else {
"\u{1F4C1}"
};
let mut btn = egui::Button::new(format!("{} {}", icon, label))
.min_size(egui::vec2(120.0, 20.0));
if is_current {
btn = btn.fill(egui::Color32::from_rgb(50, 55, 60));
}
if ui.add(btn).clicked() {
state.local_path = path.to_string();
crate::ui::panels::local_pane::refresh_local(state);
}
});
if expanded {
let children = state.tree_children.get(path).cloned().unwrap_or_default();
let mut dirs: Vec<_> = children.iter().collect();
dirs.sort_by(|a, b| a.name.cmp(&b.name));
for child in &dirs {
let child_path = child.path.clone();
let child_label = child.name.clone();
if !state.tree_children.contains_key(&child_path) {
refresh_tree(state, &child_path);
}
render_tree_node(ui, state, &child_path, &child_label, depth + 1);
}
}
}

View file

@ -1,3 +1,4 @@
use std::collections::HashMap;
use std::sync::Arc;
use crate::domain::connection::{ConnectionParams, ConnectionStatus};
@ -27,18 +28,34 @@ pub struct PendingRemoteList {
pub result: PendingResult<Vec<FileEntry>>,
}
#[derive(Clone)]
pub struct Bookmark {
pub name: String,
pub path: String,
}
#[derive(Clone)]
pub struct HistoryEntry {
pub host: String,
pub port: u16,
pub user: String,
pub time: String,
}
pub struct AppState {
pub tabs: Vec<ConnectionTab>,
pub active_tab: usize,
pub local_path: String,
pub local_entries: Vec<FileEntry>,
pub local_selected: Option<String>,
pub local_tree_open: bool,
pub show_connect_dialog: bool,
pub onboarding_host: String,
pub onboarding_user: String,
pub onboarding_pass: String,
pub onboarding_port: String,
pub show_bookmarks: bool,
pub show_history: bool,
pub bookmarks: Vec<Bookmark>,
pub history: Vec<HistoryEntry>,
pub tree_expanded: HashMap<String, bool>,
pub tree_children: HashMap<String, Vec<FileEntry>>,
pub tree_loading: HashMap<String, bool>,
pub connect_label: String,
pub connect_host: String,
pub connect_port: String,
@ -68,12 +85,18 @@ impl Default for AppState {
.to_string(),
local_entries: Vec::new(),
local_selected: None,
local_tree_open: false,
show_connect_dialog: false,
onboarding_host: String::new(),
onboarding_user: String::new(),
onboarding_pass: String::new(),
onboarding_port: String::new(),
show_bookmarks: false,
show_history: false,
bookmarks: vec![
Bookmark { name: "Home".into(), path: dirs::home_dir().unwrap_or_default().to_string_lossy().to_string() },
Bookmark { name: "Root".into(), path: "/".into() },
Bookmark { name: "Downloads".into(), path: dirs::download_dir().map(|p| p.to_string_lossy().to_string()).unwrap_or_else(|| "/tmp".into()) },
],
history: Vec::new(),
tree_expanded: HashMap::new(),
tree_children: HashMap::new(),
tree_loading: HashMap::new(),
connect_label: String::new(),
connect_host: String::new(),
connect_port: "22".into(),
@ -112,4 +135,18 @@ impl AppState {
Some(&self.tabs[idx])
}
}
pub fn add_history(&mut self, host: &str, port: u16, user: &str) {
use chrono::Local;
let now = Local::now().format("%H:%M %d.%m").to_string();
self.history.insert(0, HistoryEntry {
host: host.into(),
port,
user: user.into(),
time: now,
});
if self.history.len() > 20 {
self.history.truncate(20);
}
}
}