mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-13 04:34:21 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32671b605a |
No files matched your search
@@ -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
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -10,6 +10,7 @@ export * from "./Select";
|
||||
export * from "./Tabs";
|
||||
export {
|
||||
FILES_DRAG,
|
||||
REMOTE_FILES_DRAG,
|
||||
dragRequiresAlt,
|
||||
dragSession,
|
||||
dropZoneAt,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user