mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
feat(project-tree): Exchange files with the OS clipboard on Windows (#41)
Exchange files with the OS clipboard on Windows: CF_HDROP read/write in a new ide-clipboard crate (Win32 FFI behind cfg(windows), stub elsewhere), three thin commands, and project-view copy/paste hooks. Pasting from Explorer always copies; our own hand-off falls back to the internal clipboard so Copy/Move dialogs and refactoring stay intact. Verified: ide-clipboard round-trip tests, project-tree vitest (93), CI on all three OSes. Not verified on Windows hardware here. Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
This commit is contained in:
Generated
+5
@@ -3989,6 +3989,10 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-clipboard"
|
||||
version = "0.1.14"
|
||||
|
||||
[[package]]
|
||||
name = "ide-core"
|
||||
version = "0.1.14"
|
||||
@@ -5858,6 +5862,7 @@ dependencies = [
|
||||
"dunce",
|
||||
"ide-analysis",
|
||||
"ide-build",
|
||||
"ide-clipboard",
|
||||
"ide-core",
|
||||
"ide-coverage",
|
||||
"ide-dap",
|
||||
|
||||
@@ -34,6 +34,7 @@ ide-http = { path = "crates/ide-http" }
|
||||
ide-db = { path = "crates/ide-db" }
|
||||
ide-web = { path = "crates/ide-web" }
|
||||
ide-spell = { path = "crates/ide-spell" }
|
||||
ide-clipboard = { path = "crates/ide-clipboard" }
|
||||
|
||||
# serialization & IPC typing
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "ide-clipboard"
|
||||
description = "The OS clipboard's file list (Explorer <-> project view): Windows CF_HDROP read/write, no window or message loop"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
@@ -0,0 +1,293 @@
|
||||
//! The file list on the OS clipboard: what Explorer copies when the user
|
||||
//! copies files, so the project view can paste them, and what the project view
|
||||
//! writes so Explorer can paste the files it copied (IDEA does both).
|
||||
//!
|
||||
//! Windows keeps it as `CF_HDROP`: a `DROPFILES` header and a double-NUL
|
||||
//! terminated list of absolute paths, wide when `fWide` is set. The data is set
|
||||
//! in full rather than rendered on demand, so no window or message loop is
|
||||
//! needed and the list survives after `CloseClipboard`.
|
||||
//!
|
||||
//! Other platforms keep file lists elsewhere (`public.file-url` on macOS,
|
||||
//! `text/uri-list` on X11) and are not wired up: [`files`] is empty and
|
||||
//! [`set_files`] fails there.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Size of `DROPFILES`: `pFiles`, `pt` (two `LONG`s), `fNC`, `fWide`.
|
||||
const HEADER: usize = 20;
|
||||
|
||||
/// The absolute paths on the clipboard, empty when it holds no file list.
|
||||
pub fn files() -> Vec<PathBuf> {
|
||||
imp::files()
|
||||
}
|
||||
|
||||
/// Whether the clipboard holds a file list, without reading it.
|
||||
pub fn has_files() -> bool {
|
||||
imp::has_files()
|
||||
}
|
||||
|
||||
/// Replaces the clipboard with `paths`. False when it is busy or holds nothing
|
||||
/// to write; the clipboard is a shared resource, so callers treat this as
|
||||
/// best-effort.
|
||||
pub fn set_files(paths: &[PathBuf]) -> bool {
|
||||
if paths.is_empty() {
|
||||
return false;
|
||||
}
|
||||
imp::set_files(paths)
|
||||
}
|
||||
|
||||
/// `DROPFILES` for `paths`, in the wide form so names outside the ANSI code
|
||||
/// page survive. Empty paths are skipped.
|
||||
pub fn encode(paths: &[PathBuf]) -> Vec<u8> {
|
||||
let mut units: Vec<u16> = Vec::new();
|
||||
for path in paths {
|
||||
let path = path.to_string_lossy();
|
||||
if path.is_empty() {
|
||||
continue;
|
||||
}
|
||||
units.extend(path.encode_utf16());
|
||||
units.push(0);
|
||||
}
|
||||
// The list ends with an empty string: with one path, two NULs in a row.
|
||||
units.push(0);
|
||||
let mut out = Vec::with_capacity(HEADER + units.len() * 2);
|
||||
out.extend_from_slice(&(HEADER as u32).to_le_bytes()); // pFiles: list starts after the header
|
||||
out.extend_from_slice(&[0u8; 8]); // pt: the drop point, unused
|
||||
out.extend_from_slice(&0u32.to_le_bytes()); // fNC: not a file manager drop
|
||||
out.extend_from_slice(&1u32.to_le_bytes()); // fWide: UTF-16 paths
|
||||
for unit in units {
|
||||
out.extend_from_slice(&unit.to_le_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The paths of a `DROPFILES` block, stopping at the empty string that ends the
|
||||
/// list. An ANSI list (pre-Unicode writers) is decoded as UTF-8, lossily.
|
||||
pub fn decode(bytes: &[u8]) -> Vec<PathBuf> {
|
||||
if bytes.len() < HEADER {
|
||||
return Vec::new();
|
||||
}
|
||||
let offset = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as usize;
|
||||
let wide = u32::from_le_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]) != 0;
|
||||
if offset >= bytes.len() {
|
||||
return Vec::new();
|
||||
}
|
||||
let body = &bytes[offset..];
|
||||
let mut out = Vec::new();
|
||||
if wide {
|
||||
let mut current: Vec<u16> = Vec::new();
|
||||
for chunk in body.as_chunks::<2>().0 {
|
||||
let unit = u16::from_le_bytes(*chunk);
|
||||
if unit != 0 {
|
||||
current.push(unit);
|
||||
} else if current.is_empty() {
|
||||
break;
|
||||
} else {
|
||||
out.push(PathBuf::from(String::from_utf16_lossy(¤t)));
|
||||
current.clear();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for part in body.split(|b| *b == 0) {
|
||||
if part.is_empty() {
|
||||
break;
|
||||
}
|
||||
out.push(PathBuf::from(String::from_utf8_lossy(part).into_owned()));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod imp {
|
||||
use super::{HEADER, decode, encode};
|
||||
use std::ffi::c_void;
|
||||
use std::path::PathBuf;
|
||||
use std::ptr;
|
||||
use std::time::Duration;
|
||||
|
||||
/// `CF_HDROP`, the clipboard format holding a file list.
|
||||
const CF_HDROP: u32 = 15;
|
||||
|
||||
/// An `HGLOBAL` or a `HANDLE`: both are opaque pointers here.
|
||||
type Handle = *mut c_void;
|
||||
|
||||
const GMEM_MOVEABLE: u32 = 0x0002;
|
||||
/// Another process holds the clipboard only while it copies; retrying beats
|
||||
/// reporting "no files" for a paste the user just made.
|
||||
const OPEN_ATTEMPTS: usize = 10;
|
||||
const OPEN_RETRY: Duration = Duration::from_millis(20);
|
||||
|
||||
#[link(name = "user32")]
|
||||
unsafe extern "system" {
|
||||
fn OpenClipboard(owner: Handle) -> i32;
|
||||
fn CloseClipboard() -> i32;
|
||||
fn EmptyClipboard() -> i32;
|
||||
fn GetClipboardData(format: u32) -> Handle;
|
||||
fn SetClipboardData(format: u32, memory: Handle) -> Handle;
|
||||
fn IsClipboardFormatAvailable(format: u32) -> i32;
|
||||
}
|
||||
|
||||
#[link(name = "kernel32")]
|
||||
unsafe extern "system" {
|
||||
fn GlobalAlloc(flags: u32, bytes: usize) -> Handle;
|
||||
fn GlobalLock(memory: Handle) -> *mut c_void;
|
||||
fn GlobalUnlock(memory: Handle) -> i32;
|
||||
fn GlobalFree(memory: Handle) -> Handle;
|
||||
fn GlobalSize(memory: Handle) -> usize;
|
||||
}
|
||||
|
||||
/// Opens the clipboard as its owner: the list is written in full, so no
|
||||
/// owning window is needed.
|
||||
fn open() -> bool {
|
||||
for _ in 0..OPEN_ATTEMPTS {
|
||||
if unsafe { OpenClipboard(ptr::null_mut()) } != 0 {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(OPEN_RETRY);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn has_files() -> bool {
|
||||
(unsafe { IsClipboardFormatAvailable(CF_HDROP) }) != 0
|
||||
}
|
||||
|
||||
pub fn files() -> Vec<PathBuf> {
|
||||
if !has_files() || !open() {
|
||||
return Vec::new();
|
||||
}
|
||||
let memory = unsafe { GetClipboardData(CF_HDROP) };
|
||||
let paths = if memory.is_null() {
|
||||
Vec::new()
|
||||
} else {
|
||||
// SAFETY: the clipboard keeps the block alive while it is open, and
|
||||
// `GlobalSize` bounds the bytes read from it.
|
||||
unsafe {
|
||||
let data = GlobalLock(memory);
|
||||
if data.is_null() {
|
||||
Vec::new()
|
||||
} else {
|
||||
let bytes = std::slice::from_raw_parts(data as *const u8, GlobalSize(memory));
|
||||
let paths = decode(bytes);
|
||||
GlobalUnlock(memory);
|
||||
paths
|
||||
}
|
||||
}
|
||||
};
|
||||
unsafe { CloseClipboard() };
|
||||
paths
|
||||
}
|
||||
|
||||
pub fn set_files(paths: &[PathBuf]) -> bool {
|
||||
let bytes = encode(paths);
|
||||
if bytes.len() <= HEADER {
|
||||
return false;
|
||||
}
|
||||
// SAFETY: the block is allocated, filled, handed to the clipboard and
|
||||
// freed only on the paths where the clipboard did not take ownership.
|
||||
unsafe {
|
||||
let memory = GlobalAlloc(GMEM_MOVEABLE, bytes.len());
|
||||
if memory.is_null() {
|
||||
return false;
|
||||
}
|
||||
let data = GlobalLock(memory);
|
||||
if data.is_null() {
|
||||
GlobalFree(memory);
|
||||
return false;
|
||||
}
|
||||
ptr::copy_nonoverlapping(bytes.as_ptr(), data as *mut u8, bytes.len());
|
||||
GlobalUnlock(memory);
|
||||
if !open() {
|
||||
GlobalFree(memory);
|
||||
return false;
|
||||
}
|
||||
// Replacing the list also drops the text the user copied before.
|
||||
if EmptyClipboard() == 0 {
|
||||
CloseClipboard();
|
||||
GlobalFree(memory);
|
||||
return false;
|
||||
}
|
||||
let taken = !SetClipboardData(CF_HDROP, memory).is_null();
|
||||
CloseClipboard();
|
||||
if !taken {
|
||||
GlobalFree(memory);
|
||||
}
|
||||
taken
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
mod imp {
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn has_files() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn files() -> Vec<PathBuf> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
pub fn set_files(_paths: &[PathBuf]) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn paths(list: &[&str]) -> Vec<PathBuf> {
|
||||
list.iter().map(PathBuf::from).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_written_list_reads_back_unchanged() {
|
||||
let list = paths(&["C:/work/project/a.txt", "C:/work/project/sub"]);
|
||||
assert_eq!(decode(&encode(&list)), list);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_header_says_where_the_paths_start_and_that_they_are_wide() {
|
||||
let bytes = encode(&paths(&["C:/a"]));
|
||||
assert_eq!(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]), HEADER as u32);
|
||||
assert_eq!(u32::from_le_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]), 1);
|
||||
// `C:/a` plus its NUL, then the empty string that ends the list.
|
||||
assert_eq!(bytes.len(), HEADER + (4 + 1 + 1) * 2);
|
||||
assert_eq!(&bytes[HEADER + 6..HEADER + 8], b"a\0");
|
||||
assert_eq!(&bytes[bytes.len() - 4..], &[0, 0, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_outside_the_ansi_code_page_survive() {
|
||||
let list = paths(&["C:/工作/文档/测试.txt", "C:/a/"]);
|
||||
assert_eq!(decode(&encode(&list)), list);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_list_has_no_paths() {
|
||||
assert_eq!(decode(&encode(&[])), Vec::<PathBuf>::new());
|
||||
// A lone terminator is enough for the reader to stop.
|
||||
assert_eq!(decode(&[0u8; HEADER]), Vec::<PathBuf>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_truncated_or_foreign_block_reads_as_no_paths() {
|
||||
assert_eq!(decode(&[]), Vec::<PathBuf>::new());
|
||||
assert_eq!(decode(&[1, 2, 3]), Vec::<PathBuf>::new());
|
||||
let mut far = encode(&paths(&["C:/a"]));
|
||||
far[0] = 0xff; // pFiles past the end
|
||||
assert_eq!(decode(&far), Vec::<PathBuf>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ansi_list_is_still_read_before_the_terminator() {
|
||||
let mut bytes = vec![0u8; HEADER];
|
||||
bytes[..4].copy_from_slice(&(HEADER as u32).to_le_bytes());
|
||||
bytes[16] = 0; // fWide: the list is single-byte
|
||||
bytes.extend_from_slice(b"C:\\a\\b.txt\0C:\\c\0\0");
|
||||
assert_eq!(decode(&bytes), paths(&["C:\\a\\b.txt", "C:\\c"]));
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ dirs.workspace = true
|
||||
|
||||
ide-core.workspace = true
|
||||
dunce.workspace = true
|
||||
ide-clipboard.workspace = true
|
||||
ide-text.workspace = true
|
||||
ide-vfs.workspace = true
|
||||
ide-workspace.workspace = true
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
//! The OS clipboard's file list, so the project view and Explorer exchange files.
|
||||
|
||||
use crate::commands::doc::blocking;
|
||||
use ide_core::IdeResult;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Absolute paths on the clipboard, empty when it holds no file list.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn clipboard_file_list() -> IdeResult<Vec<String>> {
|
||||
blocking(|| Ok(ide_clipboard::files().iter().map(|p| slash(p)).collect())).await
|
||||
}
|
||||
|
||||
/// Whether the clipboard holds files: cheap, for the Paste item's enabled state.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn clipboard_has_files() -> bool {
|
||||
ide_clipboard::has_files()
|
||||
}
|
||||
|
||||
/// Replaces the clipboard with `paths`, so Explorer can paste them. Best-effort:
|
||||
/// false when the clipboard stayed busy; the caller keeps working either way.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn clipboard_set_file_list(paths: Vec<PathBuf>) -> IdeResult<bool> {
|
||||
blocking(move || Ok(ide_clipboard::set_files(&paths))).await
|
||||
}
|
||||
|
||||
/// Paths cross the IPC boundary `/`-separated, as every other backend path does.
|
||||
fn slash(path: &Path) -> String {
|
||||
path.to_string_lossy().replace('\\', "/")
|
||||
}
|
||||
@@ -11,6 +11,7 @@ pub mod app;
|
||||
pub mod build;
|
||||
pub mod bytes;
|
||||
pub mod caches;
|
||||
pub mod clipboard;
|
||||
pub mod coverage;
|
||||
pub mod db;
|
||||
pub mod debug;
|
||||
@@ -69,6 +70,9 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
fs::fs_delete,
|
||||
fs::fs_copy,
|
||||
fs::fs_read_text,
|
||||
clipboard::clipboard_file_list,
|
||||
clipboard::clipboard_has_files,
|
||||
clipboard::clipboard_set_file_list,
|
||||
doc::doc_open,
|
||||
doc::doc_open_virtual,
|
||||
doc::doc_close,
|
||||
|
||||
@@ -80,6 +80,15 @@ export const commands = {
|
||||
* definition). Prefers the unsaved buffer when the file is open.
|
||||
*/
|
||||
fsReadText: (path: string) => typedError<string, IdeError>(__TAURI_INVOKE("fs_read_text", { path })),
|
||||
/** Absolute paths on the clipboard, empty when it holds no file list. */
|
||||
clipboardFileList: () => typedError<string[], IdeError>(__TAURI_INVOKE("clipboard_file_list")),
|
||||
/** Whether the clipboard holds files: cheap, for the Paste item's enabled state. */
|
||||
clipboardHasFiles: () => __TAURI_INVOKE<boolean>("clipboard_has_files"),
|
||||
/**
|
||||
* Replaces the clipboard with `paths`, so Explorer can paste them. Best-effort:
|
||||
* false when the clipboard stayed busy; the caller keeps working either way.
|
||||
*/
|
||||
clipboardSetFileList: (paths: string[]) => typedError<boolean, IdeError>(__TAURI_INVOKE("clipboard_set_file_list", { paths })),
|
||||
/**
|
||||
* Opens a document. Async: reading and decoding a large file must not block
|
||||
* the main thread (synchronous commands run on it).
|
||||
|
||||
@@ -4,6 +4,7 @@ import { emitLocalIdeEvent } from "@/platform/ipc";
|
||||
import { getContext } from "@/platform/context";
|
||||
import { provideEditorService, type EditorService } from "@/workbench/contributions";
|
||||
import { activate } from "./index";
|
||||
import { setOsClipboardForTests } from "./clipboard";
|
||||
import { MemoryFs, setProjectFsForTests } from "./fs";
|
||||
import { MessagesHost } from "@/workbench/messages";
|
||||
import { FileRefactorDialogHost } from "./FileRefactorDialogs";
|
||||
@@ -71,6 +72,29 @@ describe("ProjectView", () => {
|
||||
await waitFor(() => expect(rows()).toEqual(["acme/w/acme", "build", "src", "App.kt", "Util.kt", "README.md"]));
|
||||
});
|
||||
|
||||
it("pastes the files Explorer copied into the selected folder, as copies", async () => {
|
||||
// What Explorer leaves on the clipboard: a path outside the project.
|
||||
fs.nodes.set("/elsewhere/Photo.png", { kind: "file", size: 3 });
|
||||
setOsClipboardForTests({
|
||||
files: async () => ["/elsewhere/Photo.png"],
|
||||
hasFiles: async () => true,
|
||||
setFiles: async () => {},
|
||||
});
|
||||
try {
|
||||
const { tree, row, rows } = setup();
|
||||
await waitFor(() => expect(rows()).toContain("README.md"));
|
||||
fireEvent.mouseDown(row("src"));
|
||||
tree.focus();
|
||||
// ⌘ is the primary modifier here, as in the undo test below.
|
||||
fireEvent.keyDown(tree, { key: "v", code: "KeyV", metaKey: true });
|
||||
await waitFor(() => expect(fs.nodes.has("/w/acme/src/Photo.png")).toBe(true));
|
||||
// A copy, not a move: the dropped-from path stays where it was.
|
||||
expect(fs.nodes.has("/elsewhere/Photo.png")).toBe(true);
|
||||
} finally {
|
||||
setOsClipboardForTests(null);
|
||||
}
|
||||
});
|
||||
|
||||
it("opens files on double click and previews on single click only when enabled", async () => {
|
||||
const { row, rows } = setup();
|
||||
await waitFor(() => expect(rows()).toContain("README.md"));
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
openRenameDialog,
|
||||
openSelection,
|
||||
paste,
|
||||
refreshOsClipboard,
|
||||
targetFolder,
|
||||
} from "./operations";
|
||||
import { isInside, nameOf, parentOf, splitExtension, tildify } from "./paths";
|
||||
@@ -504,6 +505,15 @@ export function ProjectView() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Files copied in Explorer keep Paste usable: the OS clipboard can change
|
||||
// while this window is in the background, so re-ask whenever it comes back.
|
||||
useEffect(() => {
|
||||
void refreshOsClipboard();
|
||||
const refresh = () => void refreshOsClipboard();
|
||||
window.addEventListener("focus", refresh);
|
||||
return () => window.removeEventListener("focus", refresh);
|
||||
}, []);
|
||||
|
||||
const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
if ((e.target as HTMLElement).tagName === "INPUT") return;
|
||||
const stroke = strokeOf(e.nativeEvent);
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* The OS clipboard's file list: what Explorer copies, and what the project view
|
||||
* puts there so Explorer can paste it back. The format is native (Windows
|
||||
* `CF_HDROP`), so the backend owns it; a plain browser has nothing to exchange
|
||||
* and every call degrades to "no files".
|
||||
*/
|
||||
import { call, commands, isTauri } from "@/platform/ipc";
|
||||
|
||||
export interface OsClipboard {
|
||||
/** Files Explorer copied; empty when the clipboard holds none. */
|
||||
files(): Promise<string[]>;
|
||||
/** Whether the clipboard holds files: cheap, for the Paste item's enabled state. */
|
||||
hasFiles(): Promise<boolean>;
|
||||
/** Puts `paths` there, so Explorer can paste them. */
|
||||
setFiles(paths: string[]): Promise<void>;
|
||||
}
|
||||
|
||||
const native: OsClipboard = {
|
||||
async files() {
|
||||
if (!isTauri()) return [];
|
||||
try {
|
||||
return await call(commands.clipboardFileList());
|
||||
} catch {
|
||||
// A clipboard another app holds for a moment is not an error worth showing.
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
async hasFiles() {
|
||||
if (!isTauri()) return false;
|
||||
try {
|
||||
return await commands.clipboardHasFiles();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async setFiles(paths) {
|
||||
if (!isTauri() || !paths.length) return;
|
||||
try {
|
||||
await call(commands.clipboardSetFileList(paths));
|
||||
} catch {
|
||||
// The internal clipboard still works; a failed hand-off is not an error.
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let current = native;
|
||||
|
||||
export function osClipboard(): OsClipboard {
|
||||
return current;
|
||||
}
|
||||
|
||||
/** Tests: force a clipboard implementation. */
|
||||
export function setOsClipboardForTests(clipboard: OsClipboard | null) {
|
||||
current = clipboard ?? native;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { askUsagesDetected, fileUsages, viewUsages } from "@/editor/refactor";
|
||||
import { executeAction, getAction } from "@/platform/actions";
|
||||
import { maybeEditorService, notifications } from "@/workbench/contributions";
|
||||
import { ask, confirmChecked } from "@/workbench/messages";
|
||||
import { osClipboard } from "./clipboard";
|
||||
import { runFileRefactoring } from "./fileRefactor";
|
||||
import { openFileRefactorDialog, rememberedOptions } from "./FileRefactorDialogs";
|
||||
import { projectFs } from "./fs";
|
||||
@@ -455,17 +456,36 @@ export async function deleteSelection(nodes: ProjectNode[] = selectedNodes()) {
|
||||
|
||||
export function copySelection(cut: boolean) {
|
||||
const paths = topLevelPaths(selectedNodes().filter((n) => (cut ? mutable(n) : !n.root && !n.group)));
|
||||
if (paths.length) setClipboard({ paths, cut });
|
||||
if (!paths.length) return;
|
||||
setClipboard({ paths, cut });
|
||||
// Hand the files to the OS too, so Explorer can paste them (as IDEA does).
|
||||
void osClipboard().setFiles(paths);
|
||||
}
|
||||
|
||||
/** Files on the OS clipboard, cached: `canPaste` runs while a menu renders. */
|
||||
let osHasFiles = false;
|
||||
|
||||
/** Keeps Paste enabled for what Explorer copied; called when the window gains focus. */
|
||||
export async function refreshOsClipboard() {
|
||||
osHasFiles = await osClipboard().hasFiles();
|
||||
}
|
||||
|
||||
export function canPaste(): boolean {
|
||||
return !!get().clipboard?.paths.length;
|
||||
return !!get().clipboard?.paths.length || osHasFiles;
|
||||
}
|
||||
|
||||
/** Paste: cut items go through the Move dialog, copied ones through the Copy dialog (IDEA). */
|
||||
/**
|
||||
* Paste: cut items go through the Move dialog, copied ones through the Copy
|
||||
* dialog (IDEA). Files copied in Explorer paste as copies.
|
||||
*/
|
||||
export async function paste(target = targetFolder()) {
|
||||
const { clipboard } = get();
|
||||
if (!clipboard || !target || isReadOnlyPath(target)) return;
|
||||
if (!target || isReadOnlyPath(target)) return;
|
||||
const external = await osClipboard().files();
|
||||
// The last copy is the one the user expects to paste, so another app's files
|
||||
// win over ours — unless they are the copy we handed to the OS ourselves.
|
||||
if (external.length && !sameFiles(external, clipboard?.paths)) return pasteFromOsClipboard(external, target);
|
||||
if (!clipboard?.paths.length) return;
|
||||
const isDir = (p: string) => get().listings.has(p) || currentModel().byKey.get(currentModel().keyOfPath.get(p) ?? p)?.dir === true;
|
||||
const single = clipboard.paths.length === 1 && isDir(clipboard.paths[0]!);
|
||||
if (!clipboard.cut) return openCopyDialogFor(clipboard.paths, target, single);
|
||||
@@ -476,6 +496,19 @@ export async function paste(target = targetFolder()) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Pasting from Explorer copies, whatever the source: never a move or a refactoring. */
|
||||
async function pasteFromOsClipboard(paths: string[], target: string) {
|
||||
osHasFiles = true;
|
||||
await transfer(paths, target, "copy");
|
||||
}
|
||||
|
||||
/** The same files, as the clipboard keeps them (`/`-separated, any order). */
|
||||
function sameFiles(files: string[], paths: string[] | undefined): boolean {
|
||||
if (!paths || files.length !== paths.length) return false;
|
||||
const key = (list: string[]) => list.map((p) => (caseInsensitive ? p.toLowerCase() : p)).sort().join("\n");
|
||||
return key(files) === key(paths);
|
||||
}
|
||||
|
||||
function openCopyDialogFor(paths: string[], target: string, dir?: boolean) {
|
||||
if (!paths.length) return;
|
||||
void loadDir(target);
|
||||
|
||||
Reference in New Issue
Block a user