feat: ZeroSend v2.5.0 - Live Folder Sync, WebDAV Drive (Z:), Watch Party, Full Vim Editor & Remote Grep
This commit is contained in:
Generated
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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"
|
||||
|
||||
@@ -59,6 +59,14 @@ pub struct AppConfig {
|
||||
pub check_updates_on_startup: bool,
|
||||
#[serde(default)]
|
||||
pub gitea_api_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub folder_sync_pairs: Vec<crate::transfer::FolderSyncPairConfig>,
|
||||
#[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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-1
@@ -272,6 +272,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
);
|
||||
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<dyn std::error::Error + Send + Sync>> {
|
||||
.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()
|
||||
};
|
||||
|
||||
|
||||
+125
-2
@@ -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<SharedFileSaveResponse, String> {
|
||||
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::<SharedFileSaveResponse>()
|
||||
.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<GrepResponse, String> {
|
||||
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::<GrepResponse>()
|
||||
.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<Option<WatchPartySyncEvent>, 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::<Option<WatchPartySyncEvent>>()
|
||||
.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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<TransferClient>,
|
||||
pairs: Arc<RwLock<Vec<FolderSyncPairConfig>>>,
|
||||
status_tx: mpsc::UnboundedSender<SyncStatusUpdate>,
|
||||
}
|
||||
|
||||
impl FolderSyncEngine {
|
||||
pub fn new(
|
||||
client: Arc<TransferClient>,
|
||||
pairs: Vec<FolderSyncPairConfig>,
|
||||
status_tx: mpsc::UnboundedSender<SyncStatusUpdate>,
|
||||
) -> Self {
|
||||
Self {
|
||||
client,
|
||||
pairs: Arc::new(RwLock::new(pairs)),
|
||||
status_tx,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pairs_handle(&self) -> Arc<RwLock<Vec<FolderSyncPairConfig>>> {
|
||||
self.pairs.clone()
|
||||
}
|
||||
|
||||
pub async fn start_background_sync(
|
||||
self: Arc<Self>,
|
||||
peers_map: Arc<RwLock<HashMap<String, crate::network::PeerInfo>>>,
|
||||
) {
|
||||
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<SyncStatusUpdate>,
|
||||
) -> 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<String, (u64, u64)> = 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<String, u64> = 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<Vec<(String, u64, u64)>, 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)
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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<GrepMatch>,
|
||||
pub total_files_searched: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SharedFileSaveRequest {
|
||||
pub content: String,
|
||||
#[serde(default)]
|
||||
pub pin: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
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") {
|
||||
|
||||
+223
-3
@@ -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<RwLock<String>>,
|
||||
pub auto_extract_zip: Arc<RwLock<bool>>,
|
||||
pub shared_folders: Arc<RwLock<Vec<SharedFolderConfig>>>,
|
||||
pub watch_party_state: Arc<RwLock<Option<WatchPartySyncEvent>>>,
|
||||
pub pending_requests: Arc<RwLock<HashMap<String, PrepareTransferRequest>>>,
|
||||
pub accepted_requests: Arc<RwLock<HashMap<String, PrepareTransferRequest>>>,
|
||||
pub pending_confirmations: Arc<Mutex<HashMap<String, oneshot::Sender<bool>>>>,
|
||||
@@ -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<Arc<ServerState>>,
|
||||
Path((folder_id, subpath)): Path<(String, String)>,
|
||||
Json(payload): Json<SharedFileSaveRequest>,
|
||||
) -> 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<String>,
|
||||
subpath: Option<String>,
|
||||
case_sensitive: Option<bool>,
|
||||
}
|
||||
|
||||
async fn handle_shared_grep(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
Path(folder_id): Path<String>,
|
||||
Query(params): Query<GrepQuery>,
|
||||
) -> 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<Arc<ServerState>>) -> Response {
|
||||
let guard = state.watch_party_state.read().await;
|
||||
Json((*guard).clone()).into_response()
|
||||
}
|
||||
|
||||
async fn handle_watch_party_post(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
Json(event): Json<WatchPartySyncEvent>,
|
||||
) -> 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<Arc<ServerState>>) -> Html<String> {
|
||||
let html = format!(
|
||||
r###"<!DOCTYPE html>
|
||||
|
||||
@@ -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<RwLock<Vec<SharedFolderConfig>>>,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
pub fn create_webdav_router(state: Arc<WebDavState>) -> 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<RwLock<Vec<SharedFolderConfig>>>,
|
||||
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<Arc<WebDavState>>,
|
||||
req: Request<Body>,
|
||||
) -> Response<Body> {
|
||||
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("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<D:multistatus xmlns:D=\"DAV:\">\n");
|
||||
|
||||
// Root response
|
||||
xml.push_str(" <D:response>\n");
|
||||
xml.push_str(" <D:href>/webdav/</D:href>\n");
|
||||
xml.push_str(" <D:propstat>\n");
|
||||
xml.push_str(" <D:prop>\n");
|
||||
xml.push_str(" <D:displayname>ZeroSend WebDAV</D:displayname>\n");
|
||||
xml.push_str(" <D:resourcetype><D:collection/></D:resourcetype>\n");
|
||||
xml.push_str(" </D:prop>\n");
|
||||
xml.push_str(" <D:status>HTTP/1.1 200 OK</D:status>\n");
|
||||
xml.push_str(" </D:propstat>\n");
|
||||
xml.push_str(" </D:response>\n");
|
||||
|
||||
if depth != "0" {
|
||||
for s in shares.iter() {
|
||||
let encoded_name = urlencoding::encode(&s.name);
|
||||
xml.push_str(" <D:response>\n");
|
||||
xml.push_str(&format!(" <D:href>/webdav/{}/</D:href>\n", encoded_name));
|
||||
xml.push_str(" <D:propstat>\n");
|
||||
xml.push_str(" <D:prop>\n");
|
||||
xml.push_str(&format!(" <D:displayname>{}</D:displayname>\n", escape_xml(&s.name)));
|
||||
xml.push_str(" <D:resourcetype><D:collection/></D:resourcetype>\n");
|
||||
xml.push_str(" </D:prop>\n");
|
||||
xml.push_str(" <D:status>HTTP/1.1 200 OK</D:status>\n");
|
||||
xml.push_str(" </D:propstat>\n");
|
||||
xml.push_str(" </D:response>\n");
|
||||
}
|
||||
}
|
||||
|
||||
xml.push_str("</D:multistatus>");
|
||||
|
||||
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<Arc<WebDavState>>,
|
||||
Path(raw_path): Path<String>,
|
||||
req: Request<Body>,
|
||||
) -> Response<Body> {
|
||||
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("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<D:multistatus xmlns:D=\"DAV:\">\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(" <D:response>\n");
|
||||
xml.push_str(&format!(" <D:href>{}</D:href>\n", href_path));
|
||||
xml.push_str(" <D:propstat>\n");
|
||||
xml.push_str(" <D:prop>\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!(" <D:displayname>{}</D:displayname>\n", escape_xml(&name)));
|
||||
if is_dir {
|
||||
xml.push_str(" <D:resourcetype><D:collection/></D:resourcetype>\n");
|
||||
} else {
|
||||
xml.push_str(" <D:resourcetype/>\n");
|
||||
xml.push_str(&format!(" <D:getcontentlength>{}</D:getcontentlength>\n", meta.len()));
|
||||
let mime = crate::transfer::get_mime_type(&name);
|
||||
xml.push_str(&format!(" <D:getcontenttype>{}</D:getcontenttype>\n", mime));
|
||||
}
|
||||
xml.push_str(" </D:prop>\n");
|
||||
xml.push_str(" <D:status>HTTP/1.1 200 OK</D:status>\n");
|
||||
xml.push_str(" </D:propstat>\n");
|
||||
xml.push_str(" </D:response>\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(" <D:response>\n");
|
||||
xml.push_str(&format!(" <D:href>{}</D:href>\n", child_href));
|
||||
xml.push_str(" <D:propstat>\n");
|
||||
xml.push_str(" <D:prop>\n");
|
||||
xml.push_str(&format!(" <D:displayname>{}</D:displayname>\n", escape_xml(&child_name)));
|
||||
if child_is_dir {
|
||||
xml.push_str(" <D:resourcetype><D:collection/></D:resourcetype>\n");
|
||||
} else {
|
||||
xml.push_str(" <D:resourcetype/>\n");
|
||||
xml.push_str(&format!(" <D:getcontentlength>{}</D:getcontentlength>\n", child_meta.len()));
|
||||
let mime = crate::transfer::get_mime_type(&child_name);
|
||||
xml.push_str(&format!(" <D:getcontenttype>{}</D:getcontenttype>\n", mime));
|
||||
}
|
||||
xml.push_str(" </D:prop>\n");
|
||||
xml.push_str(" <D:status>HTTP/1.1 200 OK</D:status>\n");
|
||||
xml.push_str(" </D:propstat>\n");
|
||||
xml.push_str(" </D:response>\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::from("Path not found"))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
xml.push_str("</D:multistatus>");
|
||||
|
||||
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("<h1>Directory Index</h1>"))
|
||||
.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<Body> {
|
||||
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<std::borrow::Cow<'_, str>, ()> {
|
||||
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<String, String> {
|
||||
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()))
|
||||
}
|
||||
}
|
||||
+747
-25
@@ -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<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>,
|
||||
@@ -216,8 +252,10 @@ pub struct ZeroSendApp {
|
||||
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)>,
|
||||
share_text_tx: mpsc::UnboundedSender<(String, String, String, String)>,
|
||||
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>,
|
||||
@@ -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<PeerInfo> = 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<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| {
|
||||
@@ -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() {
|
||||
// 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,6 +4213,31 @@ impl eframe::App for ZeroSendApp {
|
||||
|
||||
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 {
|
||||
@@ -3532,6 +4253,7 @@ impl eframe::App for ZeroSendApp {
|
||||
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() {
|
||||
|
||||
+233
-22
@@ -9,13 +9,22 @@ pub struct VimViewerModal {
|
||||
pub file_name: String,
|
||||
pub relative_path: String,
|
||||
pub source_peer: String,
|
||||
pub peer_url: Option<String>,
|
||||
pub folder_id: Option<String>,
|
||||
pub pin: Option<String>,
|
||||
pub is_writable: bool,
|
||||
pub lines: Vec<String>,
|
||||
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<String>,
|
||||
pub pending_save_request: Option<(String, String, String, String, Option<String>)>, // (peer_url, folder_id, rel_path, content, pin)
|
||||
pub is_dirty: bool,
|
||||
last_g_press: Option<Instant>,
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
folder_id: Option<String>,
|
||||
pin: Option<String>,
|
||||
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,9 +259,19 @@ impl VimViewerModal {
|
||||
}
|
||||
|
||||
fn execute_command(&mut self, cmd: &str) {
|
||||
if cmd == "q" || cmd == "q!" || cmd == "quit" {
|
||||
match cmd {
|
||||
"w" | "write" => {
|
||||
self.trigger_save();
|
||||
}
|
||||
"wq" | "x" => {
|
||||
self.trigger_save();
|
||||
self.close();
|
||||
} else if let Ok(line_num) = cmd.parse::<usize>() {
|
||||
}
|
||||
"q" | "q!" | "quit" => {
|
||||
self.close();
|
||||
}
|
||||
_ => {
|
||||
if let Ok(line_num) = cmd.parse::<usize>() {
|
||||
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));
|
||||
@@ -168,6 +279,8 @@ impl VimViewerModal {
|
||||
self.status_msg = Some(format!("Unknown command: :{}", cmd));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn find_next_match(&mut self, forward: bool) {
|
||||
if self.search_query.trim().is_empty() {
|
||||
@@ -207,9 +320,15 @@ 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))
|
||||
egui::Window::new(format!(
|
||||
"⚡ Vim Editor • {} ({})",
|
||||
self.file_name, self.source_peer
|
||||
))
|
||||
.open(&mut is_open)
|
||||
.collapsible(false)
|
||||
.resizable(true)
|
||||
@@ -217,7 +336,7 @@ impl VimViewerModal {
|
||||
.anchor(egui::Align2::CENTER_CENTER, Vec2::ZERO)
|
||||
.frame(
|
||||
Frame::window(&ctx.style())
|
||||
.fill(Color32::from_rgb(15, 18, 28))
|
||||
.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)),
|
||||
@@ -225,7 +344,7 @@ impl VimViewerModal {
|
||||
.show(ctx, |ui| {
|
||||
// 1. Top Header Bar
|
||||
Frame::none()
|
||||
.fill(Color32::from_rgb(22, 27, 44))
|
||||
.fill(Color32::from_rgb(20, 25, 40))
|
||||
.inner_margin(Margin::symmetric(14.0, 10.0))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
@@ -237,9 +356,53 @@ impl VimViewerModal {
|
||||
.size(13.0),
|
||||
);
|
||||
|
||||
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.is_open = false;
|
||||
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();
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -251,10 +414,57 @@ impl VimViewerModal {
|
||||
});
|
||||
});
|
||||
|
||||
// 2. Code Area with Line Numbers Gutter
|
||||
let gutter_width = 54.0;
|
||||
let available_height = ui.available_height() - 32.0;
|
||||
// 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;
|
||||
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,16 +528,19 @@ impl VimViewerModal {
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Bottom Vim Status Bar
|
||||
Frame::none()
|
||||
.fill(Color32::from_rgb(11, 15, 25))
|
||||
.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_searching {
|
||||
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)
|
||||
@@ -355,7 +566,7 @@ impl VimViewerModal {
|
||||
} 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...")
|
||||
.hint_text("w (save) / q (quit) / line...")
|
||||
.font(egui::TextStyle::Monospace)
|
||||
.desired_width(200.0);
|
||||
ui.add(edit).request_focus();
|
||||
@@ -367,7 +578,7 @@ impl VimViewerModal {
|
||||
|
||||
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")
|
||||
RichText::new("i: edit • Esc: normal • :w: save • :q: exit")
|
||||
.size(10.0)
|
||||
.color(Color32::from_rgb(100, 116, 139)),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user