rename src-tauri -> src

This commit is contained in:
loki5512344 2026-06-15 21:16:48 +02:00
parent b7b70fe22d
commit 500da04ce7
55 changed files with 11 additions and 5277 deletions

100
src/ui/panels/file_pane.rs Normal file
View file

@ -0,0 +1,100 @@
use crate::domain::file_entry::{EntryKind, FileEntry};
use crate::ui::drag::DragPayload;
use egui::{Grid, Id};
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("%Y-%m-%d %H:%M").to_string(),
None => String::new(),
}
}
pub struct FileTableResponse {
pub double_clicked: Option<String>,
pub clicked: Option<String>,
}
pub fn file_table<F>(
ui: &mut egui::Ui,
id: &str,
entries: &[FileEntry],
selected: &mut Option<String>,
make_drag_payload: F,
) -> FileTableResponse
where
F: Fn(&FileEntry) -> Option<DragPayload>,
{
let mut double_clicked: Option<String> = None;
let mut clicked: Option<String> = None;
let mut sorted = entries.to_vec();
sorted.sort_by(|a, b| match (&a.kind, &b.kind) {
(EntryKind::Dir, EntryKind::Dir) => a.name.cmp(&b.name),
(EntryKind::Dir, _) => std::cmp::Ordering::Less,
(_, EntryKind::Dir) => std::cmp::Ordering::Greater,
_ => a.name.cmp(&b.name),
});
egui::ScrollArea::vertical()
.auto_shrink([false; 2])
.show(ui, |ui| {
Grid::new(id)
.striped(true)
.min_col_width(60.0)
.show(ui, |ui| {
ui.strong("Name");
ui.strong("Size");
ui.strong("Type");
ui.strong("Modified");
ui.end_row();
for entry in &sorted {
let is_selected = selected.as_ref() == Some(&entry.name);
let response = if let Some(payload) = make_drag_payload(entry) {
let drag_id = Id::new(("drag", id, &entry.name));
ui.dnd_drag_source(drag_id, payload, |ui| {
ui.selectable_label(is_selected, &entry.name)
})
.response
} else {
ui.selectable_label(is_selected, &entry.name)
};
if response.clicked() {
*selected = Some(entry.name.clone());
clicked = Some(entry.name.clone());
}
if response.double_clicked() {
double_clicked = Some(entry.name.clone());
}
ui.label(format_size(entry.size));
ui.label(match entry.kind {
EntryKind::Dir => "Dir",
EntryKind::File => "File",
EntryKind::Symlink => "Link",
});
ui.label(format_time(entry.modified));
ui.end_row();
}
});
});
FileTableResponse {
double_clicked,
clicked,
}
}

194
src/ui/panels/local_pane.rs Normal file
View file

@ -0,0 +1,194 @@
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::{make_local_payload, DragPayload};
use crate::ui::panels::file_pane::{file_table, FileTableResponse};
use crate::ui::state::AppState;
use egui::TextEdit;
use std::sync::Arc;
fn parent_path(path: &str) -> String {
let p = std::path::Path::new(path);
p.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 egui::Ui,
state: &mut AppState,
queue: &TransferQueue,
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;
match payload {
DragPayload::RemoteFile(remote_path, file_name, conn_id) => {
if registry.get(conn_id).is_none() {
state.status_message = "Connection no longer active".into();
return;
}
let local_path_str =
format!("{}/{}", state.local_path.trim_end_matches('/'), file_name);
let task = TransferTask::new(
TransferKind::Download,
conn_id.clone(),
local_path_str,
remote_path.clone(),
file_name.clone(),
0,
);
queue.push(task);
state.status_message = "Download queued via drag & drop".into();
}
DragPayload::LocalFile(_, _) => {
state.status_message = "Cannot drop local file onto local pane".into();
}
}
}
}
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| {
let mut path = state.local_path.clone();
ui.add(
TextEdit::singleline(&mut path)
.id("local_path".into())
.desired_width(f32::INFINITY),
);
if path != state.local_path {
state.local_path = path;
refresh_local(state);
}
});
ui.separator();
let entries = state.local_entries.clone();
let mut selected = state.local_selected.clone();
let FileTableResponse { double_clicked, .. } =
file_table(ui, "local_table", &entries, &mut selected, |entry| {
let has_connection = state
.active_tab_ref()
.map(|t| t.status == ConnectionStatus::Connected)
.unwrap_or(false);
if has_connection {
make_local_payload(entry)
} else {
None
}
});
state.local_selected = selected;
if let Some(name) = double_clicked {
if name == ".." {
state.local_path = parent_path(&state.local_path);
} else if let Some(entry) = state.local_entries.iter().find(|e| e.name == name)
&& entry.kind == EntryKind::Dir
{
state.local_path = entry.path.clone();
}
refresh_local(state);
}
ui.separator();
ui.horizontal(|ui| {
ui.label(format!("{} items", state.local_entries.len()));
});
}
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 list error: {}", e);
}
}
}

