From 9cb8e47a97f09895e40ef2878aa9cd13f4785373 Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Mon, 15 Jun 2026 21:43:10 +0200 Subject: [PATCH] redesign: file tree always left, transfer area bottom, bookmarks, history --- src/ui/app.rs | 110 ++++++++-------------------- src/ui/panels/local_pane.rs | 74 ++++--------------- src/ui/panels/mod.rs | 2 + src/ui/panels/toolbar.rs | 56 +++++++++++++++ src/ui/panels/transfer_area.rs | 13 ++++ src/ui/panels/tree.rs | 126 +++++++++++++++++++++++++++++++++ src/ui/state.rs | 57 ++++++++++++--- 7 files changed, 287 insertions(+), 151 deletions(-) create mode 100644 src/ui/panels/transfer_area.rs create mode 100644 src/ui/panels/tree.rs diff --git a/src/ui/app.rs b/src/ui/app.rs index 0bf525b..d7d6932 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -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(¶ms.host, params.port, ¶ms.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); }); diff --git a/src/ui/panels/local_pane.rs b/src/ui/panels/local_pane.rs index ddb6787..85a96bc 100644 --- a/src/ui/panels/local_pane.rs +++ b/src/ui/panels/local_pane.rs @@ -35,31 +35,9 @@ pub fn render( registry: &Arc, _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::(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(); + } + }); }); } diff --git a/src/ui/panels/mod.rs b/src/ui/panels/mod.rs index 57867e3..0cc33c5 100644 --- a/src/ui/panels/mod.rs +++ b/src/ui/panels/mod.rs @@ -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; diff --git a/src/ui/panels/toolbar.rs b/src/ui/panels/toolbar.rs index e719e22..c9878f3 100644 --- a/src/ui/panels/toolbar.rs +++ b/src/ui/panels/toolbar.rs @@ -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)); + } + }); }); } diff --git a/src/ui/panels/transfer_area.rs b/src/ui/panels/transfer_area.rs new file mode 100644 index 0000000..375489d --- /dev/null +++ b/src/ui/panels/transfer_area.rs @@ -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)); + } + }); + }); +} diff --git a/src/ui/panels/tree.rs b/src/ui/panels/tree.rs new file mode 100644 index 0000000..056878f --- /dev/null +++ b/src/ui/panels/tree.rs @@ -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 = 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); + } + } +} diff --git a/src/ui/state.rs b/src/ui/state.rs index b5ef70c..97ebece 100644 --- a/src/ui/state.rs +++ b/src/ui/state.rs @@ -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>, } +#[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, pub active_tab: usize, pub local_path: String, pub local_entries: Vec, pub local_selected: Option, - 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, + pub history: Vec, + pub tree_expanded: HashMap, + pub tree_children: HashMap>, + pub tree_loading: HashMap, 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); + } + } }