Compare commits

...
Author SHA1 Message Date
l0ng-ai 32671b605a feat(deployment): download Remote Host items to a chosen folder or by dragging
Remote Host › Download to… asks for a local folder (the Project view's selected
folder preselected) and copies only the selected files and folders, each under
its own name, instead of recreating the server path below the project root.
Dragging items from Remote Host onto a folder in the Project view does the same.
Download to Project keeps using the mappings.
2026-11-12 00:29:07 +08:00
17 changed files with 337 additions and 9 deletions

No files matched your search

+30
View File
@@ -169,6 +169,25 @@ pub async fn remote_rename(state: &Host, connection: FsConnection, from: String,
state.remote.remote_rename(&connection, &from, &to).await
}
/// Remote Host › Download to…: remote files and folders into a local folder, each under its
/// own name.
pub async fn remote_download_into(
window: &str,
state: &Host,
connection: FsConnection,
paths: Vec<String>,
target: IpcPath,
request: RequestId,
) -> IdeResult<TransferReport> {
let project = state.project(window)?;
let token = state.begin_request(request);
let (mut report, done) = progress(project.sink.clone(), "Downloading", token.clone());
let r = state.remote.download_into(&connection, &paths, &target, &token, &mut report).await;
done();
state.end_request(request);
r
}
pub async fn deploy_test_connection(state: &Host, connection: FsConnection, path: String) -> IdeResult<()> {
state.remote.test_connection(&connection, &path).await
}
@@ -197,6 +216,17 @@ pub(crate) fn register(d: &mut crate::rpc::Dispatcher) {
d.add("remote_of", |c| async move { Ok(remote_of(&c.host, c.arg("path")?)) });
d.add("remote_delete", |c| async move { remote_delete(&c.host, c.arg("connection")?, c.arg("path")?).await });
d.add("remote_mkdir", |c| async move { remote_mkdir(&c.host, c.arg("connection")?, c.arg("path")?).await });
d.add("remote_download_into", |c| async move {
remote_download_into(
&c.window,
&c.host,
c.arg("connection")?,
c.arg("paths")?,
c.arg("target")?,
c.arg("request")?,
)
.await
});
d.add("remote_rename", |c| async move {
remote_rename(&c.host, c.arg("connection")?, c.arg("from")?, c.arg("to")?).await
});
+1
View File
@@ -330,6 +330,7 @@ async fn local_deployment_runs_on_the_project_host() {
"remote_delete",
"remote_mkdir",
"remote_rename",
"remote_download_into",
"db_sources",
"db_save_sources",
"db_set_password",
+1
View File
@@ -94,6 +94,7 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
"remote_delete",
"remote_mkdir",
"remote_rename",
"remote_download_into",
"remote_import_local",
"remote_bootstrap_exec",
"deploy_upload",
+1 -1
View File
@@ -69,7 +69,7 @@ pub struct TransferError {
}
impl TransferReport {
fn fail(&mut self, path: impl Into<String>, e: IdeError) {
pub(crate) fn fail(&mut self, path: impl Into<String>, e: IdeError) {
self.errors.push(TransferError { path: path.into(), message: e.message });
}
}
+131
View File
@@ -286,6 +286,70 @@ impl RemoteService {
r
}
/// Remote Host › Download to…: `remotes` (folders recursively) into the local folder `dir`,
/// each under its own name, without the server path above it. Existing files are replaced.
pub async fn download_into(
&self,
connection: &FsConnection,
remotes: &[String],
dir: &Path,
cancel: &CancellationToken,
progress: &mut (dyn FnMut(&str, usize, usize) + Send),
) -> IdeResult<TransferReport> {
let fs = self.fs(connection).await?;
let mut report = TransferReport::default();
let mut folders = Vec::new();
let mut jobs = Vec::new();
for remote in remotes {
let Some(top) = download_target(dir, remote, remote) else {
report.skipped += 1;
continue;
};
match fs.stat(remote).await {
Ok(Some(e)) if e.is_dir => {
folders.push(top);
let mut entries = Vec::new();
if let Err(e) = remote_tree(&fs, remote, cancel, &mut entries).await {
report.fail(remote.clone(), e);
}
for entry in entries {
match download_target(dir, remote, &entry.path) {
Some(local) if entry.is_dir => folders.push(local),
Some(local) => jobs.push((entry.path, local)),
None => report.skipped += 1,
}
}
}
Ok(Some(_)) => jobs.push((remote.clone(), top)),
Ok(None) => report.fail(remote.clone(), IdeError::not_found(format!("{remote} does not exist"))),
Err(e) => report.fail(remote.clone(), e),
}
}
for folder in &folders {
if let Err(e) = tokio::fs::create_dir_all(folder).await {
report.fail(folder.display().to_string(), e.into());
}
}
for (i, (remote, local)) in jobs.iter().enumerate() {
if cancel.is_cancelled() {
break;
}
progress(remote, i, jobs.len());
if let Some(parent) = local.parent()
&& let Err(e) = tokio::fs::create_dir_all(parent).await
{
report.fail(remote.clone(), e.into());
continue;
}
match fs.download(remote, local).await {
Ok(()) => report.files += 1,
Err(e) => report.fail(remote.clone(), e),
}
}
fs.close().await;
Ok(report)
}
// --- save hook -----------------------------------------------------------------
/// After a document save: uploads a remote file's copy, or (automatic upload)
@@ -497,3 +561,70 @@ fn cache_key(connection: &FsConnection) -> String {
FsConnection::Sftp { ssh } => format!("sftp-{ssh}"),
}
}
/// Files and folders under remote folder `dir` (symlinked folders are not followed).
async fn remote_tree(
fs: &RemoteFs,
dir: &str,
cancel: &CancellationToken,
out: &mut Vec<RemoteEntry>,
) -> IdeResult<()> {
for e in fs.list(dir).await? {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let descend = e.is_dir && !e.is_symlink;
let path = e.path.clone();
out.push(e);
if descend {
Box::pin(remote_tree(fs, &path, cancel, out)).await?;
}
}
Ok(())
}
/// Where remote `path`, inside the downloaded `selected` file or folder, lands under local `dir`:
/// `dir/<name of selected>/<rest>`. `None` for a name that would leave `dir`.
fn download_target(dir: &Path, selected: &str, path: &str) -> Option<PathBuf> {
let selected = selected.trim_end_matches('/');
let name = selected.rsplit('/').next()?;
let rest = path.trim_end_matches('/').strip_prefix(selected)?;
if !rest.is_empty() && !rest.starts_with('/') {
return None;
}
let mut local = dir.to_path_buf();
for part in std::iter::once(name).chain(rest.split('/').filter(|p| !p.is_empty())) {
if matches!(part, "" | "." | "..") || part.contains('\\') || (cfg!(windows) && part.contains(':')) {
return None;
}
local.push(part);
}
Some(local)
}
#[cfg(test)]
mod download_into_tests {
use super::*;
#[test]
fn downloads_keep_only_the_selected_name() {
let dir = Path::new("/p/project");
let selected = "/home/u/www/site/current/drush";
assert_eq!(download_target(dir, selected, selected), Some(dir.join("drush")));
assert_eq!(download_target(dir, "/home/u/www/site/current/drush/", selected), Some(dir.join("drush")));
assert_eq!(
download_target(dir, selected, "/home/u/www/site/current/drush/src/a.php"),
Some(dir.join("drush").join("src").join("a.php"))
);
assert_eq!(download_target(dir, "/srv/app.log", "/srv/app.log"), Some(dir.join("app.log")));
}
#[test]
fn names_that_would_leave_the_folder_are_refused() {
let dir = Path::new("/p/project");
assert_eq!(download_target(dir, "/srv/a", "/srv/a/../../etc/passwd"), None);
assert_eq!(download_target(dir, "/srv/a", "/srv/ab/x"), None, "a sibling with the same prefix");
assert_eq!(download_target(dir, "/", "/etc"), None, "the root has no name");
assert_eq!(download_target(dir, "/srv/..", "/srv/.."), None);
}
}
+34
View File
@@ -319,3 +319,37 @@ async fn browser_mappings_resolve_symlinks_and_download_to_the_project() {
assert!(report.errors.is_empty());
assert_eq!(std::fs::read_to_string(local).unwrap(), "from the server");
}
#[tokio::test]
async fn download_into_copies_only_the_selected_items() {
let tmp = tempfile::tempdir().unwrap();
let server = tmp.path().join("srv/www/site/current");
let project = tmp.path().join("project");
std::fs::create_dir_all(server.join("drush/sub")).unwrap();
std::fs::create_dir_all(server.join("drush/empty")).unwrap();
std::fs::create_dir_all(&project).unwrap();
std::fs::write(server.join("drush/a.txt"), "a").unwrap();
std::fs::write(server.join("drush/sub/b.txt"), "b").unwrap();
std::fs::write(server.join("index.php"), "php").unwrap();
std::fs::write(project.join("index.php"), "old").unwrap();
let service = service(tmp.path());
let remotes = [server.join("drush"), server.join("index.php")].map(|p| p.to_str().unwrap().to_owned());
let report = service
.download_into(&FsConnection::Local, &remotes, &project, &CancellationToken::new(), &mut |_, _, _| {})
.await
.unwrap();
assert_eq!(report.files, 3, "{report:?}");
assert!(report.errors.is_empty(), "{report:?}");
assert_eq!(std::fs::read_to_string(project.join("drush/a.txt")).unwrap(), "a");
assert_eq!(std::fs::read_to_string(project.join("drush/sub/b.txt")).unwrap(), "b");
assert!(project.join("drush/empty").is_dir());
assert_eq!(std::fs::read_to_string(project.join("index.php")).unwrap(), "php");
assert!(!project.join("srv").exists() && !project.join("www").exists());
let missing = [server.join("gone").to_str().unwrap().to_owned()];
let report = service
.download_into(&FsConnection::Local, &missing, &project, &CancellationToken::new(), &mut |_, _, _| {})
.await
.unwrap();
assert_eq!((report.files, report.errors.len()), (0, 1), "{report:?}");
}
+13
View File
@@ -128,6 +128,19 @@ pub async fn remote_mkdir(state: State<'_, AppState>, connection: FsConnection,
ide_host::ops::deploy::remote_mkdir(&state.host, connection, path).await
}
#[tauri::command]
#[specta::specta]
pub async fn remote_download_into(
window: Window,
state: State<'_, AppState>,
connection: FsConnection,
paths: Vec<String>,
target: IpcPath,
request: RequestId,
) -> IdeResult<TransferReport> {
ide_host::ops::deploy::remote_download_into(window.label(), &state.host, connection, paths, target, request).await
}
#[tauri::command]
#[specta::specta]
pub async fn remote_rename(
+1
View File
@@ -685,6 +685,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
deploy::remote_of,
deploy::remote_delete,
deploy::remote_mkdir,
deploy::remote_download_into,
deploy::remote_rename,
crate::remote_dev::remote_bootstrap_list,
crate::remote_dev::remote_connect,
+1
View File
@@ -1571,6 +1571,7 @@ export const commands = {
remoteOf: (path: IpcPath) => __TAURI_INVOKE<[FsConnection, string] | null>("remote_of", { path }),
remoteDelete: (connection: FsConnection, path: string) => typedError<null, IdeError>(__TAURI_INVOKE("remote_delete", { connection, path })),
remoteMkdir: (connection: FsConnection, path: string) => typedError<null, IdeError>(__TAURI_INVOKE("remote_mkdir", { connection, path })),
remoteDownloadInto: (connection: FsConnection, paths: string[], target: IpcPath, request: RequestId) => typedError<TransferReport, IdeError>(__TAURI_INVOKE("remote_download_into", { connection, paths, target, request })),
remoteRename: (connection: FsConnection, from: string, to: string) => typedError<null, IdeError>(__TAURI_INVOKE("remote_rename", { connection, from, to })),
/** Browse before connecting, using the desktop's SSH configuration even in a remote window. */
remoteBootstrapList: (ssh: string, path: string) => typedError<RemoteEntry[], IdeError>(__TAURI_INVOKE("remote_bootstrap_list", { ssh, path })),
@@ -10,6 +10,7 @@ import {
IconButton,
Select,
Spinner,
REMOTE_FILES_DRAG,
T,
Tree,
fileIconName,
@@ -20,6 +21,7 @@ import { resolveServer, serverLabel } from "./model";
import {
copyRemotePath,
deleteEntries,
downloadTo,
downloadToProject,
ensureRoot,
loadFolder,
@@ -87,6 +89,12 @@ export function RemoteHostView() {
disabled: !entries.length,
run: () => void downloadToProject(server, entries),
},
{
id: "downloadTo",
label: "Download to…",
disabled: !entries.length,
run: () => void downloadTo(server, entries),
},
{ kind: "separator" },
{
id: "newFolder",
@@ -181,6 +189,10 @@ export function RemoteHostView() {
speedSearch
onActivate={(e) => !e.isDir && void openRemote(server, e)}
onContextMenu={(node, selection, e) => showContextMenu(e, menuFor(node, selection))}
getDragData={(nodes) => ({
type: REMOTE_FILES_DRAG,
payload: JSON.stringify({ server: server.id, paths: nodes.map((n) => n.path) }),
})}
empty={<T>Empty folder</T>}
/>
</div>
+28 -1
View File
@@ -15,7 +15,7 @@ import {
import { tx, txTemplate, useTxTemplate } from "@/platform/i18n";
import { openExternalUrl } from "@/platform/openUrl";
import { relativePath, useProject } from "@/platform/project";
import type { AutoUploadValid, DeploymentServerValid } from "@/platform/remoteSettings";
import { deploymentConnection, type AutoUploadValid, type DeploymentServerValid } from "@/platform/remoteSettings";
import { workflowSettings } from "@/platform/workflowSettings";
import { notifications, projectViewSelection, toolWindowService } from "@/workbench/contributions";
import { chooseItem } from "@/workbench/inputDialogs";
@@ -190,6 +190,21 @@ async function transfer(direction: "upload" | "download", server: DeploymentServ
}
}
/** Downloads remote files and folders into local folder `target`, each under its own name
* (Remote Host › Download to…, or a drag onto the Project view). */
export async function downloadRemoteInto(server: DeploymentServerValid, paths: string[], target: string) {
if (!paths.length) return;
const request = nextRequestId();
try {
const report = await withSsh(server.kind === "sftp" ? server.sshConfig : "", () =>
call(commands.remoteDownloadInto(deploymentConnection(server), paths, target, request)),
);
showReport(report, "download", server);
} catch (e) {
showFailure("Cannot download from {server}", server, e);
}
}
/** Downloads the remote counterparts of `paths` (Remote Host › Download to Project). */
export function downloadPaths(server: DeploymentServerValid, paths: string[]) {
return transfer("download", server, paths);
@@ -433,6 +448,18 @@ export function registerDeploymentActions(): () => void {
keywords: ["deploy", "sftp", "upload", "remote"],
run: withTargets(async (paths) => transfer("upload", await pickServer("Upload to", paths), paths)),
},
{
id: "deployment.downloadRemoteInto",
title: "Download Remote Files Into Folder",
category: CATEGORY,
hidden: true,
description: "Download Remote Host files into a local folder (arg: { server, paths, target })",
run: async ({ arg }) => {
const a = arg as { server: string; paths: string[]; target: string } | undefined;
const server = a && resolveServer(deployment(), a.server);
if (server) await downloadRemoteInto(server, a.paths, a.target);
},
},
{
id: "deployment.downloadFromDefault",
title: "Download from Default Server",
+32 -3
View File
@@ -1,13 +1,13 @@
/** Remote Host tool window operations: list, open, create, rename, delete and download remote files. */
import { txTemplate } from "@/platform/i18n";
import { tx, txTemplate } from "@/platform/i18n";
import { call, commands, type RemoteEntry } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { deploymentConnection, type DeploymentServerValid } from "@/platform/remoteSettings";
import { copyText } from "@/ui";
import { editorService, notifications } from "@/workbench/contributions";
import { editorService, notifications, projectViewSelection } from "@/workbench/contributions";
import { promptInput } from "@/workbench/inputDialogs";
import { confirm } from "@/workbench/messages";
import { downloadPaths } from "./actions";
import { downloadPaths, downloadRemoteInto } from "./actions";
import { joinRemote, localPathForRemote, remoteName, remoteParent } from "./model";
import { withSsh } from "./ssh";
import { EMPTY_TREE, patchTree, useDeployment } from "./store";
@@ -181,4 +181,33 @@ export async function downloadToProject(server: DeploymentServerValid, entries:
if (locals.length) await downloadPaths(server, locals);
}
/** Download to…: the entries into a chosen local folder (the Project view's selected folder, or
* the project root, preselected), without the server path above them. */
export async function downloadTo(server: DeploymentServerValid, entries: RemoteEntry[]) {
if (!entries.length) return;
const { open } = await import("@/platform/dialog");
const target = await open({
directory: true,
multiple: false,
title: tx("Download to"),
defaultPath: (await defaultDownloadFolder()) ?? undefined,
});
if (typeof target !== "string") return;
await downloadRemoteInto(
server,
entries.map((e) => e.path),
target,
);
}
async function defaultDownloadFolder(): Promise<string | null> {
const selected = projectViewSelection()[0];
if (selected) {
const kind = await call(commands.fsEntryKind(selected)).catch(() => null);
if (kind === "dir") return selected;
if (kind === "file") return selected.replace(/[\\/][^\\/]*$/, "");
}
return useProject.getState().project?.root ?? null;
}
export const copyRemotePath = (entry: RemoteEntry) => void copyText(entry.path);
+20
View File
@@ -34,10 +34,12 @@ import {
} from "@/workbench/contributions";
import {
FILES_DRAG,
REMOTE_FILES_DRAG,
HighlightedText,
Icon,
Popup,
dragSession,
registerDropTarget,
Tree,
acquirePopup,
fileIconName,
@@ -729,6 +731,24 @@ export function ProjectView() {
};
}, []);
// Files and folders dragged from Remote Host download into the folder they are dropped on.
useEffect(() => {
const el = containerRef.current;
if (!el) return;
return registerDropTarget(el, {
accepts: (type) => type === REMOTE_FILES_DRAG,
over: (_, x, y) => setDropTarget(folderAt(el, x, y)?.key ?? null),
leave: () => setDropTarget(null),
drop: (data, x, y) => {
setDropTarget(null);
const target = folderAt(el, x, y);
if (!target) return;
const { server, paths } = JSON.parse(data.payload) as { server: string; paths: string[] };
void executeAction("deployment.downloadRemoteInto", { server, paths, target: target.path });
},
});
}, []);
// Reconcile external files and expired Copy/Cut state when returning from another app.
useEffect(() => {
void refreshOsClipboard();
+5 -1
View File
@@ -7694,5 +7694,9 @@
"Could not change the local data directory": "无法更改本地数据目录",
"Settings › Tools › SSH Configurations": "设置 › 工具 › SSH 配置",
"Edit SSH Configurations": "编辑 SSH 配置",
"Docker over SSH uses the system ssh: choose a configuration with OpenSSH config authentication.": "通过 SSH 连接 Docker 使用系统 ssh:请选择认证方式为“OpenSSH 配置和身份验证代理”的 SSH 配置。"
"Docker over SSH uses the system ssh: choose a configuration with OpenSSH config authentication.": "通过 SSH 连接 Docker 使用系统 ssh:请选择认证方式为“OpenSSH 配置和身份验证代理”的 SSH 配置。",
"Download to…": "下载到…",
"Download to": "下载到",
"Download Remote Files Into Folder": "将远程文件下载到文件夹",
"Download Remote Host files into a local folder (arg: { server, paths, target })": "将远程主机中的文件下载到本地文件夹(参数:{ server, paths, target })"
}
+17 -1
View File
@@ -33,6 +33,7 @@ import { T } from "./T";
import { setContext } from "@/platform/context";
import { useGivenTx } from "@/platform/i18n";
import { useTreeReorder, type TreeReorder } from "./treeReorder";
import { startPointerDrag, type DragData } from "./pointerDrag";
import { useTreeDirectoryContext } from "./treeDirectoryContext";
import { useIconRowHeight } from "./iconAppearance";
@@ -129,6 +130,9 @@ export interface TreeProps<T> {
horizontalScroll?: boolean;
/** Optional display reordering between siblings; the caller owns the order. */
reorder?: TreeReorder<T>;
/** Rows can be dragged to other components: what dragging `nodes` (the pressed row, or the
* selection containing it) carries, or null when they can't be dragged. */
getDragData?: (nodes: T[]) => DragData | null;
}
/** Remember intrinsic widths by identity, including rows outside the virtual window. */
@@ -737,7 +741,19 @@ export function Tree<T>(props: TreeProps<T>) {
reordering.drop?.key === row.key && styles[reordering.drop.position],
)}
style={style}
onPointerDown={props.reorder ? (e) => reordering.start(e, row) : undefined}
onPointerDown={
props.reorder
? (e) => reordering.start(e, row)
: props.getDragData
? (e) => {
const nodes = selected.has(row.key)
? rows.filter((r) => selected.has(r.key)).map((r) => r.node)
: [row.node];
const data = props.getDragData!(nodes);
if (data) startPointerDrag(e, e.currentTarget, data, { always: true });
}
: undefined
}
onMouseDown={(e) => onRowMouseDown(e, v.index)}
onDoubleClick={(e) => {
if (!props.reorder || !reordering.suppressActivation.current) activate(row, e);
+1
View File
@@ -10,6 +10,7 @@ export * from "./Select";
export * from "./Tabs";
export {
FILES_DRAG,
REMOTE_FILES_DRAG,
dragRequiresAlt,
dragSession,
dropZoneAt,
+9 -2
View File
@@ -15,6 +15,8 @@ export interface DragData {
/** Project files dragged from the Project view: `payload` is the paths joined by `\n`, folders ending with `/`. */
export const FILES_DRAG = "application/x-openidea-files";
/** Files and folders dragged from Remote Host: `payload` is JSON `{ server, paths }` (remote paths). */
export const REMOTE_FILES_DRAG = "application/x-openidea-remote-files";
export interface DropTarget {
accepts(type: string): boolean;
@@ -138,9 +140,14 @@ export function startPointerDrag(
e: { button: number; clientX: number; clientY: number; altKey: boolean },
source: HTMLElement,
data: DragData,
opts: { onStart?: () => void; onEnd?: (dragged: boolean, drop?: PointerDrop) => void } = {},
opts: {
onStart?: () => void;
onEnd?: (dragged: boolean, drop?: PointerDrop) => void;
/** Not subject to "Drag-and-drop with Alt pressed only" (tabs and stripe buttons). */
always?: boolean;
} = {},
) {
if (e.button !== 0 || (requiresAlt() && !e.altKey)) return;
if (e.button !== 0 || (!opts.always && requiresAlt() && !e.altKey)) return;
const start = { x: e.clientX, y: e.clientY };
let ghost: HTMLElement | null = null;
const session = dragSession(data);