From 92ad7c3000e89e116489abb555b5956e31e31560 Mon Sep 17 00:00:00 2001 From: Leo Date: Tue, 3 Feb 2026 15:12:29 +0800 Subject: [PATCH] project_panel: Add right-click download option for folders/files stored in remote development server (#47344) Closes #24431 , closes #42501 https://github.com/user-attachments/assets/13ace1d7-7699-4f2b-aa97-86235008adb3 Release Notes: - Added right click download option for folders/files stored in remote development server --- crates/collab/src/rpc.rs | 1 + crates/project/src/project.rs | 202 +++++++++++++++++++ crates/project_panel/src/project_panel.rs | 185 ++++++++++++++++- crates/proto/proto/download.proto | 36 ++++ crates/proto/proto/zed.proto | 7 +- crates/proto/src/proto.rs | 6 + crates/remote_server/src/headless_project.rs | 93 +++++++++ 7 files changed, 526 insertions(+), 4 deletions(-) create mode 100644 crates/proto/proto/download.proto diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 78206f128779c5e74bd7065ae5cd33c434d4ab6d..0cd46015266933d5050157a9c9731ec8458f0ff8 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -339,6 +339,7 @@ impl Server { .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) + .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 3d7b18987169689d74516a63dd96a29061156ac0..16b6799ae54e4e76bc76886bc036d6d3628c6846 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -241,6 +241,14 @@ pub struct Project { settings_observer: Entity, toolchain_store: Option>, agent_location: Option, + downloading_files: Arc>>, +} + +struct DownloadingFile { + destination_path: PathBuf, + chunks: Vec, + total_size: u64, + file_id: Option, // Set when we receive the State message } #[derive(Clone, Debug, PartialEq, Eq)] @@ -1091,6 +1099,7 @@ impl Project { client.add_entity_message_handler(Self::handle_create_image_for_peer); client.add_entity_request_handler(Self::handle_find_search_candidates_chunk); client.add_entity_message_handler(Self::handle_find_search_candidates_cancel); + client.add_entity_message_handler(Self::handle_create_file_for_peer); WorktreeStore::init(&client); BufferStore::init(&client); @@ -1299,6 +1308,7 @@ impl Project { toolchain_store: Some(toolchain_store), agent_location: None, + downloading_files: Default::default(), } }) } @@ -1529,6 +1539,7 @@ impl Project { toolchain_store: Some(toolchain_store), agent_location: None, + downloading_files: Default::default(), }; // remote server -> local machine handlers @@ -1544,6 +1555,7 @@ impl Project { remote_proto.add_entity_message_handler(Self::handle_create_buffer_for_peer); remote_proto.add_entity_message_handler(Self::handle_create_image_for_peer); + remote_proto.add_entity_message_handler(Self::handle_create_file_for_peer); remote_proto.add_entity_message_handler(Self::handle_update_worktree); remote_proto.add_entity_message_handler(Self::handle_update_project); remote_proto.add_entity_message_handler(Self::handle_toast); @@ -1803,6 +1815,7 @@ impl Project { remotely_created_models: Arc::new(Mutex::new(RemotelyCreatedModels::default())), toolchain_store: None, agent_location: None, + downloading_files: Default::default(), }; project.set_role(role, cx); for worktree in worktrees { @@ -2881,6 +2894,73 @@ impl Project { } } + pub fn download_file( + &mut self, + worktree_id: WorktreeId, + path: Arc, + destination_path: PathBuf, + cx: &mut Context, + ) -> Task> { + log::debug!( + "download_file called: worktree_id={:?}, path={:?}, destination={:?}", + worktree_id, + path, + destination_path + ); + + let Some(remote_client) = &self.remote_client else { + log::error!("download_file: not a remote project"); + return Task::ready(Err(anyhow!("not a remote project"))); + }; + + let proto_client = remote_client.read(cx).proto_client(); + // For SSH remote projects, use REMOTE_SERVER_PROJECT_ID instead of remote_id() + // because SSH projects have client_state: Local but still need to communicate with remote server + let project_id = self.remote_id().unwrap_or(REMOTE_SERVER_PROJECT_ID); + let downloading_files = self.downloading_files.clone(); + let path_str = path.to_proto(); + + static NEXT_FILE_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + let file_id = NEXT_FILE_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + + // Register BEFORE sending request to avoid race condition + let key = (worktree_id, path_str.clone()); + log::debug!( + "download_file: pre-registering download with key={:?}, file_id={}", + key, + file_id + ); + downloading_files.lock().insert( + key, + DownloadingFile { + destination_path: destination_path, + chunks: Vec::new(), + total_size: 0, + file_id: Some(file_id), + }, + ); + log::debug!( + "download_file: sending DownloadFileByPath request, path_str={}", + path_str + ); + + cx.spawn(async move |_this, _cx| { + log::debug!("download_file: sending request with file_id={}...", file_id); + let response = proto_client + .request(proto::DownloadFileByPath { + project_id, + worktree_id: worktree_id.to_proto(), + path: path_str.clone(), + file_id, + }) + .await?; + + log::debug!("download_file: got response, file_id={}", response.file_id); + // The file_id is set from the State message, we just confirm the request succeeded + Ok(()) + }) + } + #[ztracing::instrument(skip_all)] pub fn open_buffer( &mut self, @@ -5350,6 +5430,128 @@ impl Project { }) } + async fn handle_create_file_for_peer( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result<()> { + use proto::create_file_for_peer::Variant; + log::debug!("handle_create_file_for_peer: received message"); + + let downloading_files: Arc>> = + this.update(&mut cx, |this, _| this.downloading_files.clone()); + + match &envelope.payload.variant { + Some(Variant::State(state)) => { + log::debug!( + "handle_create_file_for_peer: got State: id={}, content_size={}", + state.id, + state.content_size + ); + + // Extract worktree_id and path from the File field + if let Some(ref file) = state.file { + let worktree_id = WorktreeId::from_proto(file.worktree_id); + let path = file.path.clone(); + let key = (worktree_id, path); + log::debug!("handle_create_file_for_peer: looking up key={:?}", key); + + let mut files = downloading_files.lock(); + log::trace!( + "handle_create_file_for_peer: current downloading_files keys: {:?}", + files.keys().collect::>() + ); + + if let Some(file_entry) = files.get_mut(&key) { + file_entry.total_size = state.content_size; + file_entry.file_id = Some(state.id); + log::debug!( + "handle_create_file_for_peer: updated file entry: total_size={}, file_id={}", + state.content_size, + state.id + ); + } else { + log::warn!( + "handle_create_file_for_peer: key={:?} not found in downloading_files", + key + ); + } + } else { + log::warn!("handle_create_file_for_peer: State has no file field"); + } + } + Some(Variant::Chunk(chunk)) => { + log::debug!( + "handle_create_file_for_peer: got Chunk: file_id={}, data_len={}", + chunk.file_id, + chunk.data.len() + ); + + // Extract data while holding the lock, then release it before await + let (key_to_remove, write_info): ( + Option<(WorktreeId, String)>, + Option<(PathBuf, Vec)>, + ) = { + let mut files = downloading_files.lock(); + let mut found_key: Option<(WorktreeId, String)> = None; + let mut write_data: Option<(PathBuf, Vec)> = None; + + for (key, file_entry) in files.iter_mut() { + if file_entry.file_id == Some(chunk.file_id) { + file_entry.chunks.extend_from_slice(&chunk.data); + log::debug!( + "handle_create_file_for_peer: accumulated {} bytes, total_size={}", + file_entry.chunks.len(), + file_entry.total_size + ); + + if file_entry.chunks.len() as u64 >= file_entry.total_size + && file_entry.total_size > 0 + { + let destination = file_entry.destination_path.clone(); + let content = std::mem::take(&mut file_entry.chunks); + found_key = Some(key.clone()); + write_data = Some((destination, content)); + } + break; + } + } + (found_key, write_data) + }; // MutexGuard is dropped here + + // Perform the async write outside the lock + if let Some((destination, content)) = write_info { + log::debug!( + "handle_create_file_for_peer: writing {} bytes to {:?}", + content.len(), + destination + ); + match smol::fs::write(&destination, &content).await { + Ok(_) => log::info!( + "handle_create_file_for_peer: successfully wrote file to {:?}", + destination + ), + Err(e) => log::error!( + "handle_create_file_for_peer: failed to write file: {:?}", + e + ), + } + } + + // Remove the completed entry + if let Some(key) = key_to_remove { + downloading_files.lock().remove(&key); + log::debug!("handle_create_file_for_peer: removed completed download entry"); + } + } + None => { + log::warn!("handle_create_file_for_peer: got None variant"); + } + } + + Ok(()) + } + fn synchronize_remote_buffers(&mut self, cx: &mut Context) -> Task> { let project_id = match self.client_state { ProjectClientState::Remote { diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index d1374ad94b0f85da6d99ec87d9e3a93ab6a36932..3ce53b8418df4d7d94be494d37a7c03898d0cf27 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -23,9 +23,9 @@ use gpui::{ DismissEvent, Div, DragMoveEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, FontWeight, Hsla, InteractiveElement, KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, - ParentElement, Pixels, Point, PromptLevel, Render, ScrollStrategy, Stateful, Styled, - Subscription, Task, UniformListScrollHandle, WeakEntity, Window, actions, anchored, deferred, - div, hsla, linear_color_stop, linear_gradient, point, px, size, transparent_white, + ParentElement, PathPromptOptions, Pixels, Point, PromptLevel, Render, ScrollStrategy, Stateful, + Styled, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, actions, anchored, + deferred, div, hsla, linear_color_stop, linear_gradient, point, px, size, transparent_white, uniform_list, }; use language::DiagnosticSeverity; @@ -304,6 +304,8 @@ actions!( Cut, /// Pastes the previously cut or copied item. Paste, + /// Downloads the selected remote file + DownloadFromRemote, /// Renames the selected file or directory. Rename, /// Opens the selected file in the editor. @@ -1160,6 +1162,10 @@ impl ProjectPanel { "Paste", Box::new(Paste), ) + .when(is_remote, |menu| { + menu.separator() + .action("Download...", Box::new(DownloadFromRemote)) + }) .separator() .action("Copy Path", Box::new(zed_actions::workspace::CopyPath)) .action( @@ -3018,6 +3024,178 @@ impl ProjectPanel { }); } + fn download_from_remote( + &mut self, + _: &DownloadFromRemote, + window: &mut Window, + cx: &mut Context, + ) { + let entries = self.effective_entries(); + if entries.is_empty() { + return; + } + + let project = self.project.read(cx); + + // Collect file entries with their worktree_id, path, and relative path for destination + // For directories, we collect all files under them recursively + let mut files_to_download: Vec<(WorktreeId, Arc, PathBuf)> = Vec::new(); + + for selected in entries.iter() { + let Some(worktree) = project.worktree_for_id(selected.worktree_id, cx) else { + continue; + }; + let worktree = worktree.read(cx); + let Some(entry) = worktree.entry_for_id(selected.entry_id) else { + continue; + }; + + if entry.is_file() { + // Single file: use just the filename + let filename = entry + .path + .file_name() + .map(str::to_string) + .unwrap_or_default(); + files_to_download.push(( + selected.worktree_id, + entry.path.clone(), + PathBuf::from(filename), + )); + } else if entry.is_dir() { + // Directory: collect all files recursively, preserving relative paths + let dir_name = entry + .path + .file_name() + .map(str::to_string) + .unwrap_or_default(); + let base_path = entry.path.clone(); + + // Use traverse_from_path to iterate all entries under this directory + let mut traversal = worktree.traverse_from_path(true, true, true, &entry.path); + while let Some(child_entry) = traversal.entry() { + // Stop when we're no longer under the directory + if !child_entry.path.starts_with(&base_path) { + break; + } + + if child_entry.is_file() { + // Calculate relative path from the directory root + let relative_path = child_entry + .path + .strip_prefix(&base_path) + .map(|p| PathBuf::from(dir_name.clone()).join(p.as_unix_str())) + .unwrap_or_else(|_| { + PathBuf::from( + child_entry + .path + .file_name() + .map(str::to_string) + .unwrap_or_default(), + ) + }); + files_to_download.push(( + selected.worktree_id, + child_entry.path.clone(), + relative_path, + )); + } + traversal.advance(); + } + } + } + + if files_to_download.is_empty() { + return; + } + + let total_files = files_to_download.len(); + let workspace = self.workspace.clone(); + + let destination_dir = cx.prompt_for_paths(PathPromptOptions { + files: false, + directories: true, + multiple: false, + prompt: Some("Download".into()), + }); + + let fs = self.fs.clone(); + let notification_id = + workspace::notifications::NotificationId::Named("download-progress".into()); + cx.spawn_in(window, async move |this, cx| { + if let Ok(Ok(Some(mut paths))) = destination_dir.await { + if let Some(dest_dir) = paths.pop() { + // Show initial toast + workspace + .update(cx, |workspace, cx| { + workspace.show_toast( + workspace::Toast::new( + notification_id.clone(), + format!("Downloading 0/{} files...", total_files), + ), + cx, + ); + }) + .ok(); + + for (index, (worktree_id, entry_path, relative_path)) in + files_to_download.into_iter().enumerate() + { + // Update progress toast + workspace + .update(cx, |workspace, cx| { + workspace.show_toast( + workspace::Toast::new( + notification_id.clone(), + format!( + "Downloading {}/{} files...", + index + 1, + total_files + ), + ), + cx, + ); + }) + .ok(); + + let destination_path = dest_dir.join(&relative_path); + + // Create parent directories if needed + if let Some(parent) = destination_path.parent() { + if !parent.exists() { + fs.create_dir(parent).await.log_err(); + } + } + + let download_task = this.update(cx, |this, cx| { + let project = this.project.clone(); + project.update(cx, |project, cx| { + project.download_file(worktree_id, entry_path, destination_path, cx) + }) + }); + if let Ok(task) = download_task { + task.await.log_err(); + } + } + + // Show completion toast + workspace + .update(cx, |workspace, cx| { + workspace.show_toast( + workspace::Toast::new( + notification_id.clone(), + format!("Downloaded {} files", total_files), + ), + cx, + ); + }) + .ok(); + } + } + }) + .detach(); + } + fn duplicate(&mut self, _: &Duplicate, window: &mut Window, cx: &mut Context) { self.copy(&Copy {}, window, cx); self.paste(&Paste {}, window, cx); @@ -6073,6 +6251,7 @@ impl Render for ProjectPanel { }) .when(project.is_via_remote_server(), |el| { el.on_action(cx.listener(Self::open_in_terminal)) + .on_action(cx.listener(Self::download_from_remote)) }) .track_focus(&self.focus_handle(cx)) .child( diff --git a/crates/proto/proto/download.proto b/crates/proto/proto/download.proto new file mode 100644 index 0000000000000000000000000000000000000000..fd1d63e78db581866981cb90372f84716be8a958 --- /dev/null +++ b/crates/proto/proto/download.proto @@ -0,0 +1,36 @@ +syntax = "proto3"; +package zed.messages; + +import "core.proto"; +import "worktree.proto"; + +message DownloadFileByPath { + uint64 project_id = 1; + uint64 worktree_id = 2; + string path = 3; + uint64 file_id = 4; +} + +message DownloadFileResponse { + uint64 file_id = 1; +} + +message CreateFileForPeer { + uint64 project_id = 1; + PeerId peer_id = 2; + oneof variant { + FileState state = 3; + FileChunk chunk = 4; + } +} + +message FileState { + uint64 id = 1; + optional File file = 2; + uint64 content_size = 3; +} + +message FileChunk { + uint64 file_id = 1; + bytes data = 2; +} diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index 0765d0712d216b98caa4d2257fd2543bbb92390b..4ea1ad3cd245b651896cd9494efe045509e15560 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -8,6 +8,7 @@ import "call.proto"; import "channel.proto"; import "core.proto"; import "debugger.proto"; +import "download.proto"; import "git.proto"; import "image.proto"; import "lsp.proto"; @@ -458,7 +459,11 @@ message Envelope { ContextServerCommand context_server_command = 412; AllocateWorktreeId allocate_worktree_id = 413; - AllocateWorktreeIdResponse allocate_worktree_id_response = 414; // current max + AllocateWorktreeIdResponse allocate_worktree_id_response = 414; + + DownloadFileByPath download_file_by_path = 415; + DownloadFileResponse download_file_response = 416; + CreateFileForPeer create_file_for_peer = 417; // current max } reserved 87 to 88; diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index a3d515c97a4033424d6f405f1b1c3c5776b688e9..3fce8f5cc9efdf45a15a77ef25875f28cc3860be 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -55,6 +55,7 @@ messages!( (CopyProjectEntry, Foreground), (CreateBufferForPeer, Foreground), (CreateImageForPeer, Foreground), + (CreateFileForPeer, Foreground), (CreateChannel, Foreground), (CreateChannelResponse, Foreground), (CreateContext, Foreground), @@ -66,6 +67,8 @@ messages!( (DeleteChannel, Foreground), (DeleteNotification, Foreground), (DeleteProjectEntry, Foreground), + (DownloadFileByPath, Background), + (DownloadFileResponse, Background), (EndStream, Foreground), (Error, Foreground), (ExpandProjectEntry, Foreground), @@ -371,6 +374,7 @@ request_messages!( (DeclineCall, Ack), (DeleteChannel, Ack), (DeleteProjectEntry, ProjectEntryResponse), + (DownloadFileByPath, DownloadFileResponse), (ExpandProjectEntry, ExpandProjectEntryResponse), (ExpandAllForProjectEntry, ExpandAllForProjectEntryResponse), (Follow, FollowResponse), @@ -576,6 +580,7 @@ entity_messages!( GetColorPresentation, CopyProjectEntry, CreateBufferForPeer, + CreateFileForPeer, CreateImageForPeer, CreateProjectEntry, GetDocumentColor, @@ -722,6 +727,7 @@ entity_messages!( RestrictWorktrees, FindSearchCandidatesChunk, FindSearchCandidatesCancelled, + DownloadFileByPath ); entity_messages!( diff --git a/crates/remote_server/src/headless_project.rs b/crates/remote_server/src/headless_project.rs index 2356028bb88551bbbe75327eaacfea837c39dd86..2b0b2be0c00ca5fb22a7ad10001ca95fce6c7ad7 100644 --- a/crates/remote_server/src/headless_project.rs +++ b/crates/remote_server/src/headless_project.rs @@ -297,6 +297,7 @@ impl HeadlessProject { session.add_entity_request_handler(Self::handle_open_image_by_path); session.add_entity_request_handler(Self::handle_trust_worktrees); session.add_entity_request_handler(Self::handle_restrict_worktrees); + session.add_entity_request_handler(Self::handle_download_file_by_path); session.add_entity_message_handler(Self::handle_find_search_candidates_cancel); session.add_entity_request_handler(BufferStore::handle_update_buffer); @@ -687,6 +688,98 @@ impl HeadlessProject { Ok(proto::Ack {}) } + pub async fn handle_download_file_by_path( + this: Entity, + message: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + log::debug!( + "handle_download_file_by_path: received request: {:?}", + message.payload + ); + + let worktree_id = WorktreeId::from_proto(message.payload.worktree_id); + let path = RelPath::from_proto(&message.payload.path)?; + let project_id = message.payload.project_id; + let file_id = message.payload.file_id; + log::debug!( + "handle_download_file_by_path: worktree_id={:?}, path={:?}, file_id={}", + worktree_id, + path, + file_id + ); + use proto::create_file_for_peer::Variant; + + let (worktree_store, session): (Entity, AnyProtoClient) = this + .read_with(&cx, |this, _| { + (this.worktree_store.clone(), this.session.clone()) + }); + + let worktree = worktree_store + .read_with(&cx, |store, cx| store.worktree_for_id(worktree_id, cx)) + .context("worktree not found")?; + + let download_task = worktree.update(&mut cx, |worktree: &mut Worktree, cx| { + worktree.load_binary_file(path.as_ref(), cx) + }); + + let downloaded_file = download_task.await?; + let content = downloaded_file.content; + let file = downloaded_file.file; + log::debug!( + "handle_download_file_by_path: file loaded, content_size={}", + content.len() + ); + + let proto_file = worktree.read_with(&cx, |_worktree: &Worktree, cx| file.to_proto(cx)); + log::debug!( + "handle_download_file_by_path: using client-provided file_id={}", + file_id + ); + + let state = proto::FileState { + id: file_id, + file: Some(proto_file), + content_size: content.len() as u64, + }; + + log::debug!("handle_download_file_by_path: sending State message"); + session.send(proto::CreateFileForPeer { + project_id, + peer_id: Some(REMOTE_SERVER_PEER_ID), + variant: Some(Variant::State(state)), + })?; + + const CHUNK_SIZE: usize = 1024 * 1024; // 1MB chunks + let num_chunks = content.len().div_ceil(CHUNK_SIZE); + log::debug!( + "handle_download_file_by_path: sending {} chunks", + num_chunks + ); + for (i, chunk) in content.chunks(CHUNK_SIZE).enumerate() { + log::trace!( + "handle_download_file_by_path: sending chunk {}/{}, size={}", + i + 1, + num_chunks, + chunk.len() + ); + session.send(proto::CreateFileForPeer { + project_id, + peer_id: Some(REMOTE_SERVER_PEER_ID), + variant: Some(Variant::Chunk(proto::FileChunk { + file_id, + data: chunk.to_vec(), + })), + })?; + } + + log::debug!( + "handle_download_file_by_path: returning file_id={}", + file_id + ); + Ok(proto::DownloadFileResponse { file_id }) + } + pub async fn handle_open_new_buffer( this: Entity, _message: TypedEnvelope,