Detailed changes
@@ -339,6 +339,7 @@ impl Server {
.add_request_handler(forward_read_only_project_request::<proto::GetColorPresentation>)
.add_request_handler(forward_read_only_project_request::<proto::OpenBufferByPath>)
.add_request_handler(forward_read_only_project_request::<proto::OpenImageByPath>)
+ .add_request_handler(forward_read_only_project_request::<proto::DownloadFileByPath>)
.add_request_handler(forward_read_only_project_request::<proto::GitGetBranches>)
.add_request_handler(forward_read_only_project_request::<proto::GetDefaultBranch>)
.add_request_handler(forward_read_only_project_request::<proto::OpenUnstagedDiff>)
@@ -241,6 +241,14 @@ pub struct Project {
settings_observer: Entity<SettingsObserver>,
toolchain_store: Option<Entity<ToolchainStore>>,
agent_location: Option<AgentLocation>,
+ downloading_files: Arc<Mutex<HashMap<(WorktreeId, String), DownloadingFile>>>,
+}
+
+struct DownloadingFile {
+ destination_path: PathBuf,
+ chunks: Vec<u8>,
+ total_size: u64,
+ file_id: Option<u64>, // 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<RelPath>,
+ destination_path: PathBuf,
+ cx: &mut Context<Self>,
+ ) -> Task<Result<()>> {
+ 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<Self>,
+ envelope: TypedEnvelope<proto::CreateFileForPeer>,
+ mut cx: AsyncApp,
+ ) -> Result<()> {
+ use proto::create_file_for_peer::Variant;
+ log::debug!("handle_create_file_for_peer: received message");
+
+ let downloading_files: Arc<Mutex<HashMap<(WorktreeId, String), DownloadingFile>>> =
+ 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::<Vec<_>>()
+ );
+
+ 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<u8>)>,
+ ) = {
+ let mut files = downloading_files.lock();
+ let mut found_key: Option<(WorktreeId, String)> = None;
+ let mut write_data: Option<(PathBuf, Vec<u8>)> = 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<Self>) -> Task<Result<()>> {
let project_id = match self.client_state {
ProjectClientState::Remote {
@@ -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<Self>,
+ ) {
+ 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<RelPath>, 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>) {
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(
@@ -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;
+}
@@ -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;
@@ -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!(
@@ -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<Self>,
+ message: TypedEnvelope<proto::DownloadFileByPath>,
+ mut cx: AsyncApp,
+ ) -> Result<proto::DownloadFileResponse> {
+ 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<WorktreeStore>, 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<Self>,
_message: TypedEnvelope<proto::OpenNewBuffer>,