Files
ZeroSend/src/ui/gui.rs
T

4730 lines
245 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
}
#[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,
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_peers: Arc<RwLock<HashMap<String, PeerInfo>>>,
server: TransferServer,
client: Arc<TransferClient>,
my_info: crate::network::DiscoveryBeacon,
current_iface: InterfaceInfo,
config: AppConfig,
tray_manager: Option<SystemTrayManager>,
// Navigation & State
current_tab: Tab,
send_mode: SendMode,
peers: Vec<PeerInfo>,
selected_peer_ids: HashSet<String>,
queued_files: Vec<PathBuf>,
transfers: Vec<TransferProgress>,
logs: Vec<String>,
// Quick Text Input
text_input: String,
analyzed_preview: AnalyzedText,
// Optional PIN for sender
recipient_pin: String,
// Channels from Server
incoming_request_rx: mpsc::UnboundedReceiver<PrepareTransferRequest>,
incoming_text_rx: mpsc::UnboundedReceiver<TextMessage>,
// Modals
pending_request: Option<PrepareTransferRequest>,
received_text_modal: Option<TextMessage>,
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<InterfaceInfo>,
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<SharedFolderConfig>,
selected_share_subtab: SharedSubTab,
selected_peer_id_for_share: Option<String>,
selected_folder_id_for_share: Option<String>,
current_share_subpath: String,
current_share_tree: Option<SharedFolderTreeResponse>,
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<String>,
// Media & Audio & Watch Party
pub active_audio: Option<AudioTrackInfo>,
pub audio_volume: f32,
pub watch_party_session: Option<crate::transfer::WatchPartySyncEvent>,
pub is_in_watch_party: bool,
// Remote Grep State
pub show_grep_modal: bool,
pub grep_query: String,
pub grep_results: Vec<crate::transfer::GrepMatch>,
pub is_grepping: bool,
pub grep_status_msg: Option<String>,
// Live Folder Sync State
pub folder_sync_pairs: Vec<crate::transfer::FolderSyncPairConfig>,
pub sync_log_messages: Vec<String>,
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
pub webdav_mounted: bool,
pub webdav_drive_letter: char,
pub webdav_status_msg: Option<String>,
// Gitea Updater State
pub update_info: Option<UpdateInfo>,
pub is_checking_update: bool,
pub update_check_status_msg: Option<String>,
pub update_progress: Option<UpdateProgress>,
pub is_updating: bool,
pub show_update_banner: bool,
pub check_updates_on_startup: bool,
// Internal UI Channels
log_rx: mpsc::UnboundedReceiver<String>,
log_tx: mpsc::UnboundedSender<String>,
transfer_rx: mpsc::UnboundedReceiver<TransferProgress>,
transfer_tx: mpsc::UnboundedSender<TransferProgress>,
share_tree_rx: mpsc::UnboundedReceiver<Option<SharedFolderTreeResponse>>,
share_tree_tx: mpsc::UnboundedSender<Option<SharedFolderTreeResponse>>,
share_text_rx: mpsc::UnboundedReceiver<(String, String, String, String, Option<String>, Option<String>, Option<String>, bool)>,
share_text_tx: mpsc::UnboundedSender<(String, String, String, String, Option<String>, Option<String>, Option<String>, bool)>,
sync_status_rx: mpsc::UnboundedReceiver<crate::transfer::SyncStatusUpdate>,
sync_status_tx: mpsc::UnboundedSender<crate::transfer::SyncStatusUpdate>,
update_rx: mpsc::UnboundedReceiver<Result<UpdateInfo, String>>,
update_tx: mpsc::UnboundedSender<Result<UpdateInfo, String>>,
update_prog_rx: mpsc::UnboundedReceiver<UpdateProgress>,
update_prog_tx: mpsc::UnboundedSender<UpdateProgress>,
last_poll: Instant,
last_ping_poll: Instant,
last_window_save: 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: &DiscoveryService,
server: TransferServer,
config: AppConfig,
initial_files: Vec<PathBuf>,
incoming_request_rx: mpsc::UnboundedReceiver<PrepareTransferRequest>,
incoming_text_rx: mpsc::UnboundedReceiver<TextMessage>,
) -> 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();
Self {
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: Vec::new(),
logs: vec!["⚡ ZeroSend v2.5.3-beta 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(),
}
}
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.current_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
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.current_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();
}
}
}
// 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.current_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();
}
}
}
}
// 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<PeerInfo> = 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);
}
}
}
}
});
}
}
}
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();
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).await;
});
}
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.current_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<PeerInfo>, files: Vec<PathBuf>) {
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.current_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
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(theme.stroke_border)
.inner_margin(Margin::same(10.0))
.show(ui, |ui| {
ui.horizontal(|ui| {
let dot_color = if self.current_iface.net_type == NetworkType::ZeroTier {
Color32::from_rgb(34, 197, 94)
} else {
Color32::from_rgb(59, 130, 246)
};
ui.label(RichText::new("●").color(dot_color).size(14.0));
ui.vertical(|ui| {
ui.label(
RichText::new(self.current_iface.net_type.as_str())
.strong()
.size(11.5)
.color(theme.text_primary),
);
ui.label(
RichText::new(format!("{}:{}", self.current_iface.ip, self.my_info.transfer_port))
.monospace()
.size(10.0)
.color(theme.text_muted),
);
});
});
});
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),
];
for (tab, title, count, is_active_pulse) in tabs {
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);
}
}
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 is_selected {
theme.bg_card_active
} else {
Color32::TRANSPARENT
};
let stroke = if is_selected {
Stroke::new(1.0_f32, if theme.is_dark { Color32::from_rgb(56, 189, 248) } else { Color32::from_rgb(2, 132, 199) })
} 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.current_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<String> = None;
let mut rename_target: Option<(String, String, String)> = None;
let mut send_to_peer: Option<String> = None;
let mut open_shares_peer: Option<String> = None;
let mut quick_drop_target: Option<(PeerInfo, Vec<PathBuf>)> = 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 badge_color = if peer.net_type.contains("ZeroTier") {
Color32::from_rgb(34, 197, 94)
} else {
Color32::from_rgb(59, 130, 246)
};
ui.label(
RichText::new(format!("[{}]", peer.net_type))
.size(10.5)
.color(badge_color),
);
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<PathBuf> = 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) {
// Mode Switcher Bar
Frame::none()
.fill(theme.bg_card)
.rounding(Rounding::same(8.0))
.stroke(theme.stroke_border)
.inner_margin(Margin::symmetric(12.0, 8.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 })
.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 })
.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.0).color(theme.text_muted));
ui.add(egui::TextEdit::singleline(&mut self.recipient_pin).hint_text("PIN").desired_width(60.0));
});
});
});
});
ui.add_space(8.0);
// Step 1: Multi-Recipient Selector 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("Шаг 1: Выберите получателей (друзей в сети)").strong().size(13.0).color(theme.text_primary));
if !self.peers.is_empty() {
let all_selected = self.selected_peer_ids.len() == self.peers.len();
if ui.small_button(if all_selected { "✕ Снять выбор со всех" } else { "✓ Выбрать всех" }).clicked() {
if all_selected {
self.selected_peer_ids.clear();
} else {
self.selected_peer_ids = self.peers.iter().map(|p| p.peer_id.clone()).collect();
}
}
}
});
ui.add_space(6.0);
if self.peers.is_empty() {
ui.label(RichText::new("В сети пока нет друзей онлайн. Подключитесь к ZeroTier или проверьте вкладку устройств.").color(theme.text_muted).size(11.5));
} else {
ui.horizontal_wrapped(|ui| {
for p in &self.peers {
let is_selected = self.selected_peer_ids.contains(&p.peer_id);
let (name_label, _) = self.get_peer_display_name(p);
let chip_text = format!("{} ({})", name_label, p.ip);
let chip_btn = Button::new(
RichText::new(format!("{} {}", if is_selected { "✅" } else { "⬜" }, chip_text))
.size(12.0)
.strong()
.color(if is_selected { Color32::WHITE } else { theme.text_secondary }),
)
.fill(if is_selected {
Color32::from_rgb(37, 99, 235)
} else {
theme.bg_card_secondary
})
.stroke(if is_selected {
Stroke::new(1.0_f32, Color32::from_rgb(96, 165, 250))
} else {
Stroke::NONE
})
.rounding(Rounding::same(6.0));
if ui.add(chip_btn).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
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 !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| {
if ui
.add(
Button::new(RichText::new("📄 Добавить файлы...").strong().color(Color32::WHITE))
.fill(Color32::from_rgb(59, 130, 246))
.min_size(Vec2::new(140.0, 32.0))
.rounding(Rounding::same(6.0)),
)
.clicked()
{
if let Some(files) = FileDialog::new().pick_files() {
for f in files {
if !self.queued_files.contains(&f) {
self.queued_files.push(f);
}
}
}
}
if ui
.add(
Button::new(RichText::new("📁 Добавить папку...").strong().color(Color32::WHITE))
.fill(Color32::from_rgb(79, 70, 229))
.min_size(Vec2::new(140.0, 32.0))
.rounding(Rounding::same(6.0)),
)
.clicked()
{
if let Some(folder) = FileDialog::new().pick_folder() {
if !self.queued_files.contains(&folder) {
self.queued_files.push(folder);
}
}
}
if ui
.add(
Button::new(RichText::new("📸 Вставить скриншот (Ctrl+V)").color(Color32::WHITE))
.fill(Color32::from_rgb(147, 51, 234))
.min_size(Vec2::new(190.0, 32.0))
.rounding(Rounding::same(6.0)),
)
.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 {
ui.label(RichText::new("• (Папки сжимаются в ZIP на лету)").size(11.0).color(Color32::from_rgb(34, 197, 94)));
}
});
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(20.0);
ui.label(RichText::new("📂 Перетащите сюда файлы или папки (Drag & Drop)").size(13.5).strong().color(theme.text_muted));
ui.add_space(4.0);
ui.label(RichText::new("или вставьте скриншот из буфера обмена (Ctrl+V)").size(11.0).color(theme.text_muted));
ui.add_space(20.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("✕").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 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)
} else {
format!("🚀 Отправить {} файл(ов) для {} получателей", self.queued_files.len(), num_recipients)
};
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) }
})
.min_size(Vec2::new(ui.available_width(), 42.0))
.rounding(Rounding::same(8.0));
if ui.add_enabled(can_send, send_btn).clicked() {
let targets: Vec<PeerInfo> = 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<PeerInfo> = 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<TextureHandle> {
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.refresh_current_share_tree();
}
}
});
});
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<PeerInfo> = 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(10.0))
.stroke(theme.stroke_border)
.inner_margin(Margin::same(40.0))
.show(ui, |ui| {
ui.vertical_centered(|ui| {
ui.label(RichText::new("🌐").size(40.0));
ui.label(
RichText::new("No shared folders discovered on your network.")
.size(16.0)
.strong()
.color(theme.text_primary),
);
ui.add_space(6.0);
ui.label(
RichText::new(
"When your friends on ZeroTier / LAN add shared folders, they will automatically appear here with streaming and fast download support.",
)
.size(12.0)
.color(theme.text_muted),
);
});
});
return;
}
ui.horizontal_top(|ui| {
// Left Column: Peer & Folder Selector (250px)
ui.allocate_ui(Vec2::new(250.0, ui.available_height()), |ui| {
Frame::none()
.fill(theme.bg_card)
.rounding(Rounding::same(8.0))
.stroke(theme.stroke_border)
.inner_margin(Margin::same(10.0))
.show(ui, |ui| {
ui.label(
RichText::new("Friends & Shares")
.strong()
.size(13.0)
.color(theme.text_primary),
);
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);
ui.horizontal(|ui| {
ui.label(RichText::new("●").color(Color32::from_rgb(34, 197, 94)).size(11.0));
ui.label(RichText::new(&peer_display).strong().color(theme.text_primary));
});
for folder in &peer.shared_folders {
let is_selected = self.selected_peer_id_for_share.as_deref() == Some(&peer.peer_id)
&& self.selected_folder_id_for_share.as_deref() == Some(&folder.id);
let badge = if folder.read_only { "RO" } else { "RW" };
let btn_text = format!("📁 {} [{}]", folder.name, badge);
let btn = Button::new(RichText::new(btn_text).size(12.0).color(if is_selected {
theme.text_accent
} else {
theme.text_secondary
}))
.fill(if is_selected {
theme.bg_card_active
} else {
Color32::TRANSPARENT
})
.min_size(Vec2::new(ui.available_width(), 26.0))
.rounding(Rounding::same(4.0));
if ui.add(btn).clicked() {
self.selected_peer_id_for_share = Some(peer.peer_id.clone());
self.selected_folder_id_for_share = Some(folder.id.clone());
self.current_share_subpath = String::new();
self.refresh_current_share_tree();
}
}
ui.add_space(6.0);
}
});
});
});
ui.add_space(10.0);
// Right Column: Active Share 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 => {
ui.label("Selected peer is offline.");
return;
}
};
let (peer_name, _) = self.get_peer_display_name(&peer);
// Top Explorer Header & Breadcrumbs
Frame::none()
.fill(theme.bg_card)
.rounding(Rounding::same(8.0))
.stroke(theme.stroke_border)
.inner_margin(Margin::same(10.0))
.show(ui, |ui| {
ui.horizontal(|ui| {
if ui.button("🏠 Root").clicked() {
self.current_share_subpath = String::new();
self.refresh_current_share_tree();
}
if !self.current_share_subpath.is_empty() {
ui.label("/");
ui.label(
RichText::new(&self.current_share_subpath)
.monospace()
.strong()
.color(theme.text_primary),
);
}
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
// Grep search button
if ui.button(RichText::new("🔎 Поиск текста/кода").color(Color32::from_rgb(56, 189, 248))).clicked() {
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("⬆️ Upload File")
.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!("⬆️ Uploading {:?} to share...", f.file_name()));
match client.upload_to_shared_folder(&peer_url, &f_id, &sub, &f, pin.as_deref()).await {
Ok(_) => {
let _ = log_tx.send(format!("✅ Uploaded {:?}", f.file_name()));
}
Err(e) => {
let _ = log_tx.send(format!("❌ Upload failed: {}", e));
}
}
}
// Refresh tree after upload
if let Ok(tree) = client.fetch_shared_tree(&peer_url, &f_id, Some(&sub), pin.as_deref()).await {
let _ = tx.send(Some(tree));
}
});
}
}
}
}
});
});
});
ui.add_space(8.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!("Selected {} item(s)", self.selected_share_items.len())).strong().color(theme.text_primary));
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.button("Clear Selection").clicked() {
self.selected_share_items.clear();
}
let dl_btn = Button::new(
RichText::new(format!("⬇️ Download Selected ({})", 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<String> = 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 sound = self.sound_notifications;
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!("⬇️ Downloading {}...", fname));
let _ = client.download_shared_file(&peer_url, &f_id, &item_rel, &dest, pin.as_deref()).await;
}
if sound {
crate::ui::sound::play_transfer_sound();
}
});
self.selected_share_items.clear();
}
});
});
});
ui.add_space(6.0);
}
// Explorer File Table
if self.share_tree_loading {
ui.vertical_centered(|ui| {
ui.add_space(30.0);
ui.spinner();
ui.label("Loading shared contents...");
});
} 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("Parent Folder").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, 6.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));
}
// 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 { "⬇️ Download" };
if ui.button(down_label).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 sound = self.sound_notifications;
tokio::spawn(async move {
let _ = log_tx.send(format!("⬇️ Downloading {}...", out_name));
match client.download_shared_file(&peer_url, &f_id, &sub, &dest, pin.as_deref()).await {
Ok(bytes) => {
let _ = log_tx.send(format!("✅ Downloaded {} ({})", out_name, human_bytes(bytes as f64)));
if sound {
crate::ui::sound::play_transfer_sound();
}
}
Err(e) => {
let _ = log_tx.send(format!("❌ Download failed: {}", e));
}
}
});
}
// 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("🎵 Play in App").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 created for '{}'", entry.name));
let _ = std::process::Command::new("explorer").arg(&stream_url).spawn();
}
if ui.button(RichText::new("▶️ Stream").color(Color32::from_rgb(56, 189, 248))).clicked() {
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 Edit" } else { "👁 Vim View" };
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(20.0))
.show(ui, |ui| {
ui.vertical_centered(|ui| {
ui.label(RichText::new("🔒 Folder Locked or Empty").strong().size(14.0).color(theme.text_primary));
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.label("Enter PIN if required:");
ui.add(egui::TextEdit::singleline(&mut self.share_pin_input).password(true).desired_width(120.0));
if ui.button("Unlock / Refresh").clicked() {
self.refresh_current_share_tree();
}
});
});
});
}
} else {
Frame::none()
.fill(theme.bg_card)
.rounding(Rounding::same(8.0))
.stroke(theme.stroke_border)
.inner_margin(Margin::same(40.0))
.show(ui, |ui| {
ui.vertical_centered(|ui| {
ui.label(RichText::new("👈 Select a shared folder on the left to browse files, watch streams, and view code in Vim.").color(theme.text_muted));
});
});
}
});
});
}
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);
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::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 size_details = if t.state == TransferState::InProgress {
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;
format!(
"{} / {} ({}%) • ⚡ {:.1} MB/s • {}",
human_bytes(t.bytes_transferred as f64),
human_bytes(t.total_bytes as f64),
progress_pct,
speed_mb,
eta_str
)
} else if is_done {
if t.checksum_verified {
format!("Доставлено • Размер: {} • 🛡️ SHA-256 Проверено", human_bytes(t.total_bytes as f64))
} else {
format!("Доставлено • Размер: {}", human_bytes(t.total_bytes as f64))
}
} 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();
}
});
}
});
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, "Сжимать папки в ZIP на лету (в 10 раз быстрее для папок с кодом и фото)").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.3-beta
ui.horizontal(|ui| {
ui.label(RichText::new("v2.5.3-beta").strong().color(Color32::from_rgb(34, 197, 94)));
ui.label(RichText::new("(Текущая версия)").size(11.0).color(Color32::GRAY));
});
ui.label(" • ⚡ Потоковая передача папок: Zip-на-лету через tar.zst — папки стримятся напрямую без создания временных файлов на диске.");
ui.label(" • 🗜️ Zstandard (Zstd) сжатие: Мгновенное сжатие уровня 1-3 в потоке — до 4-8x прирост скорости на 100Mбит и VPN-каналах.");
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,
};
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.current_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);
}
}
}
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));
}
}
}