7
src/ui/panels/mod.rs Normal file
View file

@ -0,0 +1,7 @@
pub mod file_pane;
pub mod local_pane;
pub mod queue;
pub mod remote_pane;
pub mod status_bar;
pub mod tabs;
pub mod toolbar;

83
src/ui/panels/queue.rs Normal file
View file

@ -0,0 +1,83 @@
use crate::domain::transfer::TransferTask;
use crate::ui::panels::file_pane::format_size;
use crate::ui::state::AppState;
use egui::Color32;
pub fn render(ui: &mut egui::Ui, state: &mut AppState, tasks: &[TransferTask]) {
ui.horizontal(|ui| {
ui.heading("\u{1F4E6} Transfer Queue");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.small_button("\u{2715}").clicked() {
state.show_queue = false;
}
});
});
ui.separator();
if tasks.is_empty() {
ui.label("No pending transfers");
return;
}
egui::ScrollArea::vertical()
.auto_shrink([false; 2])
.max_height(200.0)
.show(ui, |ui| {
for task in tasks {
let bg = match &task.state {
crate::domain::transfer::TaskState::Completed => {
Color32::from_rgb(220, 255, 220)
}
crate::domain::transfer::TaskState::Failed(_) => {
Color32::from_rgb(255, 220, 220)
}
crate::domain::transfer::TaskState::Running => Color32::from_rgb(220, 220, 255),
_ => Color32::TRANSPARENT,
};
egui::Frame::none()
.fill(bg)
.inner_margin(egui::Margin::symmetric(4.0, 2.0))
.show(ui, |ui| {
ui.horizontal(|ui| {
let arrow = match &task.kind {
crate::domain::transfer::TransferKind::Upload => "\u{2191}",
crate::domain::transfer::TransferKind::Download => "\u{2193}",
};
ui.label(arrow);
ui.label(&task.file_name);
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
ui.label(task.state.to_string());
},
);
});
ui.horizontal(|ui| {
let pct = task.progress_pct();
let pb = egui::ProgressBar::new(pct as f32 / 100.0)
.text(format!("{:.1}%", pct))
.desired_width(ui.available_width() * 0.6);
ui.add(pb);
ui.label(format!(
"{}/{}",
format_size(Some(task.transferred_bytes)),
format_size(Some(task.total_bytes))
));
});
ui.horizontal(|ui| {
if let Some(speed) = task.speed {
ui.label(format!("{}/s", format_size(Some(speed))));
}
if let Some(eta) = task.eta_secs {
ui.label(format!("ETA: {}s", eta));
}
});
});
}
});
}

View file

