diff --git a/Cargo.lock b/Cargo.lock index 0b8bc36..19045da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6453,7 +6453,7 @@ checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerosend" -version = "2.5.4" +version = "2.5.5" dependencies = [ "arboard", "async-stream", diff --git a/Cargo.toml b/Cargo.toml index 207208e..d008513 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zerosend" -version = "2.5.4" +version = "2.5.5" edition = "2021" description = "Fast, peer-to-peer file transfer designed for ZeroTier, Tailscale, Radmin VPN and LAN" authors = ["RarDog"] diff --git a/src/transfer/client.rs b/src/transfer/client.rs index 136ba97..eac7036 100644 --- a/src/transfer/client.rs +++ b/src/transfer/client.rs @@ -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( &self, peer_url: &str, folder_id: &str, subpath: &str, dest_path: &std::path::Path, pin: Option<&str>, - ) -> Result { + peer_name: &str, + mut on_progress: F, + ) -> Result + 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 { + 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) diff --git a/src/ui/gui.rs b/src/ui/gui.rs index e209389..341b91f 100644 --- a/src/ui/gui.rs +++ b/src/ui/gui.rs @@ -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.5 ready".to_string()], text_input: String::new(), analyzed_preview: analyze_text(""), recipient_pin: String::new(), @@ -2731,46 +2731,63 @@ impl ZeroSendApp { if online_peers_with_shares.is_empty() { Frame::none() .fill(theme.bg_card) - .rounding(Rounding::same(10.0)) + .rounding(Rounding::same(12.0)) .stroke(theme.stroke_border) - .inner_margin(Margin::same(40.0)) + .inner_margin(Margin::same(48.0)) .show(ui, |ui| { ui.vertical_centered(|ui| { - ui.label(RichText::new("🌐").size(40.0)); + ui.label(RichText::new("🌐").size(48.0)); + ui.add_space(8.0); ui.label( - RichText::new("No shared folders discovered on your network.") - .size(16.0) + RichText::new("В сети пока нет обнаруженных общих папок") + .size(17.0) .strong() .color(theme.text_primary), ); 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, Tailscale, Radmin VPN или LAN откроют доступ к папкам, они мгновенно появятся здесь с поддержкой стриминга и быстрой загрузки.", ) - .size(12.0) - .color(theme.text_muted), + .size(12.5) + .color(theme.text_secondary), ); + ui.add_space(14.0); + let refresh_btn = Button::new(RichText::new("🔄 Обновить список (Sync / Refresh)").strong().color(Color32::WHITE)) + .fill(Color32::from_rgb(37, 99, 235)) + .min_size(Vec2::new(200.0, 32.0)); + if ui.add(refresh_btn).clicked() { + self.sync_all_network_shares(); + self.refresh_current_share_tree(); + } }); }); 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 (270px) ── + ui.allocate_ui(Vec2::new(270.0, ui.available_height()), |ui| { Frame::none() .fill(theme.bg_card) - .rounding(Rounding::same(8.0)) + .rounding(Rounding::same(10.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 +2795,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 Modern Explorer ── ui.vertical(|ui| { if let (Some(peer_id), Some(folder_id)) = ( self.selected_peer_id_for_share.clone(), @@ -2828,39 +2872,63 @@ 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 = 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(sub) = navigate_subpath { + self.current_share_subpath = sub; + self.refresh_current_share_tree(); } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { // Grep search button - if ui.button(RichText::new("🔎 Поиск текста/кода").color(Color32::from_rgb(56, 189, 248))).clicked() { + if ui.button(RichText::new("🔎 Grep по коду").color(Color32::from_rgb(56, 189, 248))).clicked() { self.show_grep_modal = true; self.grep_query.clear(); self.grep_results.clear(); @@ -2873,7 +2941,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); @@ -2894,7 +2962,7 @@ impl ZeroSendApp { 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), ) @@ -2917,17 +2985,16 @@ impl ZeroSendApp { tokio::spawn(async move { for f in files { - let _ = log_tx.send(format!("⬆️ Uploading {:?} to share...", f.file_name())); + let _ = log_tx.send(format!("⬆️ Загрузка {:?} в общую папку...", f.file_name())); match client.upload_to_shared_folder(&peer_url, &f_id, &sub, &f, pin.as_deref()).await { Ok(_) => { - let _ = log_tx.send(format!("✅ Uploaded {:?}", f.file_name())); + let _ = log_tx.send(format!("✅ Загружено {:?}", f.file_name())); } Err(e) => { - let _ = log_tx.send(format!("❌ Upload failed: {}", e)); + let _ = log_tx.send(format!("❌ Ошибка загрузки: {}", e)); } } } - // 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,9 +3007,9 @@ impl ZeroSendApp { }); }); - ui.add_space(8.0); + ui.add_space(6.0); - // Multi-select batch action toolbar + // ── Multi-select Batch Action Toolbar ── if !self.selected_share_items.is_empty() { Frame::none() .fill(if theme.is_dark { Color32::from_rgb(23, 37, 84) } else { Color32::from_rgb(219, 234, 254) }) @@ -2951,15 +3018,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), ) @@ -2978,7 +3045,12 @@ 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; + let peer_display_name = peer_name.clone(); + + // Switch tab to Transfers! + self.switch_tab(Tab::Transfers); tokio::spawn(async move { for item_rel in items { @@ -2987,8 +3059,19 @@ impl ZeroSendApp { .map(|n| n.to_string_lossy().into_owned()) .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 _ = log_tx.send(format!("⬇️ Загрузка {} от {}...", fname, peer_display_name)); + let t_tx = transfer_tx.clone(); + let _ = client.download_shared_file_with_progress( + &peer_url, + &f_id, + &item_rel, + &dest, + pin.as_deref(), + &peer_display_name, + move |prog| { + let _ = t_tx.send(prog); + }, + ).await; } if sound { crate::ui::sound::play_transfer_sound(); @@ -3002,12 +3085,12 @@ impl ZeroSendApp { ui.add_space(6.0); } - // Explorer File Table + // ── Explorer File Table & Cards ── if self.share_tree_loading { 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; @@ -3021,8 +3104,8 @@ impl ZeroSendApp { .inner_margin(Margin::symmetric(10.0, 6.0)) .show(ui, |ui| { ui.horizontal(|ui| { - ui.label("📁 .."); - if ui.button("Parent Folder").clicked() { + ui.label("📁 ⮤ .."); + if ui.button(RichText::new("На уровень выше").strong().color(theme.text_accent)).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; @@ -3045,7 +3128,7 @@ impl ZeroSendApp { .fill(theme.bg_card) .rounding(Rounding::same(6.0)) .stroke(theme.stroke_border) - .inner_margin(Margin::symmetric(10.0, 6.0)) + .inner_margin(Margin::symmetric(10.0, 7.0)) .show(ui, |ui| { ui.horizontal(|ui| { // Checkbox for multi-select @@ -3071,7 +3154,7 @@ impl ZeroSendApp { ui.label(RichText::new(&entry.name).strong().color(theme.text_primary)); } - // Badge + // Category Badge Frame::none() .fill(theme.bg_card_secondary) .rounding(Rounding::same(3.0)) @@ -3090,10 +3173,14 @@ impl ZeroSendApp { } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - // Action: Download + // ── Action: Download ── let is_dir = entry.is_dir; - let down_label = if is_dir { "⬇️ Zip" } else { "⬇️ Download" }; - if ui.button(down_label).clicked() { + let down_label = if is_dir { "⬇️ Скачать папку (Zip)" } else { "⬇️ Скачать" }; + let dl_btn = Button::new(RichText::new(down_label).strong().color(Color32::WHITE)) + .fill(Color32::from_rgb(37, 99, 235)) + .rounding(Rounding::same(4.0)); + + if ui.add(dl_btn).clicked() { let client = self.client.clone(); let peer_url = peer.transfer_url(); let f_id = folder_id.clone(); @@ -3106,25 +3193,43 @@ 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; + let peer_display_name = peer_name.clone(); + + // Switch tab to Transfers immediately! + self.switch_tab(Tab::Transfers); 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 _ = log_tx.send(format!("⬇️ Загрузка {} от {}...", out_name, peer_display_name)); + let t_tx = transfer_tx.clone(); + let res = client.download_shared_file_with_progress( + &peer_url, + &f_id, + &sub, + &dest, + pin.as_deref(), + &peer_display_name, + move |prog| { + let _ = t_tx.send(prog); + }, + ).await; + + match res { Ok(bytes) => { - let _ = log_tx.send(format!("✅ Downloaded {} ({})", out_name, human_bytes(bytes as f64))); + let _ = log_tx.send(format!("✅ Загружено {} ({})", out_name, human_bytes(bytes as f64))); if sound { crate::ui::sound::play_transfer_sound(); } } Err(e) => { - let _ = log_tx.send(format!("❌ Download failed: {}", e)); + let _ = log_tx.send(format!("❌ Ошибка загрузки {}: {}", out_name, e)); } } }); } - // Action: Audio In-App Player + // ── Action: Audio In-App Player ── if badge_text == "AUDIO" { let stream_url = format!( "{}/api/shared/stream/{}/{}", @@ -3133,7 +3238,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(), @@ -3144,7 +3249,7 @@ impl ZeroSendApp { } } - // Action: Video Watch Party & Stream + // ── Action: Video Watch Party & Stream ── if badge_text == "VIDEO" { let stream_url = format!( "{}/api/shared/stream/{}/{}", @@ -3165,18 +3270,18 @@ impl ZeroSendApp { timestamp_ms: chrono::Local::now().timestamp_millis() as u64, }); self.is_in_watch_party = true; - self.logs.push(format!("🎬 Watch Party created for '{}'", entry.name)); + self.logs.push(format!("🎬 Watch Party создана для '{}'", entry.name)); 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(); } } - // Action: Vim View / Edit (for code & text) + // ── Action: Vim View / Edit (for code & text) ── if badge_text == "CODE" || badge_text == "DOC" || entry.name.ends_with(".txt") || entry.name.ends_with(".log") || entry.name.ends_with(".md") { - let edit_label = if is_writable_share { "✏️ Vim Edit" } else { "👁 Vim View" }; + let edit_label = if is_writable_share { "✏️ Vim Редактор" } else { "👁 Vim Просмотр" }; let btn_color = if is_writable_share { Color32::from_rgb(34, 197, 94) } else { Color32::from_rgb(20, 184, 166) }; if ui.button(RichText::new(edit_label).color(btn_color).strong()).clicked() { @@ -3212,15 +3317,17 @@ impl ZeroSendApp { .fill(theme.bg_card) .rounding(Rounding::same(8.0)) .stroke(theme.stroke_border) - .inner_margin(Margin::same(20.0)) + .inner_margin(Margin::same(24.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.add_space(6.0); + ui.label(RichText::new("🔒 Папка защищена PIN-кодом или пуста").strong().size(14.5).color(theme.text_primary)); + ui.add_space(8.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() { + let unlock_btn = Button::new(RichText::new("🔓 Разблокировать / Обновить").strong().color(Color32::WHITE)) + .fill(Color32::from_rgb(37, 99, 235)); + if ui.add(unlock_btn).clicked() { self.refresh_current_share_tree(); } }); @@ -3230,12 +3337,16 @@ impl ZeroSendApp { } else { Frame::none() .fill(theme.bg_card) - .rounding(Rounding::same(8.0)) + .rounding(Rounding::same(10.0)) .stroke(theme.stroke_border) - .inner_margin(Margin::same(40.0)) + .inner_margin(Margin::same(48.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(40.0)); + ui.add_space(6.0); + ui.label(RichText::new("Выберите общую папку друга на панели слева").strong().size(15.0).color(theme.text_primary)); + ui.add_space(4.0); + ui.label(RichText::new("Вы сможете просматривать структуру файлов, стримить музыку и видео, открывать код во встроенном редакторе Vim и скачивать нужные файлы.").size(12.0).color(theme.text_muted)); }); }); } @@ -4106,17 +4217,26 @@ impl ZeroSendApp { .stroke(theme.stroke_border) .inner_margin(Margin::same(14.0)) .show(ui, |ui| { + // v2.5.5 + ui.horizontal(|ui| { + ui.label(RichText::new("v2.5.5").strong().color(Color32::from_rgb(34, 197, 94))); + ui.label(RichText::new("(Текущая версия)").size(11.0).color(Color32::GRAY)); + }); + ui.label(" • 🚀 Мгновенный переход в «Передачи и файлы»: При скачивании любых файлов или архивов из общих папок друзей приложение сразу переключается во вкладку передач с отображением живой скорости (MB/s) и прогресса."); + ui.label(" • 🎨 Премиальный редизайн Сетевых общих папок: Удобная боковая панель с пингом и статусами друзей, интерактивные хлебные крошки (breadcrumbs), красивые карточки файлов по категориям и мульти-загрузка."); + ui.label(" • 🔄 Все ключевые улучшения v2.5.4: авто-обнаружение папок в реальном времени, отслеживание скачиваний из общих папок, прозрачный стриминг папок и сохранение истории передач на диск."); + 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(" • 📥 Отслеживание скачиваний из общих папок: Когда друзья качают файлы или архивы из вашей общей папки, передача в реальном времени отображается в списке передач со скоростью."); ui.label(" • 📁 Прозрачный стриминг папок: Получатель видит настоящее имя папки, примерный размер, прогресс и скорость в MB/s с момента старта стрима."); ui.label(" • 💾 Постоянное сохранение истории передач: История завершенных и прошлых передач теперь надежно сохраняется на диск и не стирается при перезапуске приложения."); - ui.label(" • ✨ Плавные анимации переходов: Шелковистые кинетические анимации переключения вкладок (Cubic Ease-Out) и плавно скользящий индикатор выбора в меню."); - ui.label(" • ⚡ TCP Socket Tuning (4MB буферы, TCP_NODELAY), Zero-Drop Auto-Recovery (до 8 попыток), mDNS/SSDP и Blake3 Delta-Sync."); ui.add_space(8.0); ui.separator(); ui.add_space(8.0);