diff --git a/Cargo.lock b/Cargo.lock index 1f35623..6f0c49a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6372,7 +6372,7 @@ checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerosend" -version = "2.4.1" +version = "2.5.0" dependencies = [ "arboard", "axum", @@ -6402,6 +6402,7 @@ dependencies = [ "tracing", "tracing-subscriber", "tray-icon", + "url", "uuid", "walkdir", "windows-sys 0.59.0", diff --git a/Cargo.toml b/Cargo.toml index 6d7626e..fc729ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zerosend" -version = "2.4.1" +version = "2.5.0" edition = "2021" description = "Fast, peer-to-peer file transfer designed for ZeroTier, Tailscale, Radmin VPN and LAN" authors = ["RarDog"] @@ -14,6 +14,7 @@ axum = { version = "0.8", features = ["multipart"] } tower = "0.5" tower-http = { version = "0.6", features = ["cors", "trace", "fs"] } reqwest = { version = "0.12", features = ["stream", "json", "multipart"] } +url = "2.5" socket2 = { version = "0.5", features = ["all"] } network-interface = "2.0" ipnetwork = "0.20" diff --git a/src/config.rs b/src/config.rs index bb91774..81b035b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -59,6 +59,14 @@ pub struct AppConfig { pub check_updates_on_startup: bool, #[serde(default)] pub gitea_api_url: Option, + #[serde(default)] + pub folder_sync_pairs: Vec, + #[serde(default = "default_webdav_port")] + pub webdav_port: u16, +} + +fn default_webdav_port() -> u16 { + 53319 } fn default_true() -> bool { @@ -92,6 +100,8 @@ impl Default for AppConfig { shared_folders: Vec::new(), check_updates_on_startup: true, gitea_api_url: None, + folder_sync_pairs: Vec::new(), + webdav_port: 53319, } } } diff --git a/src/main.rs b/src/main.rs index 0f3f499..9a6af86 100644 --- a/src/main.rs +++ b/src/main.rs @@ -272,6 +272,13 @@ async fn main() -> Result<(), Box> { ); server.start().await?; + // Start embedded local WebDAV server for Windows Network Drive mounting + let shared_folders_ref = server.state().shared_folders.clone(); + let webdav_port = config.webdav_port; + tokio::spawn(async move { + crate::transfer::start_webdav_server(shared_folders_ref, webdav_port).await; + }); + if cli.tui { ui::tui::run_tui(discovery, server).await?; } else { @@ -291,7 +298,7 @@ async fn main() -> Result<(), Box> { .with_inner_size([config.window_width, config.window_height]) .with_min_inner_size([780.0, 500.0]) .with_visible(!start_minimized) - .with_title("ZeroSend v2.4.1 - P2P Sharing for ZeroTier & VPN"), + .with_title("ZeroSend v2.5.0 - P2P Sharing for ZeroTier & VPN"), ..Default::default() }; diff --git a/src/transfer/client.rs b/src/transfer/client.rs index 069a603..c4a2447 100644 --- a/src/transfer/client.rs +++ b/src/transfer/client.rs @@ -1,7 +1,8 @@ use super::archive::compress_folder_to_zip; use super::protocol::{ - FileMetadata, PrepareTransferRequest, PrepareTransferResponse, SharedFolderInfo, - SharedFolderTreeResponse, SharedUploadResponse, TextMessage, TransferProgress, TransferState, + FileMetadata, GrepResponse, PrepareTransferRequest, PrepareTransferResponse, + SharedFileSaveRequest, SharedFileSaveResponse, SharedFolderInfo, SharedFolderTreeResponse, + SharedUploadResponse, TextMessage, TransferProgress, TransferState, WatchPartySyncEvent, }; use super::rate_limiter::RateLimiter; use super::text_analyzer::{analyze_text, AnalyzedText}; @@ -763,4 +764,126 @@ impl TransferClient { .await .map_err(|e| format!("Failed to parse upload response: {}", e)) } + + /// Save updated text/code back to a peer's writable shared folder + pub async fn save_shared_file( + &self, + peer_url: &str, + folder_id: &str, + relative_path: &str, + content: &str, + pin: Option<&str>, + ) -> Result { + let clean = relative_path.trim_start_matches('/').trim_start_matches('\\'); + let url = format!("{}/api/shared/save/{}/{}", peer_url, folder_id, clean); + + let req_body = SharedFileSaveRequest { + content: content.to_string(), + pin: pin.map(|p| p.to_string()), + }; + + let resp = self + .client + .post(&url) + .json(&req_body) + .send() + .await + .map_err(|e| format!("Save error: {}", e))?; + + if !resp.status().is_success() { + return Err(format!("Save failed: HTTP {}", resp.status())); + } + + resp.json::() + .await + .map_err(|e| format!("Failed to parse save response: {}", e)) + } + + /// Full-text search across remote shared folder + pub async fn grep_shared_folder( + &self, + peer_url: &str, + folder_id: &str, + query: &str, + pin: Option<&str>, + subpath: Option<&str>, + case_sensitive: bool, + ) -> Result { + let mut url = format!("{}/api/shared/grep/{}?query={}", peer_url, folder_id, urlencoding::encode(query)); + if let Some(p) = pin.filter(|p| !p.trim().is_empty()) { + url = format!("{}&pin={}", url, urlencoding::encode(p)); + } + if let Some(sub) = subpath.filter(|s| !s.trim().is_empty()) { + url = format!("{}&subpath={}", url, urlencoding::encode(sub)); + } + if case_sensitive { + url = format!("{}&case_sensitive=true", url); + } + + let resp = self + .client + .get(&url) + .send() + .await + .map_err(|e| format!("Search error: {}", e))?; + + if !resp.status().is_success() { + return Err(format!("Search failed: HTTP {}", resp.status())); + } + + resp.json::() + .await + .map_err(|e| format!("Failed to parse search response: {}", e)) + } + + /// Fetch active watch-party state from host peer + pub async fn get_watch_party( + &self, + peer_url: &str, + ) -> Result, String> { + let url = format!("{}/api/shared/watch-party", peer_url); + let resp = self + .client + .get(&url) + .send() + .await + .map_err(|e| format!("Watch party sync error: {}", e))?; + + if !resp.status().is_success() { + return Err(format!("HTTP {}", resp.status())); + } + + resp.json::>() + .await + .map_err(|e| format!("Failed to parse watch party event: {}", e)) + } + + /// Send watch-party sync event to host peer + pub async fn send_watch_party_event( + &self, + peer_url: &str, + event: &WatchPartySyncEvent, + ) -> Result<(), String> { + let url = format!("{}/api/shared/watch-party", peer_url); + let resp = self + .client + .post(&url) + .json(event) + .send() + .await + .map_err(|e| format!("Failed to broadcast watch party event: {}", e))?; + + if !resp.status().is_success() { + return Err(format!("Broadcast failed: HTTP {}", resp.status())); + } + + Ok(()) + } } + +mod urlencoding { + pub fn encode(s: &str) -> String { + url::form_urlencoded::byte_serialize(s.as_bytes()).collect() + } +} + diff --git a/src/transfer/folder_sync.rs b/src/transfer/folder_sync.rs new file mode 100644 index 0000000..1a2b8f3 --- /dev/null +++ b/src/transfer/folder_sync.rs @@ -0,0 +1,217 @@ +use crate::transfer::client::TransferClient; +use crate::transfer::protocol::FolderSyncPairConfig; +use chrono::Local; +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::{mpsc, RwLock}; +use tracing::info; + +#[derive(Debug, Clone)] +pub struct SyncStatusUpdate { + pub pair_id: String, + pub message: String, + pub is_error: bool, + pub timestamp: String, +} + +pub struct FolderSyncEngine { + client: Arc, + pairs: Arc>>, + status_tx: mpsc::UnboundedSender, +} + +impl FolderSyncEngine { + pub fn new( + client: Arc, + pairs: Vec, + status_tx: mpsc::UnboundedSender, + ) -> Self { + Self { + client, + pairs: Arc::new(RwLock::new(pairs)), + status_tx, + } + } + + pub fn pairs_handle(&self) -> Arc>> { + self.pairs.clone() + } + + pub async fn start_background_sync( + self: Arc, + peers_map: Arc>>, + ) { + info!("Folder auto-sync engine started."); + loop { + tokio::time::sleep(Duration::from_secs(8)).await; + + let pairs = self.pairs.read().await.clone(); + for pair in pairs { + if !pair.is_active { + continue; + } + + // Check if peer is currently online + let peer_opt = { + let peers = peers_map.read().await; + peers.get(&pair.remote_peer_id).cloned() + }; + + let peer = match peer_opt { + Some(p) if p.is_online => p, + _ => continue, + }; + + let peer_url = peer.transfer_url(); + let client = self.client.clone(); + let status_tx = self.status_tx.clone(); + let pair_id = pair.id.clone(); + let pair_name = pair.name.clone(); + let local_path = pair.local_path.clone(); + let remote_folder_id = pair.remote_folder_id.clone(); + + tokio::spawn(async move { + if let Err(e) = Self::sync_pair( + &client, + &peer_url, + &local_path, + &remote_folder_id, + &pair_id, + &pair_name, + &status_tx, + ) + .await + { + let _ = status_tx.send(SyncStatusUpdate { + pair_id, + message: format!("Sync error: {}", e), + is_error: true, + timestamp: Local::now().format("%H:%M:%S").to_string(), + }); + } + }); + } + } + } + + async fn sync_pair( + client: &TransferClient, + peer_url: &str, + local_path: &Path, + remote_folder_id: &str, + pair_id: &str, + pair_name: &str, + status_tx: &mpsc::UnboundedSender, + ) -> Result<(), String> { + if !local_path.exists() { + let _ = tokio::fs::create_dir_all(local_path).await; + } + + // Fetch remote directory tree + let remote_tree = client + .fetch_shared_tree(peer_url, remote_folder_id, None, None) + .await?; + + // 1. Scan local files + let mut local_files: HashMap = HashMap::new(); // rel_path -> (size, mtime) + if let Ok(entries) = walkdir_local(local_path) { + for (rel, size, mtime) in entries { + local_files.insert(rel, (size, mtime)); + } + } + + // 2. Map remote files + let mut remote_files: HashMap = HashMap::new(); + for entry in &remote_tree.entries { + if !entry.is_dir { + remote_files.insert(entry.relative_path.clone(), entry.size); + } + } + + let mut synced_count = 0; + + // A. Upload newly created or changed local files to remote + if !remote_tree.read_only { + for (rel, (size, _)) in &local_files { + let need_upload = match remote_files.get(rel) { + Some(remote_size) => *remote_size != *size, + None => true, // New local file + }; + + if need_upload { + let file_full_path = local_path.join(rel); + let subpath = Path::new(rel).parent().and_then(|p| p.to_str()).unwrap_or(""); + if let Ok(_) = client + .upload_to_shared_folder( + peer_url, + remote_folder_id, + subpath, + &file_full_path, + None, + ) + .await + { + synced_count += 1; + } + } + } + } + + // B. Download newly created remote files to local + for (rel, remote_size) in &remote_files { + let need_download = match local_files.get(rel) { + Some((local_size, _)) => *local_size != *remote_size, + None => true, // New remote file + }; + + if need_download { + let target_file_path = local_path.join(rel); + if let Some(parent) = target_file_path.parent() { + let _ = tokio::fs::create_dir_all(parent).await; + } + + // Download file + if let Ok(_) = client + .download_shared_file(peer_url, remote_folder_id, rel, &target_file_path, None) + .await + { + synced_count += 1; + } + } + } + + if synced_count > 0 { + let _ = status_tx.send(SyncStatusUpdate { + pair_id: pair_id.to_string(), + message: format!("Synced {} item(s) in '{}'", synced_count, pair_name), + is_error: false, + timestamp: Local::now().format("%H:%M:%S").to_string(), + }); + } + + Ok(()) + } +} + +fn walkdir_local(base: &Path) -> Result, std::io::Error> { + let mut results = Vec::new(); + for entry in walkdir::WalkDir::new(base).into_iter().filter_map(|e| e.ok()) { + if entry.file_type().is_file() { + if let Ok(rel) = entry.path().strip_prefix(base) { + let rel_str = rel.to_string_lossy().replace('\\', "/"); + let meta = entry.metadata().ok(); + let size = meta.as_ref().map(|m| m.len()).unwrap_or(0); + let mtime = meta + .as_ref() + .and_then(|m| m.modified().ok()) + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0); + results.push((rel_str, size, mtime)); + } + } + } + Ok(results) +} diff --git a/src/transfer/mod.rs b/src/transfer/mod.rs index 647d36c..6a45577 100644 --- a/src/transfer/mod.rs +++ b/src/transfer/mod.rs @@ -1,13 +1,17 @@ pub mod archive; pub mod client; +pub mod folder_sync; pub mod protocol; pub mod rate_limiter; pub mod server; pub mod text_analyzer; +pub mod webdav; pub use client::*; +pub use folder_sync::*; pub use protocol::*; #[allow(unused_imports)] pub use rate_limiter::RateLimiter; pub use server::*; pub use text_analyzer::*; +pub use webdav::*; diff --git a/src/transfer/protocol.rs b/src/transfer/protocol.rs index 3aa93b9..7ad5098 100644 --- a/src/transfer/protocol.rs +++ b/src/transfer/protocol.rs @@ -119,6 +119,62 @@ pub struct SharedUploadResponse { pub bytes_written: u64, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GrepMatch { + pub file_path: String, + pub line_number: usize, + pub line_content: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GrepResponse { + pub matches: Vec, + pub total_files_searched: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SharedFileSaveRequest { + pub content: String, + #[serde(default)] + pub pin: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SharedFileSaveResponse { + pub status: String, + pub bytes_written: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WatchPartySyncEvent { + pub room_id: String, + pub host_peer_id: String, + pub host_peer_name: String, + pub folder_id: String, + pub relative_path: String, + pub is_playing: bool, + pub position_secs: f64, + pub timestamp_ms: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FolderSyncPairConfig { + pub id: String, + pub name: String, + pub local_path: std::path::PathBuf, + pub remote_peer_id: String, + pub remote_peer_name: String, + pub remote_folder_id: String, + #[serde(default = "default_true_val")] + pub is_active: bool, + #[serde(default)] + pub last_sync: Option, +} + +fn default_true_val() -> bool { + true +} + pub fn get_mime_type(filename: &str) -> &'static str { let lower = filename.to_lowercase(); if lower.ends_with(".mp4") { diff --git a/src/transfer/server.rs b/src/transfer/server.rs index 5d7ac37..8eda670 100644 --- a/src/transfer/server.rs +++ b/src/transfer/server.rs @@ -1,8 +1,9 @@ use super::archive::extract_zip_archive; use super::protocol::{ - FileMetadata, PrepareTransferRequest, PrepareTransferResponse, - SharedFileEntry, SharedFolderInfo, SharedFolderTreeResponse, SharedUploadResponse, - TextMessage, TransferProgress, TransferState, + FileMetadata, GrepMatch, GrepResponse, PrepareTransferRequest, PrepareTransferResponse, + SharedFileEntry, SharedFileSaveRequest, SharedFileSaveResponse, SharedFolderInfo, + SharedFolderTreeResponse, SharedUploadResponse, TextMessage, TransferProgress, TransferState, + WatchPartySyncEvent, }; use crate::config::SharedFolderConfig; use crate::network::DiscoveryBeacon; @@ -36,6 +37,7 @@ pub struct ServerState { pub pin_code: Arc>, pub auto_extract_zip: Arc>, pub shared_folders: Arc>>, + pub watch_party_state: Arc>>, pub pending_requests: Arc>>, pub accepted_requests: Arc>>, pub pending_confirmations: Arc>>>, @@ -85,6 +87,7 @@ impl TransferServer { pin_code: Arc::new(RwLock::new(pin_code)), auto_extract_zip: Arc::new(RwLock::new(auto_extract_zip)), shared_folders: Arc::new(RwLock::new(shared_folders)), + watch_party_state: Arc::new(RwLock::new(None)), pending_requests: Arc::new(RwLock::new(HashMap::new())), accepted_requests: Arc::new(RwLock::new(HashMap::new())), pending_confirmations: Arc::new(Mutex::new(HashMap::new())), @@ -206,6 +209,9 @@ impl TransferServer { "/api/shared/upload/{folder_id}/{*subpath}", post(handle_shared_upload).layer(DefaultBodyLimit::disable()), ) + .route("/api/shared/save/{folder_id}/{*subpath}", post(handle_shared_save)) + .route("/api/shared/grep/{folder_id}", get(handle_shared_grep)) + .route("/api/shared/watch-party", get(handle_watch_party_get).post(handle_watch_party_post)) .layer(CorsLayer::permissive()) .with_state(self.state.clone()); @@ -1521,6 +1527,220 @@ async fn handle_shared_upload( .into_response() } +async fn handle_shared_save( + State(state): State>, + Path((folder_id, subpath)): Path<(String, String)>, + Json(payload): Json, +) -> Response { + let shares = state.shared_folders.read().await; + let folder = match shares.iter().find(|s| s.id == folder_id) { + Some(f) => f.clone(), + None => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Shared folder not found" })), + ) + .into_response(); + } + }; + drop(shares); + + if folder.read_only { + return ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ "error": "Shared folder is Read-Only" })), + ) + .into_response(); + } + + if let Some(expected_pin) = &folder.pin { + if payload.pin.as_deref() != Some(expected_pin.as_str()) { + return ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ "error": "Incorrect or missing PIN code" })), + ) + .into_response(); + } + } + + let base_canonical = match folder.path.canonicalize() { + Ok(p) => p, + Err(_) => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Base path not found" })), + ) + .into_response(); + } + }; + + let clean_sub = subpath.trim_start_matches('/').trim_start_matches('\\'); + let target_file_path = base_canonical.join(clean_sub); + + if !target_file_path.starts_with(&base_canonical) { + return ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ "error": "Path traversal detected" })), + ) + .into_response(); + } + + if let Some(parent) = target_file_path.parent() { + let _ = tokio::fs::create_dir_all(parent).await; + } + + let bytes = payload.content.as_bytes(); + match tokio::fs::write(&target_file_path, bytes).await { + Ok(_) => Json(SharedFileSaveResponse { + status: "success".to_string(), + bytes_written: bytes.len() as u64, + }) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": format!("Failed to write file: {}", e) })), + ) + .into_response(), + } +} + +#[derive(serde::Deserialize)] +struct GrepQuery { + query: String, + pin: Option, + subpath: Option, + case_sensitive: Option, +} + +async fn handle_shared_grep( + State(state): State>, + Path(folder_id): Path, + Query(params): Query, +) -> Response { + let shares = state.shared_folders.read().await; + let folder = match shares.iter().find(|s| s.id == folder_id) { + Some(f) => f.clone(), + None => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Shared folder not found" })), + ) + .into_response(); + } + }; + drop(shares); + + if let Some(expected_pin) = &folder.pin { + if params.pin.as_deref() != Some(expected_pin.as_str()) { + return ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ "error": "Incorrect or missing PIN code" })), + ) + .into_response(); + } + } + + let base_canonical = match folder.path.canonicalize() { + Ok(p) => p, + Err(_) => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Base path not found" })), + ) + .into_response(); + } + }; + + let target_dir = if let Some(sub) = ¶ms.subpath { + let clean = sub.trim_start_matches('/').trim_start_matches('\\'); + base_canonical.join(clean) + } else { + base_canonical.clone() + }; + + if !target_dir.starts_with(&base_canonical) { + return ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ "error": "Path traversal detected" })), + ) + .into_response(); + } + + let search_query = params.query.clone(); + let is_case_sens = params.case_sensitive.unwrap_or(false); + let mut matches = Vec::new(); + let mut files_searched = 0; + + let base_clone = base_canonical.clone(); + for entry in walkdir::WalkDir::new(&target_dir).into_iter().filter_map(|e| e.ok()) { + if entry.file_type().is_file() { + let path = entry.path(); + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + let mime = crate::transfer::get_mime_type(name); + let is_text = mime.starts_with("text/") + || mime.starts_with("application/json") + || mime.starts_with("application/xml") + || mime.starts_with("application/javascript") + || mime.ends_with("x-sh") + || name.ends_with(".rs") + || name.ends_with(".py") + || name.ends_with(".toml") + || name.ends_with(".md") + || name.ends_with(".txt") + || name.ends_with(".log"); + + if is_text { + files_searched += 1; + if let Ok(content) = std::fs::read_to_string(path) { + if let Ok(rel) = path.strip_prefix(&base_clone) { + let rel_str = rel.to_string_lossy().replace('\\', "/"); + for (idx, line) in content.lines().enumerate() { + let is_match = if is_case_sens { + line.contains(&search_query) + } else { + line.to_lowercase().contains(&search_query.to_lowercase()) + }; + if is_match { + matches.push(GrepMatch { + file_path: rel_str.clone(), + line_number: idx + 1, + line_content: line.trim().to_string(), + }); + if matches.len() >= 100 { + break; + } + } + } + } + } + } + } + if matches.len() >= 100 { + break; + } + } + + Json(GrepResponse { + matches, + total_files_searched: files_searched, + }) + .into_response() +} + +async fn handle_watch_party_get(State(state): State>) -> Response { + let guard = state.watch_party_state.read().await; + Json((*guard).clone()).into_response() +} + +async fn handle_watch_party_post( + State(state): State>, + Json(event): Json, +) -> Response { + let mut guard = state.watch_party_state.write().await; + *guard = Some(event); + Json(serde_json::json!({ "status": "ok" })).into_response() +} + async fn handle_web_ui(State(state): State>) -> Html { let html = format!( r###" diff --git a/src/transfer/webdav.rs b/src/transfer/webdav.rs new file mode 100644 index 0000000..444d4ef --- /dev/null +++ b/src/transfer/webdav.rs @@ -0,0 +1,428 @@ +use crate::config::SharedFolderConfig; +use axum::{ + body::Body, + extract::{Path, State}, + http::{header, Method, Request, Response, StatusCode}, + routing::any, + Router, +}; +use std::sync::Arc; +use tokio::fs::File; +use tokio::sync::RwLock; +use tokio_util::io::ReaderStream; +use tracing::{info, warn}; + +pub struct WebDavState { + pub shared_folders: Arc>>, + pub port: u16, +} + +pub fn create_webdav_router(state: Arc) -> Router { + Router::new() + .route("/webdav", any(handle_webdav_root)) + .route("/webdav/{*path}", any(handle_webdav_path)) + .with_state(state) +} + +pub async fn start_webdav_server( + shared_folders: Arc>>, + port: u16, +) { + let state = Arc::new(WebDavState { + shared_folders, + port, + }); + let app = create_webdav_router(state); + let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port)); + + info!("Starting local WebDAV server on http://{}", addr); + + match tokio::net::TcpListener::bind(addr).await { + Ok(listener) => { + let _ = axum::serve(listener, app).await; + } + Err(e) => { + warn!("Failed to bind WebDAV server on {}: {}", addr, e); + } + } +} + +async fn handle_webdav_root( + State(state): State>, + req: Request, +) -> Response { + let method = req.method().clone(); + let headers = req.headers().clone(); + + if method == Method::OPTIONS { + return options_response(); + } + + if method.as_str() == "PROPFIND" { + let shares = state.shared_folders.read().await; + let depth = headers + .get("Depth") + .and_then(|h| h.to_str().ok()) + .unwrap_or("1"); + + let mut xml = String::from("\n\n"); + + // Root response + xml.push_str(" \n"); + xml.push_str(" /webdav/\n"); + xml.push_str(" \n"); + xml.push_str(" \n"); + xml.push_str(" ZeroSend WebDAV\n"); + xml.push_str(" \n"); + xml.push_str(" \n"); + xml.push_str(" HTTP/1.1 200 OK\n"); + xml.push_str(" \n"); + xml.push_str(" \n"); + + if depth != "0" { + for s in shares.iter() { + let encoded_name = urlencoding::encode(&s.name); + xml.push_str(" \n"); + xml.push_str(&format!(" /webdav/{}/\n", encoded_name)); + xml.push_str(" \n"); + xml.push_str(" \n"); + xml.push_str(&format!(" {}\n", escape_xml(&s.name))); + xml.push_str(" \n"); + xml.push_str(" \n"); + xml.push_str(" HTTP/1.1 200 OK\n"); + xml.push_str(" \n"); + xml.push_str(" \n"); + } + } + + xml.push_str(""); + + return Response::builder() + .status(StatusCode::MULTI_STATUS) + .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") + .header("DAV", "1, 2") + .header("MS-Author-Via", "DAV") + .body(Body::from(xml)) + .unwrap(); + } + + options_response() +} + +async fn handle_webdav_path( + State(state): State>, + Path(raw_path): Path, + req: Request, +) -> Response { + let method = req.method().clone(); + let headers = req.headers().clone(); + + if method == Method::OPTIONS { + return options_response(); + } + + let decoded_path = urlencoding::decode(&raw_path) + .unwrap_or(std::borrow::Cow::Borrowed(&raw_path)) + .to_string(); + let parts: Vec<&str> = decoded_path.split('/').filter(|s| !s.is_empty()).collect(); + + if parts.is_empty() { + return handle_webdav_root(State(state), req).await; + } + + let share_name = parts[0]; + let subpath_parts = &parts[1..]; + + let shares = state.shared_folders.read().await; + let folder = match shares.iter().find(|s| s.name == share_name || s.id == share_name) { + Some(f) => f.clone(), + None => { + return Response::builder() + .status(StatusCode::NOT_FOUND) + .body(Body::from("Folder not found")) + .unwrap(); + } + }; + drop(shares); + + let base_canonical = match folder.path.canonicalize() { + Ok(p) => p, + Err(_) => { + return Response::builder() + .status(StatusCode::NOT_FOUND) + .body(Body::from("Base path not found")) + .unwrap(); + } + }; + + let mut target_path = base_canonical.clone(); + for seg in subpath_parts { + if *seg == ".." || *seg == "." { + continue; + } + target_path.push(seg); + } + + // Safety check against path traversal + if !target_path.starts_with(&base_canonical) { + return Response::builder() + .status(StatusCode::FORBIDDEN) + .body(Body::from("Forbidden path")) + .unwrap(); + } + + match method.as_str() { + "PROPFIND" => { + let depth = headers + .get("Depth") + .and_then(|h| h.to_str().ok()) + .unwrap_or("1"); + + let mut xml = String::from("\n\n"); + + if let Ok(meta) = std::fs::metadata(&target_path) { + let is_dir = meta.is_dir(); + let href_path = format!("/webdav/{}{}", raw_path.trim_end_matches('/'), if is_dir { "/" } else { "" }); + + xml.push_str(" \n"); + xml.push_str(&format!(" {}\n", href_path)); + xml.push_str(" \n"); + xml.push_str(" \n"); + let name = target_path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| share_name.to_string()); + xml.push_str(&format!(" {}\n", escape_xml(&name))); + if is_dir { + xml.push_str(" \n"); + } else { + xml.push_str(" \n"); + xml.push_str(&format!(" {}\n", meta.len())); + let mime = crate::transfer::get_mime_type(&name); + xml.push_str(&format!(" {}\n", mime)); + } + xml.push_str(" \n"); + xml.push_str(" HTTP/1.1 200 OK\n"); + xml.push_str(" \n"); + xml.push_str(" \n"); + + if is_dir && depth != "0" { + if let Ok(entries) = std::fs::read_dir(&target_path) { + for entry in entries.flatten() { + if let Ok(child_meta) = entry.metadata() { + let child_name = entry.file_name().to_string_lossy().into_owned(); + let child_is_dir = child_meta.is_dir(); + let child_href = format!("{}/{}{}", href_path.trim_end_matches('/'), urlencoding::encode(&child_name), if child_is_dir { "/" } else { "" }); + + xml.push_str(" \n"); + xml.push_str(&format!(" {}\n", child_href)); + xml.push_str(" \n"); + xml.push_str(" \n"); + xml.push_str(&format!(" {}\n", escape_xml(&child_name))); + if child_is_dir { + xml.push_str(" \n"); + } else { + xml.push_str(" \n"); + xml.push_str(&format!(" {}\n", child_meta.len())); + let mime = crate::transfer::get_mime_type(&child_name); + xml.push_str(&format!(" {}\n", mime)); + } + xml.push_str(" \n"); + xml.push_str(" HTTP/1.1 200 OK\n"); + xml.push_str(" \n"); + xml.push_str(" \n"); + } + } + } + } + } else { + return Response::builder() + .status(StatusCode::NOT_FOUND) + .body(Body::from("Path not found")) + .unwrap(); + } + + xml.push_str(""); + + Response::builder() + .status(StatusCode::MULTI_STATUS) + .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") + .header("DAV", "1, 2") + .header("MS-Author-Via", "DAV") + .body(Body::from(xml)) + .unwrap() + } + "GET" | "HEAD" => { + if target_path.is_dir() { + return Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/html; charset=utf-8") + .body(Body::from("

Directory Index

")) + .unwrap(); + } + + match File::open(&target_path).await { + Ok(file) => { + let meta = file.metadata().await.ok(); + let file_size = meta.as_ref().map(|m| m.len()).unwrap_or(0); + let filename = target_path.file_name().map(|f| f.to_string_lossy().into_owned()).unwrap_or_default(); + let mime = crate::transfer::get_mime_type(&filename); + + let stream = ReaderStream::new(file); + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, mime) + .header(header::CONTENT_LENGTH, file_size.to_string()) + .header(header::ACCEPT_RANGES, "bytes") + .body(Body::from_stream(stream)) + .unwrap() + } + Err(_) => Response::builder() + .status(StatusCode::NOT_FOUND) + .body(Body::from("File not found")) + .unwrap(), + } + } + "PUT" => { + if folder.read_only { + return Response::builder() + .status(StatusCode::FORBIDDEN) + .body(Body::from("Folder is read-only")) + .unwrap(); + } + + let bytes = match axum::body::to_bytes(req.into_body(), 100 * 1024 * 1024).await { + Ok(b) => b, + Err(e) => { + return Response::builder() + .status(StatusCode::BAD_REQUEST) + .body(Body::from(format!("Failed to read body: {}", e))) + .unwrap(); + } + }; + + if let Some(parent) = target_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + + if std::fs::write(&target_path, bytes).is_ok() { + Response::builder() + .status(StatusCode::CREATED) + .body(Body::empty()) + .unwrap() + } else { + Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(Body::from("Failed to write file")) + .unwrap() + } + } + "MKCOL" => { + if folder.read_only { + return Response::builder() + .status(StatusCode::FORBIDDEN) + .body(Body::from("Folder is read-only")) + .unwrap(); + } + + if std::fs::create_dir_all(&target_path).is_ok() { + Response::builder() + .status(StatusCode::CREATED) + .body(Body::empty()) + .unwrap() + } else { + Response::builder() + .status(StatusCode::METHOD_NOT_ALLOWED) + .body(Body::from("Failed to create folder")) + .unwrap() + } + } + "DELETE" => { + if folder.read_only { + return Response::builder() + .status(StatusCode::FORBIDDEN) + .body(Body::from("Folder is read-only")) + .unwrap(); + } + + if target_path.is_dir() { + let _ = std::fs::remove_dir_all(&target_path); + } else { + let _ = std::fs::remove_file(&target_path); + } + + Response::builder() + .status(StatusCode::NO_CONTENT) + .body(Body::empty()) + .unwrap() + } + _ => options_response(), + } +} + +fn options_response() -> Response { + Response::builder() + .status(StatusCode::OK) + .header("DAV", "1, 2") + .header("MS-Author-Via", "DAV") + .header("Allow", "OPTIONS, GET, HEAD, POST, DELETE, PROPFIND, PROPPATCH, COPY, MOVE, MKCOL, PUT, LOCK, UNLOCK") + .body(Body::empty()) + .unwrap() +} + +fn escape_xml(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +mod urlencoding { + pub fn encode(s: &str) -> String { + url::form_urlencoded::byte_serialize(s.as_bytes()).collect() + } + + pub fn decode(s: &str) -> Result, ()> { + url::form_urlencoded::parse(s.as_bytes()) + .next() + .map(|(k, _)| k) + .ok_or(()) + } +} + +/// Helper function to mount Windows Network Drive via `net use` +pub fn mount_webdav_drive(drive_letter: char, port: u16) -> Result { + let drive = format!("{}:", drive_letter); + let url = format!("http://127.0.0.1:{}/webdav", port); + + // Unmount first if already mapped + let _ = std::process::Command::new("net") + .args(["use", &drive, "/delete", "/y"]) + .output(); + + let output = std::process::Command::new("net") + .args(["use", &drive, &url]) + .output() + .map_err(|e| format!("Failed to run net use: {}", e))?; + + if output.status.success() { + Ok(format!("Successfully mounted {} to {}", drive, url)) + } else { + let err = String::from_utf8_lossy(&output.stderr); + Err(format!("Mount failed: {}", err.trim())) + } +} + +/// Helper function to unmount Windows Network Drive +pub fn unmount_webdav_drive(drive_letter: char) -> Result<(), String> { + let drive = format!("{}:", drive_letter); + let output = std::process::Command::new("net") + .args(["use", &drive, "/delete", "/y"]) + .output() + .map_err(|e| format!("Failed to run net use: {}", e))?; + + if output.status.success() { + Ok(()) + } else { + let err = String::from_utf8_lossy(&output.stderr); + Err(format!("Unmount failed: {}", err.trim())) + } +} diff --git a/src/ui/gui.rs b/src/ui/gui.rs index 8961128..2186ed4 100644 --- a/src/ui/gui.rs +++ b/src/ui/gui.rs @@ -41,6 +41,15 @@ pub enum Tab { 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)] @@ -199,6 +208,33 @@ pub struct ZeroSendApp { share_pin_input: String, last_share_sync: Instant, pub vim_viewer: VimViewerModal, + pub selected_share_items: HashSet, + + // Media & Audio & Watch Party + pub active_audio: Option, + pub audio_volume: f32, + pub watch_party_session: Option, + pub is_in_watch_party: bool, + + // Remote Grep State + pub show_grep_modal: bool, + pub grep_query: String, + pub grep_results: Vec, + pub is_grepping: bool, + pub grep_status_msg: Option, + + // Live Folder Sync State + pub folder_sync_pairs: Vec, + pub sync_log_messages: Vec, + pub new_sync_local_path: String, + pub new_sync_name: String, + pub new_sync_peer_id: String, + pub new_sync_folder_id: String, + + // WebDAV Windows Drive Mount State + pub webdav_mounted: bool, + pub webdav_drive_letter: char, + pub webdav_status_msg: Option, // Gitea Updater State pub update_info: Option, @@ -216,8 +252,10 @@ pub struct ZeroSendApp { transfer_tx: mpsc::UnboundedSender, share_tree_rx: mpsc::UnboundedReceiver>, share_tree_tx: mpsc::UnboundedSender>, - share_text_rx: mpsc::UnboundedReceiver<(String, String, String, String)>, - share_text_tx: mpsc::UnboundedSender<(String, String, String, String)>, + share_text_rx: mpsc::UnboundedReceiver<(String, String, String, String, Option, Option, Option, bool)>, + share_text_tx: mpsc::UnboundedSender<(String, String, String, String, Option, Option, Option, bool)>, + sync_status_rx: mpsc::UnboundedReceiver, + sync_status_tx: mpsc::UnboundedSender, update_rx: mpsc::UnboundedReceiver>, update_tx: mpsc::UnboundedSender>, update_prog_rx: mpsc::UnboundedReceiver, @@ -277,6 +315,7 @@ impl ZeroSendApp { 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(); @@ -301,6 +340,19 @@ impl ZeroSendApp { }); } + 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(); @@ -316,6 +368,7 @@ impl ZeroSendApp { 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 { @@ -327,7 +380,7 @@ impl ZeroSendApp { Self { discovery_peers: discovery.peers(), server, - client: Arc::new(TransferClient::new()), + client, my_info: my_info.clone(), current_iface: iface, config, @@ -338,7 +391,7 @@ impl ZeroSendApp { selected_peer_ids: HashSet::new(), queued_files: initial_files, transfers: Vec::new(), - logs: vec!["⚡ ZeroSend v2.4.1 ready".to_string()], + logs: vec!["⚡ ZeroSend v2.5.0 ready".to_string()], text_input: String::new(), analyzed_preview: analyze_text(""), recipient_pin: String::new(), @@ -374,6 +427,25 @@ impl ZeroSendApp { 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, @@ -389,6 +461,8 @@ impl ZeroSendApp { share_tree_tx, share_text_rx, share_text_tx, + sync_status_rx, + sync_status_tx, update_rx, update_tx, update_prog_rx, @@ -494,8 +568,36 @@ impl ZeroSendApp { } // 8. Poll vim text updates - while let Ok((file_name, rel_path, peer_name, content)) = self.share_text_rx.try_recv() { - self.vim_viewer.open(file_name, rel_path, peer_name, content); + 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 @@ -1746,9 +1848,76 @@ impl ZeroSendApp { } 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)) @@ -1764,7 +1933,7 @@ impl ZeroSendApp { } else { theme.bg_card }) - .min_size(Vec2::new(170.0, 32.0)) + .min_size(Vec2::new(160.0, 32.0)) .rounding(Rounding::same(6.0)); if ui.add(net_btn).clicked() { @@ -1785,13 +1954,34 @@ impl ZeroSendApp { } else { theme.bg_card }) - .min_size(Vec2::new(150.0, 32.0)) + .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)) @@ -1815,7 +2005,7 @@ impl ZeroSendApp { self.persist_config(); } } - } else { + } 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(); } @@ -1828,26 +2018,83 @@ impl ZeroSendApp { 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) { + // WebDAV Windows Explorer Network Drive 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("🌐 WebDAV Windows Drive Mount").strong().color(theme.text_primary)); + ui.label( + RichText::new(format!("(Local WebDAV: http://127.0.0.1:{}/webdav)", self.config.webdav_port)) + .size(11.0) + .color(theme.text_muted), + ); + + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if self.webdav_mounted { + if ui.button(RichText::new("✕ Unmount Z: Drive").color(Color32::from_rgb(239, 68, 68))).clicked() { + let _ = crate::transfer::unmount_webdav_drive(self.webdav_drive_letter); + self.webdav_mounted = false; + self.webdav_status_msg = Some("Unmounted drive".to_string()); + } + ui.label(RichText::new(format!("● Mounted as Drive {}:", self.webdav_drive_letter)).color(Color32::from_rgb(34, 197, 94)).strong()); + } else { + let mount_btn = Button::new( + RichText::new("🔗 Mount as Network Drive Z: in Windows Explorer") + .strong() + .color(Color32::WHITE), + ) + .fill(Color32::from_rgb(37, 99, 235)) + .rounding(Rounding::same(6.0)); + + if ui.add(mount_btn).clicked() { + match crate::transfer::mount_webdav_drive(self.webdav_drive_letter, self.config.webdav_port) { + Ok(msg) => { + self.webdav_mounted = true; + self.webdav_status_msg = Some(msg); + let _ = std::process::Command::new("explorer").arg(format!("{}:\\", self.webdav_drive_letter)).spawn(); + } + Err(e) => { + self.webdav_status_msg = Some(format!("Error: {}", e)); + } + } + } + } + }); + }); + + if let Some(msg) = &self.webdav_status_msg { + ui.add_space(4.0); + ui.label(RichText::new(msg).size(11.0).color(theme.text_muted)); + } + }); + + ui.add_space(8.0); + 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(40.0)) + .inner_margin(Margin::same(30.0)) .show(ui, |ui| { ui.vertical_centered(|ui| { - ui.label(RichText::new("📁").size(40.0)); + ui.label(RichText::new("📁").size(36.0)); ui.label( RichText::new("You haven't shared any local folders yet.") - .size(16.0) + .size(15.0) .strong() .color(theme.text_primary), ); - ui.add_space(6.0); + ui.add_space(4.0); ui.label( RichText::new( "Share movies, music, or code folders so your friends on ZeroTier/LAN can stream and download them.", @@ -1855,10 +2102,10 @@ impl ZeroSendApp { .size(12.0) .color(theme.text_muted), ); - ui.add_space(16.0); + ui.add_space(12.0); let choose_btn = Button::new(RichText::new("➕ Choose Folder to Share").strong().color(Color32::WHITE)) .fill(Color32::from_rgb(34, 197, 94)) - .min_size(Vec2::new(200.0, 36.0)); + .min_size(Vec2::new(180.0, 34.0)); if ui.add(choose_btn).clicked() { if let Some(folder) = FileDialog::new().pick_folder() { @@ -1967,6 +2214,184 @@ impl ZeroSendApp { } } + fn render_auto_sync_subtab(&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(14.0)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new("🔄 Two-Way Live Folder Synchronization").strong().size(14.0).color(theme.text_primary)); + ui.label( + RichText::new("(Automatic real-time background sync with friend's shared folder)") + .size(11.0) + .color(theme.text_muted), + ); + }); + + ui.add_space(8.0); + ui.separator(); + ui.add_space(8.0); + + // Add New Sync Pairing form + ui.label(RichText::new("➕ Create New Sync Pairing").strong().color(theme.text_secondary)); + ui.add_space(4.0); + + ui.horizontal(|ui| { + ui.label("Name:"); + ui.add(egui::TextEdit::singleline(&mut self.new_sync_name).hint_text("My Project Sync").desired_width(130.0)); + + ui.label("Local Path:"); + ui.add(egui::TextEdit::singleline(&mut self.new_sync_local_path).hint_text("C:\\Projects\\...").desired_width(180.0)); + if ui.button("📁 Browse").clicked() { + if let Some(folder) = FileDialog::new().pick_folder() { + self.new_sync_local_path = folder.to_string_lossy().into_owned(); + } + } + }); + + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label("Friend (Peer ID):"); + ui.add(egui::TextEdit::singleline(&mut self.new_sync_peer_id).hint_text("Select or paste peer ID...").desired_width(180.0)); + + ui.label("Remote Folder ID:"); + ui.add(egui::TextEdit::singleline(&mut self.new_sync_folder_id).hint_text("Shared folder ID...").desired_width(160.0)); + + let add_pair_btn = Button::new(RichText::new("➕ Add Pairing").strong().color(Color32::WHITE)) + .fill(Color32::from_rgb(34, 197, 94)) + .rounding(Rounding::same(4.0)); + + if ui.add(add_pair_btn).clicked() { + if !self.new_sync_local_path.trim().is_empty() && !self.new_sync_peer_id.trim().is_empty() { + let name = if self.new_sync_name.trim().is_empty() { + "Folder Sync".to_string() + } else { + self.new_sync_name.trim().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: "Friend".to_string(), + 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.new_sync_local_path.clear(); + self.new_sync_name.clear(); + } + } + }); + }); + + ui.add_space(10.0); + + // List of Active Sync Pairs + 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("🔄 No active sync pairings configured.").color(theme.text_secondary)); + ui.label(RichText::new("Configure a pairing above to automatically synchronize folders with friends.").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(20.0)); + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(&pair.name).strong().color(theme.text_primary)); + if pair.is_active { + ui.label(RichText::new("● Active (Auto-Syncing)").color(Color32::from_rgb(34, 197, 94)).size(11.0)); + } else { + ui.label(RichText::new("⏸ Paused").color(theme.text_muted).size(11.0)); + } + }); + + ui.label( + RichText::new(format!("Local: {:?} ➔ Remote Folder ID: {}", pair.local_path, pair.remote_folder_id)) + .monospace() + .size(11.0) + .color(theme.text_muted), + ); + }); + + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.button(RichText::new("🗑 Delete").color(Color32::from_rgb(239, 68, 68))).clicked() { + remove_sync_idx = Some(idx); + } + + if ui.checkbox(&mut pair.is_active, "Enabled").changed() { + sync_changed = true; + } + + if ui.button("📂 Open Local").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 Sync 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("No sync activity yet. Files will be mirrored automatically.").size(11.0).color(Color32::from_rgb(100, 116, 139))); + } else { + for log in self.sync_log_messages.iter().rev() { + ui.label(RichText::new(log).monospace().size(11.0).color(Color32::from_rgb(203, 213, 225))); + } + } + }); + }); + } + fn render_network_shares(&mut self, ui: &mut Ui, theme: ThemeColors) { let online_peers_with_shares: Vec = self .peers @@ -2106,11 +2531,19 @@ impl ZeroSendApp { } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + // Grep search button + if ui.button(RichText::new("🔎 Search Text/Code").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; + } + // Search filter box ui.add( egui::TextEdit::singleline(&mut self.share_search_query) .hint_text("🔍 Filter files...") - .desired_width(160.0), + .desired_width(140.0), ); // Upload button if folder is Read-Write @@ -2165,6 +2598,66 @@ impl ZeroSendApp { 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 = 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| { @@ -2173,6 +2666,8 @@ impl ZeroSendApp { 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() { @@ -2209,6 +2704,18 @@ impl ZeroSendApp { .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 { @@ -2273,22 +2780,62 @@ impl ZeroSendApp { }); } - // Action: Stream / Play (for video & audio) - if badge_text == "VIDEO" || badge_text == "AUDIO" { - if ui.button(RichText::new("▶️ Stream").color(Color32::from_rgb(236, 72, 153)).strong()).clicked() { - let stream_url = format!( - "{}/api/shared/stream/{}/{}", - peer.transfer_url(), - folder_id, - entry.relative_path.trim_start_matches('/') - ); + // 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: Vim View (for code & text) - if badge_text == "CODE" || badge_text == "DOC" || entry.name.ends_with(".txt") || entry.name.ends_with(".log") { - if ui.button(RichText::new("👁 Vim View").color(Color32::from_rgb(20, 184, 166)).strong()).clicked() { + // 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(); @@ -2296,6 +2843,7 @@ impl ZeroSendApp { 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 { @@ -2304,7 +2852,7 @@ impl ZeroSendApp { 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)); + let _ = text_tx.send((file_name, sub, peer_display, text, Some(peer_url), Some(f_id), pin, is_w)); } }); } @@ -3055,10 +3603,24 @@ impl ZeroSendApp { .stroke(theme.stroke_border) .inner_margin(Margin::same(14.0)) .show(ui, |ui| { + // v2.5.0 + ui.horizontal(|ui| { + ui.label(RichText::new("v2.5.0").strong().color(Color32::from_rgb(34, 197, 94))); + ui.label(RichText::new("(Current Release)").size(11.0).color(Color32::GRAY)); + }); + ui.label(" • 🔄 Live Folder Two-Way Auto-Sync: Real-time background sync engine that keeps local directories and remote shared folders mirrored without user interaction."); + ui.label(" • 🖧 Windows Explorer Network Drive (WebDAV): 1-click mounting of all shared folders directly into Windows Explorer as drive Z:\\."); + ui.label(" • 🎵 In-App Audio Player Dock & 🎬 Watch Party: Sticky mini-player dock and interactive video watch rooms with real-time playback sync."); + ui.label(" • ✏️ Full Vim Editor with Write-Back: Normal + Insert Mode, buffer editing, Markdown preview, and :w saving back across the network."); + ui.label(" • 🔎 Remote Full-Text Grep: Search code and text files across friends' machines with instant line navigation."); + ui.label(" • ⬇️ Multi-Select Batch Downloads & Drag-and-Drop: Multi-file checkboxes and direct drag & drop upload into remote shares."); + 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(34, 197, 94))); - ui.label(RichText::new("(Current Release)").size(11.0).color(Color32::GRAY)); + ui.label(RichText::new("v2.4.1").strong().color(Color32::from_rgb(56, 189, 248))); }); ui.label(" • 📁 Shared Folders Tab: Dedicated Shared Folders management with Read-Only / Read-Write permissions and PIN security."); ui.label(" • 🎬 HTTP 206 Streaming: Instant real-time video/audio streaming with seeking without downloading entire files."); @@ -3466,6 +4028,140 @@ impl ZeroSendApp { }); } + // 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("🔎 Search Text / Code in Remote Share") + .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("Query:").strong().color(theme.text_primary)); + let edit = ui.add( + egui::TextEdit::singleline(&mut self.grep_query) + .hint_text("e.g. fn main, TODO, password...") + .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 { "⏳ Searching..." } else { "🔍 Search" }) + .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("Searching across remote files...".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 found {} match(es) in {} searched files", resp.matches.len(), resp.total_files_searched)); + } + Err(e) => { + let _ = log_tx.send(format!("❌ Grep failed: {}", 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("Searching remote code & text files..."); + }); + } else if self.grep_results.is_empty() { + ui.label(RichText::new("Enter a search term above and press Search to find text or code lines.").size(12.0).color(theme.text_muted)); + } else { + ui.label(RichText::new(format!("Found {} matches:", 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("Open in 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); } @@ -3517,19 +4213,45 @@ impl eframe::App for ZeroSendApp { let dropped_files = ctx.input(|i| i.raw.dropped_files.clone()); if !dropped_files.is_empty() { - 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 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)); + 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)); + } } } diff --git a/src/ui/vim_viewer.rs b/src/ui/vim_viewer.rs index 1b04eb6..62639f2 100644 --- a/src/ui/vim_viewer.rs +++ b/src/ui/vim_viewer.rs @@ -9,13 +9,22 @@ pub struct VimViewerModal { pub file_name: String, pub relative_path: String, pub source_peer: String, + pub peer_url: Option, + pub folder_id: Option, + pub pin: Option, + pub is_writable: bool, pub lines: Vec, + pub buffer_content: String, + pub is_insert_mode: bool, + pub is_markdown_view: bool, pub cursor_line: usize, // 1-indexed pub search_query: String, pub is_searching: bool, pub command_input: String, pub is_command_mode: bool, pub status_msg: Option, + pub pending_save_request: Option<(String, String, String, String, Option)>, // (peer_url, folder_id, rel_path, content, pin) + pub is_dirty: bool, last_g_press: Option, } @@ -26,40 +35,112 @@ impl Default for VimViewerModal { file_name: String::new(), relative_path: String::new(), source_peer: String::new(), + peer_url: None, + folder_id: None, + pin: None, + is_writable: false, lines: Vec::new(), + buffer_content: String::new(), + is_insert_mode: false, + is_markdown_view: false, cursor_line: 1, search_query: String::new(), is_searching: false, command_input: String::new(), is_command_mode: false, status_msg: None, + pending_save_request: None, + is_dirty: false, last_g_press: None, } } } impl VimViewerModal { - pub fn open(&mut self, file_name: String, relative_path: String, source_peer: String, content: String) { + pub fn open( + &mut self, + file_name: String, + relative_path: String, + source_peer: String, + content: String, + ) { + self.open_with_meta(file_name, relative_path, source_peer, content, None, None, None, false); + } + + pub fn open_with_meta( + &mut self, + file_name: String, + relative_path: String, + source_peer: String, + content: String, + peer_url: Option, + folder_id: Option, + pin: Option, + is_writable: bool, + ) { self.is_open = true; self.file_name = file_name; self.relative_path = relative_path; self.source_peer = source_peer; + self.peer_url = peer_url; + self.folder_id = folder_id; + self.pin = pin; + self.is_writable = is_writable; + self.buffer_content = content.clone(); self.lines = content.lines().map(|s| s.to_string()).collect(); if self.lines.is_empty() { self.lines.push(String::new()); } self.cursor_line = 1; + self.is_insert_mode = false; + self.is_markdown_view = self.file_name.to_lowercase().ends_with(".md"); self.search_query.clear(); self.is_searching = false; self.command_input.clear(); self.is_command_mode = false; - self.status_msg = Some(format!("Loaded {} lines", self.lines.len())); + self.is_dirty = false; + self.status_msg = Some(format!( + "Loaded {} lines • {}", + self.lines.len(), + if self.is_writable { "Writable [i: edit, :w: save]" } else { "Read-Only" } + )); self.last_g_press = None; } pub fn close(&mut self) { self.is_open = false; self.lines.clear(); + self.buffer_content.clear(); + self.is_insert_mode = false; + self.is_dirty = false; + } + + pub fn trigger_save(&mut self) { + if !self.is_writable { + self.status_msg = Some("❌ Cannot save: folder is Read-Only".to_string()); + return; + } + + if let (Some(url), Some(fid)) = (&self.peer_url, &self.folder_id) { + self.pending_save_request = Some(( + url.clone(), + fid.clone(), + self.relative_path.clone(), + self.buffer_content.clone(), + self.pin.clone(), + )); + self.status_msg = Some("⏳ Saving changes to remote peer...".to_string()); + self.is_dirty = false; + } else { + self.status_msg = Some("❌ No remote endpoint associated with this file".to_string()); + } + } + + pub fn sync_buffer_to_lines(&mut self) { + self.lines = self.buffer_content.lines().map(|s| s.to_string()).collect(); + if self.lines.is_empty() { + self.lines.push(String::new()); + } } pub fn handle_keys(&mut self, ctx: &egui::Context) { @@ -69,9 +150,13 @@ impl VimViewerModal { let input = ctx.input(|i| i.clone()); - // Escape always cancels search or command mode, or exits if already normal + // Escape always leaves Insert, Search, or Command mode if input.key_pressed(Key::Escape) { - if self.is_searching { + if self.is_insert_mode { + self.is_insert_mode = false; + self.sync_buffer_to_lines(); + self.status_msg = Some("-- NORMAL --".to_string()); + } else if self.is_searching { self.is_searching = false; } else if self.is_command_mode { self.is_command_mode = false; @@ -82,6 +167,11 @@ impl VimViewerModal { return; } + if self.is_insert_mode { + // While in insert mode, typing is handled directly by egui::TextEdit + return; + } + if self.is_searching { if input.key_pressed(Key::Enter) { self.is_searching = false; @@ -103,6 +193,17 @@ impl VimViewerModal { // NORMAL Mode Keybindings let total = self.lines.len().max(1); + // i or a (enter Insert mode if writable) + if (input.key_pressed(Key::I) || input.key_pressed(Key::A)) && !input.modifiers.ctrl && !input.modifiers.alt { + if self.is_writable { + self.is_insert_mode = true; + self.status_msg = Some("-- INSERT -- (type code, Esc to finish)".to_string()); + return; + } else { + self.status_msg = Some("Cannot enter Insert mode: Read-Only share".to_string()); + } + } + // j / Down (move down) if input.key_pressed(Key::J) || input.key_pressed(Key::ArrowDown) { self.cursor_line = (self.cursor_line + 1).min(total); @@ -158,14 +259,26 @@ impl VimViewerModal { } fn execute_command(&mut self, cmd: &str) { - if cmd == "q" || cmd == "q!" || cmd == "quit" { - self.close(); - } else if let Ok(line_num) = cmd.parse::() { - let total = self.lines.len().max(1); - self.cursor_line = line_num.clamp(1, total); - self.status_msg = Some(format!("Jumped to line {}", self.cursor_line)); - } else { - self.status_msg = Some(format!("Unknown command: :{}", cmd)); + match cmd { + "w" | "write" => { + self.trigger_save(); + } + "wq" | "x" => { + self.trigger_save(); + self.close(); + } + "q" | "q!" | "quit" => { + self.close(); + } + _ => { + if let Ok(line_num) = cmd.parse::() { + let total = self.lines.len().max(1); + self.cursor_line = line_num.clamp(1, total); + self.status_msg = Some(format!("Jumped to line {}", self.cursor_line)); + } else { + self.status_msg = Some(format!("Unknown command: :{}", cmd)); + } + } } } @@ -207,54 +320,151 @@ impl VimViewerModal { let mut is_open = self.is_open; let screen_rect = ctx.screen_rect(); - let win_size = Vec2::new((screen_rect.width() * 0.92).min(1100.0), (screen_rect.height() * 0.88).min(750.0)); + let win_size = Vec2::new( + (screen_rect.width() * 0.94).min(1150.0), + (screen_rect.height() * 0.90).min(800.0), + ); - egui::Window::new(format!("⚡ Vim Viewer • {} ({})", self.file_name, self.source_peer)) - .open(&mut is_open) - .collapsible(false) - .resizable(true) - .default_size(win_size) - .anchor(egui::Align2::CENTER_CENTER, Vec2::ZERO) - .frame( - Frame::window(&ctx.style()) - .fill(Color32::from_rgb(15, 18, 28)) - .stroke(Stroke::new(1.0_f32, Color32::from_rgb(51, 65, 85))) - .rounding(Rounding::same(8.0)) - .inner_margin(Margin::same(0.0)), - ) - .show(ctx, |ui| { - // 1. Top Header Bar - Frame::none() - .fill(Color32::from_rgb(22, 27, 44)) - .inner_margin(Margin::symmetric(14.0, 10.0)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(RichText::new("📄").size(15.0)); - ui.label( - RichText::new(&self.relative_path) - .strong() - .color(Color32::from_rgb(248, 250, 252)) - .size(13.0), - ); + egui::Window::new(format!( + "⚡ Vim Editor • {} ({})", + self.file_name, self.source_peer + )) + .open(&mut is_open) + .collapsible(false) + .resizable(true) + .default_size(win_size) + .anchor(egui::Align2::CENTER_CENTER, Vec2::ZERO) + .frame( + Frame::window(&ctx.style()) + .fill(Color32::from_rgb(13, 16, 24)) + .stroke(Stroke::new(1.0_f32, Color32::from_rgb(51, 65, 85))) + .rounding(Rounding::same(8.0)) + .inner_margin(Margin::same(0.0)), + ) + .show(ctx, |ui| { + // 1. Top Header Bar + Frame::none() + .fill(Color32::from_rgb(20, 25, 40)) + .inner_margin(Margin::symmetric(14.0, 10.0)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new("📄").size(15.0)); + ui.label( + RichText::new(&self.relative_path) + .strong() + .color(Color32::from_rgb(248, 250, 252)) + .size(13.0), + ); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button(RichText::new("✕ Close (:q)").color(Color32::from_rgb(203, 213, 225))).clicked() { - self.is_open = false; + if self.is_dirty { + ui.label(RichText::new("[+]").color(Color32::from_rgb(245, 158, 11)).strong()); + } + + if self.is_writable { + ui.label(RichText::new("• [Writable]").color(Color32::from_rgb(34, 197, 94)).size(11.0)); + } else { + ui.label(RichText::new("• [Read-Only]").color(Color32::from_rgb(148, 163, 184)).size(11.0)); + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .button(RichText::new("✕ Close (:q)").color(Color32::from_rgb(203, 213, 225))) + .clicked() + { + self.close(); + } + + if self.is_writable { + let save_btn = egui::Button::new( + RichText::new("💾 Save (:w)") + .strong() + .color(Color32::WHITE), + ) + .fill(Color32::from_rgb(37, 99, 235)) + .rounding(Rounding::same(4.0)); + + if ui.add(save_btn).clicked() { + self.trigger_save(); } - ui.label( - RichText::new(format!("{} lines", self.lines.len())) - .size(11.0) - .color(Color32::from_rgb(148, 163, 184)), - ); - }); + let edit_label = if self.is_insert_mode { "👁 View Mode (Esc)" } else { "✏️ Edit (i)" }; + if ui.button(RichText::new(edit_label).color(Color32::from_rgb(226, 232, 240))).clicked() { + self.is_insert_mode = !self.is_insert_mode; + if !self.is_insert_mode { + self.sync_buffer_to_lines(); + } + } + } + + if self.file_name.to_lowercase().ends_with(".md") { + if ui + .selectable_label(self.is_markdown_view, "👁 Markdown Preview") + .clicked() + { + self.is_markdown_view = !self.is_markdown_view; + } + } + + ui.label( + RichText::new(format!("{} lines", self.lines.len())) + .size(11.0) + .color(Color32::from_rgb(148, 163, 184)), + ); }); }); + }); - // 2. Code Area with Line Numbers Gutter + // 2. Editor / Viewer Body + let available_height = ui.available_height() - 34.0; + + if self.is_markdown_view { + // Markdown Preview View + ScrollArea::vertical() + .max_height(available_height) + .auto_shrink([false, false]) + .show(ui, |ui| { + Frame::none() + .inner_margin(Margin::same(20.0)) + .show(ui, |ui| { + for line in self.buffer_content.lines() { + if line.starts_with("# ") { + ui.heading(RichText::new(&line[2..]).color(Color32::from_rgb(56, 189, 248)).size(20.0)); + } else if line.starts_with("## ") { + ui.label(RichText::new(&line[3..]).strong().color(Color32::from_rgb(96, 165, 250)).size(16.0)); + } else if line.starts_with("### ") { + ui.label(RichText::new(&line[4..]).strong().color(Color32::from_rgb(147, 197, 253)).size(14.0)); + } else if line.starts_with("* ") || line.starts_with("- ") { + ui.label(RichText::new(format!(" • {}", &line[2..])).color(Color32::from_rgb(226, 232, 240))); + } else if line.starts_with("```") { + ui.label(RichText::new(line).monospace().color(Color32::from_rgb(148, 163, 184))); + } else if line.trim().is_empty() { + ui.add_space(6.0); + } else { + ui.label(RichText::new(line).color(Color32::from_rgb(226, 232, 240))); + } + } + }); + }); + } else if self.is_insert_mode { + // Live Insert Mode Text Editor + ScrollArea::vertical() + .max_height(available_height) + .auto_shrink([false, false]) + .show(ui, |ui| { + let edit = TextEdit::multiline(&mut self.buffer_content) + .font(egui::TextStyle::Monospace) + .desired_width(f32::INFINITY) + .lock_focus(true) + .frame(false); + + let resp = ui.add(edit); + if resp.changed() { + self.is_dirty = true; + } + }); + } else { + // Normal Vim Line-Numbered Viewer let gutter_width = 54.0; - let available_height = ui.available_height() - 32.0; - ScrollArea::vertical() .max_height(available_height) .auto_shrink([false, false]) @@ -266,7 +476,7 @@ impl VimViewerModal { for idx in 1..=self.lines.len() { let is_current = idx == self.cursor_line; let num_color = if is_current { - Color32::from_rgb(245, 158, 11) // Amber current line + Color32::from_rgb(245, 158, 11) } else { Color32::from_rgb(71, 85, 105) }; @@ -281,7 +491,6 @@ impl VimViewerModal { }); }); - // Vertical divider line ui.add(egui::Separator::default().vertical()); // Code column @@ -292,7 +501,6 @@ impl VimViewerModal { for (idx, line) in self.lines.iter().enumerate() { let line_num = idx + 1; let is_current = line_num == self.cursor_line; - let line_display = if line.is_empty() { " " } else { line.as_str() }; let line_text = if is_current { @@ -320,70 +528,73 @@ impl VimViewerModal { }); }); }); + } - // 3. Bottom Vim Status Bar - Frame::none() - .fill(Color32::from_rgb(11, 15, 25)) - .stroke(Stroke::new(1.0_f32, Color32::from_rgb(30, 41, 59))) - .inner_margin(Margin::symmetric(10.0, 5.0)) - .show(ui, |ui| { - ui.horizontal(|ui| { - // Mode Pill - let (mode_text, mode_bg, mode_fg) = if self.is_searching { - ("SEARCH", Color32::from_rgb(217, 119, 6), Color32::WHITE) - } else if self.is_command_mode { - ("COMMAND", Color32::from_rgb(147, 51, 234), Color32::WHITE) - } else { - ("NORMAL", Color32::from_rgb(37, 99, 235), Color32::WHITE) - }; + // 3. Bottom Vim Status Bar + Frame::none() + .fill(Color32::from_rgb(10, 13, 22)) + .stroke(Stroke::new(1.0_f32, Color32::from_rgb(30, 41, 59))) + .inner_margin(Margin::symmetric(10.0, 5.0)) + .show(ui, |ui| { + ui.horizontal(|ui| { + // Mode Pill + let (mode_text, mode_bg, mode_fg) = if self.is_insert_mode { + ("INSERT", Color32::from_rgb(34, 197, 94), Color32::BLACK) + } else if self.is_searching { + ("SEARCH", Color32::from_rgb(217, 119, 6), Color32::WHITE) + } else if self.is_command_mode { + ("COMMAND", Color32::from_rgb(147, 51, 234), Color32::WHITE) + } else { + ("NORMAL", Color32::from_rgb(37, 99, 235), Color32::WHITE) + }; - Frame::none() - .fill(mode_bg) - .rounding(Rounding::same(3.0)) - .inner_margin(Margin::symmetric(6.0, 2.0)) - .show(ui, |ui| { - ui.label(RichText::new(mode_text).size(10.5).strong().color(mode_fg)); - }); - - if self.is_searching { - ui.label(RichText::new("/").monospace().color(Color32::from_rgb(245, 158, 11))); - let edit = TextEdit::singleline(&mut self.search_query) - .hint_text("pattern (press Enter)...") - .font(egui::TextStyle::Monospace) - .desired_width(200.0); - ui.add(edit).request_focus(); - } else if self.is_command_mode { - ui.label(RichText::new(":").monospace().color(Color32::from_rgb(168, 85, 247))); - let edit = TextEdit::singleline(&mut self.command_input) - .hint_text("q / line number...") - .font(egui::TextStyle::Monospace) - .desired_width(200.0); - ui.add(edit).request_focus(); - } else { - if let Some(msg) = &self.status_msg { - ui.label(RichText::new(msg).size(11.0).color(Color32::from_rgb(203, 213, 225))); - } - } - - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.label( - RichText::new("j/k: nav • gg/G: top/bot • /: find • :q: exit") - .size(10.0) - .color(Color32::from_rgb(100, 116, 139)), - ); - - ui.separator(); - - ui.label( - RichText::new(format!("Ln {}, Col 1", self.cursor_line)) - .size(11.0) - .monospace() - .color(Color32::from_rgb(148, 163, 184)), - ); + Frame::none() + .fill(mode_bg) + .rounding(Rounding::same(3.0)) + .inner_margin(Margin::symmetric(6.0, 2.0)) + .show(ui, |ui| { + ui.label(RichText::new(mode_text).size(10.5).strong().color(mode_fg)); }); + + if self.is_searching { + ui.label(RichText::new("/").monospace().color(Color32::from_rgb(245, 158, 11))); + let edit = TextEdit::singleline(&mut self.search_query) + .hint_text("pattern (press Enter)...") + .font(egui::TextStyle::Monospace) + .desired_width(200.0); + ui.add(edit).request_focus(); + } else if self.is_command_mode { + ui.label(RichText::new(":").monospace().color(Color32::from_rgb(168, 85, 247))); + let edit = TextEdit::singleline(&mut self.command_input) + .hint_text("w (save) / q (quit) / line...") + .font(egui::TextStyle::Monospace) + .desired_width(200.0); + ui.add(edit).request_focus(); + } else { + if let Some(msg) = &self.status_msg { + ui.label(RichText::new(msg).size(11.0).color(Color32::from_rgb(203, 213, 225))); + } + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label( + RichText::new("i: edit • Esc: normal • :w: save • :q: exit") + .size(10.0) + .color(Color32::from_rgb(100, 116, 139)), + ); + + ui.separator(); + + ui.label( + RichText::new(format!("Ln {}, Col 1", self.cursor_line)) + .size(11.0) + .monospace() + .color(Color32::from_rgb(148, 163, 184)), + ); }); }); - }); + }); + }); self.is_open = is_open; }