@ -0,0 +1,215 @@
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::{make_remote_payload, DragPayload};
use crate::ui::panels::file_pane::{file_table, FileTableResponse};
use crate::ui::state::{AppState, PendingRemoteList};
use egui::TextEdit;
use std::sync::Arc;
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 parent = &trimmed[..idx];
if parent.is_empty() {
"/".into()
} else {
parent.to_string()
}
} else {
"/".into()
}
}
pub fn render(
ui: &mut egui::Ui,
state: &mut AppState,
tab_idx: usize,
registry: &Arc<RemoteRegistry>,
rt_handle: &tokio::runtime::Handle,
queue: &TransferQueue,
) {
ui.horizontal(|ui| {
ui.heading("\u{1F310} Remote");
});
let frame = egui::Frame::none().inner_margin(egui::Margin::symmetric(4.0, 2.0));
let (_inner, dropped) = ui.dnd_drop_zone::<DragPayload, _>(frame, |ui| {
render_content(ui, state, tab_idx, registry, rt_handle);
});
let is_connected = state.tabs[tab_idx].status == ConnectionStatus::Connected;
let connection_id = state.tabs[tab_idx].id.clone();
let remote_path = state.tabs[tab_idx].remote_path.clone();
if let Some(payload_arc) = dropped {
let payload = &*payload_arc;
match payload {
DragPayload::LocalFile(local_path, file_name) => {
if !is_connected {
state.status_message = "No active connection".into();
return;
}
let remote_path_str =
format!("{}/{}", remote_path.trim_end_matches('/'), file_name);
let task = TransferTask::new(
TransferKind::Upload,
connection_id.clone(),
local_path.clone(),
remote_path_str,
file_name.clone(),
0,
);
queue.push(task);
state.status_message = "Upload queued via drag & drop".into();
}
DragPayload::RemoteFile(_, _, _) => {
state.status_message = "Cannot drop remote file onto remote pane".into();
}
}
}
}
fn render_content(
ui: &mut egui::Ui,
state: &mut AppState,
tab_idx: usize,
registry: &Arc<RemoteRegistry>,
rt_handle: &tokio::runtime::Handle,
) {
let label = state.tabs[tab_idx].label.clone();
ui.horizontal(|ui| {
ui.label(format!("\u{25CF} {}", label));
if state.tabs[tab_idx].loading {
ui.label("loading...");
}
});
let path_changed;
{
let tab = &state.tabs[tab_idx];
let mut path = tab.remote_path.clone();
ui.horizontal(|ui| {
ui.add(
TextEdit::singleline(&mut path)
.id("remote_path".into())
.desired_width(f32::INFINITY),
);
});
path_changed = path != tab.remote_path;
if path_changed {
state.tabs[tab_idx].remote_path = path;
}
}
if path_changed {
let loading = state.tabs[tab_idx].loading;
if !loading {
trigger_list(state, tab_idx, registry, rt_handle);
}
}
ui.separator();
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 FileTableResponse { double_clicked, .. } =
file_table(ui, &table_id, &entries, &mut selected, move |entry| {
if is_connected {
make_remote_payload(entry, &conn_id)
} else {
None
}
});
state.tabs[tab_idx].remote_selected = selected;
if let Some(name) = double_clicked {
let (nav_path, is_dir) = {
if name == ".." {
let parent = remote_parent(state.tabs[tab_idx].remote_path.as_str());
(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;
let loading = state.tabs[tab_idx].loading;
if !loading {
trigger_list(state, tab_idx, registry, rt_handle);
}
}
}
ui.separator();
{
let tab = &state.tabs[tab_idx];
ui.horizontal(|ui| {
ui.label(format!("{} items", tab.remote_entries.len()));
if tab.loading {
ui.label(" loading...");
}
});
}
}
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<std::sync::Mutex<Option<Result<Vec<FileEntry>, String>>>> =
Arc::new(std::sync::Mutex::new(None));
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 });
}

View file

@ -0,0 +1,20 @@
use crate::domain::connection::ConnectionStatus;
use crate::ui::state::AppState;
pub fn render(ui: &mut egui::Ui, state: &AppState) {
ui.horizontal(|ui| {
ui.label(&state.status_message);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let queued = state.queue_tasks.len();
ui.label(format!("Queue: {}", queued));
ui.separator();
let connected = state
.tabs
.iter()
.filter(|t| t.status == ConnectionStatus::Connected)
.count();
let total = state.tabs.len();
ui.label(format!("Connections: {}/{}", connected, total));
});
});
}

55
src/ui/panels/tabs.rs Normal file
View file

@ -0,0 +1,55 @@
use crate::domain::connection::ConnectionStatus;
use crate::ui::state::AppState;
pub fn render(ui: &mut egui::Ui, state: &mut AppState) {
ui.horizontal(|ui| {
if state.tabs.is_empty() {
ui.label("No connections");
if ui.button("+ New Connection").clicked() {
state.show_connect_dialog = true;
}
return;
}
let mut to_close: Option<usize> = None;
for (i, tab) in state.tabs.iter().enumerate() {
let is_active = i == state.active_tab;
let dot = match &tab.status {
ConnectionStatus::Connected => "\u{25CF}",
ConnectionStatus::Disconnected => "\u{25CB}",
ConnectionStatus::Connecting => "\u{25D0}",
ConnectionStatus::Error(_) => "\u{2716}",
};
let label = format!("{} {}", dot, tab.label);
let mut tab_button = egui::Button::new(&label);
if is_active {
tab_button = tab_button.fill(egui::Color32::from_rgb(60, 60, 80));
}
let response = ui.add(tab_button);
if response.clicked() {
state.active_tab = i;
}
if ui.small_button("\u{2715}").clicked() {
to_close = Some(i);
}
}
if let Some(idx) = to_close {
state.tabs.remove(idx);
if state.active_tab >= state.tabs.len() && !state.tabs.is_empty() {
state.active_tab = state.tabs.len() - 1;
}
}
ui.separator();
if ui.button("+").clicked() {
state.show_connect_dialog = true;
}
});
}

