redesign: file tree always left, transfer area bottom, bookmarks, history
This commit is contained in:
parent
4e637535f0
commit
9cb8e47a97
7 changed files with 287 additions and 151 deletions
108
src/ui/app.rs
108
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,12 +167,19 @@ 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()
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -35,32 +35,10 @@ 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);
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(payload_arc) = dropped {
|
||||
let payload = &*payload_arc;
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
13
src/ui/panels/transfer_area.rs
Normal file
13
src/ui/panels/transfer_area.rs
Normal 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
126
src/ui/panels/tree.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue