8 Commits
6 changed files with 436 additions and 139 deletions
Generated
+1 -1
View File
@@ -6453,7 +6453,7 @@ checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]]
name = "zerosend"
version = "2.5.4"
version = "2.5.6"
dependencies = [
"arboard",
"async-stream",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "zerosend"
version = "2.5.4"
version = "2.5.6"
edition = "2021"
description = "Fast, peer-to-peer file transfer designed for ZeroTier, Tailscale, Radmin VPN and LAN"
authors = ["RarDog"]
+6
View File
@@ -41,6 +41,10 @@ pub struct AppConfig {
pub peer_aliases: HashMap<String, String>,
pub window_width: f32,
pub window_height: f32,
#[serde(default)]
pub window_x: Option<f32>,
#[serde(default)]
pub window_y: Option<f32>,
pub close_to_tray: bool,
pub minimize_to_tray: bool,
pub auto_zip_folders: bool,
@@ -89,6 +93,8 @@ impl Default for AppConfig {
peer_aliases: HashMap::new(),
window_width: 940.0,
window_height: 640.0,
window_x: None,
window_y: None,
close_to_tray: true,
minimize_to_tray: true,
auto_zip_folders: true,
+11 -5
View File
@@ -304,12 +304,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
}
let start_minimized = (cli.tray || cli.minimized) && initial_files.is_empty();
let mut viewport = eframe::egui::ViewportBuilder::default()
.with_inner_size([config.window_width, config.window_height])
.with_min_inner_size([780.0, 500.0])
.with_visible(!start_minimized)
.with_title(format!("ZeroSend v{} - P2P Sharing for ZeroTier & VPN", env!("CARGO_PKG_VERSION")));
if let (Some(x), Some(y)) = (config.window_x, config.window_y) {
viewport = viewport.with_position([x, y]);
}
let options = eframe::NativeOptions {
viewport: eframe::egui::ViewportBuilder::default()
.with_inner_size([config.window_width, config.window_height])
.with_min_inner_size([780.0, 500.0])
.with_visible(!start_minimized)
.with_title(format!("ZeroSend v{} - P2P Sharing for ZeroTier & VPN", env!("CARGO_PKG_VERSION"))),
viewport,
..Default::default()
};
+146 -14
View File
@@ -926,37 +926,89 @@ impl TransferClient {
.map_err(|e| format!("Failed to read file text: {}", e))
}
/// Download a file or folder (zip) from a remote shared folder
pub async fn download_shared_file(
/// Download a file or folder (zip) from a remote shared folder with live progress tracking
pub async fn download_shared_file_with_progress<F>(
&self,
peer_url: &str,
folder_id: &str,
subpath: &str,
dest_path: &std::path::Path,
pin: Option<&str>,
) -> Result<u64, String> {
peer_name: &str,
mut on_progress: F,
) -> Result<u64, String>
where
F: FnMut(TransferProgress) + Send + 'static,
{
let clean = subpath.trim_start_matches('/').trim_start_matches('\\');
let mut url = format!("{}/api/shared/download/{}/{}", peer_url, folder_id, clean);
if let Some(p) = pin.filter(|p| !p.trim().is_empty()) {
url = format!("{}?pin={}", url, p);
}
let fname = dest_path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "downloaded".to_string());
let session_id = format!("shared-dl-{}", uuid::Uuid::new_v4());
on_progress(TransferProgress {
session_id: session_id.clone(),
peer_name: peer_name.to_string(),
is_incoming: true,
current_file_index: 0,
total_files: 1,
current_file_name: fname.clone(),
bytes_transferred: 0,
total_bytes: 0,
speed_bps: 0,
state: TransferState::InProgress,
checksum_verified: false,
compressed_bytes: 0,
});
let resp = self
.client
.get(&url)
.send()
.await
.map_err(|e| format!("Network error: {}", e))?;
.map_err(|e| {
let err_msg = format!("Network error: {}", e);
on_progress(TransferProgress {
session_id: session_id.clone(),
peer_name: peer_name.to_string(),
is_incoming: true,
current_file_index: 0,
total_files: 1,
current_file_name: fname.clone(),
bytes_transferred: 0,
total_bytes: 0,
speed_bps: 0,
state: TransferState::Failed(err_msg.clone()),
checksum_verified: false,
compressed_bytes: 0,
});
err_msg
})?;
if !resp.status().is_success() {
return Err(format!("Download failed: HTTP {}", resp.status()));
let status = resp.status();
let err_msg = format!("Download failed: HTTP {}", status);
on_progress(TransferProgress {
session_id: session_id.clone(),
peer_name: peer_name.to_string(),
is_incoming: true,
current_file_index: 0,
total_files: 1,
current_file_name: fname.clone(),
bytes_transferred: 0,
total_bytes: 0,
speed_bps: 0,
state: TransferState::Failed(err_msg.clone()),
checksum_verified: false,
compressed_bytes: 0,
});
return Err(err_msg);
}
let bytes = resp
.bytes()
.await
.map_err(|e| format!("Failed to read stream: {}", e))?;
let len = bytes.len() as u64;
let total_size = resp.content_length().unwrap_or(0);
if let Some(parent) = dest_path.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
@@ -966,14 +1018,94 @@ impl TransferClient {
.await
.map_err(|e| format!("Could not create file: {}", e))?;
tokio::io::AsyncWriteExt::write_all(&mut file, &bytes)
.await
.map_err(|e| format!("Could not write file: {}", e))?;
use futures_util::StreamExt;
let mut stream = resp.bytes_stream();
let mut bytes_received: u64 = 0;
let start_time = std::time::Instant::now();
let mut last_emit = std::time::Instant::now();
while let Some(chunk_res) = stream.next().await {
match chunk_res {
Ok(chunk) => {
let len = chunk.len() as u64;
bytes_received += len;
tokio::io::AsyncWriteExt::write_all(&mut file, &chunk)
.await
.map_err(|e| format!("Could not write file: {}", e))?;
if last_emit.elapsed() > std::time::Duration::from_millis(250) {
last_emit = std::time::Instant::now();
let elapsed = start_time.elapsed().as_secs_f64().max(0.001);
let speed = (bytes_received as f64 / elapsed) as u64;
on_progress(TransferProgress {
session_id: session_id.clone(),
peer_name: peer_name.to_string(),
is_incoming: true,
current_file_index: 0,
total_files: 1,
current_file_name: fname.clone(),
bytes_transferred: bytes_received,
total_bytes: if total_size > 0 { total_size } else { bytes_received },
speed_bps: speed,
state: TransferState::InProgress,
checksum_verified: false,
compressed_bytes: 0,
});
}
}
Err(e) => {
let err_msg = format!("Stream error: {}", e);
on_progress(TransferProgress {
session_id: session_id.clone(),
peer_name: peer_name.to_string(),
is_incoming: true,
current_file_index: 0,
total_files: 1,
current_file_name: fname.clone(),
bytes_transferred: bytes_received,
total_bytes: total_size,
speed_bps: 0,
state: TransferState::Failed(err_msg.clone()),
checksum_verified: false,
compressed_bytes: 0,
});
return Err(err_msg);
}
}
}
tokio::io::AsyncWriteExt::flush(&mut file)
.await
.map_err(|e| format!("Could not flush file: {}", e))?;
Ok(len)
on_progress(TransferProgress {
session_id: session_id.clone(),
peer_name: peer_name.to_string(),
is_incoming: true,
current_file_index: 1,
total_files: 1,
current_file_name: fname.clone(),
bytes_transferred: bytes_received,
total_bytes: if total_size > 0 { total_size } else { bytes_received },
speed_bps: 0,
state: TransferState::Completed,
checksum_verified: true,
compressed_bytes: 0,
});
Ok(bytes_received)
}
/// Download a file or folder (zip) from a remote shared folder
pub async fn download_shared_file(
&self,
peer_url: &str,
folder_id: &str,
subpath: &str,
dest_path: &std::path::Path,
pin: Option<&str>,
) -> Result<u64, String> {
self.download_shared_file_with_progress(peer_url, folder_id, subpath, dest_path, pin, "Shared", |_| {}).await
}
/// Upload a file directly to a friend's shared folder (if Read & Write allowed)
+271 -118
View File
@@ -414,7 +414,7 @@ impl ZeroSendApp {
selected_peer_ids: HashSet::new(),
queued_files: initial_files,
transfers: initial_transfers,
logs: vec!["⚡ ZeroSend v2.5.4 ready".to_string()],
logs: vec!["⚡ ZeroSend v2.5.6 ready".to_string()],
text_input: String::new(),
analyzed_preview: analyze_text(""),
recipient_pin: String::new(),
@@ -1366,11 +1366,11 @@ impl ZeroSendApp {
.fill(theme.bg_card)
.rounding(Rounding::same(8.0))
.stroke(theme.stroke_border)
.inner_margin(Margin::symmetric(12.0, 8.0))
.inner_margin(Margin::symmetric(14.0, 10.0))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(RichText::new("📤").size(20.0));
ui.label(RichText::new("Режим отправки:").strong().size(13.5).color(theme.text_primary));
ui.label(RichText::new("Режим:").strong().size(13.5).color(theme.text_primary));
let files_active = self.send_mode == SendMode::Files;
let text_active = self.send_mode == SendMode::QuickText;
@@ -1403,8 +1403,8 @@ impl ZeroSendApp {
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
ui.horizontal(|ui| {
ui.label(RichText::new("🔒 PIN (опционально):").size(11.0).color(theme.text_muted));
ui.add(egui::TextEdit::singleline(&mut self.recipient_pin).hint_text("PIN").desired_width(60.0));
ui.label(RichText::new("🔒 PIN:").size(11.0).color(theme.text_muted));
ui.add(egui::TextEdit::singleline(&mut self.recipient_pin).hint_text("PIN").desired_width(65.0));
});
});
});
@@ -1424,46 +1424,85 @@ impl ZeroSendApp {
if !self.peers.is_empty() {
let all_selected = self.selected_peer_ids.len() == self.peers.len();
if ui.small_button(if all_selected { "✕ Снять выбор со всех" } else { "✓ Выбрать всех" }).clicked() {
if all_selected {
self.selected_peer_ids.clear();
} else {
self.selected_peer_ids = self.peers.iter().map(|p| p.peer_id.clone()).collect();
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.button(RichText::new(if all_selected { "✕ Снять выбор" } else { "✓ Выбрать всех" }).color(theme.text_accent).size(11.5)).clicked() {
if all_selected {
self.selected_peer_ids.clear();
} else {
self.selected_peer_ids = self.peers.iter().map(|p| p.peer_id.clone()).collect();
}
}
}
});
}
});
ui.add_space(6.0);
ui.add_space(8.0);
if self.peers.is_empty() {
ui.label(RichText::new("В сети пока нет друзей онлайн. Подключитесь к ZeroTier или проверьте вкладку устройств.").color(theme.text_muted).size(11.5));
Frame::none()
.fill(theme.bg_card_secondary)
.rounding(Rounding::same(6.0))
.inner_margin(Margin::same(12.0))
.show(ui, |ui| {
ui.label(RichText::new("ℹ️ В сети пока нет обнаруженных друзей. Подключитесь к ZeroTier/LAN для автоматического поиска.").color(theme.text_muted).size(11.5));
});
} else {
ui.horizontal_wrapped(|ui| {
for p in &self.peers {
let is_selected = self.selected_peer_ids.contains(&p.peer_id);
let (name_label, _) = self.get_peer_display_name(p);
let chip_text = format!("{} ({})", name_label, p.ip);
let os_icon = match p.os.to_lowercase().as_str() {
s if s.contains("win") => "🪟",
s if s.contains("linux") => "🐧",
s if s.contains("mac") || s.contains("darwin") => "🍎",
s if s.contains("android") || s.contains("ios") => "📱",
_ => "💻",
};
let chip_btn = Button::new(
RichText::new(format!("{} {}", if is_selected { "✅" } else { "⬜" }, chip_text))
.size(12.0)
.strong()
.color(if is_selected { Color32::WHITE } else { theme.text_secondary }),
)
.fill(if is_selected {
Color32::from_rgb(37, 99, 235)
} else {
theme.bg_card_secondary
})
.stroke(if is_selected {
Stroke::new(1.0_f32, Color32::from_rgb(96, 165, 250))
} else {
Stroke::NONE
})
.rounding(Rounding::same(6.0));
let (net_badge, net_color) = match p.net_type.to_lowercase().as_str() {
s if s.contains("zerotier") => ("🪐 ZeroTier", Color32::from_rgb(251, 146, 60)),
s if s.contains("tailscale") => ("🪢 Tailscale", Color32::from_rgb(56, 189, 248)),
s if s.contains("lan") || s.contains("wi-fi") || s.contains("eth") => ("🏠 LAN", Color32::from_rgb(34, 197, 94)),
s if s.contains("radmin") => ("🛡️ Radmin", Color32::from_rgb(168, 85, 247)),
_ => ("🌐 Direct", Color32::from_rgb(100, 116, 139)),
};
if ui.add(chip_btn).clicked() {
let card = Frame::none()
.fill(if is_selected { theme.bg_card_active } else { theme.bg_card_secondary })
.rounding(Rounding::same(8.0))
.stroke(if is_selected {
Stroke::new(1.5_f32, Color32::from_rgb(96, 165, 250))
} else {
theme.stroke_border
})
.inner_margin(Margin::symmetric(10.0, 8.0));
let res = card.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(RichText::new(if is_selected { "✅" } else { "⬜" }).size(13.0));
ui.label(RichText::new(os_icon).size(14.0));
ui.vertical(|ui| {
ui.horizontal(|ui| {
ui.label(RichText::new(&name_label).strong().size(12.0).color(if is_selected { Color32::WHITE } else { theme.text_primary }));
if let Some(ping) = p.ping_ms {
ui.label(RichText::new(format!("⚡ {}ms", ping)).size(10.0).color(Color32::from_rgb(34, 197, 94)));
}
});
ui.horizontal(|ui| {
ui.label(RichText::new(p.ip.to_string()).size(10.0).monospace().color(theme.text_muted));
Frame::none()
.fill(theme.bg_card)
.rounding(Rounding::same(3.0))
.inner_margin(Margin::symmetric(4.0, 1.0))
.show(ui, |ui| {
ui.label(RichText::new(net_badge).size(8.5).color(net_color).strong());
});
});
});
});
});
if res.response.interact(egui::Sense::click()).clicked() {
if is_selected {
self.selected_peer_ids.remove(&p.peer_id);
} else {
@@ -1664,7 +1703,7 @@ impl ZeroSendApp {
} else {
if theme.is_dark { Color32::from_rgb(51, 65, 85) } else { Color32::from_rgb(203, 213, 225) }
})
.min_size(Vec2::new(ui.available_width(), 42.0))
.min_size(Vec2::new(ui.available_width(), 44.0))
.rounding(Rounding::same(8.0));
if ui.add_enabled(can_send, send_btn).clicked() {
@@ -2738,7 +2777,7 @@ impl ZeroSendApp {
ui.vertical_centered(|ui| {
ui.label(RichText::new("🌐").size(40.0));
ui.label(
RichText::new("No shared folders discovered on your network.")
RichText::new("В сети пока нет обнаруженных общих папок.")
.size(16.0)
.strong()
.color(theme.text_primary),
@@ -2746,7 +2785,7 @@ impl ZeroSendApp {
ui.add_space(6.0);
ui.label(
RichText::new(
"When your friends on ZeroTier / LAN add shared folders, they will automatically appear here with streaming and fast download support.",
"Когда друзья в ZeroTier/LAN откроют доступ к своим папкам, они мгновенно появятся здесь со стримингом и скачиванием.",
)
.size(12.0)
.color(theme.text_muted),
@@ -2756,21 +2795,29 @@ impl ZeroSendApp {
return;
}
let total_shared_folders_count: usize = online_peers_with_shares.iter().map(|p| p.shared_folders.len()).sum();
ui.horizontal_top(|ui| {
// Left Column: Peer & Folder Selector (250px)
ui.allocate_ui(Vec2::new(250.0, ui.available_height()), |ui| {
// ── Left Column: Modern Friends & Shares Sidebar (260px) ──
ui.allocate_ui(Vec2::new(260.0, ui.available_height()), |ui| {
Frame::none()
.fill(theme.bg_card)
.rounding(Rounding::same(8.0))
.stroke(theme.stroke_border)
.inner_margin(Margin::same(10.0))
.inner_margin(Margin::symmetric(12.0, 10.0))
.show(ui, |ui| {
ui.label(
RichText::new("Friends & Shares")
.strong()
.size(13.0)
.color(theme.text_primary),
);
ui.horizontal(|ui| {
ui.label(RichText::new("🌐 Общие папки").strong().size(13.5).color(theme.text_primary));
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
Frame::none()
.fill(theme.bg_card_secondary)
.rounding(Rounding::same(10.0))
.inner_margin(Margin::symmetric(6.0, 2.0))
.show(ui, |ui| {
ui.label(RichText::new(format!("{}", total_shared_folders_count)).size(10.5).strong().color(Color32::from_rgb(56, 189, 248)));
});
});
});
ui.add_space(6.0);
ui.separator();
ui.add_space(6.0);
@@ -2778,47 +2825,74 @@ impl ZeroSendApp {
ScrollArea::vertical().show(ui, |ui| {
for peer in &online_peers_with_shares {
let (peer_display, _) = self.get_peer_display_name(peer);
ui.horizontal(|ui| {
ui.label(RichText::new("●").color(Color32::from_rgb(34, 197, 94)).size(11.0));
ui.label(RichText::new(&peer_display).strong().color(theme.text_primary));
});
Frame::none()
.fill(theme.bg_card_secondary)
.rounding(Rounding::same(8.0))
.stroke(theme.stroke_border)
.inner_margin(Margin::same(8.0))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(RichText::new("●").color(Color32::from_rgb(34, 197, 94)).size(10.0));
ui.label(RichText::new(&peer_display).strong().size(12.0).color(theme.text_primary));
if let Some(ping) = peer.ping_ms {
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
ui.label(RichText::new(format!("⚡ {}ms", ping)).size(9.5).color(Color32::from_rgb(34, 197, 94)));
});
}
});
ui.add_space(4.0);
for folder in &peer.shared_folders {
let is_selected = self.selected_peer_id_for_share.as_deref() == Some(&peer.peer_id)
&& self.selected_folder_id_for_share.as_deref() == Some(&folder.id);
for folder in &peer.shared_folders {
let is_selected = self.selected_peer_id_for_share.as_deref() == Some(&peer.peer_id)
&& self.selected_folder_id_for_share.as_deref() == Some(&folder.id);
let badge = if folder.read_only { "RO" } else { "RW" };
let btn_text = format!("📁 {} [{}]", folder.name, badge);
let badge_color = if folder.read_only { Color32::from_rgb(59, 130, 246) } else { Color32::from_rgb(34, 197, 94) };
let badge_text = if folder.read_only { "RO" } else { "RW" };
let btn = Button::new(RichText::new(btn_text).size(12.0).color(if is_selected {
theme.text_accent
} else {
theme.text_secondary
}))
.fill(if is_selected {
theme.bg_card_active
} else {
Color32::TRANSPARENT
})
.min_size(Vec2::new(ui.available_width(), 26.0))
.rounding(Rounding::same(4.0));
let btn = Button::new(
RichText::new(format!("📁 {}", folder.name))
.size(11.5)
.strong()
.color(if is_selected { theme.text_accent } else { theme.text_primary }),
)
.fill(if is_selected { theme.bg_card_active } else { Color32::TRANSPARENT })
.stroke(if is_selected { Stroke::new(1.0_f32, theme.text_accent) } else { Stroke::NONE })
.min_size(Vec2::new(ui.available_width(), 26.0))
.rounding(Rounding::same(6.0));
if ui.add(btn).clicked() {
self.selected_peer_id_for_share = Some(peer.peer_id.clone());
self.selected_folder_id_for_share = Some(folder.id.clone());
self.current_share_subpath = String::new();
self.refresh_current_share_tree();
}
}
ui.horizontal(|ui| {
if ui.add(btn).clicked() {
self.selected_peer_id_for_share = Some(peer.peer_id.clone());
self.selected_folder_id_for_share = Some(folder.id.clone());
self.current_share_subpath = String::new();
self.refresh_current_share_tree();
}
// Pill badge
Frame::none()
.fill(theme.bg_card)
.rounding(Rounding::same(4.0))
.stroke(Stroke::new(1.0_f32, badge_color))
.inner_margin(Margin::symmetric(4.0, 1.0))
.show(ui, |ui| {
ui.label(RichText::new(badge_text).size(9.0).color(badge_color));
});
if folder.requires_pin {
ui.label(RichText::new("🔒").size(10.0));
}
});
ui.add_space(2.0);
}
});
ui.add_space(6.0);
}
});
});
});
ui.add_space(10.0);
ui.add_space(8.0);
// Right Column: Active Share Explorer
// ── Right Column: Active Share Explorer ──
ui.vertical(|ui| {
if let (Some(peer_id), Some(folder_id)) = (
self.selected_peer_id_for_share.clone(),
@@ -2828,39 +2902,70 @@ impl ZeroSendApp {
let peer = match peer_opt {
Some(p) => p,
None => {
ui.label("Selected peer is offline.");
Frame::none()
.fill(theme.bg_card)
.rounding(Rounding::same(8.0))
.stroke(theme.stroke_border)
.inner_margin(Margin::same(20.0))
.show(ui, |ui| {
ui.label(RichText::new("⚠️ Выбранный узел не в сети.").color(Color32::from_rgb(239, 68, 68)));
});
return;
}
};
let (peer_name, _) = self.get_peer_display_name(&peer);
// Top Explorer Header & Breadcrumbs
// Top Explorer Header & Interactive Breadcrumbs Bar
Frame::none()
.fill(theme.bg_card)
.rounding(Rounding::same(8.0))
.stroke(theme.stroke_border)
.inner_margin(Margin::same(10.0))
.inner_margin(Margin::symmetric(10.0, 8.0))
.show(ui, |ui| {
ui.horizontal(|ui| {
if ui.button("🏠 Root").clicked() {
if ui.button(RichText::new("🏠 Корень").strong().color(theme.text_accent)).clicked() {
self.current_share_subpath = String::new();
self.refresh_current_share_tree();
}
let mut navigate_subpath = None;
if !self.current_share_subpath.is_empty() {
ui.label("/");
ui.label(
RichText::new(&self.current_share_subpath)
.monospace()
.strong()
.color(theme.text_primary),
);
let parts: Vec<String> = self.current_share_subpath.split('/').filter(|s| !s.is_empty()).map(|s| s.to_string()).collect();
let mut accumulated = String::new();
for (idx, part) in parts.iter().enumerate() {
ui.label(RichText::new("/").color(theme.text_muted));
if !accumulated.is_empty() {
accumulated.push('/');
}
accumulated.push_str(part);
let is_last = idx == parts.len() - 1;
if is_last {
ui.label(RichText::new(part).strong().color(theme.text_primary));
} else {
let acc_clone = accumulated.clone();
if ui.link(RichText::new(part).color(theme.text_accent)).clicked() {
navigate_subpath = Some(acc_clone);
}
}
}
}
if let Some(target) = navigate_subpath {
self.current_share_subpath = target;
self.refresh_current_share_tree();
}
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
// Search filter box
ui.add(
egui::TextEdit::singleline(&mut self.share_search_query)
.hint_text("🔍 Фильтр...")
.desired_width(120.0),
);
// Grep search button
if ui.button(RichText::new("🔎 Поиск текста/кода").color(Color32::from_rgb(56, 189, 248))).clicked() {
if ui.button(RichText::new("🔎 Поиск кода").color(Color32::from_rgb(56, 189, 248))).clicked() {
self.show_grep_modal = true;
self.grep_query.clear();
self.grep_results.clear();
@@ -2873,7 +2978,7 @@ impl ZeroSendApp {
let peer_name_clone = peer_name.clone();
let f_name = peer.shared_folders.iter().find(|f| f.id == folder_id_clone).map(|f| f.name.clone()).unwrap_or_else(|| "Shared".to_string());
if ui.button(RichText::new("🔄 Авто-Синхронизация").color(Color32::from_rgb(168, 85, 247))).clicked() {
if ui.button(RichText::new("🔄 Синхронизация").color(Color32::from_rgb(168, 85, 247))).clicked() {
self.new_sync_peer_id = peer_id_clone;
self.new_sync_folder_id = folder_id_clone;
self.new_sync_name = format!("Синхронизация {} ({})", f_name, peer_name_clone);
@@ -2883,18 +2988,11 @@ impl ZeroSendApp {
}
}
// Search filter box
ui.add(
egui::TextEdit::singleline(&mut self.share_search_query)
.hint_text("🔍 Фильтр файлов...")
.desired_width(130.0),
);
// Upload button if folder is Read-Write
if let Some(tree) = &self.current_share_tree {
if !tree.read_only {
let upload_btn = Button::new(
RichText::new("⬆️ Upload File")
RichText::new("⬆️ Загрузить")
.strong()
.color(Color32::WHITE),
)
@@ -2927,7 +3025,6 @@ impl ZeroSendApp {
}
}
}
// Refresh tree after upload
if let Ok(tree) = client.fetch_shared_tree(&peer_url, &f_id, Some(&sub), pin.as_deref()).await {
let _ = tx.send(Some(tree));
}
@@ -2940,7 +3037,7 @@ impl ZeroSendApp {
});
});
ui.add_space(8.0);
ui.add_space(6.0);
// Multi-select batch action toolbar
if !self.selected_share_items.is_empty() {
@@ -2951,15 +3048,15 @@ impl ZeroSendApp {
.inner_margin(Margin::symmetric(12.0, 8.0))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(RichText::new(format!("Selected {} item(s)", self.selected_share_items.len())).strong().color(theme.text_primary));
ui.label(RichText::new(format!("Выбрано: {} элементов", self.selected_share_items.len())).strong().color(theme.text_primary));
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.button("Clear Selection").clicked() {
if ui.button("✕ Снять выбор").clicked() {
self.selected_share_items.clear();
}
let dl_btn = Button::new(
RichText::new(format!("⬇️ Download Selected ({})", self.selected_share_items.len()))
RichText::new(format!("⬇️ Скачать выбранные ({})", self.selected_share_items.len()))
.strong()
.color(Color32::WHITE),
)
@@ -2967,8 +3064,10 @@ impl ZeroSendApp {
.rounding(Rounding::same(4.0));
if ui.add(dl_btn).clicked() {
self.switch_tab(Tab::Transfers);
let client = self.client.clone();
let peer_url = peer.transfer_url();
let peer_name_clone = peer_name.clone();
let f_id = folder_id.clone();
let items: Vec<String> = self.selected_share_items.iter().cloned().collect();
let dl_dir = self.download_dir.clone();
@@ -2978,6 +3077,7 @@ impl ZeroSendApp {
Some(self.share_pin_input.trim().to_string())
};
let log_tx = self.log_tx.clone();
let transfer_tx = self.transfer_tx.clone();
let sound = self.sound_notifications;
tokio::spawn(async move {
@@ -2988,7 +3088,10 @@ impl ZeroSendApp {
.unwrap_or_else(|| "downloaded".to_string());
let dest = dl_dir.join(&fname);
let _ = log_tx.send(format!("⬇️ Downloading {}...", fname));
let _ = client.download_shared_file(&peer_url, &f_id, &item_rel, &dest, pin.as_deref()).await;
let tx_clone = transfer_tx.clone();
let _ = client.download_shared_file_with_progress(&peer_url, &f_id, &item_rel, &dest, pin.as_deref(), &peer_name_clone, move |p| {
let _ = tx_clone.send(p);
}).await;
}
if sound {
crate::ui::sound::play_transfer_sound();
@@ -3007,7 +3110,7 @@ impl ZeroSendApp {
ui.vertical_centered(|ui| {
ui.add_space(30.0);
ui.spinner();
ui.label("Loading shared contents...");
ui.label("Загрузка содержимого папки...");
});
} else if let Some(tree) = self.current_share_tree.clone() {
let is_writable_share = !tree.read_only;
@@ -3022,7 +3125,7 @@ impl ZeroSendApp {
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label("📁 ..");
if ui.button("Parent Folder").clicked() {
if ui.button("Вверх (Родительская папка)").clicked() {
let p = std::path::Path::new(&self.current_share_subpath);
let parent_str = p.parent().map(|p| p.to_string_lossy().replace('\\', "/")).unwrap_or_default();
self.current_share_subpath = parent_str;
@@ -3063,7 +3166,7 @@ impl ZeroSendApp {
ui.label(RichText::new(icon).size(16.0));
if entry.is_dir {
if ui.link(RichText::new(&entry.name).strong().color(theme.text_primary)).clicked() {
if ui.link(RichText::new(&entry.name).strong().color(theme.text_accent)).clicked() {
self.current_share_subpath = entry.relative_path.clone();
self.refresh_current_share_tree();
}
@@ -3092,10 +3195,12 @@ impl ZeroSendApp {
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
// Action: Download
let is_dir = entry.is_dir;
let down_label = if is_dir { "⬇️ Zip" } else { "⬇️ Download" };
let down_label = if is_dir { "⬇️ Zip" } else { "⬇️ Скачать" };
if ui.button(down_label).clicked() {
self.switch_tab(Tab::Transfers);
let client = self.client.clone();
let peer_url = peer.transfer_url();
let peer_name_clone = peer_name.clone();
let f_id = folder_id.clone();
let sub = entry.relative_path.clone();
let out_name = if is_dir { format!("{}.zip", entry.name) } else { entry.name.clone() };
@@ -3106,11 +3211,15 @@ impl ZeroSendApp {
Some(self.share_pin_input.trim().to_string())
};
let log_tx = self.log_tx.clone();
let transfer_tx = self.transfer_tx.clone();
let sound = self.sound_notifications;
tokio::spawn(async move {
let _ = log_tx.send(format!("⬇️ Downloading {}...", out_name));
match client.download_shared_file(&peer_url, &f_id, &sub, &dest, pin.as_deref()).await {
let tx_clone = transfer_tx.clone();
match client.download_shared_file_with_progress(&peer_url, &f_id, &sub, &dest, pin.as_deref(), &peer_name_clone, move |p| {
let _ = tx_clone.send(p);
}).await {
Ok(bytes) => {
let _ = log_tx.send(format!("✅ Downloaded {} ({})", out_name, human_bytes(bytes as f64)));
if sound {
@@ -3133,7 +3242,7 @@ impl ZeroSendApp {
entry.relative_path.trim_start_matches('/')
);
if ui.button(RichText::new("🎵 Play in App").color(Color32::from_rgb(236, 72, 153)).strong()).clicked() {
if ui.button(RichText::new("🎵 Слушать").color(Color32::from_rgb(236, 72, 153)).strong()).clicked() {
self.active_audio = Some(AudioTrackInfo {
name: entry.name.clone(),
url: stream_url.clone(),
@@ -3169,7 +3278,7 @@ impl ZeroSendApp {
let _ = std::process::Command::new("explorer").arg(&stream_url).spawn();
}
if ui.button(RichText::new("▶️ Stream").color(Color32::from_rgb(56, 189, 248))).clicked() {
if ui.button(RichText::new("▶️ Смотреть").color(Color32::from_rgb(56, 189, 248))).clicked() {
let _ = std::process::Command::new("explorer").arg(&stream_url).spawn();
}
}
@@ -3215,12 +3324,12 @@ impl ZeroSendApp {
.inner_margin(Margin::same(20.0))
.show(ui, |ui| {
ui.vertical_centered(|ui| {
ui.label(RichText::new("🔒 Folder Locked or Empty").strong().size(14.0).color(theme.text_primary));
ui.label(RichText::new("🔒 Папка защищена или пуста").strong().size(14.0).color(theme.text_primary));
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.label("Enter PIN if required:");
ui.label("Введите PIN если требуется:");
ui.add(egui::TextEdit::singleline(&mut self.share_pin_input).password(true).desired_width(120.0));
if ui.button("Unlock / Refresh").clicked() {
if ui.button("Разблокировать / Обновить").clicked() {
self.refresh_current_share_tree();
}
});
@@ -3232,10 +3341,12 @@ impl ZeroSendApp {
.fill(theme.bg_card)
.rounding(Rounding::same(8.0))
.stroke(theme.stroke_border)
.inner_margin(Margin::same(40.0))
.inner_margin(Margin::same(30.0))
.show(ui, |ui| {
ui.vertical_centered(|ui| {
ui.label(RichText::new("👈 Select a shared folder on the left to browse files, watch streams, and view code in Vim.").color(theme.text_muted));
ui.label(RichText::new("👈 Выберите общую папку слева").size(15.0).strong().color(theme.text_primary));
ui.add_space(4.0);
ui.label(RichText::new("Выберите узел и папку в списке слева для просмотра и скачивания файлов.").size(12.0).color(theme.text_muted));
});
});
}
@@ -4106,10 +4217,31 @@ impl ZeroSendApp {
.stroke(theme.stroke_border)
.inner_margin(Margin::same(14.0))
.show(ui, |ui| {
// v2.5.6
ui.horizontal(|ui| {
ui.label(RichText::new("v2.5.6").strong().color(Color32::from_rgb(34, 197, 94)));
ui.label(RichText::new("(Текущая версия)").size(11.0).color(Color32::GRAY));
});
ui.label(" • 🪟 Сохранение положения и размера окна: Позиция окна (X, Y) и его размеры теперь непрерывно отслеживаются и восстанавливаются при каждом запуске приложения.");
ui.label(" • 📤 Новый дизайн вкладки «Отправить файлы»: Мульти-выбор друзей карточками с ОС-иконками, бейджами сетей (ZeroTier/Tailscale/LAN), пингом и Drag & Drop.");
ui.label(" • 📁 Обновлённый проводник «Сетевые общие папки»: Интерактивные Breadcrumbs, поиск Grep, стриминг и скачивание с мгновенным переходом в историю передач.");
ui.add_space(8.0);
ui.separator();
ui.add_space(8.0);
// v2.5.5
ui.horizontal(|ui| {
ui.label(RichText::new("v2.5.5").strong().color(Color32::from_rgb(56, 189, 248)));
});
ui.label(" • 📥 Мгновенное перенаправление в передачу файлов: При начале скачивания из общей папки приложение сразу переключается во вкладку передач и отображает статус загрузки.");
ui.label(" • ⚡ Отслеживание скорости скачивания общих файлов в реальном времени с выводом прогресса в TransferProgress.");
ui.add_space(8.0);
ui.separator();
ui.add_space(8.0);
// v2.5.4
ui.horizontal(|ui| {
ui.label(RichText::new("v2.5.4").strong().color(Color32::from_rgb(34, 197, 94)));
ui.label(RichText::new("(Текущая версия)").size(11.0).color(Color32::GRAY));
ui.label(RichText::new("v2.5.4").strong().color(Color32::from_rgb(56, 189, 248)));
});
ui.label(" • 🔄 Мгновенное авто-обнаружение общих папок: Динамическое обновление списка папок друзей при добавлении/удалении в реальном времени и по кнопке Sync/Refresh.");
ui.label(" • 📥 Отслеживание скачиваний из общих папок: Когда друзья качают файлы или архивы из вашей общей папки, передача в реальном времени отображается в списке передач со скоростью.");
@@ -4704,20 +4836,41 @@ impl eframe::App for ZeroSendApp {
self.poll_events(ctx);
if ctx.input(|i| i.viewport().close_requested()) {
let _ = self.config.save();
if self.close_to_tray {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false));
}
}
// Track & persist window position (X, Y)
if let Some(pos) = ctx.input(|i| i.viewport().outer_rect.map(|r| r.min)) {
// Filter out invalid / minimized off-screen coordinates (e.g. Windows minimized to -32000)
if pos.x > -10000.0 && pos.x < 30000.0 && pos.y > -10000.0 && pos.y < 30000.0 {
let pos_changed = match (self.config.window_x, self.config.window_y) {
(Some(x), Some(y)) => (x - pos.x).abs() > 4.0 || (y - pos.y).abs() > 4.0,
_ => true,
};
if pos_changed {
self.config.window_x = Some(pos.x);
self.config.window_y = Some(pos.y);
if self.last_window_save.elapsed() > Duration::from_secs(1) {
self.last_window_save = Instant::now();
let _ = self.config.save();
}
}
}
}
// Track & persist window size (Width, Height)
let cur_size = ctx.screen_rect().size();
if cur_size.x >= 700.0 && cur_size.y >= 450.0 {
if (self.config.window_width - cur_size.x).abs() > 10.0
|| (self.config.window_height - cur_size.y).abs() > 10.0
if (self.config.window_width - cur_size.x).abs() > 4.0
|| (self.config.window_height - cur_size.y).abs() > 4.0
{
self.config.window_width = cur_size.x;
self.config.window_height = cur_size.y;
if self.last_window_save.elapsed() > Duration::from_secs(2) {
if self.last_window_save.elapsed() > Duration::from_secs(1) {
self.last_window_save = Instant::now();
let _ = self.config.save();
}