139
src/ui/panels/toolbar.rs Normal file
View file

@ -0,0 +1,139 @@
use crate::domain::connection::ConnectionStatus;
use crate::domain::file_entry::EntryKind;
use crate::domain::transfer::{TransferKind, TransferTask};
use crate::fs::remote::RemoteRegistry;
use crate::transfer::queue::TransferQueue;
use crate::ui::state::AppState;
use std::sync::Arc;
pub fn render(
ui: &mut egui::Ui,
state: &mut AppState,
queue: &TransferQueue,
registry: &Arc<RemoteRegistry>,
_rt_handle: &tokio::runtime::Handle,
) {
ui.horizontal(|ui| {
let add_btn = egui::Button::new("+ New Connection");
if ui.add(add_btn).clicked() {
state.show_connect_dialog = true;
}
ui.separator();
let has_local_selection = state.local_selected.is_some();
let has_remote_selection = state
.active_tab_ref()
.and_then(|t| t.remote_selected.as_ref())
.is_some();
let (is_connected, tab_exists) = {
let active = state.active_tab_ref();
(
active
.map(|t| t.status == ConnectionStatus::Connected)
.unwrap_or(false),
active.is_some(),
)
};
let tab_id = state.active_tab_ref().map(|t| t.id.clone());
let remote_path = state.active_tab_ref().map(|t| t.remote_path.clone());
let upload_btn = egui::Button::new("\u{2191} Upload")
.min_size(egui::vec2(100.0, 24.0));
ui.add_enabled_ui(is_connected && has_local_selection && tab_exists, |ui| {
if ui.add(upload_btn).clicked()
&& let Some(sel) = state.local_selected.clone()
&& let Some(entry) = state.local_entries.iter().find(|e| e.name == sel)
{
if entry.kind == EntryKind::File {
let file_name = entry.name.clone();
let remote_base = remote_path.clone().unwrap_or_else(|| "/".into());
let remote_path_str =
format!("{}/{}", remote_base.trim_end_matches('/'), file_name);
let cid = tab_id.clone().unwrap_or_default();
let task = TransferTask::new(
TransferKind::Upload,
cid,
entry.path.clone(),
remote_path_str,
file_name,
entry.size.unwrap_or(0),
);
if registry.get(&task.connection_id).is_some() {
queue.push(task);
state.status_message = "Upload queued".into();
} else {
state.status_message = "No connection".into();
}
} else {
state.status_message = "Cannot upload directory".into();
}
}
});
let download_btn = egui::Button::new("\u{2193} Download")
.min_size(egui::vec2(100.0, 24.0));
ui.add_enabled_ui(is_connected && has_remote_selection && tab_exists, |ui| {
if ui.add(download_btn).clicked()
&& let Some(sel) = tab_remote_selected(state)
{
let file_name = sel.clone();
let remote_base = remote_path.clone().unwrap_or_else(|| "/".into());
let remote_path_str =
format!("{}/{}", remote_base.trim_end_matches('/'), file_name);
let local_path_str =
format!("{}/{}", state.local_path.trim_end_matches('/'), file_name);
let size = state
.active_tab_ref()
.map(|t| {
t.remote_entries
.iter()
.find(|e| e.name == sel)
.and_then(|e| e.size)
.unwrap_or(0)
})
.unwrap_or(0);
let cid = tab_id.clone().unwrap_or_default();
let task = TransferTask::new(
TransferKind::Download,
cid,
local_path_str,
remote_path_str,
file_name,
size,
);
if registry.get(&task.connection_id).is_some() {
queue.push(task);
state.status_message = "Download queued".into();
} else {
state.status_message = "No connection".into();
}
}
});
ui.separator();
let queue_label = if state.show_queue {
"Queue \u{25BC}"
} else {
"Queue \u{25B2}"
};
if ui.button(queue_label).clicked() {
state.show_queue = !state.show_queue;
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.separator();
let task_count = state.queue_tasks.len();
if task_count > 0 {
ui.label(format!("{} tasks", task_count));
}
});
});
}
fn tab_remote_selected(state: &AppState) -> Option<String> {
let idx = state.active_tab.min(state.tabs.len().saturating_sub(1));
state.tabs.get(idx).and_then(|t| t.remote_selected.clone())
}