use crate::config::{AppConfig, SharedFolderConfig, ThemeMode}; use crate::network::{get_available_interfaces, DiscoveryService, InterfaceInfo, NetworkType, PeerInfo}; use crate::registry::{ is_autostart_registered, is_system_dark_mode, register_autostart, unregister_autostart, }; use crate::transfer::{ analyze_text, AnalyzedText, ContentType, PrepareTransferRequest, SharedFolderTreeResponse, TextMessage, TransferClient, TransferProgress, TransferServer, TransferState, }; use crate::ui::tray::{SystemTrayManager, TrayAction}; use crate::ui::vim_viewer::VimViewerModal; use crate::updater::{ check_gitea_update, perform_update_download, UpdateInfo, UpdateProgress, GITEA_DEFAULT_REPO_URL, }; use eframe::egui::{ self, Align, Button, CentralPanel, Color32, ColorImage, Frame, Layout, Margin, ProgressBar, RichText, Rounding, ScrollArea, SidePanel, Stroke, TextureHandle, TextureOptions, Ui, Vec2, }; use human_bytes::human_bytes; use qrcode::{Color as QrColor, QrCode}; use rfd::FileDialog; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::{mpsc, RwLock}; #[derive(PartialEq, Eq, Clone, Copy)] pub enum Tab { Nearby, Send, Receive, Shared, Transfers, Settings, } impl Tab { pub fn index(&self) -> usize { match self { Tab::Nearby => 0, Tab::Send => 1, Tab::Receive => 2, Tab::Shared => 3, Tab::Transfers => 4, Tab::Settings => 5, } } } #[derive(PartialEq, Eq, Clone, Copy)] pub enum SharedSubTab { NetworkShares, MyShares, AutoSync, } #[derive(Clone, Debug)] pub struct AudioTrackInfo { pub name: String, pub url: String, pub peer_name: String, #[allow(dead_code)] pub is_playing: bool, } #[derive(PartialEq, Eq, Clone, Copy)] pub enum SendMode { Files, QuickText, } #[derive(Clone, Copy, Debug)] pub struct ThemeColors { pub is_dark: bool, pub bg_app: Color32, pub bg_sidebar: Color32, pub bg_card: Color32, pub bg_card_secondary: Color32, pub bg_card_active: Color32, pub bg_hover: Color32, pub stroke_border: Stroke, pub stroke_subtle: Stroke, pub text_primary: Color32, pub text_secondary: Color32, pub text_muted: Color32, pub text_accent: Color32, } impl ThemeColors { pub fn new(is_dark: bool) -> Self { if is_dark { Self { is_dark: true, bg_app: Color32::from_rgb(11, 15, 25), bg_sidebar: Color32::from_rgb(15, 23, 42), bg_card: Color32::from_rgb(30, 41, 59), bg_card_secondary: Color32::from_rgb(15, 23, 42), bg_card_active: Color32::from_rgb(30, 58, 138), bg_hover: Color32::from_rgb(51, 65, 85), stroke_border: Stroke::new(1.0_f32, Color32::from_rgb(51, 65, 85)), stroke_subtle: Stroke::new(1.0_f32, Color32::from_rgba_premultiplied(255, 255, 255, 15)), text_primary: Color32::from_rgb(248, 250, 252), text_secondary: Color32::from_rgb(203, 213, 225), text_muted: Color32::from_rgb(148, 163, 184), text_accent: Color32::from_rgb(56, 189, 248), } } else { Self { is_dark: false, bg_app: Color32::from_rgb(241, 245, 249), bg_sidebar: Color32::from_rgb(255, 255, 255), bg_card: Color32::from_rgb(255, 255, 255), bg_card_secondary: Color32::from_rgb(248, 250, 252), bg_card_active: Color32::from_rgb(219, 234, 254), bg_hover: Color32::from_rgb(226, 232, 240), stroke_border: Stroke::new(1.0_f32, Color32::from_rgb(203, 213, 225)), stroke_subtle: Stroke::new(1.0_f32, Color32::from_rgb(226, 232, 240)), text_primary: Color32::from_rgb(15, 23, 42), text_secondary: Color32::from_rgb(51, 65, 85), text_muted: Color32::from_rgb(100, 116, 139), text_accent: Color32::from_rgb(2, 132, 199), } } } } pub fn get_file_badge_info(name: &str, is_dir: bool) -> (&'static str, Color32, &'static str) { if is_dir { return ("📁", Color32::from_rgb(234, 179, 8), "FOLDER"); } let lower = name.to_lowercase(); if lower.ends_with(".zip") || lower.ends_with(".rar") || lower.ends_with(".7z") || lower.ends_with(".tar") || lower.ends_with(".gz") || lower.ends_with(".bz2") || lower.ends_with(".xz") { ("📦", Color32::from_rgb(245, 158, 11), "ARCHIVE") } else if lower.ends_with(".png") || lower.ends_with(".jpg") || lower.ends_with(".jpeg") || lower.ends_with(".gif") || lower.ends_with(".webp") || lower.ends_with(".bmp") || lower.ends_with(".svg") || lower.ends_with(".ico") { ("🖼", Color32::from_rgb(56, 189, 248), "IMAGE") } else if lower.ends_with(".mp4") || lower.ends_with(".mkv") || lower.ends_with(".avi") || lower.ends_with(".mov") || lower.ends_with(".webm") || lower.ends_with(".flv") { ("🎬", Color32::from_rgb(168, 85, 247), "VIDEO") } else if lower.ends_with(".mp3") || lower.ends_with(".wav") || lower.ends_with(".flac") || lower.ends_with(".ogg") || lower.ends_with(".m4a") || lower.ends_with(".aac") { ("🎵", Color32::from_rgb(236, 72, 153), "AUDIO") } else if lower.ends_with(".exe") || lower.ends_with(".msi") || lower.ends_with(".iso") || lower.ends_with(".bin") || lower.ends_with(".dll") { ("💾", Color32::from_rgb(34, 197, 94), "BIN/ISO") } else if lower.ends_with(".txt") || lower.ends_with(".md") || lower.ends_with(".pdf") || lower.ends_with(".docx") || lower.ends_with(".doc") || lower.ends_with(".xlsx") || lower.ends_with(".pptx") { ("📄", Color32::from_rgb(59, 130, 246), "DOC") } else if lower.ends_with(".rs") || lower.ends_with(".py") || lower.ends_with(".js") || lower.ends_with(".ts") || lower.ends_with(".html") || lower.ends_with(".css") || lower.ends_with(".json") || lower.ends_with(".toml") || lower.ends_with(".cpp") || lower.ends_with(".c") { ("💻", Color32::from_rgb(20, 184, 166), "CODE") } else { ("📄", Color32::from_rgb(148, 163, 184), "FILE") } } pub fn get_file_icon(name: &str) -> &'static str { get_file_badge_info(name, false).0 } pub struct ZeroSendApp { // Services discovery: Arc, discovery_peers: Arc>>, server: TransferServer, client: Arc, my_info: crate::network::DiscoveryBeacon, current_iface: InterfaceInfo, config: AppConfig, tray_manager: Option, // Navigation & State current_tab: Tab, send_mode: SendMode, peers: Vec, selected_peer_ids: HashSet, queued_files: Vec, transfers: Vec, logs: Vec, // Quick Text Input text_input: String, analyzed_preview: AnalyzedText, // Optional PIN for sender recipient_pin: String, // Channels from Server incoming_request_rx: mpsc::UnboundedReceiver, incoming_text_rx: mpsc::UnboundedReceiver, // Modals pending_request: Option, received_text_modal: Option, show_direct_ip_modal: bool, direct_ip_input: String, direct_ip_status: String, // Peer Rename Modal renaming_peer: Option<(String, String, String)>, // Settings custom_device_name: String, available_interfaces: Vec, auto_accept: bool, require_pin: bool, pin_code: String, download_dir: PathBuf, close_to_tray: bool, auto_zip_folders: bool, auto_extract_zip: bool, max_upload_speed_mbps: u32, sound_notifications: bool, autostart_enabled: bool, pub theme_mode: ThemeMode, // Shared Folders State shared_folders: Vec, selected_share_subtab: SharedSubTab, selected_peer_id_for_share: Option, selected_folder_id_for_share: Option, current_share_subpath: String, current_share_tree: Option, share_tree_loading: bool, share_search_query: String, share_pin_input: String, last_share_sync: Instant, pub vim_viewer: VimViewerModal, pub selected_share_items: HashSet, // Media & Audio & Watch Party pub active_audio: Option, #[allow(dead_code)] pub audio_volume: f32, pub watch_party_session: Option, pub is_in_watch_party: bool, // Remote Grep State pub show_grep_modal: bool, pub grep_query: String, pub grep_results: Vec, pub is_grepping: bool, pub grep_status_msg: Option, // Live Folder Sync State pub folder_sync_pairs: Vec, pub sync_log_messages: Vec, pub new_sync_local_path: String, pub new_sync_name: String, pub new_sync_peer_id: String, pub new_sync_folder_id: String, // WebDAV Windows Drive Mount State #[allow(dead_code)] pub webdav_mounted: bool, #[allow(dead_code)] pub webdav_drive_letter: char, #[allow(dead_code)] pub webdav_status_msg: Option, // Gitea Updater State pub update_info: Option, pub is_checking_update: bool, pub update_check_status_msg: Option, pub update_progress: Option, pub is_updating: bool, pub show_update_banner: bool, pub check_updates_on_startup: bool, // Internal UI Channels log_rx: mpsc::UnboundedReceiver, log_tx: mpsc::UnboundedSender, transfer_rx: mpsc::UnboundedReceiver, transfer_tx: mpsc::UnboundedSender, share_tree_rx: mpsc::UnboundedReceiver>, share_tree_tx: mpsc::UnboundedSender>, share_text_rx: mpsc::UnboundedReceiver<(String, String, String, String, Option, Option, Option, bool)>, share_text_tx: mpsc::UnboundedSender<(String, String, String, String, Option, Option, Option, bool)>, sync_status_rx: mpsc::UnboundedReceiver, #[allow(dead_code)] sync_status_tx: mpsc::UnboundedSender, update_rx: mpsc::UnboundedReceiver>, update_tx: mpsc::UnboundedSender>, update_prog_rx: mpsc::UnboundedReceiver, update_prog_tx: mpsc::UnboundedSender, last_poll: Instant, last_ping_poll: Instant, last_window_save: Instant, last_tab_change: Instant, } fn setup_custom_fonts(ctx: &egui::Context) { let mut fonts = egui::FontDefinitions::default(); #[cfg(target_os = "windows")] { let win_dir = std::env::var("WINDIR").unwrap_or_else(|_| "C:\\Windows".to_string()); let candidate_fonts = [ ("segoe_ui", format!("{}\\Fonts\\segoeui.ttf", win_dir)), ("segoe_ui_sym", format!("{}\\Fonts\\seguisym.ttf", win_dir)), ("segoe_ui_emoji", format!("{}\\Fonts\\seguiemj.ttf", win_dir)), ("arial", format!("{}\\Fonts\\arial.ttf", win_dir)), ]; for (name, path) in candidate_fonts { if let Ok(bytes) = std::fs::read(&path) { fonts.font_data.insert( name.to_string(), egui::FontData::from_owned(bytes), ); if let Some(family) = fonts.families.get_mut(&egui::FontFamily::Proportional) { family.push(name.to_string()); } if let Some(family) = fonts.families.get_mut(&egui::FontFamily::Monospace) { family.push(name.to_string()); } } } } ctx.set_fonts(fonts); } impl ZeroSendApp { pub fn new( cc: &eframe::CreationContext<'_>, discovery: Arc, server: TransferServer, config: AppConfig, initial_files: Vec, incoming_request_rx: mpsc::UnboundedReceiver, incoming_text_rx: mpsc::UnboundedReceiver, ) -> Self { setup_custom_fonts(&cc.egui_ctx); let (log_tx, log_rx) = mpsc::unbounded_channel(); let (transfer_tx, transfer_rx) = mpsc::unbounded_channel(); let (share_tree_tx, share_tree_rx) = mpsc::unbounded_channel(); let (share_text_tx, share_text_rx) = mpsc::unbounded_channel(); let (sync_status_tx, sync_status_rx) = mpsc::unbounded_channel(); let (update_tx, update_rx) = mpsc::unbounded_channel(); let (update_prog_tx, update_prog_rx) = mpsc::unbounded_channel(); // Subscribe to server transfer events (web uploads, incoming P2P transfers) let mut server_transfer_rx = server.state().transfer_events.subscribe(); let transfer_tx_clone = transfer_tx.clone(); let ctx_clone = cc.egui_ctx.clone(); tokio::spawn(async move { while let Ok(progress) = server_transfer_rx.recv().await { let _ = transfer_tx_clone.send(progress); ctx_clone.request_repaint(); } }); // Trigger startup update check from Gitea if enabled if config.check_updates_on_startup { let u_tx = update_tx.clone(); let api_url = config.gitea_api_url.clone(); tokio::spawn(async move { let res = check_gitea_update(api_url.as_deref()).await; let _ = u_tx.send(res); }); } let client = Arc::new(TransferClient::new()); // Initialize and spawn Folder Auto-Sync background worker let sync_engine = Arc::new(crate::transfer::FolderSyncEngine::new( client.clone(), config.folder_sync_pairs.clone(), sync_status_tx.clone(), )); let peers_map_ref = discovery.peers(); tokio::spawn(async move { sync_engine.start_background_sync(peers_map_ref).await; }); let my_info = discovery.my_info().clone(); let iface = discovery.selected_interface().clone(); let download_dir = config.download_dir.clone(); let auto_accept = config.auto_accept; let require_pin = config.require_pin; let pin_code = config.pin_code.clone(); let close_to_tray = config.close_to_tray; let auto_zip_folders = config.auto_zip_folders; let auto_extract_zip = config.auto_extract_zip; let max_upload_speed_mbps = config.max_upload_speed_mbps; let sound_notifications = config.sound_notifications; let theme_mode = config.theme_mode; let check_updates_on_startup = config.check_updates_on_startup; let device_name = my_info.name.clone(); let shared_folders = config.shared_folders.clone(); let folder_sync_pairs = config.folder_sync_pairs.clone(); let initial_tab = if !initial_files.is_empty() { Tab::Send } else { Tab::Nearby }; let tray_manager = SystemTrayManager::new(cc.egui_ctx.clone(), download_dir.clone()).ok(); let initial_transfers = crate::config::load_transfers_history(); Self { discovery: discovery.clone(), discovery_peers: discovery.peers(), server, client, my_info: my_info.clone(), current_iface: iface, config, tray_manager, current_tab: initial_tab, send_mode: SendMode::Files, peers: Vec::new(), selected_peer_ids: HashSet::new(), queued_files: initial_files, transfers: initial_transfers, logs: vec!["⚡ ZeroSend v2.5.5 ready".to_string()], text_input: String::new(), analyzed_preview: analyze_text(""), recipient_pin: String::new(), incoming_request_rx, incoming_text_rx, pending_request: None, received_text_modal: None, show_direct_ip_modal: false, direct_ip_input: String::new(), direct_ip_status: String::new(), renaming_peer: None, custom_device_name: device_name, available_interfaces: get_available_interfaces(), auto_accept, require_pin, pin_code, download_dir, close_to_tray, auto_zip_folders, auto_extract_zip, max_upload_speed_mbps, sound_notifications, autostart_enabled: is_autostart_registered(), theme_mode, shared_folders, selected_share_subtab: SharedSubTab::NetworkShares, selected_peer_id_for_share: None, selected_folder_id_for_share: None, current_share_subpath: String::new(), current_share_tree: None, share_tree_loading: false, share_search_query: String::new(), share_pin_input: String::new(), last_share_sync: Instant::now(), vim_viewer: VimViewerModal::default(), selected_share_items: HashSet::new(), active_audio: None, audio_volume: 1.0, watch_party_session: None, is_in_watch_party: false, show_grep_modal: false, grep_query: String::new(), grep_results: Vec::new(), is_grepping: false, grep_status_msg: None, folder_sync_pairs, sync_log_messages: Vec::new(), new_sync_local_path: String::new(), new_sync_name: String::new(), new_sync_peer_id: String::new(), new_sync_folder_id: String::new(), webdav_mounted: false, webdav_drive_letter: 'Z', webdav_status_msg: None, update_info: None, is_checking_update: false, update_check_status_msg: None, update_progress: None, is_updating: false, show_update_banner: false, check_updates_on_startup, log_rx, log_tx, transfer_rx, transfer_tx, share_tree_rx, share_tree_tx, share_text_rx, share_text_tx, sync_status_rx, sync_status_tx, update_rx, update_tx, update_prog_rx, update_prog_tx, last_poll: Instant::now(), last_ping_poll: Instant::now(), last_window_save: Instant::now(), last_tab_change: Instant::now(), } } fn poll_events(&mut self, ctx: &egui::Context) { // 1. Poll Tray Actions if let Some(tray) = &self.tray_manager { if let Some(action) = tray.try_recv_action() { match action { TrayAction::ShowWindow => { ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); ctx.send_viewport_cmd(egui::ViewportCommand::Focus); } TrayAction::OpenDownloads => { let _ = std::process::Command::new("explorer").arg(&self.download_dir).spawn(); } } } } // 2. Poll incoming file transfer requests while let Ok(req) = self.incoming_request_rx.try_recv() { self.pending_request = Some(req.clone()); self.switch_tab(Tab::Transfers); self.logs.push(format!("📥 Incoming transfer request from {}", req.sender_name)); ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); ctx.send_viewport_cmd(egui::ViewportCommand::Focus); } // 3. Poll incoming text messages while let Ok(msg) = self.incoming_text_rx.try_recv() { self.received_text_modal = Some(msg.clone()); self.logs.push(format!("📋 Received text from {}", msg.sender_name)); ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); ctx.send_viewport_cmd(egui::ViewportCommand::Focus); } // 4. Poll UI logs while let Ok(log) = self.log_rx.try_recv() { self.logs.push(log); if self.logs.len() > 100 { self.logs.remove(0); } } // 5. Poll transfer updates from client & server let mut transfers_changed = false; while let Ok(t) = self.transfer_rx.try_recv() { let just_completed = t.state == TransferState::Completed; if let Some(pos) = self.transfers.iter().position(|x| x.session_id == t.session_id) { let prev_state = self.transfers[pos].state.clone(); self.transfers[pos] = t; if just_completed && prev_state != TransferState::Completed && self.sound_notifications { crate::ui::sound::play_transfer_sound(); } } else { self.transfers.insert(0, t); self.switch_tab(Tab::Transfers); ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); ctx.send_viewport_cmd(egui::ViewportCommand::Focus); if just_completed && self.sound_notifications { crate::ui::sound::play_transfer_sound(); } } transfers_changed = true; } // 6. Direct sync with Server active transfers (Incoming downloads fallback) if let Ok(server_transfers) = self.server.state().active_transfers.try_read() { for (id, st) in server_transfers.iter() { let just_completed = st.state == TransferState::Completed; if let Some(pos) = self.transfers.iter().position(|x| x.session_id == *id) { let prev_state = self.transfers[pos].state.clone(); self.transfers[pos] = st.clone(); if just_completed && prev_state != TransferState::Completed && self.sound_notifications { crate::ui::sound::play_transfer_sound(); } } else { self.transfers.insert(0, st.clone()); self.switch_tab(Tab::Transfers); ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); ctx.send_viewport_cmd(egui::ViewportCommand::Focus); if just_completed && self.sound_notifications { crate::ui::sound::play_transfer_sound(); } } transfers_changed = true; } } if transfers_changed { crate::config::save_transfers_history(&self.transfers); } // 7. Poll share tree updates while let Ok(maybe_tree) = self.share_tree_rx.try_recv() { self.share_tree_loading = false; self.current_share_tree = maybe_tree; } // 8. Poll vim text updates while let Ok((file_name, rel_path, peer_name, content, peer_url, folder_id, pin, is_writable)) = self.share_text_rx.try_recv() { self.vim_viewer.open_with_meta(file_name, rel_path, peer_name, content, peer_url, folder_id, pin, is_writable); } // 8b. Check if Vim Editor requested saving back to remote peer if let Some((peer_url, folder_id, rel_path, content, pin)) = self.vim_viewer.pending_save_request.take() { let client = self.client.clone(); let log_tx = self.log_tx.clone(); tokio::spawn(async move { match client.save_shared_file(&peer_url, &folder_id, &rel_path, &content, pin.as_deref()).await { Ok(resp) => { let _ = log_tx.send(format!("💾 Saved '{}' to remote share ({} bytes)", rel_path, resp.bytes_written)); } Err(e) => { let _ = log_tx.send(format!("❌ Failed to save '{}': {}", rel_path, e)); } } }); } // 8c. Poll folder sync status messages while let Ok(update) = self.sync_status_rx.try_recv() { let msg = format!("[{}] {}", update.timestamp, update.message); self.sync_log_messages.push(msg.clone()); if self.sync_log_messages.len() > 100 { self.sync_log_messages.remove(0); } if !update.is_error { self.logs.push(format!("🔄 AutoSync: {}", update.message)); } } // 9. Poll Gitea update check results while let Ok(res) = self.update_rx.try_recv() { self.is_checking_update = false; match res { Ok(info) => { if info.has_update { self.show_update_banner = true; self.logs.push(format!("✨ Gitea: New version v{} is available!", info.latest_version)); } else { self.update_check_status_msg = Some(format!("ZeroSend v{} is up to date!", info.current_version)); } self.update_info = Some(info); } Err(e) => { self.update_check_status_msg = Some(format!("Update check failed: {}", e)); } } } // 10. Poll Gitea update installation progress while let Ok(prog) = self.update_prog_rx.try_recv() { match &prog { UpdateProgress::Completed => { self.is_updating = false; self.logs.push("🎉 Update completed! Restarting ZeroSend...".to_string()); } UpdateProgress::Error(e) => { self.is_updating = false; self.logs.push(format!("❌ Update error: {}", e)); } _ => {} } self.update_progress = Some(prog); } // 9. Auto-sync network shared folder view (every 4 seconds) if self.last_share_sync.elapsed() > Duration::from_secs(4) { self.last_share_sync = Instant::now(); if self.current_tab == Tab::Shared && self.selected_share_subtab == SharedSubTab::NetworkShares { self.sync_active_share_tree_background(); } } // 10. Discovery & Server pending requests if self.last_poll.elapsed() > Duration::from_millis(300) { self.last_poll = Instant::now(); // Refresh discovered peer list with Favorite Pins prioritized if let Ok(peers_guard) = self.discovery_peers.try_read() { let mut list: Vec = peers_guard.values().cloned().collect(); let favs = &self.config.favorite_peers; list.sort_by(|a, b| { let a_fav = favs.contains(&a.peer_id); let b_fav = favs.contains(&b.peer_id); b_fav.cmp(&a_fav) .then_with(|| b.is_online.cmp(&a.is_online)) .then_with(|| a.name.cmp(&b.name)) }); self.peers = list; } // Double check if there's an active pending request in server state if self.pending_request.is_none() { if let Ok(pending_guard) = self.server.state().pending_requests.try_read() { if let Some((_, req)) = pending_guard.iter().next() { self.pending_request = Some(req.clone()); ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); ctx.send_viewport_cmd(egui::ViewportCommand::Focus); } } } } // 11. Periodic ping update for online peers (every 3 seconds) if self.last_ping_poll.elapsed() > Duration::from_secs(3) { self.last_ping_poll = Instant::now(); let peers_to_ping: Vec<(String, String, u16)> = self .peers .iter() .filter(|p| p.is_online) .map(|p| (p.peer_id.clone(), p.ip.to_string(), p.transfer_port)) .collect(); if !peers_to_ping.is_empty() { let disc_peers = self.discovery_peers.clone(); let client = self.client.clone(); tokio::spawn(async move { for (peer_id, ip, port) in peers_to_ping { if let Some(ms) = client.measure_ping(&ip, port).await { if let Ok(mut guard) = disc_peers.try_write() { if let Some(peer_entry) = guard.get_mut(&peer_id) { peer_entry.ping_ms = Some(ms); } } } let peer_url = format!("http://{}:{}", ip, port); if let Ok(shares) = client.fetch_shared_folders(&peer_url, None).await { let summaries: Vec = shares.into_iter().map(|s| crate::network::SharedFolderSummary { id: s.id, name: s.name, read_only: s.read_only, requires_pin: s.requires_pin, }).collect(); if let Ok(mut guard) = disc_peers.try_write() { if let Some(peer_entry) = guard.get_mut(&peer_id) { peer_entry.shared_folders = summaries; } } } } }); } } } fn persist_config(&mut self) { self.config.download_dir = self.download_dir.clone(); self.config.auto_accept = self.auto_accept; self.config.require_pin = self.require_pin; self.config.pin_code = self.pin_code.clone(); self.config.close_to_tray = self.close_to_tray; self.config.auto_zip_folders = self.auto_zip_folders; self.config.auto_extract_zip = self.auto_extract_zip; self.config.max_upload_speed_mbps = self.max_upload_speed_mbps; self.config.sound_notifications = self.sound_notifications; self.config.theme_mode = self.theme_mode; self.config.device_name = Some(self.custom_device_name.clone()); self.config.preferred_interface = Some(self.current_iface.name.clone()); self.config.shared_folders = self.shared_folders.clone(); let _ = self.config.save(); let srv = self.server.clone(); let auto_acc = self.auto_accept; let req_pin = self.require_pin; let p_code = self.pin_code.clone(); let auto_ext = self.auto_extract_zip; let sf_clone = self.shared_folders.clone(); let discovery_clone = self.discovery.clone(); tokio::spawn(async move { srv.update_auto_accept(auto_acc).await; srv.update_pin_settings(req_pin, p_code).await; srv.update_extract_settings(auto_ext).await; srv.update_shared_folders(sf_clone.clone()).await; let shared_summaries: Vec = sf_clone.into_iter().map(|s| crate::network::SharedFolderSummary { id: s.id, name: s.name, read_only: s.read_only, requires_pin: s.pin.is_some(), }).collect(); discovery_clone.update_shared_folders(shared_summaries).await; }); } pub fn sync_all_network_shares(&self) { let online_peers: Vec = self.peers.iter().filter(|p| p.is_online).cloned().collect(); if online_peers.is_empty() { return; } let client = self.client.clone(); let disc_peers = self.discovery_peers.clone(); let pin = if self.share_pin_input.trim().is_empty() { None } else { Some(self.share_pin_input.trim().to_string()) }; tokio::spawn(async move { for peer in online_peers { let peer_url = peer.transfer_url(); if let Ok(shares) = client.fetch_shared_folders(&peer_url, pin.as_deref()).await { let summaries: Vec = shares.into_iter().map(|s| crate::network::SharedFolderSummary { id: s.id, name: s.name, read_only: s.read_only, requires_pin: s.requires_pin, }).collect(); if let Ok(mut guard) = disc_peers.try_write() { if let Some(p) = guard.get_mut(&peer.peer_id) { p.shared_folders = summaries; } } } } }); } pub fn switch_tab(&mut self, new_tab: Tab) { if self.current_tab != new_tab { self.current_tab = new_tab; self.last_tab_change = Instant::now(); } } fn get_peer_display_name(&self, peer: &PeerInfo) -> (String, bool) { if let Some(alias) = self.config.peer_aliases.get(&peer.peer_id) { if !alias.trim().is_empty() { return (format!("{} ({})", alias, peer.name), true); } } (peer.name.clone(), false) } fn try_paste_clipboard_image(&mut self) -> bool { for _ in 0..5 { if let Ok(mut clipboard) = arboard::Clipboard::new() { // 1. Check for image in clipboard if let Ok(img_data) = clipboard.get_image() { let temp_dir = std::env::temp_dir().join("ZeroSend"); let _ = std::fs::create_dir_all(&temp_dir); let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S"); let file_path = temp_dir.join(format!("screenshot_{}.png", timestamp)); if let Some(img_buffer) = image::RgbaImage::from_raw( img_data.width as u32, img_data.height as u32, img_data.bytes.into_owned(), ) { if img_buffer.save(&file_path).is_ok() { if !self.queued_files.contains(&file_path) { self.queued_files.push(file_path.clone()); } self.current_tab = Tab::Send; self.send_mode = SendMode::Files; self.logs.push(format!( "📸 Pasted screenshot ({}x{}) to queue", img_data.width, img_data.height )); return true; } } } // 2. Check for copied file paths or text in clipboard if let Ok(text) = clipboard.get_text() { let mut added_any = false; for line in text.lines() { let trimmed = line.trim().trim_matches('"'); let path = PathBuf::from(trimmed); if path.exists() { if !self.queued_files.contains(&path) { self.queued_files.push(path.clone()); added_any = true; } } } if added_any { self.switch_tab(Tab::Send); self.send_mode = SendMode::Files; self.logs.push("📄 Pasted copied file(s) from clipboard to queue".to_string()); return true; } } break; } std::thread::sleep(Duration::from_millis(25)); } false } fn start_sending_files(&mut self, targets: Vec, files: Vec) { if targets.is_empty() || files.is_empty() { return; } let client = self.client.clone(); let my_info = self.my_info.clone(); let transfer_tx = self.transfer_tx.clone(); let log_tx = self.log_tx.clone(); let auto_zip = self.auto_zip_folders; let speed_limit = self.max_upload_speed_mbps; let pin = if self.recipient_pin.trim().is_empty() { None } else { Some(self.recipient_pin.trim().to_string()) }; self.queued_files.clear(); self.switch_tab(Tab::Transfers); for peer in targets { let peer_url = peer.transfer_url(); let peer_name = self.get_peer_display_name(&peer).0; let files_clone = files.clone(); let client_clone = client.clone(); let my_info_clone = my_info.clone(); let transfer_tx_clone = transfer_tx.clone(); let log_tx_clone = log_tx.clone(); let pin_clone = pin.clone(); tokio::spawn(async move { let _ = log_tx_clone.send(format!("📤 Sending {} items to {}...", files_clone.len(), peer_name)); let res = client_clone .send_files(&peer_url, &peer_name, &my_info_clone, &files_clone, auto_zip, speed_limit, pin_clone, move |progress| { let _ = transfer_tx_clone.send(progress); }) .await; match res { Ok(_) => { let _ = log_tx_clone.send(format!("🎉 All files delivered to {}!", peer_name)); } Err(e) => { let _ = log_tx_clone.send(format!("⚠️ Transfer failed to {}: {}", peer_name, e)); } } }); } } fn render_sidebar(&mut self, _ctx: &egui::Context, ui: &mut Ui, theme: ThemeColors) { ui.add_space(8.0); ui.horizontal(|ui| { ui.add_space(4.0); ui.label(RichText::new("⚡").size(26.0).color(theme.text_accent)); ui.vertical(|ui| { ui.label(RichText::new("ZeroSend").size(18.0).strong().color(theme.text_primary)); ui.label(RichText::new("P2P Mesh & LAN Sharing").size(10.5).color(theme.text_muted)); }); }); ui.add_space(12.0); ui.separator(); ui.add_space(8.0); // Network Status Badge Card (ZeroTier / Tailscale / LAN / VPN) let (net_icon, net_badge_title, net_accent_color) = match self.current_iface.net_type { NetworkType::ZeroTier => ("🪐", "ZeroTier Network", Color32::from_rgb(251, 146, 60)), NetworkType::Tailscale => ("🪢", "Tailscale VPN", Color32::from_rgb(56, 189, 248)), NetworkType::LAN => ("🏠", "Локальная сеть (LAN)", Color32::from_rgb(34, 197, 94)), NetworkType::Radmin => ("🛡️", "Radmin VPN", Color32::from_rgb(168, 85, 247)), _ => ("🌐", self.current_iface.net_type.as_str(), Color32::from_rgb(59, 130, 246)), }; Frame::none() .fill(if theme.is_dark { Color32::from_rgb(15, 23, 42) } else { Color32::from_rgb(241, 245, 249) }) .rounding(Rounding::same(8.0)) .stroke(Stroke::new(1.0_f32, net_accent_color.linear_multiply(0.5))) .inner_margin(Margin::symmetric(10.0, 8.0)) .show(ui, |ui| { ui.horizontal(|ui| { ui.label(RichText::new(net_icon).size(20.0)); ui.vertical(|ui| { ui.horizontal(|ui| { ui.label( RichText::new(net_badge_title) .strong() .size(11.5) .color(theme.text_primary), ); ui.label(RichText::new("●").color(Color32::from_rgb(34, 197, 94)).size(9.0)); }); ui.horizontal(|ui| { let ip_str = format!("{}:{}", self.current_iface.ip, self.my_info.transfer_port); ui.label( RichText::new(&ip_str) .monospace() .size(10.0) .color(theme.text_muted), ); if ui.small_button("📋").on_hover_text("Скопировать IP:Port в буфер обмена").clicked() { if let Ok(mut clipboard) = arboard::Clipboard::new() { let _ = clipboard.set_text(&ip_str); self.logs.push(format!("📋 IP скопирован: {}", ip_str)); } } }); }); }); }); ui.add_space(14.0); let active_count = self .transfers .iter() .filter(|t| t.state == TransferState::InProgress || t.state == TransferState::PendingConfirmation) .count(); let friend_shares_count: usize = self.peers.iter().map(|p| p.shared_folders.len()).sum(); let total_shares = self.shared_folders.len() + friend_shares_count; let tabs = [ (Tab::Nearby, "📡 Устройства в сети", self.peers.len(), false), (Tab::Send, "📤 Отправить файлы", self.queued_files.len(), false), (Tab::Receive, "📥 Прием / Web-ссылка", 0, false), (Tab::Shared, "📁 Общие папки", total_shares, false), (Tab::Transfers, "📜 Передачи и файлы", active_count, active_count > 0), (Tab::Settings, "⚙ Настройки и инфо", 0, false), ]; let anim_target = self.current_tab.index() as f32; let animated_tab_idx = ui.ctx().animate_value_with_time(egui::Id::new("sidebar_active_tab"), anim_target, 0.20); if (animated_tab_idx - anim_target).abs() > 0.001 { ui.ctx().request_repaint(); } for (idx, (tab, title, count, is_active_pulse)) in tabs.into_iter().enumerate() { let is_selected = self.current_tab == tab; let mut btn_text = title.to_string(); if count > 0 && tab != Tab::Receive && tab != Tab::Settings { if is_active_pulse { btn_text = format!("{} (⚡ {})", title, count); } else { btn_text = format!("{} ({})", title, count); } } // Smooth proximity factor (1.0 = active, 0.0 = inactive) let proximity = (1.0 - (animated_tab_idx - (idx as f32)).abs()).clamp(0.0, 1.0); let text_color = if is_selected { if theme.is_dark { Color32::from_rgb(56, 189, 248) } else { Color32::from_rgb(2, 132, 199) } } else if is_active_pulse { Color32::from_rgb(250, 204, 21) } else { theme.text_secondary }; let bg_color = if proximity > 0.01 { let r = theme.bg_card_active.r(); let g = theme.bg_card_active.g(); let b = theme.bg_card_active.b(); let a = (theme.bg_card_active.a() as f32 * proximity) as u8; Color32::from_rgba_premultiplied(r, g, b, a) } else { Color32::TRANSPARENT }; let stroke = if proximity > 0.25 { let stroke_color = if theme.is_dark { Color32::from_rgba_premultiplied(56, 189, 248, (255.0 * proximity) as u8) } else { Color32::from_rgba_premultiplied(2, 132, 199, (255.0 * proximity) as u8) }; Stroke::new(1.0_f32, stroke_color) } else { Stroke::NONE }; let btn = Button::new(RichText::new(btn_text).size(12.5).color(text_color).strong()) .fill(bg_color) .stroke(stroke) .min_size(Vec2::new(ui.available_width(), 34.0)) .rounding(Rounding::same(6.0)); if ui.add(btn).clicked() { self.switch_tab(tab); } ui.add_space(3.0); } ui.with_layout(Layout::bottom_up(Align::Center), |ui| { ui.add_space(10.0); ui.label( RichText::new(format!("v{}", env!("CARGO_PKG_VERSION"))) .monospace() .size(10.5) .color(theme.text_muted), ); }); } fn render_nearby_tab(&mut self, ui: &mut Ui, theme: ThemeColors) { // Hero Top Banner Frame::none() .fill(if theme.is_dark { Color32::from_rgb(15, 23, 42) } else { Color32::from_rgb(238, 242, 255) }) .rounding(Rounding::same(8.0)) .stroke(Stroke::new(1.0_f32, Color32::from_rgb(56, 189, 248))) .inner_margin(Margin::symmetric(14.0, 10.0)) .show(ui, |ui| { ui.horizontal(|ui| { ui.label(RichText::new("📡").size(24.0)); ui.vertical(|ui| { ui.horizontal(|ui| { ui.label( RichText::new("Устройства и друзья в сети") .strong() .size(14.0) .color(theme.text_primary), ); Frame::none() .fill(Color32::from_rgba_premultiplied(34, 197, 94, 30)) .rounding(Rounding::same(4.0)) .stroke(Stroke::new(1.0_f32, Color32::from_rgb(34, 197, 94))) .inner_margin(Margin::symmetric(6.0, 2.0)) .show(ui, |ui| { ui.label(RichText::new("● Онлайн в сети").size(10.5).color(Color32::from_rgb(34, 197, 94)).strong()); }); }); ui.label( RichText::new(format!("Ваше имя: {} • IP: {}:{}", self.custom_device_name, self.current_iface.ip, self.my_info.transfer_port)) .size(11.0) .color(theme.text_muted), ); }); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { let direct_btn = Button::new(RichText::new("🔍 Прямое подключение (IP)").strong().color(Color32::WHITE)) .fill(Color32::from_rgb(37, 99, 235)) .rounding(Rounding::same(6.0)); if ui.add(direct_btn).clicked() { self.show_direct_ip_modal = true; self.direct_ip_status.clear(); } }); }); }); ui.add_space(10.0); if self.peers.is_empty() { Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(10.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(40.0)) .show(ui, |ui| { ui.vertical_centered(|ui| { ui.label(RichText::new("🔍").size(42.0)); ui.add_space(8.0); ui.label(RichText::new("Поиск устройств в сети ZeroTier / LAN...").size(16.0).strong().color(theme.text_primary)); ui.add_space(4.0); ui.label( RichText::new("Убедитесь, что друзья подключены к вашей сети ZeroTier/VPN или воспользуйтесь кнопкой «Прямое подключение (IP)» выше.") .size(12.0) .color(theme.text_muted), ); }); }); return; } let dropped_files = ui.ctx().input(|i| i.raw.dropped_files.clone()); let is_hovering_files = ui.ctx().input(|i| !i.raw.hovered_files.is_empty()); let pointer_pos = ui.ctx().input(|i| i.pointer.hover_pos()); if is_hovering_files { Frame::none() .fill(theme.bg_card_secondary) .rounding(Rounding::same(8.0)) .stroke(Stroke::new(1.5_f32, Color32::from_rgb(34, 197, 94))) .inner_margin(Margin::same(10.0)) .show(ui, |ui| { ui.horizontal(|ui| { ui.label(RichText::new("🎯 Перетащите файлы на карточку любого друга для мгновенной отправки!").size(13.0).strong().color(Color32::from_rgb(34, 197, 94))); }); }); ui.add_space(8.0); } let mut toggle_fav: Option = None; let mut rename_target: Option<(String, String, String)> = None; let mut send_to_peer: Option = None; let mut open_shares_peer: Option = None; let mut quick_drop_target: Option<(PeerInfo, Vec)> = None; ScrollArea::vertical().show(ui, |ui| { for peer in &self.peers { let peer_id = peer.peer_id.clone(); let is_checked = self.selected_peer_ids.contains(&peer_id); let (display_name, has_alias) = self.get_peer_display_name(peer); let card_response = Frame::none() .fill(if is_checked { theme.bg_card_active } else { theme.bg_card }) .rounding(Rounding::same(10.0)) .stroke(if is_hovering_files { Stroke::new(1.5_f32, theme.text_accent) } else { theme.stroke_border }) .inner_margin(Margin::same(12.0)) .show(ui, |ui| { ui.horizontal(|ui| { let os_icon = match peer.os.to_lowercase().as_str() { "windows" => "🪟", "linux" => "🐧", "macos" => "🍎", _ => "💻", }; ui.label(RichText::new(os_icon).size(30.0)); ui.add_space(6.0); ui.vertical(|ui| { ui.horizontal(|ui| { ui.label( RichText::new(&display_name) .size(15.0) .strong() .color(if has_alias { Color32::from_rgb(250, 204, 21) } else { theme.text_primary }), ); let is_fav = self.config.favorite_peers.contains(&peer.peer_id); let star_icon = if is_fav { "⭐" } else { "☆" }; if ui.small_button(star_icon).on_hover_text(if is_fav { "Убрать из избранного" } else { "Закрепить в избранном" }).clicked() { toggle_fav = Some(peer.peer_id.clone()); } if ui.small_button("✏").on_hover_text("Переименовать / задать псевдоним").clicked() { let curr = self.config.peer_aliases.get(&peer.peer_id).cloned().unwrap_or_default(); rename_target = Some((peer.peer_id.clone(), peer.name.clone(), curr)); } let (net_badge_icon, net_badge_color) = if peer.net_type.contains("ZeroTier") { ("🪐 ZeroTier", Color32::from_rgb(251, 146, 60)) } else if peer.net_type.contains("Tailscale") { ("🪢 Tailscale", Color32::from_rgb(56, 189, 248)) } else if peer.net_type.contains("Radmin") { ("🛡️ Radmin", Color32::from_rgb(168, 85, 247)) } else { ("🏠 LAN", Color32::from_rgb(34, 197, 94)) }; Frame::none() .fill(theme.bg_card_secondary) .rounding(Rounding::same(4.0)) .stroke(Stroke::new(1.0_f32, net_badge_color.linear_multiply(0.6))) .inner_margin(Margin::symmetric(5.0, 1.5)) .show(ui, |ui| { ui.label( RichText::new(net_badge_icon) .size(9.5) .color(net_badge_color) .strong(), ); }); if !peer.shared_folders.is_empty() { Frame::none() .fill(Color32::from_rgba_premultiplied(168, 85, 247, 30)) .rounding(Rounding::same(4.0)) .stroke(Stroke::new(1.0_f32, Color32::from_rgb(168, 85, 247))) .inner_margin(Margin::symmetric(5.0, 1.5)) .show(ui, |ui| { ui.label( RichText::new(format!("📁 {} папок", peer.shared_folders.len())) .size(10.0) .color(Color32::from_rgb(168, 85, 247)) .strong(), ); }); } }); ui.horizontal(|ui| { ui.label( RichText::new(format!("{}:{} • {}", peer.ip, peer.transfer_port, peer.os)) .monospace() .size(11.0) .color(theme.text_muted), ); if let Some(ms) = peer.ping_ms { let (ping_color, ping_icon) = if ms < 40 { (Color32::from_rgb(34, 197, 94), "🟢") } else if ms <= 100 { (Color32::from_rgb(250, 204, 21), "🟡") } else { (Color32::from_rgb(239, 68, 68), "🔴") }; ui.label( RichText::new(format!("• {} {} ms", ping_icon, ms)) .size(11.0) .color(ping_color), ); } }); }); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { let send_btn = Button::new(RichText::new("📤 Отправить").strong().color(Color32::WHITE)) .fill(Color32::from_rgb(37, 99, 235)) .rounding(Rounding::same(6.0)) .min_size(Vec2::new(95.0, 32.0)); if ui.add(send_btn).clicked() { send_to_peer = Some(peer_id.clone()); } if !peer.shared_folders.is_empty() { let shares_btn = Button::new(RichText::new("📁 Общие папки").color(Color32::from_rgb(168, 85, 247))) .fill(theme.bg_card_secondary) .rounding(Rounding::same(6.0)) .min_size(Vec2::new(105.0, 32.0)); if ui.add(shares_btn).clicked() { open_shares_peer = Some(peer_id.clone()); } } }); }); }) .response; if !dropped_files.is_empty() { let is_target = if let Some(pos) = pointer_pos { card_response.rect.contains(pos) } else { card_response.hovered() }; if is_target { let paths: Vec = dropped_files.iter().filter_map(|f| f.path.clone()).collect(); if !paths.is_empty() { quick_drop_target = Some((peer.clone(), paths)); } } } ui.add_space(8.0); } }); if let Some((target_peer, files)) = quick_drop_target { self.logs.push(format!("🎯 Быстрая отправка: {} файлов для {}...", files.len(), self.get_peer_display_name(&target_peer).0)); self.start_sending_files(vec![target_peer], files); } if let Some(pid) = toggle_fav { if self.config.favorite_peers.contains(&pid) { self.config.favorite_peers.retain(|id| id != &pid); } else { self.config.favorite_peers.push(pid); } self.persist_config(); } if let Some(ren) = rename_target { self.renaming_peer = Some(ren); } if let Some(pid) = send_to_peer { self.selected_peer_ids.clear(); self.selected_peer_ids.insert(pid); self.current_tab = Tab::Send; } if let Some(pid) = open_shares_peer { self.selected_peer_id_for_share = Some(pid); self.selected_share_subtab = SharedSubTab::NetworkShares; self.current_tab = Tab::Shared; } } fn render_send_tab(&mut self, ui: &mut Ui, theme: ThemeColors) { // ── Top Mode Switcher Bar ── Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(10.0)) .stroke(theme.stroke_border) .inner_margin(Margin::symmetric(14.0, 9.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)); let files_active = self.send_mode == SendMode::Files; let text_active = self.send_mode == SendMode::QuickText; let files_btn = Button::new( RichText::new("📁 Файлы и папки") .size(12.5) .strong() .color(if files_active { Color32::WHITE } else { theme.text_secondary }), ) .fill(if files_active { Color32::from_rgb(37, 99, 235) } else { theme.bg_card_secondary }) .min_size(Vec2::new(140.0, 30.0)) .rounding(Rounding::same(6.0)); if ui.add(files_btn).clicked() { self.send_mode = SendMode::Files; } let text_btn = Button::new( RichText::new("💬 Быстрый текст / Ссылка") .size(12.5) .strong() .color(if text_active { Color32::WHITE } else { theme.text_secondary }), ) .fill(if text_active { Color32::from_rgb(37, 99, 235) } else { theme.bg_card_secondary }) .min_size(Vec2::new(180.0, 30.0)) .rounding(Rounding::same(6.0)); if ui.add(text_btn).clicked() { self.send_mode = SendMode::QuickText; } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { ui.horizontal(|ui| { ui.label(RichText::new("🔒 PIN-защита:").size(11.5).color(theme.text_muted)); ui.add(egui::TextEdit::singleline(&mut self.recipient_pin).hint_text("PIN").password(true).desired_width(70.0)); }); }); }); }); ui.add_space(8.0); // ── Step 1: Multi-Recipient Modern Cards ── let total_peers = self.peers.len(); let selected_peers_count = self.selected_peer_ids.len(); Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(10.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(12.0)) .show(ui, |ui| { ui.horizontal(|ui| { ui.label( RichText::new(format!("Шаг 1: Выберите получателей ({}/{} выбрано)", selected_peers_count, total_peers)) .strong() .size(13.0) .color(theme.text_primary), ); if !self.peers.is_empty() { ui.with_layout(Layout::right_to_left(Align::Center), |ui| { let all_selected = selected_peers_count == total_peers; if ui.button(RichText::new(if all_selected { "✕ Снять выбор со всех" } else { "✓ Выбрать всех друзей" }).size(11.5).color(theme.text_accent)).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(8.0); if self.peers.is_empty() { Frame::none() .fill(theme.bg_card_secondary) .rounding(Rounding::same(8.0)) .inner_margin(Margin::same(12.0)) .show(ui, |ui| { ui.label(RichText::new("В сети пока нет друзей онлайн. Подключитесь к ZeroTier / VPN или проверьте вкладку устройств.").color(theme.text_muted).size(12.0)); }); } else { ui.horizontal_wrapped(|ui| { for p in &self.peers { let is_selected = self.selected_peer_ids.contains(&p.peer_id); let (name_label, has_alias) = self.get_peer_display_name(p); let os_icon = match p.os.to_lowercase().as_str() { "windows" => "🪟", "linux" => "🐧", "macos" => "🍎", _ => "💻", }; let (net_badge_icon, net_badge_color) = if p.net_type.contains("ZeroTier") { ("🪐 ZeroTier", Color32::from_rgb(251, 146, 60)) } else if p.net_type.contains("Tailscale") { ("🪢 Tailscale", Color32::from_rgb(56, 189, 248)) } else if p.net_type.contains("Radmin") { ("🛡️ Radmin", Color32::from_rgb(168, 85, 247)) } else { ("🏠 LAN", Color32::from_rgb(34, 197, 94)) }; let stroke = if is_selected { Stroke::new(1.5_f32, Color32::from_rgb(56, 189, 248)) } else { theme.stroke_border }; let card = Frame::none() .fill(if is_selected { if theme.is_dark { Color32::from_rgb(23, 37, 84) } else { Color32::from_rgb(219, 234, 254) } } else { theme.bg_card_secondary }) .rounding(Rounding::same(8.0)) .stroke(stroke) .inner_margin(Margin::symmetric(10.0, 6.0)) .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.label( RichText::new(&name_label) .size(12.5) .strong() .color(if has_alias { Color32::from_rgb(250, 204, 21) } else { theme.text_primary }), ); // Network Pill Badge Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(4.0)) .stroke(Stroke::new(1.0_f32, net_badge_color.linear_multiply(0.6))) .inner_margin(Margin::symmetric(5.0, 1.0)) .show(ui, |ui| { ui.label(RichText::new(net_badge_icon).size(9.5).color(net_badge_color).strong()); }); ui.label(RichText::new(p.ip.to_string()).monospace().size(10.0).color(theme.text_muted)); if let Some(ping) = p.ping_ms { ui.label(RichText::new(format!("⚡ {}ms", ping)).size(9.5).color(Color32::from_rgb(34, 197, 94))); } }); }); if ui.interact(card.response.rect, card.response.id, egui::Sense::click()).clicked() { if is_selected { self.selected_peer_ids.remove(&p.peer_id); } else { self.selected_peer_ids.insert(p.peer_id.clone()); } } } }); } }); ui.add_space(8.0); match self.send_mode { SendMode::Files => self.render_send_files_subview(ui, theme), SendMode::QuickText => self.render_send_text_subview(ui, theme), } } fn render_send_files_subview(&mut self, ui: &mut Ui, theme: ThemeColors) { // ── Step 2: Choose Files & Folders ── Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(10.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(12.0)) .show(ui, |ui| { ui.horizontal(|ui| { ui.label(RichText::new("Шаг 2: Выберите файлы для отправки").strong().size(13.0).color(theme.text_primary)); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if !self.queued_files.is_empty() && ui.button(RichText::new("🗑 Очистить очередь").color(Color32::from_rgb(239, 68, 68))).clicked() { self.queued_files.clear(); } }); }); ui.add_space(8.0); ui.horizontal(|ui| { let add_files_btn = Button::new(RichText::new("📄 Добавить файлы...").strong().color(Color32::WHITE)) .fill(Color32::from_rgb(37, 99, 235)) .min_size(Vec2::new(145.0, 32.0)) .rounding(Rounding::same(6.0)); if ui.add(add_files_btn).clicked() { if let Some(files) = FileDialog::new().pick_files() { for f in files { if !self.queued_files.contains(&f) { self.queued_files.push(f); } } } } let add_folder_btn = Button::new(RichText::new("📁 Добавить папку...").strong().color(Color32::WHITE)) .fill(Color32::from_rgb(79, 70, 229)) .min_size(Vec2::new(145.0, 32.0)) .rounding(Rounding::same(6.0)); if ui.add(add_folder_btn).clicked() { if let Some(folder) = FileDialog::new().pick_folder() { if !self.queued_files.contains(&folder) { self.queued_files.push(folder); } } } let paste_btn = Button::new(RichText::new("📸 Вставить скриншот (Ctrl+V)").strong().color(Color32::WHITE)) .fill(Color32::from_rgb(147, 51, 234)) .min_size(Vec2::new(195.0, 32.0)) .rounding(Rounding::same(6.0)); if ui.add(paste_btn).clicked() { self.try_paste_clipboard_image(); } }); ui.add_space(10.0); let total_size: u64 = self .queued_files .iter() .filter_map(|p| std::fs::metadata(p).ok().map(|m| m.len())) .sum(); ui.horizontal(|ui| { ui.label( RichText::new(format!( "Выбрано: {} элементов ({}) • Получателей: {}", self.queued_files.len(), human_bytes(total_size as f64), self.selected_peer_ids.len() )) .size(12.5) .strong() .color(theme.text_primary), ); if self.auto_zip_folders { Frame::none() .fill(Color32::from_rgba_premultiplied(34, 197, 94, 25)) .rounding(Rounding::same(4.0)) .inner_margin(Margin::symmetric(6.0, 2.0)) .show(ui, |ui| { ui.label(RichText::new("⚡ Zero-Disk Streaming (tar.zst)").size(10.5).color(Color32::from_rgb(34, 197, 94)).strong()); }); } }); ui.add_space(6.0); // ── Files Dropzone Box ── 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| { ScrollArea::vertical().max_height(200.0).show(ui, |ui| { if self.queued_files.is_empty() { ui.vertical_centered(|ui| { ui.add_space(24.0); ui.label(RichText::new("📂").size(32.0)); ui.add_space(4.0); ui.label(RichText::new("Перетащите файлы или папки сюда (Drag & Drop)").size(13.5).strong().color(theme.text_primary)); ui.add_space(2.0); ui.label(RichText::new("или нажмите Ctrl+V для быстрой вставки файлов и скриншотов из буфера обмена").size(11.5).color(theme.text_muted)); ui.add_space(24.0); }); } else { let mut to_remove = None; for (idx, path) in self.queued_files.iter().enumerate() { let is_dir = path.is_dir(); let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let (icon, badge_color, category) = get_file_badge_info(&name, is_dir); let size_str = std::fs::metadata(path).ok().map(|m| human_bytes(m.len() as f64)).unwrap_or_default(); Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(6.0)) .stroke(theme.stroke_border) .inner_margin(Margin::symmetric(10.0, 6.0)) .show(ui, |ui| { ui.horizontal(|ui| { ui.label(RichText::new(icon).size(18.0)); // Category Badge Pill Frame::none() .fill(badge_color.linear_multiply(0.2)) .rounding(Rounding::same(4.0)) .inner_margin(Margin::symmetric(6.0, 2.0)) .show(ui, |ui| { ui.label(RichText::new(category).size(9.5).color(badge_color).strong()); }); ui.label(RichText::new(&name).strong().color(theme.text_primary)); ui.label(RichText::new(format!("({})", size_str)).size(11.0).color(theme.text_muted)); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if ui.small_button(RichText::new("✕").color(Color32::from_rgb(239, 68, 68))).on_hover_text("Удалить из очереди").clicked() { to_remove = Some(idx); } }); }); }); ui.add_space(4.0); } if let Some(i) = to_remove { self.queued_files.remove(i); } } }); }); }); ui.add_space(10.0); // ── Step 3: Big Send Button ── let num_recipients = self.selected_peer_ids.len(); let total_size: u64 = self .queued_files .iter() .filter_map(|p| std::fs::metadata(p).ok().map(|m| m.len())) .sum(); let can_send = !self.queued_files.is_empty() && num_recipients > 0; let send_btn_text = if num_recipients == 1 { let target_name = self .peers .iter() .find(|p| self.selected_peer_ids.contains(&p.peer_id)) .map(|p| self.get_peer_display_name(p).0) .unwrap_or_else(|| "...".to_string()); format!("🚀 Отправить {} файл(ов) для {} ({})", self.queued_files.len(), target_name, human_bytes(total_size as f64)) } else if num_recipients > 1 { format!("🚀 Отправить {} файл(ов) для {} получателей ({})", self.queued_files.len(), num_recipients, human_bytes(total_size as f64)) } else { "🚀 Выберите получателя и добавьте файлы для отправки".to_string() }; let send_btn = Button::new(RichText::new(send_btn_text).size(14.5).strong().color(Color32::WHITE)) .fill(if can_send { Color32::from_rgb(16, 185, 129) } else { if theme.is_dark { Color32::from_rgb(51, 65, 85) } else { Color32::from_rgb(203, 213, 225) } }) .stroke(if can_send { Stroke::new(1.0_f32, Color32::from_rgb(52, 211, 153)) } else { Stroke::NONE }) .min_size(Vec2::new(ui.available_width(), 44.0)) .rounding(Rounding::same(8.0)); if ui.add_enabled(can_send, send_btn).clicked() { let targets: Vec = self .peers .iter() .filter(|p| self.selected_peer_ids.contains(&p.peer_id)) .cloned() .collect(); let files = self.queued_files.clone(); self.start_sending_files(targets, files); } } fn render_send_text_subview(&mut self, ui: &mut Ui, theme: ThemeColors) { Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(8.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(12.0)) .show(ui, |ui| { ui.horizontal(|ui| { ui.label(RichText::new("Шаг 2: Введите текст или ссылку для отправки:").strong().size(13.0).color(theme.text_primary)); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if ui.button("📋 Вставить из буфера обмена").clicked() { if let Ok(mut clipboard) = arboard::Clipboard::new() { if let Ok(text) = clipboard.get_text() { self.text_input = text; self.analyzed_preview = analyze_text(&self.text_input); } } } }); }); ui.add_space(8.0); let response = ui.add( egui::TextEdit::multiline(&mut self.text_input) .desired_rows(6) .desired_width(ui.available_width()) .font(egui::TextStyle::Monospace), ); if response.changed() { self.analyzed_preview = analyze_text(&self.text_input); } ui.add_space(8.0); if !self.text_input.trim().is_empty() { let (r, g, b) = self.analyzed_preview.content_type.badge_color_rgb(); Frame::none() .fill(theme.bg_card_secondary) .rounding(Rounding::same(6.0)) .stroke(Stroke::new(1.0_f32, Color32::from_rgb(r, g, b))) .inner_margin(Margin::same(8.0)) .show(ui, |ui| { ui.horizontal(|ui| { ui.label(RichText::new("Тип содержимого:").size(11.0).color(theme.text_muted)); ui.label( RichText::new(self.analyzed_preview.content_type.label()) .size(12.0) .strong() .color(Color32::from_rgb(r, g, b)), ); }); }); } }); ui.add_space(10.0); let num_recipients = self.selected_peer_ids.len(); let can_send_text = !self.text_input.trim().is_empty() && num_recipients > 0; let btn_label = if num_recipients == 1 { let target_name = self .peers .iter() .find(|p| self.selected_peer_ids.contains(&p.peer_id)) .map(|p| self.get_peer_display_name(p).0) .unwrap_or_else(|| "...".to_string()); format!("🚀 Отправить текст для {}", target_name) } else { format!("🚀 Отправить текст для {} получателей", num_recipients) }; let send_btn = Button::new(RichText::new(btn_label).size(14.0).strong().color(Color32::WHITE)) .fill(if can_send_text { Color32::from_rgb(16, 185, 129) } 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(), 40.0)) .rounding(Rounding::same(8.0)); if ui.add_enabled(can_send_text, send_btn).clicked() { let targets: Vec = self .peers .iter() .filter(|p| self.selected_peer_ids.contains(&p.peer_id)) .cloned() .collect(); let text = self.text_input.clone(); let client = self.client.clone(); let my_info = self.my_info.clone(); let log_tx = self.log_tx.clone(); let pin = if self.recipient_pin.trim().is_empty() { None } else { Some(self.recipient_pin.trim().to_string()) }; for peer in targets { let peer_url = peer.transfer_url(); let peer_name = self.get_peer_display_name(&peer).0; let text_clone = text.clone(); let client_clone = client.clone(); let my_info_clone = my_info.clone(); let log_tx_clone = log_tx.clone(); let pin_clone = pin.clone(); tokio::spawn(async move { match client_clone.send_text(&peer_url, &my_info_clone, &text_clone, pin_clone).await { Ok(analyzed) => { let _ = log_tx_clone.send(format!( "🎉 Текст [{}] доставлен для {}!", analyzed.content_type.label(), peer_name )); } Err(e) => { let _ = log_tx_clone.send(format!("⚠️ Ошибка отправки текста для {}: {}", peer_name, e)); } } }); } self.text_input.clear(); } } fn render_qr_code(ctx: &egui::Context, url: &str, tex_id: &str) -> Option { if let Ok(code) = QrCode::new(url.as_bytes()) { let width = code.width(); let mut pixels = Vec::with_capacity(width * width); for y in 0..width { for x in 0..width { let is_dark = code[(x, y)] == QrColor::Dark; pixels.push(if is_dark { Color32::BLACK } else { Color32::WHITE }); } } let image = ColorImage { size: [width, width], pixels, }; Some(ctx.load_texture(tex_id, image, TextureOptions::NEAREST)) } else { None } } fn render_receive_tab(&mut self, ui: &mut Ui, theme: ThemeColors) { ScrollArea::vertical().show(ui, |ui| { // Hero Top Banner Frame::none() .fill(if theme.is_dark { Color32::from_rgb(15, 23, 42) } else { Color32::from_rgb(238, 242, 255) }) .rounding(Rounding::same(8.0)) .stroke(Stroke::new(1.0_f32, Color32::from_rgb(99, 102, 241))) .inner_margin(Margin::symmetric(14.0, 10.0)) .show(ui, |ui| { ui.horizontal(|ui| { ui.label(RichText::new("📥").size(24.0)); ui.vertical(|ui| { ui.label( RichText::new("Прием файлов и Web Dropzone") .strong() .size(14.0) .color(theme.text_primary), ); ui.label( RichText::new("Вы можете отправлять фото, видео и файлы с телефонов и ноутбуков прямо через браузер без установки приложения!") .size(11.5) .color(theme.text_secondary), ); }); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if ui.button(RichText::new("📂 Открыть папку загрузок").strong()).clicked() { let _ = std::process::Command::new("explorer").arg(&self.download_dir).spawn(); } }); }); }); ui.add_space(10.0); // Storage & Auto-Accept Settings Card Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(8.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(12.0)) .show(ui, |ui| { ui.horizontal(|ui| { ui.label(RichText::new("📁 Папка для сохранения:").strong().color(theme.text_primary)); ui.label(RichText::new(format!("{:?}", self.download_dir)).monospace().size(11.5).color(theme.text_secondary)); }); ui.add_space(6.0); ui.horizontal(|ui| { if ui.checkbox(&mut self.auto_accept, "Автоматически принимать входящие файлы от друзей").changed() { self.persist_config(); } if ui.checkbox(&mut self.auto_extract_zip, "Автораспаковка ZIP архивов").changed() { self.persist_config(); } }); }); ui.add_space(12.0); let port = self.my_info.transfer_port; // 1. ZeroTier / VPN Interface let vpn_iface = if self.current_iface.is_virtual_vpn() { Some(self.current_iface.clone()) } else { self.available_interfaces.iter().find(|i| i.is_virtual_vpn()).cloned() }; // 2. Local Wi-Fi / LAN Interface let lan_iface = if !self.current_iface.is_virtual_vpn() && self.current_iface.net_type != NetworkType::Loopback { Some(self.current_iface.clone()) } else { self.available_interfaces.iter().find(|i| !i.is_virtual_vpn() && i.net_type != NetworkType::Loopback).cloned() }; // Card 1: ZeroTier / VPN Mesh if let Some(iface) = &vpn_iface { let url = format!("http://{}:{}", iface.ip, port); let qr_tex = Self::render_qr_code(ui.ctx(), &url, "qr_vpn"); Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(10.0)) .stroke(Stroke::new(1.0_f32, Color32::from_rgb(168, 85, 247))) .inner_margin(Margin::same(14.0)) .show(ui, |ui| { ui.horizontal(|ui| { ui.vertical(|ui| { ui.horizontal(|ui| { ui.label(RichText::new("🌐 Ссылка для ZeroTier / VPN Mesh").size(14.5).strong().color(theme.text_primary)); Frame::none() .fill(Color32::from_rgba_premultiplied(168, 85, 247, 40)) .rounding(Rounding::same(4.0)) .inner_margin(Margin::symmetric(6.0, 2.0)) .show(ui, |ui| { ui.label(RichText::new(iface.net_type.as_str()).size(10.0).color(Color32::from_rgb(192, 132, 252)).strong()); }); }); ui.add_space(3.0); ui.label(RichText::new("Для телефонов или ноутбуков, подключенных через сеть ZeroTier, Tailscale или Radmin VPN:").size(11.0).color(theme.text_muted)); ui.add_space(8.0); ui.horizontal(|ui| { ui.label(RichText::new(&url).monospace().size(13.5).strong().color(theme.text_accent)); if ui.button("📋 Скопировать").on_hover_text("Скопировать ссылку").clicked() { ui.output_mut(|o| o.copied_text = url.clone()); } if ui.button("🌐 Открыть").on_hover_text("Открыть в браузере").clicked() { ui.ctx().open_url(egui::OpenUrl::same_tab(&url)); } }); }); if let Some(tex) = qr_tex { ui.with_layout(Layout::right_to_left(Align::Center), |ui| { Frame::none() .fill(Color32::WHITE) .rounding(Rounding::same(6.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(4.0)) .show(ui, |ui| { ui.add(egui::Image::new(&tex).fit_to_exact_size(Vec2::new(90.0, 90.0))); }); }); } }); }); ui.add_space(10.0); } // Card 2: Local Wi-Fi / LAN Network if let Some(iface) = &lan_iface { let url = format!("http://{}:{}", iface.ip, port); let qr_tex = Self::render_qr_code(ui.ctx(), &url, "qr_lan"); Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(10.0)) .stroke(Stroke::new(1.0_f32, Color32::from_rgb(56, 189, 248))) .inner_margin(Margin::same(14.0)) .show(ui, |ui| { ui.horizontal(|ui| { ui.vertical(|ui| { ui.horizontal(|ui| { ui.label(RichText::new("🏠 Ссылка для домашней сети Wi-Fi / LAN").size(14.5).strong().color(theme.text_primary)); Frame::none() .fill(Color32::from_rgba_premultiplied(56, 189, 248, 40)) .rounding(Rounding::same(4.0)) .inner_margin(Margin::symmetric(6.0, 2.0)) .show(ui, |ui| { ui.label(RichText::new("Локальный Wi-Fi").size(10.0).color(Color32::from_rgb(56, 189, 248)).strong()); }); }); ui.add_space(3.0); ui.label(RichText::new("Для смартфонов и планшетов, подключенных к одному домашнему Wi-Fi роутеру:").size(11.0).color(theme.text_muted)); ui.add_space(8.0); ui.horizontal(|ui| { ui.label(RichText::new(&url).monospace().size(13.5).strong().color(theme.text_accent)); if ui.button("📋 Скопировать").on_hover_text("Скопировать ссылку").clicked() { ui.output_mut(|o| o.copied_text = url.clone()); } if ui.button("🌐 Открыть").on_hover_text("Открыть в браузере").clicked() { ui.ctx().open_url(egui::OpenUrl::same_tab(&url)); } }); }); if let Some(tex) = qr_tex { ui.with_layout(Layout::right_to_left(Align::Center), |ui| { Frame::none() .fill(Color32::WHITE) .rounding(Rounding::same(6.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(4.0)) .show(ui, |ui| { ui.add(egui::Image::new(&tex).fit_to_exact_size(Vec2::new(90.0, 90.0))); }); }); } }); }); ui.add_space(10.0); } else if vpn_iface.is_none() { // Fallback if neither was separately classified let url = format!("http://{}:{}", self.current_iface.ip, port); let qr_tex = Self::render_qr_code(ui.ctx(), &url, "qr_fallback"); Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(10.0)) .stroke(Stroke::new(1.0_f32, Color32::from_rgb(59, 130, 246))) .inner_margin(Margin::same(14.0)) .show(ui, |ui| { ui.horizontal(|ui| { ui.vertical(|ui| { ui.label(RichText::new("🌐 Web Dropzone Ссылка").size(14.5).strong().color(theme.text_primary)); ui.add_space(3.0); ui.label(RichText::new("Откройте эту ссылку в браузере или отсканируйте QR-код на телефоне:").size(11.0).color(theme.text_muted)); ui.add_space(8.0); ui.horizontal(|ui| { ui.label(RichText::new(&url).monospace().size(13.5).strong().color(theme.text_accent)); if ui.button("📋 Скопировать").clicked() { ui.output_mut(|o| o.copied_text = url.clone()); } if ui.button("🌐 Открыть").clicked() { ui.ctx().open_url(egui::OpenUrl::same_tab(&url)); } }); }); if let Some(tex) = qr_tex { ui.with_layout(Layout::right_to_left(Align::Center), |ui| { Frame::none() .fill(Color32::WHITE) .rounding(Rounding::same(6.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(4.0)) .show(ui, |ui| { ui.add(egui::Image::new(&tex).fit_to_exact_size(Vec2::new(90.0, 90.0))); }); }); } }); }); } ui.add_space(14.0); }); } fn refresh_current_share_tree(&mut self) { if let (Some(peer_id), Some(folder_id)) = ( self.selected_peer_id_for_share.clone(), self.selected_folder_id_for_share.clone(), ) { if let Some(peer) = self.peers.iter().find(|p| p.peer_id == peer_id) { let peer_url = peer.transfer_url(); let subpath = self.current_share_subpath.clone(); let pin = if self.share_pin_input.trim().is_empty() { None } else { Some(self.share_pin_input.trim().to_string()) }; let client = self.client.clone(); let tx = self.share_tree_tx.clone(); let log_tx = self.log_tx.clone(); self.share_tree_loading = true; tokio::spawn(async move { match client .fetch_shared_tree(&peer_url, &folder_id, Some(&subpath), pin.as_deref()) .await { Ok(tree) => { let _ = tx.send(Some(tree)); } Err(e) => { let _ = tx.send(None); let _ = log_tx.send(format!("⚠️ Shared folder error: {}", e)); } } }); } } } fn sync_active_share_tree_background(&self) { if let (Some(peer_id), Some(folder_id)) = ( self.selected_peer_id_for_share.clone(), self.selected_folder_id_for_share.clone(), ) { if let Some(peer) = self.peers.iter().find(|p| p.peer_id == peer_id && p.is_online) { let peer_url = peer.transfer_url(); let subpath = self.current_share_subpath.clone(); let pin = if self.share_pin_input.trim().is_empty() { None } else { Some(self.share_pin_input.trim().to_string()) }; let client = self.client.clone(); let tx = self.share_tree_tx.clone(); tokio::spawn(async move { if let Ok(tree) = client .fetch_shared_tree(&peer_url, &folder_id, Some(&subpath), pin.as_deref()) .await { let _ = tx.send(Some(tree)); } }); } } } fn render_shared_tab(&mut self, ui: &mut Ui, theme: ThemeColors) { // 1. Audio Dock (if track is active) let mut stop_audio = false; if let Some(track) = &self.active_audio { Frame::none() .fill(if theme.is_dark { Color32::from_rgb(26, 17, 36) } else { Color32::from_rgb(253, 242, 248) }) .stroke(Stroke::new(1.0_f32, Color32::from_rgb(236, 72, 153))) .rounding(Rounding::same(8.0)) .inner_margin(Margin::symmetric(14.0, 8.0)) .show(ui, |ui| { ui.horizontal(|ui| { ui.label(RichText::new("🎵").size(18.0)); ui.label(RichText::new(&track.name).strong().color(theme.text_primary)); ui.label(RichText::new(format!("• from {}", track.peer_name)).size(11.0).color(theme.text_muted)); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if ui.button(RichText::new("✕ Stop").color(theme.text_muted)).clicked() { stop_audio = true; } if ui.button("🌐 Open in System Player").clicked() { let _ = std::process::Command::new("explorer").arg(&track.url).spawn(); } }); }); }); ui.add_space(8.0); } if stop_audio { self.active_audio = None; } // 2. Watch Party Session Dock (if active) let mut leave_party = false; if let Some(session) = &self.watch_party_session { Frame::none() .fill(if theme.is_dark { Color32::from_rgb(30, 20, 45) } else { Color32::from_rgb(245, 243, 255) }) .stroke(Stroke::new(1.0_f32, Color32::from_rgb(168, 85, 247))) .rounding(Rounding::same(8.0)) .inner_margin(Margin::symmetric(14.0, 8.0)) .show(ui, |ui| { ui.horizontal(|ui| { ui.label(RichText::new("🎬").size(18.0)); ui.label(RichText::new("Active Watch Party:").strong().color(Color32::from_rgb(168, 85, 247))); ui.label(RichText::new(&session.relative_path).strong().color(theme.text_primary)); ui.label(RichText::new(format!("• Host: {}", session.host_peer_name)).size(11.0).color(theme.text_muted)); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if ui.button(RichText::new("✕ Leave Room").color(Color32::from_rgb(239, 68, 68))).clicked() { leave_party = true; } if ui.button("📋 Copy Watch Party Link").clicked() { let link = format!("zerosend://watch-party?room={}&file={}", session.room_id, session.relative_path); ui.output_mut(|o| o.copied_text = link); } }); }); }); ui.add_space(8.0); } if leave_party { self.watch_party_session = None; self.is_in_watch_party = false; } // Subtabs: Network Shares, My Shares, Live Folder Sync ui.horizontal(|ui| { let my_shares_count = self.shared_folders.len(); let network_shares_count: usize = self.peers.iter().map(|p| p.shared_folders.len()).sum(); let sync_count = self.folder_sync_pairs.len(); let net_btn = Button::new( RichText::new(format!("🌐 Network Shares ({})", network_shares_count)) .strong() .color(if self.selected_share_subtab == SharedSubTab::NetworkShares { theme.text_primary } else { theme.text_secondary }), ) .fill(if self.selected_share_subtab == SharedSubTab::NetworkShares { theme.bg_card_active } else { theme.bg_card }) .min_size(Vec2::new(160.0, 32.0)) .rounding(Rounding::same(6.0)); if ui.add(net_btn).clicked() { self.selected_share_subtab = SharedSubTab::NetworkShares; } let my_btn = Button::new( RichText::new(format!("💻 My Shares ({})", my_shares_count)) .strong() .color(if self.selected_share_subtab == SharedSubTab::MyShares { theme.text_primary } else { theme.text_secondary }), ) .fill(if self.selected_share_subtab == SharedSubTab::MyShares { theme.bg_card_active } else { theme.bg_card }) .min_size(Vec2::new(140.0, 32.0)) .rounding(Rounding::same(6.0)); if ui.add(my_btn).clicked() { self.selected_share_subtab = SharedSubTab::MyShares; } let sync_btn = Button::new( RichText::new(format!("🔄 Auto-Sync ({})", sync_count)) .strong() .color(if self.selected_share_subtab == SharedSubTab::AutoSync { theme.text_primary } else { theme.text_secondary }), ) .fill(if self.selected_share_subtab == SharedSubTab::AutoSync { theme.bg_card_active } else { theme.bg_card }) .min_size(Vec2::new(140.0, 32.0)) .rounding(Rounding::same(6.0)); if ui.add(sync_btn).clicked() { self.selected_share_subtab = SharedSubTab::AutoSync; } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if self.selected_share_subtab == SharedSubTab::MyShares { let add_btn = Button::new(RichText::new("➕ Add Shared Folder").strong().color(Color32::WHITE)) .fill(Color32::from_rgb(34, 197, 94)) .min_size(Vec2::new(160.0, 30.0)); if ui.add(add_btn).clicked() { if let Some(folder) = FileDialog::new().pick_folder() { let name = folder .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| "Shared Folder".to_string()); let id = uuid::Uuid::new_v4().to_string(); self.shared_folders.push(SharedFolderConfig { id, name, path: folder, read_only: true, pin: None, }); self.persist_config(); } } } else if self.selected_share_subtab == SharedSubTab::NetworkShares { if ui.button(RichText::new("🔄 Sync / Refresh").color(theme.text_primary)).clicked() { self.sync_all_network_shares(); self.refresh_current_share_tree(); self.logs.push("🔄 Обновлен список общих папок узлов в сети".to_string()); } } }); }); ui.add_space(10.0); match self.selected_share_subtab { SharedSubTab::MyShares => self.render_my_shares(ui, theme), SharedSubTab::NetworkShares => self.render_network_shares(ui, theme), SharedSubTab::AutoSync => self.render_auto_sync_subtab(ui, theme), } } fn render_my_shares(&mut self, ui: &mut Ui, theme: ThemeColors) { if self.shared_folders.is_empty() { Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(10.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(30.0)) .show(ui, |ui| { ui.vertical_centered(|ui| { ui.label(RichText::new("📁").size(36.0)); ui.label( RichText::new("У вас пока нет локальных общих папок.") .size(15.0) .strong() .color(theme.text_primary), ); ui.add_space(4.0); ui.label( RichText::new( "Откройте доступ к папкам с фильмами, музыкой или кодом, чтобы ваши друзья в ZeroTier/LAN могли смотреть и скачивать их.", ) .size(12.0) .color(theme.text_muted), ); ui.add_space(12.0); let choose_btn = Button::new(RichText::new("➕ Выбрать папку для общего доступа").strong().color(Color32::WHITE)) .fill(Color32::from_rgb(34, 197, 94)) .min_size(Vec2::new(220.0, 36.0)); if ui.add(choose_btn).clicked() { if let Some(folder) = FileDialog::new().pick_folder() { let name = folder .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| "Shared Folder".to_string()); let id = uuid::Uuid::new_v4().to_string(); self.shared_folders.push(SharedFolderConfig { id, name, path: folder, read_only: true, pin: None, }); self.persist_config(); } } }); }); return; } let mut config_changed = false; let mut remove_index = None; ScrollArea::vertical().show(ui, |ui| { for (idx, share) in self.shared_folders.iter_mut().enumerate() { Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(8.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(12.0)) .show(ui, |ui| { ui.horizontal(|ui| { ui.label(RichText::new("📁").size(24.0)); ui.vertical(|ui| { ui.horizontal(|ui| { ui.label(RichText::new(&share.name).strong().size(14.0).color(theme.text_primary)); let (ro_label, ro_color) = if share.read_only { ("Только чтение (Стриминг/Скачивание)", Color32::from_rgb(59, 130, 246)) } else { ("Чтение и запись (Загрузка разрешена)", Color32::from_rgb(34, 197, 94)) }; Frame::none() .fill(theme.bg_card_secondary) .rounding(Rounding::same(4.0)) .stroke(Stroke::new(1.0_f32, ro_color)) .inner_margin(Margin::symmetric(6.0, 2.0)) .show(ui, |ui| { ui.label(RichText::new(ro_label).size(10.5).color(ro_color)); }); if share.pin.is_some() { Frame::none() .fill(theme.bg_card_secondary) .rounding(Rounding::same(4.0)) .stroke(Stroke::new(1.0_f32, Color32::from_rgb(245, 158, 11))) .inner_margin(Margin::symmetric(6.0, 2.0)) .show(ui, |ui| { ui.label( RichText::new("🔒 PIN-защита") .size(10.5) .color(Color32::from_rgb(245, 158, 11)), ); }); } }); ui.label( RichText::new(format!("{:?}", share.path)) .monospace() .size(11.0) .color(theme.text_muted), ); }); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if ui.button(RichText::new("🗑 Удалить").color(Color32::from_rgb(239, 68, 68))).clicked() { remove_index = Some(idx); } if ui.button("📂 Открыть папку").clicked() { let _ = std::process::Command::new("explorer").arg(&share.path).spawn(); } if ui.checkbox(&mut share.read_only, "Только чтение").changed() { config_changed = true; } }); }); }); ui.add_space(8.0); } }); if let Some(idx) = remove_index { self.shared_folders.remove(idx); config_changed = true; } if config_changed { self.persist_config(); } } fn render_auto_sync_subtab(&mut self, ui: &mut Ui, theme: ThemeColors) { // Explanatory Banner Frame::none() .fill(if theme.is_dark { Color32::from_rgb(15, 23, 42) } else { Color32::from_rgb(238, 242, 255) }) .rounding(Rounding::same(8.0)) .stroke(Stroke::new(1.0_f32, Color32::from_rgb(99, 102, 241))) .inner_margin(Margin::symmetric(14.0, 10.0)) .show(ui, |ui| { ui.horizontal(|ui| { ui.label(RichText::new("🔄").size(22.0)); ui.vertical(|ui| { ui.label( RichText::new("Двусторонняя авто-синхронизация (Two-Way Live Sync)") .strong() .size(14.0) .color(theme.text_primary), ); ui.label( RichText::new( "Автоматически зеркалирует локальную папку на вашем ПК с общей папкой друга в фоновом режиме каждые 8 секунд без лишних действий.", ) .size(11.5) .color(theme.text_secondary), ); }); }); }); ui.add_space(10.0); // Collect all online friends with shared folders let mut online_shares: Vec<(String, String, String, String)> = Vec::new(); for peer in &self.peers { if peer.is_online && !peer.shared_folders.is_empty() { let (pname, _) = self.get_peer_display_name(peer); for folder in &peer.shared_folders { online_shares.push((peer.peer_id.clone(), pname.clone(), folder.id.clone(), folder.name.clone())); } } } // STEP-BY-STEP PAIRING WIZARD Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(8.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(14.0)) .show(ui, |ui| { ui.label(RichText::new("➕ Настройка новой авто-синхронизации (3 простых шага)").strong().size(13.5).color(theme.text_accent)); ui.add_space(8.0); ui.separator(); ui.add_space(8.0); // --- ШАГ 1: Выбор папки друга --- ui.label(RichText::new("Шаг 1: Выберите общую папку друга из сети").strong().color(theme.text_primary)); ui.add_space(4.0); if online_shares.is_empty() { Frame::none() .fill(theme.bg_card_secondary) .rounding(Rounding::same(6.0)) .inner_margin(Margin::same(8.0)) .show(ui, |ui| { ui.label( RichText::new("ℹ️ В сети пока не обнаружено общих папок у друзей. Подключитесь к ZeroTier/LAN, чтобы друзья появились онлайн.") .size(11.5) .color(theme.text_muted), ); }); } else { ui.horizontal_wrapped(|ui| { for (pid, pname, fid, fname) in &online_shares { let is_chosen = self.new_sync_peer_id == *pid && self.new_sync_folder_id == *fid; let btn_text = format!("📁 {} (от {})", fname, pname); let share_btn = Button::new( RichText::new(btn_text) .size(12.0) .strong() .color(if is_chosen { Color32::WHITE } else { theme.text_primary }), ) .fill(if is_chosen { Color32::from_rgb(37, 99, 235) } else { theme.bg_card_secondary }) .stroke(if is_chosen { Stroke::new(1.5_f32, Color32::from_rgb(96, 165, 250)) } else { Stroke::NONE }) .rounding(Rounding::same(6.0)) .min_size(Vec2::new(180.0, 32.0)); if ui.add(share_btn).clicked() { self.new_sync_peer_id = pid.clone(); self.new_sync_folder_id = fid.clone(); if self.new_sync_name.trim().is_empty() { self.new_sync_name = format!("Синхронизация {} ({})", fname, pname); } } } }); } ui.add_space(10.0); // --- ШАГ 2: Выбор локальной папки на диске --- ui.label(RichText::new("Шаг 2: Выберите локальную папку на вашем компьютере").strong().color(theme.text_primary)); ui.add_space(4.0); ui.horizontal(|ui| { let browse_btn = Button::new(RichText::new("📁 Выбрать папку на диске...").strong().color(Color32::WHITE)) .fill(Color32::from_rgb(59, 130, 246)) .rounding(Rounding::same(4.0)); if ui.add(browse_btn).clicked() { if let Some(folder) = FileDialog::new().pick_folder() { self.new_sync_local_path = folder.to_string_lossy().into_owned(); } } if self.new_sync_local_path.is_empty() { ui.label(RichText::new("Папка не выбрана").color(Color32::from_rgb(239, 68, 68)).size(11.5)); } else { ui.label(RichText::new(&self.new_sync_local_path).strong().color(Color32::from_rgb(34, 197, 94)).monospace().size(11.5)); } }); ui.add_space(10.0); // --- ШАГ 3: Запуск синхронизации --- ui.label(RichText::new("Шаг 3: Назовите и запустите синхронизацию").strong().color(theme.text_primary)); ui.add_space(4.0); ui.horizontal(|ui| { ui.label("Имя пары:"); ui.add(egui::TextEdit::singleline(&mut self.new_sync_name).hint_text("Мой проект").desired_width(180.0)); let is_ready = !self.new_sync_local_path.trim().is_empty() && !self.new_sync_peer_id.trim().is_empty() && !self.new_sync_folder_id.trim().is_empty(); let start_btn = Button::new(RichText::new("🚀 Запустить авто-синхронизацию").strong().color(Color32::WHITE)) .fill(if is_ready { Color32::from_rgb(34, 197, 94) } else { Color32::from_rgb(100, 116, 139) }) .rounding(Rounding::same(4.0)) .min_size(Vec2::new(200.0, 30.0)); if ui.add_enabled(is_ready, start_btn).clicked() { let name = if self.new_sync_name.trim().is_empty() { "Folder Sync".to_string() } else { self.new_sync_name.trim().to_string() }; let peer_name = self.peers.iter() .find(|p| p.peer_id == self.new_sync_peer_id) .map(|p| self.get_peer_display_name(p).0) .unwrap_or_else(|| "Friend".to_string()); let pair = crate::transfer::FolderSyncPairConfig { id: uuid::Uuid::new_v4().to_string(), name, local_path: PathBuf::from(self.new_sync_local_path.trim()), remote_peer_id: self.new_sync_peer_id.trim().to_string(), remote_peer_name: peer_name, remote_folder_id: self.new_sync_folder_id.trim().to_string(), is_active: true, last_sync: None, }; self.folder_sync_pairs.push(pair); self.config.folder_sync_pairs = self.folder_sync_pairs.clone(); self.persist_config(); self.logs.push(format!("🔄 Создана новая авто-синхронизация: {}", self.new_sync_name)); self.new_sync_local_path.clear(); self.new_sync_name.clear(); self.new_sync_peer_id.clear(); self.new_sync_folder_id.clear(); } }); }); ui.add_space(10.0); // List of Active Sync Pairs ui.label(RichText::new(format!("Активные пары синхронизации ({})", self.folder_sync_pairs.len())).strong().size(13.0).color(theme.text_primary)); ui.add_space(4.0); if self.folder_sync_pairs.is_empty() { Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(8.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(24.0)) .show(ui, |ui| { ui.vertical_centered(|ui| { ui.label(RichText::new("🔄 Нет активных настроенных пар синхронизации.").color(theme.text_secondary)); ui.label(RichText::new("Выберите общую папку друга в мастере выше и укажите локальную папку для старта.").size(11.5).color(theme.text_muted)); }); }); } else { let mut remove_sync_idx = None; let mut sync_changed = false; for (idx, pair) in self.folder_sync_pairs.iter_mut().enumerate() { Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(8.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(12.0)) .show(ui, |ui| { ui.horizontal(|ui| { ui.label(RichText::new("🔄").size(22.0)); ui.vertical(|ui| { ui.horizontal(|ui| { ui.label(RichText::new(&pair.name).strong().size(13.5).color(theme.text_primary)); if pair.is_active { ui.label(RichText::new("● Авто-синхронизация активна (каждые 8с)").color(Color32::from_rgb(34, 197, 94)).size(11.0).strong()); } else { ui.label(RichText::new("⏸ На паузе").color(theme.text_muted).size(11.0)); } }); ui.horizontal(|ui| { ui.label(RichText::new("💻 Локально:").strong().size(11.0).color(theme.text_secondary)); ui.label(RichText::new(format!("{:?}", pair.local_path)).monospace().size(11.0).color(theme.text_muted)); ui.label(RichText::new("➔ 🌐 Удаленно (Друг):").strong().size(11.0).color(theme.text_secondary)); ui.label(RichText::new(&pair.remote_peer_name).size(11.0).color(theme.text_accent)); }); }); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if ui.button(RichText::new("🗑 Удалить").color(Color32::from_rgb(239, 68, 68))).clicked() { remove_sync_idx = Some(idx); } if ui.checkbox(&mut pair.is_active, "Включено").changed() { sync_changed = true; } if ui.button("📂 Открыть локально").clicked() { let _ = std::process::Command::new("explorer").arg(&pair.local_path).spawn(); } }); }); }); ui.add_space(6.0); } if let Some(idx) = remove_sync_idx { self.folder_sync_pairs.remove(idx); sync_changed = true; } if sync_changed { self.config.folder_sync_pairs = self.folder_sync_pairs.clone(); self.persist_config(); } } ui.add_space(10.0); // Sync Live Console Log Frame::none() .fill(Color32::from_rgb(10, 13, 20)) .rounding(Rounding::same(8.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(10.0)) .show(ui, |ui| { ui.label(RichText::new("📜 Журнал работы авто-синхронизации (Live Activity Log)").strong().size(12.0).color(Color32::from_rgb(148, 163, 184))); ui.add_space(4.0); ScrollArea::vertical().max_height(140.0).show(ui, |ui| { if self.sync_log_messages.is_empty() { ui.label(RichText::new("Синхронизация ожидает изменений. Новые и измененные файлы будут синхронизироваться автоматически.").size(11.0).color(Color32::from_rgb(100, 116, 139))); } else { for log in self.sync_log_messages.iter().rev() { ui.label(RichText::new(log).monospace().size(11.0).color(Color32::from_rgb(203, 213, 225))); } } }); }); } fn render_network_shares(&mut self, ui: &mut Ui, theme: ThemeColors) { let online_peers_with_shares: Vec = self .peers .iter() .filter(|p| p.is_online && !p.shared_folders.is_empty()) .cloned() .collect(); if online_peers_with_shares.is_empty() { Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(12.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(48.0)) .show(ui, |ui| { ui.vertical_centered(|ui| { ui.label(RichText::new("🌐").size(48.0)); ui.add_space(8.0); ui.label( RichText::new("В сети пока нет обнаруженных общих папок") .size(17.0) .strong() .color(theme.text_primary), ); ui.add_space(6.0); ui.label( RichText::new( "Когда ваши друзья в ZeroTier, Tailscale, Radmin VPN или LAN откроют доступ к папкам, они мгновенно появятся здесь с поддержкой стриминга и быстрой загрузки.", ) .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: 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(10.0)) .stroke(theme.stroke_border) .inner_margin(Margin::symmetric(12.0, 10.0)) .show(ui, |ui| { 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); ScrollArea::vertical().show(ui, |ui| { for peer in &online_peers_with_shares { let (peer_display, _) = self.get_peer_display_name(peer); 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); 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(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)); 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(8.0); // ── Right Column: Active Share Modern Explorer ── ui.vertical(|ui| { if let (Some(peer_id), Some(folder_id)) = ( self.selected_peer_id_for_share.clone(), self.selected_folder_id_for_share.clone(), ) { let peer_opt = self.peers.iter().find(|p| p.peer_id == peer_id).cloned(); let peer = match peer_opt { Some(p) => p, None => { 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 & Interactive Breadcrumbs Bar ── Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(8.0)) .stroke(theme.stroke_border) .inner_margin(Margin::symmetric(10.0, 8.0)) .show(ui, |ui| { ui.horizontal(|ui| { 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() { 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("🔎 Grep по коду").color(Color32::from_rgb(56, 189, 248))).clicked() { self.show_grep_modal = true; self.grep_query.clear(); self.grep_results.clear(); self.grep_status_msg = None; } // Quick Auto-Sync pairing shortcut let folder_id_clone = folder_id.clone(); let peer_id_clone = peer.peer_id.clone(); 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() { 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); self.selected_share_subtab = SharedSubTab::AutoSync; if let Some(folder) = FileDialog::new().pick_folder() { self.new_sync_local_path = folder.to_string_lossy().into_owned(); } } // 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("⬆️ Загрузить файл") .strong() .color(Color32::WHITE), ) .fill(Color32::from_rgb(34, 197, 94)) .rounding(Rounding::same(6.0)); if ui.add(upload_btn).clicked() { if let Some(files) = FileDialog::new().pick_files() { let client = self.client.clone(); let peer_url = peer.transfer_url(); let f_id = folder_id.clone(); let sub = self.current_share_subpath.clone(); let pin = if self.share_pin_input.trim().is_empty() { None } else { Some(self.share_pin_input.trim().to_string()) }; let log_tx = self.log_tx.clone(); let tx = self.share_tree_tx.clone(); tokio::spawn(async move { for f in files { 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!("✅ Загружено {:?}", f.file_name())); } Err(e) => { let _ = log_tx.send(format!("❌ Ошибка загрузки: {}", e)); } } } if let Ok(tree) = client.fetch_shared_tree(&peer_url, &f_id, Some(&sub), pin.as_deref()).await { let _ = tx.send(Some(tree)); } }); } } } } }); }); }); ui.add_space(6.0); // ── 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) }) .rounding(Rounding::same(6.0)) .stroke(theme.stroke_border) .inner_margin(Margin::symmetric(12.0, 8.0)) .show(ui, |ui| { ui.horizontal(|ui| { 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("✖ Снять выбор").clicked() { self.selected_share_items.clear(); } let dl_btn = Button::new( RichText::new(format!("⬇️ Скачать выбранное ({})", self.selected_share_items.len())) .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(); let items: Vec = self.selected_share_items.iter().cloned().collect(); let dl_dir = self.download_dir.clone(); let pin = if self.share_pin_input.trim().is_empty() { None } else { 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 { let fname = std::path::Path::new(&item_rel) .file_name() .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!("⬇️ Загрузка {} от {}...", 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(); } }); self.selected_share_items.clear(); } }); }); }); ui.add_space(6.0); } // ── Explorer File Table & Cards ── if self.share_tree_loading { ui.vertical_centered(|ui| { ui.add_space(30.0); ui.spinner(); ui.label("Загрузка содержимого папки..."); }); } else if let Some(tree) = self.current_share_tree.clone() { let is_writable_share = !tree.read_only; ScrollArea::vertical().show(ui, |ui| { // Up to parent row if !self.current_share_subpath.is_empty() { Frame::none() .fill(theme.bg_card_secondary) .rounding(Rounding::same(6.0)) .inner_margin(Margin::symmetric(10.0, 6.0)) .show(ui, |ui| { ui.horizontal(|ui| { 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; self.refresh_current_share_tree(); } }); }); ui.add_space(4.0); } let q_lower = self.share_search_query.to_lowercase(); for entry in &tree.entries { if !q_lower.is_empty() && !entry.name.to_lowercase().contains(&q_lower) { continue; } let (icon, badge_color, badge_text) = get_file_badge_info(&entry.name, entry.is_dir); Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(6.0)) .stroke(theme.stroke_border) .inner_margin(Margin::symmetric(10.0, 7.0)) .show(ui, |ui| { ui.horizontal(|ui| { // Checkbox for multi-select if !entry.is_dir { let mut is_sel = self.selected_share_items.contains(&entry.relative_path); if ui.checkbox(&mut is_sel, "").changed() { if is_sel { self.selected_share_items.insert(entry.relative_path.clone()); } else { self.selected_share_items.remove(&entry.relative_path); } } } 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() { self.current_share_subpath = entry.relative_path.clone(); self.refresh_current_share_tree(); } } else { ui.label(RichText::new(&entry.name).strong().color(theme.text_primary)); } // Category Badge Frame::none() .fill(theme.bg_card_secondary) .rounding(Rounding::same(3.0)) .stroke(Stroke::new(1.0_f32, badge_color)) .inner_margin(Margin::symmetric(5.0, 1.0)) .show(ui, |ui| { ui.label(RichText::new(badge_text).size(9.5).color(badge_color)); }); if !entry.is_dir { ui.label( RichText::new(format!("({})", human_bytes(entry.size as f64))) .size(11.0) .color(theme.text_muted), ); } 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 { "⬇️ Скачать" }; 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(); let sub = entry.relative_path.clone(); let out_name = if is_dir { format!("{}.zip", entry.name) } else { entry.name.clone() }; let dest = self.download_dir.join(&out_name); let pin = if self.share_pin_input.trim().is_empty() { None } else { 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!("⬇️ Загрузка {} от {}...", 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!("✅ Загружено {} ({})", out_name, human_bytes(bytes as f64))); if sound { crate::ui::sound::play_transfer_sound(); } } Err(e) => { let _ = log_tx.send(format!("❌ Ошибка загрузки {}: {}", out_name, e)); } } }); } // ── Action: Audio In-App Player ── if badge_text == "AUDIO" { let stream_url = format!( "{}/api/shared/stream/{}/{}", peer.transfer_url(), folder_id, entry.relative_path.trim_start_matches('/') ); 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(), peer_name: peer_name.clone(), is_playing: true, }); let _ = std::process::Command::new("explorer").arg(&stream_url).spawn(); } } // ── Action: Video Watch Party & Stream ── if badge_text == "VIDEO" { let stream_url = format!( "{}/api/shared/stream/{}/{}", peer.transfer_url(), folder_id, entry.relative_path.trim_start_matches('/') ); if ui.button(RichText::new("🎬 Watch Party").color(Color32::from_rgb(168, 85, 247)).strong()).clicked() { self.watch_party_session = Some(crate::transfer::WatchPartySyncEvent { room_id: uuid::Uuid::new_v4().to_string(), host_peer_id: self.my_info.peer_id.clone(), host_peer_name: self.my_info.name.clone(), folder_id: folder_id.clone(), relative_path: entry.relative_path.clone(), is_playing: true, position_secs: 0.0, timestamp_ms: chrono::Local::now().timestamp_millis() as u64, }); self.is_in_watch_party = true; self.logs.push(format!("🎬 Watch Party создана для '{}'", entry.name)); let _ = std::process::Command::new("explorer").arg(&stream_url).spawn(); } 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) ── 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 Редактор" } 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() { let client = self.client.clone(); let peer_url = peer.transfer_url(); let f_id = folder_id.clone(); let sub = entry.relative_path.clone(); let file_name = entry.name.clone(); let peer_display = peer_name.clone(); let text_tx = self.share_text_tx.clone(); let is_w = is_writable_share; let pin = if self.share_pin_input.trim().is_empty() { None } else { Some(self.share_pin_input.trim().to_string()) }; tokio::spawn(async move { if let Ok(text) = client.fetch_shared_file_text(&peer_url, &f_id, &sub, pin.as_deref()).await { let _ = text_tx.send((file_name, sub, peer_display, text, Some(peer_url), Some(f_id), pin, is_w)); } }); } } }); }); }); ui.add_space(3.0); } }); } else { Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(8.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(24.0)) .show(ui, |ui| { ui.vertical_centered(|ui| { ui.label(RichText::new("🔒 Папка защищена PIN-кодом или пуста").strong().size(14.5).color(theme.text_primary)); ui.add_space(8.0); ui.horizontal(|ui| { ui.label("Введите PIN-код:"); ui.add(egui::TextEdit::singleline(&mut self.share_pin_input).password(true).desired_width(120.0)); 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(); } }); }); }); } } else { Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(10.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(48.0)) .show(ui, |ui| { ui.vertical_centered(|ui| { 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)); }); }); } }); }); } fn render_transfers_tab(&mut self, ui: &mut Ui, theme: ThemeColors) { let total_count = self.transfers.len(); let active_count = self.transfers.iter().filter(|t| t.state == TransferState::InProgress || t.state == TransferState::PendingConfirmation).count(); let completed_count = self.transfers.iter().filter(|t| t.state == TransferState::Completed).count(); let total_speed_bps: u64 = self.transfers.iter().filter(|t| t.state == TransferState::InProgress).map(|t| t.speed_bps).sum(); let total_speed_mb = (total_speed_bps as f64) / 1_048_576.0; // Top Metrics Summary Dashboard Frame::none() .fill(if theme.is_dark { Color32::from_rgb(15, 23, 42) } else { Color32::from_rgb(238, 242, 255) }) .rounding(Rounding::same(8.0)) .stroke(Stroke::new(1.0_f32, Color32::from_rgb(56, 189, 248))) .inner_margin(Margin::symmetric(14.0, 10.0)) .show(ui, |ui| { ui.horizontal(|ui| { ui.label(RichText::new("📜").size(24.0)); ui.vertical(|ui| { ui.label( RichText::new("История и мониторинг передач") .strong() .size(14.0) .color(theme.text_primary), ); ui.label( RichText::new("Отслеживание скорости в реальном времени, криптографическая проверка SHA-256 и управление файлами.") .size(11.5) .color(theme.text_secondary), ); }); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if !self.transfers.is_empty() { let clear_btn = Button::new(RichText::new("🗑 Очистить завершенные").color(Color32::from_rgb(239, 68, 68))) .fill(theme.bg_card_secondary) .rounding(Rounding::same(6.0)); if ui.add(clear_btn).clicked() { self.transfers.retain(|t| t.state == TransferState::InProgress || t.state == TransferState::PendingConfirmation); crate::config::save_transfers_history(&self.transfers); let srv = self.server.clone(); tokio::spawn(async move { srv.clear_finished().await; }); } } let open_btn = Button::new(RichText::new("📂 Папка загрузок").strong().color(Color32::WHITE)) .fill(Color32::from_rgb(37, 99, 235)) .rounding(Rounding::same(6.0)); if ui.add(open_btn).clicked() { let _ = std::process::Command::new("explorer").arg(&self.download_dir).spawn(); } }); }); ui.add_space(8.0); ui.separator(); ui.add_space(6.0); // 4 Metric Pill Cards with equal columns and perfect rounding ui.columns(4, |cols| { // Metric 1: Total Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(6.0)) .stroke(theme.stroke_border) .inner_margin(Margin::symmetric(10.0, 6.0)) .show(&mut cols[0], |ui| { ui.set_width(ui.available_width()); ui.vertical(|ui| { ui.label(RichText::new("Всего передач").size(10.5).color(theme.text_muted)); ui.label(RichText::new(format!("{}", total_count)).strong().size(15.0).color(theme.text_primary)); }); }); // Metric 2: Active Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(6.0)) .stroke(theme.stroke_border) .inner_margin(Margin::symmetric(10.0, 6.0)) .show(&mut cols[1], |ui| { ui.set_width(ui.available_width()); ui.vertical(|ui| { ui.label(RichText::new("В процессе").size(10.5).color(theme.text_muted)); ui.label(RichText::new(format!("{}", active_count)).strong().size(15.0).color(Color32::from_rgb(56, 189, 248))); }); }); // Metric 3: Completed Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(6.0)) .stroke(theme.stroke_border) .inner_margin(Margin::symmetric(10.0, 6.0)) .show(&mut cols[2], |ui| { ui.set_width(ui.available_width()); ui.vertical(|ui| { ui.label(RichText::new("Завершено").size(10.5).color(theme.text_muted)); ui.label(RichText::new(format!("{}", completed_count)).strong().size(15.0).color(Color32::from_rgb(34, 197, 94))); }); }); // Metric 4: Speed Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(6.0)) .stroke(theme.stroke_border) .inner_margin(Margin::symmetric(10.0, 6.0)) .show(&mut cols[3], |ui| { ui.set_width(ui.available_width()); ui.vertical(|ui| { ui.label(RichText::new("Общая скорость").size(10.5).color(theme.text_muted)); ui.label(RichText::new(format!("{:.1} MB/s", total_speed_mb)).strong().size(15.0).color(if total_speed_mb > 0.0 { Color32::from_rgb(250, 204, 21) } else { theme.text_secondary })); }); }); }); }); ui.add_space(10.0); if self.transfers.is_empty() { Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(10.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(40.0)) .show(ui, |ui| { ui.vertical_centered(|ui| { ui.label(RichText::new("📦").size(36.0)); ui.add_space(6.0); ui.label(RichText::new("История передач пока пуста.").size(15.0).strong().color(theme.text_secondary)); ui.add_space(4.0); ui.label(RichText::new("Отправьте файлы друзьям или примите передачу — здесь появится прогресс, скорость и SHA-256 отчет.").size(12.0).color(theme.text_muted)); }); }); return; } ScrollArea::vertical().show(ui, |ui| { for t in &self.transfers { let is_done = t.state == TransferState::Completed; let is_active = t.state == TransferState::InProgress; let icon = get_file_icon(&t.current_file_name); Frame::none() .fill(if is_active { if theme.is_dark { Color32::from_rgb(23, 37, 84) } else { Color32::from_rgb(219, 234, 254) } } else { theme.bg_card }) .rounding(Rounding::same(10.0)) .stroke(if is_active { Stroke::new(1.0_f32, Color32::from_rgb(59, 130, 246)) } else if is_done { Stroke::new(1.0_f32, Color32::from_rgb(34, 197, 94)) } else { theme.stroke_border }) .inner_margin(Margin::same(12.0)) .show(ui, |ui| { // Top row: Direction, Peer, and Status Badge ui.horizontal(|ui| { let (dir_badge, dir_color) = if t.is_incoming { (format!("📥 Получено от {}", t.peer_name), Color32::from_rgb(192, 132, 252)) } else { (format!("📤 Отправлено для {}", t.peer_name), Color32::from_rgb(96, 165, 250)) }; ui.label(RichText::new(dir_badge).size(13.0).strong().color(dir_color)); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { match &t.state { TransferState::Completed => { ui.horizontal(|ui| { ui.label(RichText::new("✅ Завершено").color(Color32::from_rgb(34, 197, 94)).strong().size(13.0)); if t.checksum_verified { Frame::none() .fill(Color32::from_rgba_premultiplied(34, 197, 94, 35)) .rounding(Rounding::same(4.0)) .stroke(Stroke::new(1.0_f32, Color32::from_rgb(34, 197, 94))) .inner_margin(Margin::symmetric(6.0, 2.0)) .show(ui, |ui| { ui.label(RichText::new("🛡️ SHA-256 Проверено").size(10.5).color(Color32::from_rgb(74, 222, 128)).strong()) .on_hover_text("Криптографическая контрольная сумма SHA-256 проверена на лету. 100% гарантия целостности."); }); } }); } TransferState::InProgress => { let speed_mb = (t.speed_bps as f64) / 1_048_576.0; ui.label( RichText::new(format!("⚡ {:.1} MB/s", speed_mb)) .color(if theme.is_dark { Color32::from_rgb(56, 189, 248) } else { Color32::from_rgb(2, 132, 199) }) .strong() .size(13.0), ); } TransferState::PendingConfirmation => { ui.label(RichText::new("⏳ Ожидание подтверждения...").color(Color32::YELLOW).size(13.0)); } TransferState::Failed(e) => { ui.label(RichText::new(format!("❌ Ошибка: {}", e)).color(Color32::RED).size(12.0)); } TransferState::Rejected => { ui.label(RichText::new("⛔ Передача отклонена").color(Color32::RED).size(12.0)); } TransferState::Paused => { ui.label(RichText::new("⏸ На паузе").color(Color32::from_rgb(234, 179, 8)).strong().size(13.0)); } TransferState::Canceled => { ui.label(RichText::new("🛑 Отменено").color(theme.text_muted).size(12.0)); } } }); }); ui.add_space(6.0); // Middle row: File Icon, Detailed Filename, Size, and Buttons ui.horizontal(|ui| { ui.label(RichText::new(icon).size(24.0)); ui.add_space(4.0); ui.vertical(|ui| { ui.label( RichText::new(&t.current_file_name) .size(14.5) .strong() .color(theme.text_primary), ); let progress_pct = if t.total_bytes > 0 { ((t.bytes_transferred as f64 / t.total_bytes as f64) * 100.0).clamp(0.0, 100.0) as u32 } else { 100 }; let comp_info = if t.compressed_bytes > 0 && t.compressed_bytes < t.bytes_transferred { let saved_pct = ((t.bytes_transferred.saturating_sub(t.compressed_bytes) as f64 / t.bytes_transferred as f64) * 100.0) as u32; let ratio = t.bytes_transferred as f64 / t.compressed_bytes.max(1) as f64; format!(" • 🗜️ {:.1}x (сжато на {}%)", ratio, saved_pct) } else { String::new() }; let size_details = if t.state == TransferState::InProgress || t.state == TransferState::Paused { let speed = t.speed_bps.max(1); let remaining_bytes = t.total_bytes.saturating_sub(t.bytes_transferred); let eta_secs = remaining_bytes / speed; let eta_str = if eta_secs < 60 { format!("~{}с осталось", eta_secs) } else if eta_secs < 3600 { format!("~{}м {}с осталось", eta_secs / 60, eta_secs % 60) } else { format!("~{}ч {}м осталось", eta_secs / 3600, (eta_secs % 3600) / 60) }; let speed_mb = (t.speed_bps as f64) / 1_048_576.0; let state_prefix = if t.state == TransferState::Paused { "⏸ На паузе • " } else { "" }; format!( "{}{} / {} ({}%) • ⚡ {:.1} MB/s • {}{}", state_prefix, human_bytes(t.bytes_transferred as f64), human_bytes(t.total_bytes as f64), progress_pct, speed_mb, eta_str, comp_info ) } else if is_done { if t.checksum_verified { format!("Доставлено • Размер: {}{} • 🛡️ SHA-256 Проверено", human_bytes(t.total_bytes as f64), comp_info) } else { format!("Доставлено • Размер: {}{}", human_bytes(t.total_bytes as f64), comp_info) } } else { format!("Размер: {}", human_bytes(t.total_bytes as f64)) }; ui.label(RichText::new(size_details).size(11.0).color(theme.text_muted)); }); if is_done { ui.with_layout(Layout::right_to_left(Align::Center), |ui| { let target_file = self.download_dir.join(&t.current_file_name); let exists = target_file.exists(); if ui .add( Button::new(RichText::new("📂 В проводнике").strong().color(Color32::WHITE)) .fill(Color32::from_rgb(37, 99, 235)) .rounding(Rounding::same(6.0)), ) .clicked() { if exists { let _ = std::process::Command::new("explorer") .args(["/select,", &target_file.to_string_lossy()]) .spawn(); } else { let _ = std::process::Command::new("explorer").arg(&self.download_dir).spawn(); } } if exists && ui .add( Button::new(RichText::new("📄 Открыть").color(if theme.is_dark { Color32::WHITE } else { theme.text_primary })) .fill(if theme.is_dark { Color32::from_rgb(51, 65, 85) } else { Color32::from_rgb(226, 232, 240) }) .rounding(Rounding::same(6.0)), ) .clicked() { let _ = std::process::Command::new("explorer").arg(&target_file).spawn(); } }); } else if is_active || t.state == TransferState::Paused { ui.with_layout(Layout::right_to_left(Align::Center), |ui| { let client_c = self.client.clone(); let s_id = t.session_id.clone(); if t.state == TransferState::Paused { let resume_btn = Button::new(RichText::new("▶️ Продолжить").strong().color(Color32::WHITE)) .fill(Color32::from_rgb(34, 197, 94)) .rounding(Rounding::same(6.0)); if ui.add(resume_btn).clicked() { tokio::spawn(async move { client_c.resume_transfer(&s_id).await; }); } } else { let pause_btn = Button::new(RichText::new("⏸ Пауза").strong().color(Color32::WHITE)) .fill(Color32::from_rgb(234, 179, 8)) .rounding(Rounding::same(6.0)); if ui.add(pause_btn).clicked() { tokio::spawn(async move { client_c.pause_transfer(&s_id).await; }); } } }); } }); ui.add_space(6.0); // Progress Bar let progress_ratio = if t.total_bytes > 0 { (t.bytes_transferred as f32 / t.total_bytes as f32).clamp(0.0, 1.0) } else { 1.0 }; let pb = ProgressBar::new(progress_ratio) .show_percentage() .fill(if is_done { Color32::from_rgb(16, 185, 129) } else { Color32::from_rgb(56, 189, 248) }); ui.add(pb); }); ui.add_space(6.0); } }); } fn render_settings_tab(&mut self, ui: &mut Ui, theme: ThemeColors) { ScrollArea::vertical().show(ui, |ui| { // Header Banner Frame::none() .fill(if theme.is_dark { Color32::from_rgb(15, 23, 42) } else { Color32::from_rgb(238, 242, 255) }) .rounding(Rounding::same(8.0)) .stroke(Stroke::new(1.0_f32, Color32::from_rgb(99, 102, 241))) .inner_margin(Margin::symmetric(14.0, 10.0)) .show(ui, |ui| { ui.horizontal(|ui| { ui.label(RichText::new("⚙️").size(24.0)); ui.vertical(|ui| { ui.label( RichText::new("Настройки приложения и информация") .strong() .size(14.0) .color(theme.text_primary), ); ui.label( RichText::new("Конфигурация сети, папок загрузки, шифрования, темы оформления и обновлений.") .size(11.5) .color(theme.text_secondary), ); }); }); }); ui.add_space(10.0); // Card 1: Theme & Appearance Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(8.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(14.0)) .show(ui, |ui| { ui.label(RichText::new("🎨 Внешний вид и тема").strong().size(13.5).color(theme.text_accent)); ui.add_space(6.0); ui.horizontal(|ui| { let themes = [ (ThemeMode::Auto, "🖥 Системная (Авто)"), (ThemeMode::Dark, "🌙 Темная тема"), (ThemeMode::Light, "☀️ Светлая тема"), ]; for (mode, label) in themes { let is_active = self.theme_mode == mode; let btn = Button::new( RichText::new(label) .size(12.0) .color(if is_active { Color32::WHITE } else { theme.text_secondary }) .strong(), ) .fill(if is_active { Color32::from_rgb(37, 99, 235) } else { theme.bg_card_secondary }) .rounding(Rounding::same(6.0)) .min_size(Vec2::new(140.0, 32.0)); if ui.add(btn).clicked() { self.theme_mode = mode; self.config.theme_mode = mode; self.persist_config(); } } }); ui.add_space(4.0); let current_status = match self.theme_mode { ThemeMode::Auto => { if is_system_dark_mode() { "Автоматически повторяет темную тему Windows" } else { "Автоматически повторяет светлую тему Windows" } } ThemeMode::Dark => "Всегда используется темная тема", ThemeMode::Light => "Всегда используется светлая тема", }; ui.label(RichText::new(current_status).size(11.0).color(theme.text_muted)); }); ui.add_space(10.0); // Card 2: Network & Identity Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(8.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(14.0)) .show(ui, |ui| { ui.label(RichText::new("🌐 Сеть и идентификация").strong().size(13.5).color(theme.text_accent)); ui.add_space(8.0); ui.horizontal(|ui| { ui.label("Имя устройства в сети:"); if ui.text_edit_singleline(&mut self.custom_device_name).lost_focus() { self.persist_config(); } }); ui.add_space(8.0); ui.horizontal(|ui| { ui.label("Сетевой адаптер (IP):"); let mut iface_changed = false; let ifaces = self.available_interfaces.clone(); egui::ComboBox::from_id_salt("iface_combo") .selected_text(format!("{} - {}", self.current_iface.net_type.as_str(), self.current_iface.ip)) .width(360.0) .show_ui(ui, |ui| { for iface in &ifaces { let label = format!("{} (IP: {})", iface.name, iface.ip); if ui.selectable_value(&mut self.current_iface, iface.clone(), label).changed() { iface_changed = true; } } }); if iface_changed { self.persist_config(); } }); }); ui.add_space(10.0); // Card 3: Storage & Downloads Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(8.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(14.0)) .show(ui, |ui| { ui.label(RichText::new("💾 Папка сохранения файлов").strong().size(13.5).color(theme.text_accent)); ui.add_space(6.0); ui.horizontal(|ui| { ui.label(RichText::new(format!("{:?}", self.download_dir)).monospace().size(11.5).color(theme.text_secondary)); let change_btn = Button::new(RichText::new("📁 Изменить папку...").strong().color(Color32::WHITE)) .fill(Color32::from_rgb(59, 130, 246)) .rounding(Rounding::same(6.0)); if ui.add(change_btn).clicked() { if let Some(dir) = FileDialog::new().pick_folder() { self.download_dir = dir; self.persist_config(); } } if ui.button("📂 Открыть папку").clicked() { let _ = std::process::Command::new("explorer").arg(&self.download_dir).spawn(); } }); }); ui.add_space(10.0); // Card 4: Compression & Speed Cap Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(8.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(14.0)) .show(ui, |ui| { ui.label(RichText::new("⚡ Производительность, сжатие и лимиты скорости").strong().size(13.5).color(theme.text_accent)); ui.add_space(6.0); if ui.checkbox(&mut self.auto_zip_folders, "⚡ Потоковая передача папок (tar.zst) — без временных файлов на диске, Zstd сжатие Level 1").changed() { self.persist_config(); } if ui.checkbox(&mut self.auto_extract_zip, "Автоматически распаковывать полученные ZIP архивы").changed() { self.persist_config(); } ui.add_space(8.0); ui.separator(); ui.add_space(8.0); ui.horizontal(|ui| { ui.label("Ограничение скорости отдачи:"); if ui .add( egui::DragValue::new(&mut self.max_upload_speed_mbps) .range(0..=5000) .speed(1.0) .custom_formatter(|n, _| { if n == 0.0 { "Без ограничений (0 MB/s)".to_string() } else { format!("{:.0} MB/s", n) } }), ) .changed() { self.persist_config(); } ui.add_space(8.0); ui.label(RichText::new("(0 = максимальная скорость канала)").size(11.0).color(theme.text_muted)); }); ui.add_space(4.0); ui.horizontal(|ui| { ui.label(RichText::new("Быстрые пресеты:").size(11.5).color(theme.text_muted)); for preset in [0, 5, 10, 25, 50, 100] { let label = if preset == 0 { "Без лимита".to_string() } else { format!("{} MB/s", preset) }; let is_current = self.max_upload_speed_mbps == preset; let btn = egui::Button::new( RichText::new(label).size(11.0).color(if is_current { if theme.is_dark { Color32::from_rgb(56, 189, 248) } else { Color32::WHITE } } else { theme.text_secondary }), ) .fill(if is_current { Color32::from_rgb(37, 99, 235) } else { theme.bg_card_secondary }); if ui.add(btn).clicked() { self.max_upload_speed_mbps = preset; self.persist_config(); } } }); }); ui.add_space(10.0); // Card 5: Security & PIN Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(8.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(14.0)) .show(ui, |ui| { ui.label(RichText::new("🔒 Безопасность и PIN-код").strong().size(13.5).color(theme.text_accent)); ui.add_space(6.0); if ui.checkbox(&mut self.require_pin, "Требовать 4-значный PIN-код для входящих передач и сообщений").changed() { self.persist_config(); } if self.require_pin { ui.add_space(4.0); ui.horizontal(|ui| { ui.label("Установите PIN-код:"); if ui .add( egui::TextEdit::singleline(&mut self.pin_code) .password(true) .desired_width(80.0), ) .changed() { self.persist_config(); } }); } }); ui.add_space(10.0); // Card 6: System & Tray Behavior Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(8.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(14.0)) .show(ui, |ui| { ui.label(RichText::new("🎛 Системное поведение и уведомления").strong().size(13.5).color(theme.text_accent)); ui.add_space(6.0); if ui.checkbox(&mut self.close_to_tray, "Сворачивать в системный трей при закрытии крестиком").changed() { self.persist_config(); } if ui.checkbox(&mut self.sound_notifications, "Воспроизводить звуковой сигнал при завершении передачи").changed() { self.persist_config(); } ui.add_space(8.0); ui.horizontal(|ui| { if self.autostart_enabled { ui.label(RichText::new("Автозапуск с Windows: Включен (в трее)").color(Color32::from_rgb(34, 197, 94))); if ui.button("➖ Отключить автозапуск").clicked() { let _ = unregister_autostart(); self.autostart_enabled = is_autostart_registered(); } } else { ui.label(RichText::new("Автозапуск с Windows: Отключен").color(theme.text_muted)); if ui.button("➕ Запускать вместе с Windows").clicked() { let _ = register_autostart(); self.autostart_enabled = is_autostart_registered(); } } }); }); ui.add_space(10.0); // Card 7: Gitea Auto-Updater Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(8.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(14.0)) .show(ui, |ui| { ui.label(RichText::new("🔄 Обновления через Gitea").strong().size(13.5).color(theme.text_accent)); ui.add_space(6.0); ui.horizontal(|ui| { ui.label("Репозиторий Gitea:"); if ui.link(RichText::new(GITEA_DEFAULT_REPO_URL).color(Color32::from_rgb(56, 189, 248))).clicked() { let _ = std::process::Command::new("explorer").arg(GITEA_DEFAULT_REPO_URL).spawn(); } }); ui.add_space(4.0); if ui.checkbox(&mut self.check_updates_on_startup, "Автоматически проверять обновления при запуске").changed() { self.config.check_updates_on_startup = self.check_updates_on_startup; self.persist_config(); } ui.add_space(6.0); let mut check_clicked = false; ui.horizontal(|ui| { let check_btn = Button::new( RichText::new(if self.is_checking_update { "⏳ Проверка Gitea..." } else { "🔍 Проверить обновления сейчас" }) .color(theme.text_primary) ) .fill(theme.bg_card_secondary) .rounding(Rounding::same(6.0)); if ui.add_enabled(!self.is_checking_update, check_btn).clicked() { check_clicked = true; } if let Some(msg) = &self.update_check_status_msg { ui.label(RichText::new(msg).size(12.0).color(theme.text_muted)); } }); if check_clicked { self.trigger_update_check(); } let mut trigger_install_url = None; let mut open_gitea_url = None; if let Some(info) = &self.update_info { if info.has_update { ui.add_space(8.0); Frame::none() .fill(if theme.is_dark { Color32::from_rgb(17, 36, 26) } else { Color32::from_rgb(236, 253, 245) }) .stroke(Stroke::new(1.0_f32, Color32::from_rgb(34, 197, 94))) .rounding(Rounding::same(8.0)) .inner_margin(Margin::same(12.0)) .show(ui, |ui| { ui.horizontal(|ui| { ui.label(RichText::new("✨ Доступна новая версия:").strong().color(Color32::from_rgb(34, 197, 94))); ui.label(RichText::new(format!("v{}", info.latest_version)).strong().size(14.0).color(theme.text_primary)); ui.label(RichText::new(format!("(Текущая: v{})", info.current_version)).size(11.0).color(theme.text_muted)); }); if !info.release_notes.is_empty() { ui.add_space(4.0); ui.label(RichText::new(&info.release_notes).size(11.5).color(theme.text_secondary)); } ui.add_space(8.0); if self.is_updating { if let Some(prog) = &self.update_progress { match prog { UpdateProgress::Downloading { percent, downloaded, total } => { let pct_100 = (percent * 100.0) as u32; ui.label( RichText::new(format!("Загрузка обновления: {} / {} ({}%)", human_bytes(*downloaded as f64), human_bytes(*total as f64), pct_100)) .size(11.5) .color(theme.text_primary) ); ui.add(ProgressBar::new(*percent).animate(true)); } UpdateProgress::Verifying => { ui.label(RichText::new("🛡️ Проверка целостности файла...").color(Color32::from_rgb(56, 189, 248))); } UpdateProgress::Installing => { ui.label(RichText::new("⚡ Установка обновления и перезапуск ZeroSend...").color(Color32::from_rgb(34, 197, 94)).strong()); } UpdateProgress::Completed => { ui.label(RichText::new("🎉 Обновление успешно установлено! Перезапуск...").color(Color32::from_rgb(34, 197, 94)).strong()); } UpdateProgress::Error(err) => { ui.label(RichText::new(format!("❌ Ошибка обновления: {}", err)).color(Color32::from_rgb(239, 68, 68))); } } } } else { ui.horizontal(|ui| { if let Some(dl_url) = &info.download_url { let install_btn = Button::new( RichText::new("🚀 Скачать и установить обновление") .strong() .color(Color32::WHITE) ) .fill(Color32::from_rgb(34, 197, 94)) .rounding(Rounding::same(6.0)); if ui.add(install_btn).clicked() { trigger_install_url = Some(dl_url.clone()); } } let gitea_btn = Button::new( RichText::new("🌐 Страница релиза на Gitea") .color(theme.text_primary) ) .fill(theme.bg_card_secondary) .rounding(Rounding::same(6.0)); if ui.add(gitea_btn).clicked() { open_gitea_url = Some(info.gitea_url.clone()); } }); } }); } } if let Some(dl) = trigger_install_url { self.trigger_install_update(dl); } if let Some(g_url) = open_gitea_url { let _ = std::process::Command::new("explorer").arg(&g_url).spawn(); } }); ui.add_space(10.0); // Collapsible Credits & About Card egui::CollapsingHeader::new(RichText::new("👥 О программе и Разработчики").strong().size(14.0).color(theme.text_primary)) .default_open(false) .show(ui, |ui| { Frame::none() .fill(theme.bg_card) .rounding(Rounding::same(10.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(16.0)) .show(ui, |ui| { ui.horizontal(|ui| { ui.label(RichText::new("⚡ ZeroSend").size(16.0).strong().color(theme.text_accent)); ui.label(RichText::new(format!("v{}", env!("CARGO_PKG_VERSION"))).size(13.0).color(Color32::from_rgb(34, 197, 94)).strong()); }); ui.add_space(4.0); ui.label(RichText::new("Быстрый децентрализованный обмен файлами, медиа и текстом, оптимизированный для сетей ZeroTier, Tailscale, Radmin VPN и локального Wi-Fi/LAN.").color(theme.text_secondary)); ui.add_space(8.0); ui.label(RichText::new("🛠 Создатели и разработка:").strong().color(theme.text_primary)); ui.label(RichText::new(" • RarDog (Идея, архитектура, требования и руководство проектом)").color(theme.text_secondary)); ui.label(RichText::new(" • Antigravity (Google DeepMind AI Coding Assistant — парное программирование и реализация)").color(theme.text_secondary)); ui.add_space(6.0); ui.label(RichText::new("⚙ Стек технологий и возможности:").strong().color(theme.text_primary)); ui.label(RichText::new(" • Rust 1.98, асинхронный рантайм Tokio, потоковый HTTP сервер Axum").color(theme.text_secondary)); ui.label(RichText::new(" • 🔄 Двусторонняя автосинхронизация папок в реальном времени (Auto-Sync Mirroring)").color(theme.text_secondary)); ui.label(RichText::new(" • 🖧 Монтирование сетевого диска в Проводник Windows (WebDAV RFC 4918, диск Z:\\)").color(theme.text_secondary)); ui.label(RichText::new(" • 🎬 Потоковое воспроизведение видео/аудио без скачивания (HTTP 206) и совместный просмотр Watch Party").color(theme.text_secondary)); ui.label(RichText::new(" • ✏️ Встроенный Vim-редактор: режимы Normal/Insert, сохранение :w по сети, предпросмотр Markdown").color(theme.text_secondary)); ui.label(RichText::new(" • 🔎 Удаленный полнотекстовый поиск Grep по коду и файлам друзей с переходом по строкам").color(theme.text_secondary)); ui.label(RichText::new(" • 📦 Портативный режим (Portable): конфиг и загрузки хранятся рядом с exe в одной папке").color(theme.text_secondary)); ui.label(RichText::new(" • 🛡️ Криптографическая проверка целостности SHA-256 на лету").color(theme.text_secondary)); ui.label(RichText::new(" • ⚡ 4-поточный параллельный чанк-движок быстрой передачи").color(theme.text_secondary)); ui.label(RichText::new(" • 🔄 Докачка и контрольные точки (.zerosend_part) при обрыве связи").color(theme.text_secondary)); ui.label(RichText::new(" • 🌐 Автоматическое UDP-обнаружение узлов в сетях ZeroTier / LAN").color(theme.text_secondary)); }); }); ui.add_space(8.0); // Collapsible Version History / Changelog Card egui::CollapsingHeader::new(RichText::new("📜 История версий (Список изменений)").strong().size(14.0).color(theme.text_primary)) .default_open(false) .show(ui, |ui| { Frame::none() .fill(theme.bg_card_secondary) .rounding(Rounding::same(8.0)) .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(" • 🎨 Редизайн вкладки «Отправить файлы»: Удобные карточки друзей с аватарками ОС, бейджами сетей (🪐 ZeroTier, 🪢 Tailscale, 🏠 LAN), пингом, стильной зоной Drag & Drop и новой большой кнопкой отправки."); ui.label(" • 🪐 Премиальный статус-блок ZeroTier & IP: Новая фирменная плашка сети в боковой панели с индикатором онлайна, иконкой 🪐 ZeroTier и кнопкой копирования IP:Port в один клик."); 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(56, 189, 248))); }); ui.label(" • 🔄 Мгновенное авто-обнаружение общих папок: Динамическое обновление списка папок друзей при добавлении/удалении в реальном времени и по кнопке Sync/Refresh."); ui.label(" • 📥 Отслеживание скачиваний из общих папок: Когда друзья качают файлы или архивы из вашей общей папки, передача в реальном времени отображается в списке передач со скоростью."); ui.label(" • 📁 Прозрачный стриминг папок: Получатель видит настоящее имя папки, примерный размер, прогресс и скорость в MB/s с момента старта стрима."); ui.label(" • 💾 Постоянное сохранение истории передач: История завершенных и прошлых передач теперь надежно сохраняется на диск и не стирается при перезапуске приложения."); ui.add_space(8.0); ui.separator(); ui.add_space(8.0); // v2.5.3-beta.2 ui.horizontal(|ui| { ui.label(RichText::new("v2.5.3-beta.2").strong().color(Color32::from_rgb(56, 189, 248))); }); ui.label(" • ✨ Плавные анимации переходов: Шелковистые кинетические анимации переключения вкладок (Cubic Ease-Out) и плавно скользящий индикатор выбора в меню."); ui.label(" • ⚡ TCP Socket Tuning: 4MB буферы сокетов, TCP_NODELAY и пул соединений через socket2 для максимальной скорости на VPN и LAN."); ui.label(" • 🌊 Zero-Drop Auto-Recovery: Авто-восстановление передач при сбоях Wi-Fi/VPN с экспоненциальным backoff до 8 попыток без обрыва."); ui.label(" • 📡 mDNS & Multicast Discovery: Мультикаст группы (239.255.255.250 и 224.0.0.251) для 100% обнаружения пиров на любых роутерах."); ui.label(" • 🗜️ Умная Zstd-компрессия: Авто-пропуск сжатия для медиа (.jpg/.mp4/.zip...) и потоковое Zstd Level 1 сжатие кода/текста."); ui.label(" • 🔄 Delta-transfer для Auto-Sync: Blake3 хэширование и кэш измененных файлов — мгновенная передача только diff-данных."); ui.label(" • 👥 Мульти-отправка, ⏸️ Пауза / ▶️ Возобновление и 🏎️ Очередь с приоритетами."); ui.add_space(8.0); ui.separator(); ui.add_space(8.0); // v2.5.2 ui.horizontal(|ui| { ui.label(RichText::new("v2.5.2").strong().color(Color32::from_rgb(56, 189, 248))); }); ui.label(" • 🎨 Исправление макета передач: Идеальное скругление и выравнивание карточек скорости и статистики дашборда."); ui.label(" • 🧹 Чистое автообновление: Автоматическое удаление старых версий (.old и временных файлов) при установке обновлений."); ui.label(" • 🇷🇺 Полная русская локализация: Переведены Credits, стек технологий, модальные окна, Grep, редактор Vim и история версий."); ui.add_space(8.0); ui.separator(); ui.add_space(8.0); // v2.5.1 ui.horizontal(|ui| { ui.label(RichText::new("v2.5.1").strong().color(Color32::from_rgb(56, 189, 248))); }); ui.label(" • 🎨 Полная модернизация интерфейса: Единый стиль карточек, дашборд статистики передач, пошаговые мастера отправки и автосинхронизации, баннеры подсказок."); ui.label(" • 🔄 Улучшенный мастер Auto-Sync: Наглядный 3-шаговый визард выбора папок и онлайн-друзей с интуитивным управлением синхронизацией."); ui.label(" • 🚀 Gitea Self-Updater: Интеграция автопроверки и бесшовного обновления приложения прямо из встроенного репозитория Gitea."); ui.add_space(8.0); ui.separator(); ui.add_space(8.0); // v2.5.0 ui.horizontal(|ui| { ui.label(RichText::new("v2.5.0").strong().color(Color32::from_rgb(56, 189, 248))); }); ui.label(" • 🔄 Двусторонняя автосинхронизация папок: Фоновый движок синхронизации локальных и удаленных каталогов без участия пользователя."); ui.label(" • 🖧 Сетевой диск Windows (WebDAV): Подключение общих папок напрямую в Проводник Windows как диск Z:\\ в 1 клик."); ui.label(" • 🎵 Аудиоплеер и 🎬 Watch Party: Док мини-плеера и совместные комнаты просмотра видео с синхронизацией воспроизведения."); ui.label(" • ✏️ Полноценный Vim-редактор с сохранением по сети: Режимы Normal/Insert, правка буфера, предпросмотр Markdown и запись (:w)."); ui.label(" • 🔎 Удаленный Grep-поиск: Поиск кода и текста на компьютерах друзей с мгновенным переходом к строке."); ui.label(" • ⬇️ Пакетное скачивание и Drag & Drop: Выбор файлов чекбоксами и прямое перетаскивание в сетевые папки."); ui.add_space(8.0); ui.separator(); ui.add_space(8.0); // v2.4.1 ui.horizontal(|ui| { ui.label(RichText::new("v2.4.1").strong().color(Color32::from_rgb(56, 189, 248))); }); ui.label(" • 📁 Вкладка Общие папки: Управление сетевыми папками с правами «Только чтение» / «Чтение и запись» и защитой PIN."); ui.label(" • 🎬 HTTP 206 Streaming: Мгновенное воспроизведение видео/аудио с перемоткой без предварительного скачивания."); ui.label(" • ⚡ Встроенный просмотрщик Vim: Терминальный просмотр кода с горячими клавишами Vim, поиском (/) и прыжком по строкам."); ui.label(" • 📦 Единая папка (Portable): Все настройки (config.json) и загрузки создаются и хранятся рядом с exe."); ui.label(" • 🔄 Auto-Sync маяки: Оповещение об общих папках при подключении к ZeroTier/VPN."); ui.add_space(8.0); ui.separator(); ui.add_space(8.0); // v2.4.0 ui.horizontal(|ui| { ui.label(RichText::new("v2.4.0").strong().color(Color32::from_rgb(56, 189, 248))); }); ui.label(" • 🛡️ Контрольная сумма SHA-256 на лету: Проверка целостности передаваемых файлов с зелеными бейджами гарантии."); ui.label(" • 📸 Превью фото на мобильных: Галерея миниатюр перед отправкой с возможностью удаления лишних снимков."); ui.label(" • 🎙️ Голосовые сообщения на мобильных: Встроенный диктофон с пульсирующей анимацией волны."); ui.label(" • 📲 Поддержка PWA: Установка веб-приложения на главный экран смартфона прямо из браузера."); ui.label(" • 🎨 Авто-тема: Темная/светлая тема с синхронизацией с реестром Windows."); ui.add_space(8.0); ui.separator(); ui.add_space(8.0); // v2.3.2 ui.horizontal(|ui| { ui.label(RichText::new("v2.3.2").strong().color(Color32::from_rgb(56, 189, 248))); }); ui.label(" • 🚀 Автозапуск с Windows: Опция запуска ZeroSend при старте системы в свёрнутом виде в трей."); ui.label(" • 🎯 Быстрая отправка Drag & Drop: Перетаскивание файлов прямо на карточку любого друга во вкладке Устройства."); ui.label(" • Двойные ссылки и QR-коды (ZeroTier/VPN + Домашний Wi-Fi)."); ui.label(" • Всплывающее окно подтверждения передачи через ZeroTier."); ui.label(" • Сглаживание показаний скорости алгоритмом EMA (без скачков)."); ui.label(" • 4-поточная параллельная передача файлов."); ui.add_space(8.0); ui.separator(); ui.add_space(8.0); // v2.3.1 ui.horizontal(|ui| { ui.label(RichText::new("v2.3.1").strong().color(Color32::from_rgb(34, 197, 94))); }); ui.label(" • Модернизированная мобильная Web Dropzone с кнопками камеры и фото."); ui.label(" • Раздельные QR-коды для ZeroTier и локальной сети Wi-Fi."); ui.label(" • Автоматическое переключение на вкладку Передач при старте отправки."); ui.label(" • Отображение статуса сжатия папок в ZIP в реальном времени."); ui.add_space(8.0); ui.separator(); ui.add_space(8.0); // v2.0.0 ui.label(RichText::new("v2.0.0").strong().color(Color32::from_rgb(56, 189, 248))); ui.label(" • Современный стеклянный интерфейс (Glassmorphism) для Web Dropzone."); ui.label(" • Быстрая передача текста из браузера и адаптивный Drag & Drop."); ui.label(" • Глобальная вставка скриншотов из буфера (Ctrl+V)."); ui.label(" • Реальная скорость и прогресс на стороне отправителя."); ui.label(" • Постоянные UUID устройств и защита от дубликатов."); ui.add_space(8.0); ui.separator(); ui.add_space(8.0); // v1.2.0 ui.label(RichText::new("v1.2.0").strong().color(Color32::from_rgb(56, 189, 248))); ui.label(" • Наглядная визуализация передач, иконки типов файлов и спидометры."); ui.label(" • Фоновая работа в системном трее Windows и мгновенный выход."); ui.label(" • Сжатие папок в ZIP на лету и автораспаковка в папке загрузок."); ui.add_space(8.0); ui.separator(); ui.add_space(8.0); // v1.0.0 ui.label(RichText::new("v1.0.0").strong().color(Color32::GRAY)); ui.label(" • Нативный графический интерфейс (GUI) без консоли."); ui.label(" • Параллельная рассылка файлов и текста нескольким получателям."); ui.label(" • Генератор QR-кодов для отправки с телефонов."); ui.label(" • Защита 4-значным PIN-кодом входящих передач."); ui.label(" • Кнопки «Открыть файл» и «Показать в папке»."); }); }); ui.add_space(14.0); }); } fn render_modals(&mut self, ctx: &egui::Context, theme: ThemeColors) { // Direct IP Modal if self.show_direct_ip_modal { egui::Window::new("🔍 Прямое подключение (IP)") .collapsible(false) .resizable(false) .anchor(egui::Align2::CENTER_CENTER, Vec2::ZERO) .show(ctx, |ui| { ui.label(RichText::new("Введите IP-адрес узла в сети ZeroTier / Tailscale / Radmin:").color(theme.text_primary)); ui.add_space(4.0); ui.text_edit_singleline(&mut self.direct_ip_input); if !self.direct_ip_status.is_empty() { ui.add_space(4.0); ui.label(RichText::new(&self.direct_ip_status).size(11.0).color(Color32::YELLOW)); } ui.add_space(12.0); ui.horizontal(|ui| { if ui.add(Button::new(RichText::new("Подключить и проверить").strong().color(Color32::WHITE)).fill(Color32::from_rgb(37, 99, 235))).clicked() { let ip = self.direct_ip_input.trim().to_string(); if !ip.is_empty() { let client = self.client.clone(); let peers_store = self.discovery_peers.clone(); let log_tx = self.log_tx.clone(); self.direct_ip_status = "Подключение...".to_string(); tokio::spawn(async move { match client.ping_peer(&ip, 53318).await { Ok(beacon) => { let _ = log_tx.send(format!("✨ Добавлен узел: {} ({})", beacon.name, beacon.ip)); let mut peers = peers_store.write().await; peers.insert( beacon.peer_id.clone(), PeerInfo { peer_id: beacon.peer_id, name: beacon.name, os: beacon.os, ip: beacon.ip, transfer_port: beacon.transfer_port, net_type: beacon.net_type, version: beacon.version, last_seen: chrono::Utc::now(), is_online: true, ping_ms: None, shared_folders: beacon.shared_folders, }, ); } Err(e) => { let _ = log_tx.send(format!("❌ Ошибка прямого подключения: {}", e)); } } }); self.show_direct_ip_modal = false; } } if ui.button("Отмена").clicked() { self.show_direct_ip_modal = false; } }); }); } // Rename Peer Modal if let Some((peer_id, peer_name, mut current_alias)) = self.renaming_peer.clone() { egui::Window::new("✏️ Переименовать друга / Псевдоним") .collapsible(false) .resizable(false) .anchor(egui::Align2::CENTER_CENTER, Vec2::ZERO) .show(ctx, |ui| { ui.label(RichText::new(format!("Укажите удобное имя (псевдоним) для {}", peer_name)).color(theme.text_primary)); ui.add_space(6.0); ui.text_edit_singleline(&mut current_alias); ui.add_space(10.0); ui.horizontal(|ui| { if ui.add(Button::new(RichText::new("Сохранить псевдоним").strong().color(Color32::WHITE)).fill(Color32::from_rgb(37, 99, 235))).clicked() { if current_alias.trim().is_empty() { self.config.peer_aliases.remove(&peer_id); } else { self.config.peer_aliases.insert(peer_id.clone(), current_alias.trim().to_string()); } self.persist_config(); self.renaming_peer = None; } if ui.button("Отмена").clicked() { self.renaming_peer = None; } }); }); } // Received Quick Text Modal if let Some(msg) = self.received_text_modal.clone() { let (r, g, b) = msg.content_type.badge_color_rgb(); egui::Window::new("📋 Входящий быстрый текст") .collapsible(false) .resizable(true) .min_width(400.0) .anchor(egui::Align2::CENTER_CENTER, Vec2::ZERO) .show(ctx, |ui| { ui.horizontal(|ui| { ui.label(RichText::new(format!("От: {}", msg.sender_name)).strong().size(14.0).color(theme.text_primary)); ui.label( RichText::new(format!("[{}]", msg.content_type.label())) .strong() .color(Color32::from_rgb(r, g, b)), ); }); ui.add_space(8.0); Frame::none() .fill(theme.bg_card_secondary) .rounding(Rounding::same(6.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(12.0)) .show(ui, |ui| { ScrollArea::vertical().max_height(200.0).show(ui, |ui| { ui.label( RichText::new(&msg.content) .monospace() .color(theme.text_primary), ); }); }); ui.add_space(12.0); ui.horizontal(|ui| { let content_copy = msg.content.clone(); if ui.add(Button::new(RichText::new("📋 Скопировать в буфер").strong().color(Color32::WHITE)).fill(Color32::from_rgb(37, 99, 235))).clicked() { ui.output_mut(|o| o.copied_text = content_copy.clone()); } if msg.content_type == ContentType::WebUrl { if ui.button("🌐 Открыть ссылку").clicked() { ui.ctx().open_url(egui::OpenUrl::same_tab(&content_copy)); } } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if ui.button("Закрыть").clicked() { self.received_text_modal = None; } }); }); }); } // PROMINENT INCOMING FILE TRANSFER CONFIRMATION MODAL if let Some(req) = self.pending_request.clone() { egui::Window::new("📥 ВХОДЯЩИЙ ЗАПРОС НА ПЕРЕДАЧУ ФАЙЛОВ") .collapsible(false) .resizable(false) .min_width(450.0) .anchor(egui::Align2::CENTER_CENTER, Vec2::ZERO) .show(ctx, |ui| { ui.add_space(4.0); ui.horizontal(|ui| { ui.label(RichText::new("📥").size(32.0)); ui.vertical(|ui| { ui.label( RichText::new(format!("Входящие файлы от: {}", req.sender_name)) .size(16.0) .strong() .color(theme.text_accent), ); ui.label( RichText::new(format!( "Всего: {} файл(ов) • Общий размер: {}", req.files.len(), human_bytes(req.total_size as f64) )) .size(12.0) .color(theme.text_secondary), ); }); }); ui.add_space(10.0); ui.label(RichText::new("Список передаваемых файлов:").strong().size(13.0).color(theme.text_primary)); ui.add_space(4.0); Frame::none() .fill(theme.bg_card_secondary) .rounding(Rounding::same(8.0)) .stroke(theme.stroke_border) .inner_margin(Margin::same(10.0)) .show(ui, |ui| { ScrollArea::vertical().max_height(140.0).show(ui, |ui| { for f in req.files.iter().take(8) { let item_icon = get_file_icon(&f.name); ui.horizontal(|ui| { ui.label(item_icon); ui.label(RichText::new(&f.name).color(theme.text_primary).strong()); ui.label(RichText::new(format!("({})", human_bytes(f.size as f64))).size(11.0).color(theme.text_muted)); }); ui.separator(); } if req.files.len() > 8 { ui.label(RichText::new(format!(" ... и ещё {} файлов", req.files.len() - 8)).color(theme.text_muted)); } }); }); ui.add_space(8.0); ui.horizontal(|ui| { ui.label(RichText::new("Папка сохранения:").size(11.0).color(theme.text_muted)); ui.label(RichText::new(format!("{:?}", self.download_dir)).size(11.0).color(theme.text_secondary)); }); ui.add_space(16.0); ui.horizontal(|ui| { let accept_btn = Button::new( RichText::new("✅ ПРИНЯТЬ И СКАЧАТЬ") .size(14.0) .strong() .color(Color32::WHITE), ) .fill(Color32::from_rgb(34, 197, 94)) .min_size(Vec2::new(190.0, 42.0)) .rounding(Rounding::same(8.0)); if ui.add(accept_btn).clicked() { let server_clone = self.server.clone(); let session_id = req.session_id.clone(); let initial_progress = TransferProgress { session_id: session_id.clone(), peer_name: req.sender_name.clone(), is_incoming: true, current_file_index: 0, total_files: req.files.len(), current_file_name: req.files.first().map(|f| f.name.clone()).unwrap_or_default(), bytes_transferred: 0, total_bytes: req.total_size, speed_bps: 0, state: TransferState::InProgress, checksum_verified: false, compressed_bytes: 0, }; if let Some(pos) = self.transfers.iter().position(|x| x.session_id == session_id) { self.transfers[pos] = initial_progress; } else { self.transfers.insert(0, initial_progress); } tokio::spawn(async move { server_clone.accept_transfer(&session_id).await; }); self.pending_request = None; self.switch_tab(Tab::Transfers); } let reject_btn = Button::new( RichText::new("❌ ОТКЛОНИТЬ") .size(14.0) .strong() .color(Color32::WHITE), ) .fill(Color32::from_rgb(239, 68, 68)) .min_size(Vec2::new(130.0, 42.0)) .rounding(Rounding::same(8.0)); if ui.add(reject_btn).clicked() { let server_clone = self.server.clone(); let session_id = req.session_id.clone(); tokio::spawn(async move { server_clone.reject_transfer(&session_id).await; }); self.pending_request = None; } }); ui.add_space(4.0); }); } // REMOTE GREP & CODE SEARCH MODAL if self.show_grep_modal { let mut is_open = self.show_grep_modal; let mut trigger_open_file = None; egui::Window::new("🔎 Поиск текста и кода в общей папке (Grep)") .open(&mut is_open) .collapsible(false) .resizable(true) .default_size(Vec2::new(650.0, 420.0)) .anchor(egui::Align2::CENTER_CENTER, Vec2::ZERO) .show(ctx, |ui| { ui.horizontal(|ui| { ui.label(RichText::new("Запрос:").strong().color(theme.text_primary)); let edit = ui.add( egui::TextEdit::singleline(&mut self.grep_query) .hint_text("например: fn main, TODO, пароль...") .desired_width(260.0), ); let mut trigger_search = false; if edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { trigger_search = true; } let search_btn = Button::new( RichText::new(if self.is_grepping { "⏳ Поиск..." } else { "🔍 Искать" }) .strong() .color(Color32::WHITE), ) .fill(Color32::from_rgb(37, 99, 235)) .rounding(Rounding::same(4.0)); if ui.add_enabled(!self.is_grepping && !self.grep_query.trim().is_empty(), search_btn).clicked() { trigger_search = true; } if trigger_search && !self.grep_query.trim().is_empty() { if let (Some(peer_id), Some(folder_id)) = (&self.selected_peer_id_for_share, &self.selected_folder_id_for_share) { if let Some(peer) = self.peers.iter().find(|p| &p.peer_id == peer_id).cloned() { self.is_grepping = true; self.grep_status_msg = Some("Поиск по удаленным файлам...".to_string()); let client = self.client.clone(); let peer_url = peer.transfer_url(); let f_id = folder_id.clone(); let q = self.grep_query.clone(); let sub = self.current_share_subpath.clone(); let pin = if self.share_pin_input.trim().is_empty() { None } else { Some(self.share_pin_input.trim().to_string()) }; let log_tx = self.log_tx.clone(); tokio::spawn(async move { match client.grep_shared_folder(&peer_url, &f_id, &q, pin.as_deref(), Some(&sub), false).await { Ok(resp) => { let _ = log_tx.send(format!("🔎 Grep: найдено {} совпадений в {} проверенных файлах", resp.matches.len(), resp.total_files_searched)); } Err(e) => { let _ = log_tx.send(format!("❌ Ошибка Grep: {}", e)); } } }); } } } }); ui.add_space(8.0); ui.separator(); ui.add_space(8.0); // Results list if self.is_grepping { ui.vertical_centered(|ui| { ui.spinner(); ui.label("Поиск по удаленным файлам кода и текста..."); }); } else if self.grep_results.is_empty() { ui.label(RichText::new("Введите поисковый запрос выше и нажмите «Искать» для поиска строк в коде.").size(12.0).color(theme.text_muted)); } else { ui.label(RichText::new(format!("Найдено совпадений: {}", self.grep_results.len())).strong().color(theme.text_accent)); ui.add_space(4.0); ScrollArea::vertical().max_height(280.0).show(ui, |ui| { for m in &self.grep_results { Frame::none() .fill(theme.bg_card_secondary) .rounding(Rounding::same(4.0)) .inner_margin(Margin::symmetric(10.0, 6.0)) .show(ui, |ui| { ui.horizontal(|ui| { ui.label( RichText::new(format!("{}:{}", m.file_path, m.line_number)) .strong() .color(Color32::from_rgb(56, 189, 248)), ); ui.label(RichText::new(&m.line_content).monospace().color(theme.text_primary)); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if ui.button("Открыть в Vim").clicked() { trigger_open_file = Some((m.file_path.clone(), m.line_number)); } }); }); }); ui.add_space(3.0); } }); } }); if let Some((file_rel, _line)) = trigger_open_file { if let (Some(peer_id), Some(folder_id)) = (&self.selected_peer_id_for_share, &self.selected_folder_id_for_share) { if let Some(peer) = self.peers.iter().find(|p| &p.peer_id == peer_id).cloned() { let client = self.client.clone(); let peer_url = peer.transfer_url(); let f_id = folder_id.clone(); let sub = file_rel.clone(); let fname = std::path::Path::new(&file_rel).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let peer_display = peer.name.clone(); let text_tx = self.share_text_tx.clone(); let pin = if self.share_pin_input.trim().is_empty() { None } else { Some(self.share_pin_input.trim().to_string()) }; tokio::spawn(async move { if let Ok(text) = client.fetch_shared_file_text(&peer_url, &f_id, &sub, pin.as_deref()).await { let _ = text_tx.send((fname, sub, peer_display, text, Some(peer_url), Some(f_id), pin, true)); } }); } } is_open = false; } self.show_grep_modal = is_open; } self.vim_viewer.render(ctx, theme); } pub fn trigger_update_check(&mut self) { self.is_checking_update = true; self.update_check_status_msg = Some("Подключение к Gitea и проверка обновлений...".to_string()); let tx = self.update_tx.clone(); let api_url = self.config.gitea_api_url.clone(); tokio::spawn(async move { let res = check_gitea_update(api_url.as_deref()).await; let _ = tx.send(res); }); } pub fn trigger_install_update(&mut self, download_url: String) { self.is_updating = true; self.update_progress = Some(UpdateProgress::Downloading { percent: 0.0, downloaded: 0, total: 0 }); let prog_tx = self.update_prog_tx.clone(); tokio::spawn(async move { perform_update_download(download_url, prog_tx).await; }); } } impl eframe::App for ZeroSendApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.poll_events(ctx); if ctx.input(|i| i.viewport().close_requested()) { if self.close_to_tray { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false)); } } 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 { self.config.window_width = cur_size.x; self.config.window_height = cur_size.y; if self.last_window_save.elapsed() > Duration::from_secs(2) { self.last_window_save = Instant::now(); let _ = self.config.save(); } } } let dropped_files = ctx.input(|i| i.raw.dropped_files.clone()); if !dropped_files.is_empty() { if self.current_tab == Tab::Shared && self.selected_peer_id_for_share.is_some() && self.selected_folder_id_for_share.is_some() { let peer_id = self.selected_peer_id_for_share.clone().unwrap(); let folder_id = self.selected_folder_id_for_share.clone().unwrap(); let sub = self.current_share_subpath.clone(); let peer_opt = self.peers.iter().find(|p| p.peer_id == peer_id).cloned(); if let Some(peer) = peer_opt { let client = self.client.clone(); let peer_url = peer.transfer_url(); let pin = if self.share_pin_input.trim().is_empty() { None } else { Some(self.share_pin_input.trim().to_string()) }; let log_tx = self.log_tx.clone(); let tree_tx = self.share_tree_tx.clone(); tokio::spawn(async move { for f in dropped_files { if let Some(path) = f.path { let _ = log_tx.send(format!("⬆️ Dropped upload {:?} to shared folder...", path.file_name())); let _ = client.upload_to_shared_folder(&peer_url, &folder_id, &sub, &path, pin.as_deref()).await; } } if let Ok(tree) = client.fetch_shared_tree(&peer_url, &folder_id, Some(&sub), pin.as_deref()).await { let _ = tree_tx.send(Some(tree)); } }); } } else { let mut added_count = 0; for file in dropped_files { if let Some(path) = file.path { if !self.queued_files.contains(&path) { self.queued_files.push(path); added_count += 1; } } } if added_count > 0 { self.current_tab = Tab::Send; self.send_mode = SendMode::Files; self.logs.push(format!("📥 Added {} dropped item(s) to send queue", added_count)); } } } let is_paste = ctx.input(|i| (i.modifiers.ctrl || i.modifiers.command) && i.key_pressed(egui::Key::V)); if is_paste && !ctx.wants_keyboard_input() { self.try_paste_clipboard_image(); } let is_dark = match self.theme_mode { ThemeMode::Auto => is_system_dark_mode(), ThemeMode::Dark => true, ThemeMode::Light => false, }; let theme = ThemeColors::new(is_dark); let mut style = (*ctx.style()).clone(); style.visuals.dark_mode = is_dark; style.visuals.panel_fill = theme.bg_app; style.visuals.window_fill = theme.bg_card; style.visuals.override_text_color = Some(theme.text_primary); style.visuals.widgets.noninteractive.bg_fill = theme.bg_card; style.visuals.widgets.inactive.bg_fill = theme.bg_card_secondary; style.visuals.widgets.inactive.rounding = Rounding::same(6.0); style.visuals.widgets.hovered.bg_fill = theme.bg_hover; style.visuals.widgets.hovered.rounding = Rounding::same(6.0); style.visuals.widgets.active.bg_fill = Color32::from_rgb(37, 99, 235); style.visuals.widgets.active.rounding = Rounding::same(6.0); ctx.set_style(style); SidePanel::left("sidebar_panel") .resizable(false) .exact_width(195.0) .frame( Frame::none() .fill(theme.bg_sidebar) .stroke(theme.stroke_subtle) .inner_margin(Margin::same(12.0)), ) .show(ctx, |ui| { self.render_sidebar(ctx, ui, theme); }); CentralPanel::default() .frame( Frame::none() .fill(theme.bg_app) .inner_margin(Margin::same(20.0)), ) .show(ctx, |ui| { // Top Update Banner if update available on Gitea if self.show_update_banner { if let Some(info) = &self.update_info { if info.has_update { let mut dismiss = false; let mut trigger_install = None; let mut open_gitea = false; let gitea_url = info.gitea_url.clone(); let latest_ver = info.latest_version.clone(); let download_url = info.download_url.clone(); Frame::none() .fill(if theme.is_dark { Color32::from_rgb(17, 43, 31) } else { Color32::from_rgb(220, 252, 231) }) .stroke(Stroke::new(1.0_f32, Color32::from_rgb(34, 197, 94))) .rounding(Rounding::same(8.0)) .inner_margin(Margin::symmetric(14.0, 10.0)) .show(ui, |ui| { ui.horizontal(|ui| { ui.label(RichText::new("✨").size(16.0)); ui.label( RichText::new(format!("Update Available: ZeroSend v{} is out on Gitea!", latest_ver)) .strong() .color(if theme.is_dark { Color32::WHITE } else { Color32::from_rgb(20, 83, 45) }) ); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if ui.button(RichText::new("✕").size(12.0).color(theme.text_muted)).clicked() { dismiss = true; } if ui.button(RichText::new("🌐 View on Gitea").size(11.5)).clicked() { open_gitea = true; } if let Some(dl) = download_url { if !self.is_updating { let update_btn = Button::new( RichText::new("🚀 Update Now").strong().size(12.0).color(Color32::WHITE) ) .fill(Color32::from_rgb(34, 197, 94)) .rounding(Rounding::same(6.0)); if ui.add(update_btn).clicked() { trigger_install = Some(dl); } } else { ui.label(RichText::new("⏳ Updating...").color(Color32::from_rgb(34, 197, 94)).strong()); } } }); }); }); if dismiss { self.show_update_banner = false; } if open_gitea { let _ = std::process::Command::new("explorer").arg(&gitea_url).spawn(); } if let Some(dl) = trigger_install { self.trigger_install_update(dl); } ui.add_space(10.0); } } } // Fluid Tab Transition Animation (Smooth Slide-in & Fade-in) let elapsed = self.last_tab_change.elapsed().as_secs_f32(); let duration = 0.20; // 200 ms let progress = (elapsed / duration).min(1.0); if progress < 1.0 { ctx.request_repaint(); } // Cubic ease-out curve let ease = 1.0 - (1.0 - progress).powi(3); let slide_offset = (1.0 - ease) * 12.0; ui.add_space(slide_offset); match self.current_tab { Tab::Nearby => self.render_nearby_tab(ui, theme), Tab::Send => self.render_send_tab(ui, theme), Tab::Receive => self.render_receive_tab(ui, theme), Tab::Shared => self.render_shared_tab(ui, theme), Tab::Transfers => self.render_transfers_tab(ui, theme), Tab::Settings => self.render_settings_tab(ui, theme), } }); self.render_modals(ctx, theme); if self.transfers.iter().any(|t| t.state == TransferState::InProgress) || self.is_updating { ctx.request_repaint_after(Duration::from_millis(50)); } else { ctx.request_repaint_after(Duration::from_millis(200)); } } }