дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-15 14:15:39 +02:00
Порівняти коміти
1
Коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
21338d557c |
No files matched your search
Створено
@@ -4232,6 +4232,7 @@ dependencies = [
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"png 0.18.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ide-clipboard"
|
||||
description = "The OS clipboard's file list (Explorer <-> project view): Windows CF_HDROP or macOS NSPasteboard file URLs, no window or message loop"
|
||||
description = "The OS clipboard's file list (Explorer <-> project view) and pasted images: Windows CF_HDROP / PNG / CF_DIB or macOS NSPasteboard, no window or message loop"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -9,8 +9,14 @@ license.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = { version = "0.6", default-features = false, features = ["std"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSDictionary", "NSEnumerator", "NSObject", "NSString", "NSURL", "NSValue"] }
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSPasteboard"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSData", "NSDictionary", "NSEnumerator", "NSObject", "NSString", "NSURL", "NSValue"] }
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSBitmapImageRep", "NSImageRep", "NSPasteboard"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
png = "0.18"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
gtk = "0.18"
|
||||
|
||||
[dev-dependencies]
|
||||
png = "0.18"
|
||||
@@ -18,6 +18,10 @@
|
||||
//!
|
||||
//! Other platforms (`text/uri-list` on X11) are not wired up: [`files`] is
|
||||
//! empty and [`set_files`] fails there.
|
||||
//!
|
||||
//! [`png`] reads an image (a screenshot) for the project view's Paste, which
|
||||
//! saves it as a file: `public.png` or `public.tiff` on macOS, the registered
|
||||
//! `PNG` format or `CF_DIB` on Windows. Linux reads it through GTK in the app.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -34,6 +38,19 @@ pub fn text() -> Option<String> {
|
||||
imp::text()
|
||||
}
|
||||
|
||||
/// The clipboard's image as PNG bytes, None when it holds none. Call on the main
|
||||
/// thread on macOS.
|
||||
#[cfg(any(windows, target_os = "macos"))]
|
||||
pub fn png() -> Option<Vec<u8>> {
|
||||
imp::png()
|
||||
}
|
||||
|
||||
/// Whether the clipboard holds an image, without reading it. Call on the main
|
||||
/// thread on macOS and Linux.
|
||||
pub fn has_image() -> bool {
|
||||
imp::has_image()
|
||||
}
|
||||
|
||||
/// Size of `DROPFILES`: `pFiles`, `pt` (two `LONG`s), `fNC`, `fWide`.
|
||||
const HEADER: usize = 20;
|
||||
|
||||
@@ -134,9 +151,62 @@ pub fn decode(bytes: &[u8]) -> Vec<PathBuf> {
|
||||
out
|
||||
}
|
||||
|
||||
/// `BI_RGB` and `BI_BITFIELDS`: uncompressed pixels, the forms screenshots take.
|
||||
#[cfg(any(windows, test))]
|
||||
const BI_RGB: u32 = 0;
|
||||
#[cfg(any(windows, test))]
|
||||
const BI_BITFIELDS: u32 = 3;
|
||||
|
||||
/// A packed DIB (`BITMAPINFOHEADER` or a later header, then the pixels) as PNG.
|
||||
/// Only 24- and 32-bit uncompressed bitmaps, which is what screenshots and
|
||||
/// copied images are; None for anything else. A 32-bit bitmap whose alpha is
|
||||
/// all zero (most writers leave it unset) is opaque.
|
||||
#[cfg(any(windows, test))]
|
||||
fn dib_to_png(dib: &[u8]) -> Option<Vec<u8>> {
|
||||
let u32_at = |at: usize| dib.get(at..at + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]));
|
||||
let header = u32_at(0)? as usize;
|
||||
let width = u32_at(4)? as i32;
|
||||
let height = u32_at(8)? as i32;
|
||||
let bits = u16::from_le_bytes([*dib.get(14)?, *dib.get(15)?]);
|
||||
let compression = u32_at(16)?;
|
||||
if header < 40 || width <= 0 || height == 0 || !matches!(bits, 24 | 32) {
|
||||
return None;
|
||||
}
|
||||
if compression != BI_RGB && !(compression == BI_BITFIELDS && bits == 32) {
|
||||
return None;
|
||||
}
|
||||
// A `BITMAPINFOHEADER` keeps the three bitfield masks after it; later
|
||||
// headers hold them inside. Masks other than BGRA's are not supported.
|
||||
let masks = if compression == BI_BITFIELDS && header == 40 { 12 } else { 0 };
|
||||
let colors = u32_at(32)? as usize;
|
||||
let (width, rows) = (width as usize, height.unsigned_abs() as usize);
|
||||
let stride = (width * bits as usize).div_ceil(32) * 4;
|
||||
let start = header + masks + colors * 4;
|
||||
let pixels = dib.get(start..start.checked_add(stride.checked_mul(rows)?)?)?;
|
||||
let step = bits as usize / 8;
|
||||
let mut rgba = Vec::with_capacity(width * rows * 4);
|
||||
for row in 0..rows {
|
||||
// A positive height stores the bottom row first.
|
||||
let source = if height > 0 { rows - 1 - row } else { row };
|
||||
let line = &pixels[source * stride..source * stride + width * step];
|
||||
for px in line.chunks_exact(step) {
|
||||
rgba.extend_from_slice(&[px[2], px[1], px[0], if step == 4 { px[3] } else { 255 }]);
|
||||
}
|
||||
}
|
||||
if step == 4 && rgba.as_chunks::<4>().0.iter().all(|px| px[3] == 0) {
|
||||
rgba.as_chunks_mut::<4>().0.iter_mut().for_each(|px| px[3] = 255);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut encoder = png::Encoder::new(&mut out, width as u32, rows as u32);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder.write_header().ok()?.write_image_data(&rgba).ok()?;
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod imp {
|
||||
use super::{HEADER, decode, encode, names_text};
|
||||
use super::{HEADER, decode, dib_to_png, encode, names_text};
|
||||
use std::ffi::c_void;
|
||||
use std::path::PathBuf;
|
||||
use std::ptr;
|
||||
@@ -164,6 +234,59 @@ mod imp {
|
||||
fn GetClipboardData(format: u32) -> Handle;
|
||||
fn SetClipboardData(format: u32, memory: Handle) -> Handle;
|
||||
fn IsClipboardFormatAvailable(format: u32) -> i32;
|
||||
fn RegisterClipboardFormatW(name: *const u16) -> u32;
|
||||
}
|
||||
|
||||
/// `CF_DIB`: Windows synthesizes it from `CF_BITMAP` and `CF_DIBV5`, so every
|
||||
/// bitmap on the clipboard reads as one.
|
||||
const CF_DIB: u32 = 8;
|
||||
|
||||
/// The `PNG` format browsers and the Snipping Tool add beside the bitmap; it
|
||||
/// keeps transparency, so it is read first.
|
||||
fn png_format() -> u32 {
|
||||
let name: Vec<u16> = "PNG\0".encode_utf16().collect();
|
||||
unsafe { RegisterClipboardFormatW(name.as_ptr()) }
|
||||
}
|
||||
|
||||
pub fn has_image() -> bool {
|
||||
let png = png_format();
|
||||
(png != 0 && unsafe { IsClipboardFormatAvailable(png) } != 0)
|
||||
|| unsafe { IsClipboardFormatAvailable(CF_DIB) } != 0
|
||||
}
|
||||
|
||||
/// A copy of the clipboard's `format` block. Call with the clipboard open.
|
||||
fn read_block(format: u32) -> Option<Vec<u8>> {
|
||||
// SAFETY: the clipboard keeps the block alive while it is open, and
|
||||
// `GlobalSize` bounds the bytes read from it.
|
||||
unsafe {
|
||||
let memory = GetClipboardData(format);
|
||||
if memory.is_null() {
|
||||
return None;
|
||||
}
|
||||
let data = GlobalLock(memory);
|
||||
if data.is_null() {
|
||||
return None;
|
||||
}
|
||||
let bytes = std::slice::from_raw_parts(data as *const u8, GlobalSize(memory)).to_vec();
|
||||
GlobalUnlock(memory);
|
||||
Some(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn png() -> Option<Vec<u8>> {
|
||||
if !open() {
|
||||
return None;
|
||||
}
|
||||
let png = png_format();
|
||||
let image = if png != 0 && unsafe { IsClipboardFormatAvailable(png) } != 0 {
|
||||
read_block(png)
|
||||
} else if unsafe { IsClipboardFormatAvailable(CF_DIB) } != 0 {
|
||||
read_block(CF_DIB).and_then(|dib| dib_to_png(&dib))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
unsafe { CloseClipboard() };
|
||||
image
|
||||
}
|
||||
|
||||
#[link(name = "kernel32")]
|
||||
@@ -327,8 +450,8 @@ mod imp {
|
||||
use objc2::runtime::{AnyClass, AnyObject, ProtocolObject};
|
||||
use objc2::{ClassType, MainThreadMarker};
|
||||
use objc2_app_kit::{
|
||||
NSPasteboard, NSPasteboardReadingOptionKey, NSPasteboardTypeString, NSPasteboardURLReadingFileURLsOnlyKey,
|
||||
NSPasteboardWriting,
|
||||
NSBitmapImageFileType, NSBitmapImageRep, NSPasteboard, NSPasteboardReadingOptionKey, NSPasteboardTypePNG,
|
||||
NSPasteboardTypeString, NSPasteboardTypeTIFF, NSPasteboardURLReadingFileURLsOnlyKey, NSPasteboardWriting,
|
||||
};
|
||||
use objc2_foundation::{NSArray, NSDictionary, NSNumber, NSString, NSURL};
|
||||
use std::path::PathBuf;
|
||||
@@ -380,6 +503,33 @@ mod imp {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn has_image() -> bool {
|
||||
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
|
||||
autoreleasepool(|_| {
|
||||
// SAFETY: the types are AppKit constants, valid for the process' lifetime.
|
||||
let types = NSArray::from_slice(&[unsafe { NSPasteboardTypePNG }, unsafe { NSPasteboardTypeTIFF }]);
|
||||
NSPasteboard::generalPasteboard().availableTypeFromArray(&types).is_some()
|
||||
})
|
||||
}
|
||||
|
||||
/// PNG as copied, else TIFF (what macOS screenshots and most apps write) converted.
|
||||
pub fn png() -> Option<Vec<u8>> {
|
||||
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
|
||||
autoreleasepool(|_| {
|
||||
let board = NSPasteboard::generalPasteboard();
|
||||
// SAFETY: the types are AppKit constants, valid for the process' lifetime.
|
||||
if let Some(png) = board.dataForType(unsafe { NSPasteboardTypePNG }) {
|
||||
return Some(png.to_vec());
|
||||
}
|
||||
let tiff = board.dataForType(unsafe { NSPasteboardTypeTIFF })?;
|
||||
let image = NSBitmapImageRep::imageRepWithData(&tiff)?;
|
||||
// SAFETY: an empty property dictionary is valid for every file type.
|
||||
let png =
|
||||
unsafe { image.representationUsingType_properties(NSBitmapImageFileType::PNG, &NSDictionary::new()) }?;
|
||||
Some(png.to_vec())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn text() -> Option<String> {
|
||||
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
|
||||
autoreleasepool(|_| {
|
||||
@@ -448,6 +598,17 @@ mod imp {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn has_image() -> bool {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
gtk::Clipboard::get(>k::gdk::SELECTION_CLIPBOARD).wait_is_image_available()
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn files() -> std::io::Result<Vec<PathBuf>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
@@ -521,4 +682,62 @@ mod tests {
|
||||
bytes.extend_from_slice(b"C:\\a\\b.txt\0C:\\c\0\0");
|
||||
assert_eq!(decode(&bytes), paths(&["C:\\a\\b.txt", "C:\\c"]));
|
||||
}
|
||||
|
||||
/// A `BITMAPINFOHEADER` DIB of `width` × `height` (positive: bottom-up).
|
||||
fn dib(width: i32, height: i32, bits: u16, compression: u32, rows: &[&[u8]]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(&40u32.to_le_bytes());
|
||||
out.extend_from_slice(&width.to_le_bytes());
|
||||
out.extend_from_slice(&height.to_le_bytes());
|
||||
out.extend_from_slice(&1u16.to_le_bytes());
|
||||
out.extend_from_slice(&bits.to_le_bytes());
|
||||
out.extend_from_slice(&compression.to_le_bytes());
|
||||
out.extend_from_slice(&[0u8; 20]);
|
||||
if compression == super::BI_BITFIELDS {
|
||||
for mask in [0x00ff_0000u32, 0x0000_ff00, 0x0000_00ff] {
|
||||
out.extend_from_slice(&mask.to_le_bytes());
|
||||
}
|
||||
}
|
||||
for row in rows {
|
||||
out.extend_from_slice(row);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn decode_png(png: &[u8]) -> (u32, u32, Vec<u8>) {
|
||||
let mut reader = png::Decoder::new(std::io::Cursor::new(png)).read_info().unwrap();
|
||||
let mut buf = vec![0; reader.output_buffer_size().unwrap()];
|
||||
let info = reader.next_frame(&mut buf).unwrap();
|
||||
buf.truncate(info.buffer_size());
|
||||
(info.width, info.height, buf)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bottom_up_24_bit_bitmap_becomes_an_upright_opaque_png() {
|
||||
// Rows are padded to 4 bytes; the first stored row is the bottom one.
|
||||
let bottom: &[u8] = &[255, 0, 0, 0, 255, 0, 0, 0];
|
||||
let top: &[u8] = &[0, 0, 255, 255, 255, 255, 0, 0];
|
||||
let png = super::dib_to_png(&dib(2, 2, 24, super::BI_RGB, &[bottom, top])).unwrap();
|
||||
let (w, h, rgba) = decode_png(&png);
|
||||
assert_eq!((w, h), (2, 2));
|
||||
assert_eq!(rgba, [255, 0, 0, 255, 255, 255, 255, 255, 0, 0, 255, 255, 0, 255, 0, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_32_bit_bitmap_keeps_its_alpha_unless_none_was_written() {
|
||||
let row: &[u8] = &[10, 20, 30, 0, 40, 50, 60, 0];
|
||||
let (_, _, rgba) = decode_png(&super::dib_to_png(&dib(2, -1, 32, super::BI_BITFIELDS, &[row])).unwrap());
|
||||
assert_eq!(rgba, [30, 20, 10, 255, 60, 50, 40, 255]);
|
||||
let row: &[u8] = &[10, 20, 30, 128, 40, 50, 60, 0];
|
||||
let (_, _, rgba) = decode_png(&super::dib_to_png(&dib(2, -1, 32, super::BI_RGB, &[row])).unwrap());
|
||||
assert_eq!(rgba, [30, 20, 10, 128, 60, 50, 40, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_or_truncated_bitmaps_are_not_images() {
|
||||
let row: &[u8] = &[1, 2, 3, 4];
|
||||
assert!(super::dib_to_png(&dib(4, 1, 8, super::BI_RGB, &[row])).is_none());
|
||||
assert!(super::dib_to_png(&dib(2, 2, 24, super::BI_RGB, &[row])).is_none());
|
||||
assert!(super::dib_to_png(&[0; 10]).is_none());
|
||||
}
|
||||
}
|
||||
@@ -342,6 +342,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "clear_caches"
|
||||
| "clipboard_file_list"
|
||||
| "clipboard_has_files"
|
||||
| "clipboard_has_image"
|
||||
| "clipboard_image"
|
||||
| "clipboard_set_file_list"
|
||||
| "clipboard_write_text"
|
||||
|
||||
@@ -46,6 +46,7 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
|
||||
"clear_caches",
|
||||
"clipboard_file_list",
|
||||
"clipboard_has_files",
|
||||
"clipboard_has_image",
|
||||
"clipboard_image",
|
||||
"clipboard_set_file_list",
|
||||
"clipboard_write_text",
|
||||
|
||||
@@ -76,15 +76,26 @@ pub async fn clipboard_set_file_list(app: AppHandle, paths: Vec<PathBuf>) -> Ide
|
||||
with_clipboard(app, move || Ok(ide_clipboard::set_files(&paths))).await
|
||||
}
|
||||
|
||||
/// The clipboard's image as a PNG data URL, for a paste whose event carried no
|
||||
/// files: WebKitGTK leaves `clipboardData` empty for a raw image (a screenshot).
|
||||
/// None when the clipboard holds no image, and always on other platforms.
|
||||
/// Whether the clipboard holds an image (a screenshot): cheap, for the Paste item's
|
||||
/// enabled state.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn clipboard_has_image() -> bool {
|
||||
// Synchronous Tauri commands run on the main thread, as macOS and GTK require.
|
||||
ide_clipboard::has_image()
|
||||
}
|
||||
|
||||
/// The clipboard's image as a PNG data URL: for a paste whose event carried no files
|
||||
/// (WebKitGTK leaves `clipboardData` empty for a raw image, a screenshot), and for
|
||||
/// the project view's Paste, which has no paste event. None when it holds no image.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn clipboard_image(app: AppHandle) -> IdeResult<Option<String>> {
|
||||
use base64::Engine;
|
||||
let data_url =
|
||||
|bytes: Vec<u8>| format!("data:image/png;base64,{}", base64::engine::general_purpose::STANDARD.encode(bytes));
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use base64::Engine;
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
app.run_on_main_thread(move || {
|
||||
let sender = std::cell::Cell::new(Some(tx));
|
||||
@@ -98,14 +109,11 @@ pub async fn clipboard_image(app: AppHandle) -> IdeResult<Option<String>> {
|
||||
.map_err(|error| {
|
||||
ide_core::IdeError::internal(format!("Cannot dispatch clipboard access to the main thread: {error}"))
|
||||
})?;
|
||||
let png = rx.await.ok().flatten();
|
||||
Ok(png
|
||||
.map(|bytes| format!("data:image/png;base64,{}", base64::engine::general_purpose::STANDARD.encode(bytes))))
|
||||
Ok(rx.await.ok().flatten().map(data_url))
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = app;
|
||||
Ok(None)
|
||||
Ok(with_clipboard(app, || Ok(ide_clipboard::png())).await?.map(data_url))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -195,6 +195,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
clipboard::clipboard_text,
|
||||
clipboard::clipboard_file_list,
|
||||
clipboard::clipboard_has_files,
|
||||
clipboard::clipboard_has_image,
|
||||
clipboard::clipboard_image,
|
||||
clipboard::clipboard_set_file_list,
|
||||
fonts::system_font_families,
|
||||
|
||||
@@ -310,9 +310,14 @@ export const commands = {
|
||||
/** Whether the clipboard holds files: cheap, for the Paste item's enabled state. */
|
||||
clipboardHasFiles: () => __TAURI_INVOKE<boolean>("clipboard_has_files"),
|
||||
/**
|
||||
* The clipboard's image as a PNG data URL, for a paste whose event carried no
|
||||
* files: WebKitGTK leaves `clipboardData` empty for a raw image (a screenshot).
|
||||
* None when the clipboard holds no image, and always on other platforms.
|
||||
* Whether the clipboard holds an image (a screenshot): cheap, for the Paste item's
|
||||
* enabled state.
|
||||
*/
|
||||
clipboardHasImage: () => __TAURI_INVOKE<boolean>("clipboard_has_image"),
|
||||
/**
|
||||
* The clipboard's image as a PNG data URL: for a paste whose event carried no files
|
||||
* (WebKitGTK leaves `clipboardData` empty for a raw image, a screenshot), and for
|
||||
* the project view's Paste, which has no paste event. None when it holds no image.
|
||||
*/
|
||||
clipboardImage: () => typedError<string | null, IdeError>(__TAURI_INVOKE("clipboard_image")),
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* 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
|
||||
* puts there so Explorer can paste it back; and its image (a screenshot), which
|
||||
* Paste saves as a file. 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".
|
||||
*/
|
||||
@@ -13,6 +14,10 @@ export interface OsClipboard {
|
||||
hasFiles(): Promise<boolean>;
|
||||
/** True only when the OS accepted the file list; false for unsupported hosts. */
|
||||
setFiles(paths: string[]): Promise<boolean>;
|
||||
/** Whether the clipboard holds an image: cheap, for the Paste item's enabled state. */
|
||||
hasImage(): Promise<boolean>;
|
||||
/** The clipboard's image as PNG bytes, null when it holds none. */
|
||||
image(): Promise<Uint8Array | null>;
|
||||
}
|
||||
|
||||
const native: OsClipboard = {
|
||||
@@ -39,6 +44,23 @@ const native: OsClipboard = {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async hasImage() {
|
||||
if (!isTauri()) return false;
|
||||
try {
|
||||
return await commands.clipboardHasImage();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async image() {
|
||||
if (!isTauri()) return null;
|
||||
const url = await call(commands.clipboardImage());
|
||||
if (!url) return null;
|
||||
const binary = atob(url.slice(url.indexOf(",") + 1));
|
||||
return Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
||||
},
|
||||
};
|
||||
|
||||
let current = native;
|
||||
@@ -47,7 +69,16 @@ export function osClipboard(): OsClipboard {
|
||||
return current;
|
||||
}
|
||||
|
||||
/** Tests: force a clipboard implementation. */
|
||||
export function setOsClipboardForTests(clipboard: OsClipboard | null) {
|
||||
current = clipboard ?? native;
|
||||
/** Tests: force a clipboard implementation; what it leaves out holds nothing. */
|
||||
export function setOsClipboardForTests(clipboard: Partial<OsClipboard> | null) {
|
||||
current = clipboard
|
||||
? {
|
||||
files: async () => [],
|
||||
hasFiles: async () => false,
|
||||
setFiles: async () => false,
|
||||
hasImage: async () => false,
|
||||
image: async () => null,
|
||||
...clipboard,
|
||||
}
|
||||
: native;
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import { isInside, joinPath, nameOf, parentOf } from "./paths";
|
||||
export interface ProjectFs {
|
||||
readDir(path: string): Promise<FileEntry[]>;
|
||||
createFile(path: string): Promise<void>;
|
||||
/** Creates `path` with `data`; fails when it exists. */
|
||||
createFileBytes(path: string, data: Uint8Array): Promise<void>;
|
||||
createDir(path: string): Promise<void>;
|
||||
rename(from: string, to: string): Promise<void>;
|
||||
/** Moves to the trash when possible. */
|
||||
@@ -28,6 +30,10 @@ export const tauriFs: ProjectFs = {
|
||||
await call(commands.fsCreateFile(path));
|
||||
notifyFileOperation({ kind: "created", paths: [path] });
|
||||
},
|
||||
createFileBytes: async (path, data) => {
|
||||
await call(commands.fsCreateFileBytes(path, [...data]));
|
||||
notifyFileOperation({ kind: "created", paths: [path] });
|
||||
},
|
||||
createDir: async (path) => void (await call(commands.fsCreateDir(path))),
|
||||
rename: async (from, to) => void (await call(commands.fsRename(from, to))),
|
||||
delete: async (path) => {
|
||||
@@ -161,6 +167,13 @@ export class MemoryFs implements ProjectFs {
|
||||
this.emit({ created: [path] });
|
||||
}
|
||||
|
||||
async createFileBytes(path: string, data: Uint8Array) {
|
||||
if (this.nodes.has(path)) throw err("alreadyExists", `${path} already exists`);
|
||||
this.mkdirp(parentOf(path));
|
||||
this.nodes.set(path, { kind: "file", size: data.length });
|
||||
this.emit({ created: [path] });
|
||||
}
|
||||
|
||||
async createDir(path: string) {
|
||||
if (this.nodes.has(path)) throw err("alreadyExists", `${path} already exists`);
|
||||
this.mkdirp(path);
|
||||
|
||||
@@ -185,6 +185,26 @@ describe("OS file imports", () => {
|
||||
expect(fs.nodes.has("/p/target/local.txt")).toBe(true);
|
||||
});
|
||||
|
||||
it("saves a pasted image as img.png, then img_1.png, and selects it", async () => {
|
||||
const png = new Uint8Array([137, 80, 78, 71]);
|
||||
setOsClipboardForTests({ hasImage: async () => true, image: async () => png });
|
||||
await refreshOsClipboard();
|
||||
expect(canPaste()).toBe(true);
|
||||
await paste("/p/target");
|
||||
await paste("/p/target");
|
||||
expect(fs.nodes.get("/p/target/img.png")).toEqual({ kind: "file", size: 4 });
|
||||
expect(fs.nodes.has("/p/target/img_1.png")).toBe(true);
|
||||
expect(useProjectTree.getState().revealRequest?.path).toBe("/p/target/img_1.png");
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("says the clipboard is empty when it holds neither files nor an image", async () => {
|
||||
setOsClipboardForTests({});
|
||||
await paste("/p/target");
|
||||
expect(notify).toHaveBeenCalledWith(expect.objectContaining({ title: "No files on the clipboard" }));
|
||||
expect([...fs.nodes.keys()].filter((p) => p.startsWith("/p/target/"))).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports a clipboard read failure", async () => {
|
||||
setOsClipboardForTests({
|
||||
files: async () => {
|
||||
|
||||
@@ -13,7 +13,7 @@ import { osClipboard } from "./clipboard";
|
||||
import { lastFileRefactoring, nextFileOperation, runFileRefactoring, undoFileRefactoring } from "./fileRefactor";
|
||||
import { openFileRefactorDialog, rememberedOptions } from "./FileRefactorDialogs";
|
||||
import { beginLocalImport, projectFs } from "./fs";
|
||||
import { call, commands, isCancelled } from "@/platform/ipc";
|
||||
import { call, commands, IpcError, isCancelled } from "@/platform/ipc";
|
||||
import { MEMBER_PREFIX, type ProjectNode } from "./model";
|
||||
import {
|
||||
isInside,
|
||||
@@ -573,8 +573,9 @@ export function copySelection(cut: boolean) {
|
||||
setClipboard({ paths, cut, remote, osWrite });
|
||||
}
|
||||
|
||||
/** Files on the OS clipboard, cached: `canPaste` runs while a menu renders. */
|
||||
/** Files or an image on the OS clipboard, cached: `canPaste` runs while a menu renders. */
|
||||
let osHasFiles = false;
|
||||
let osHasImage = false;
|
||||
|
||||
/** Keeps Paste enabled for what Explorer copied; called when the window gains focus. */
|
||||
export async function refreshOsClipboard() {
|
||||
@@ -588,10 +589,11 @@ export async function refreshOsClipboard() {
|
||||
async function readOsClipboard(): Promise<string[] | null> {
|
||||
const { clipboard } = get();
|
||||
const handedOff = await clipboard?.osWrite;
|
||||
const external = await osClipboard().files();
|
||||
const [external, image] = await Promise.all([osClipboard().files(), osClipboard().hasImage()]);
|
||||
// A newer Copy/Cut owns the state if this read (or its hand-off) was delayed.
|
||||
if (get().clipboard !== clipboard) return null;
|
||||
osHasFiles = external.length > 0;
|
||||
osHasImage = image;
|
||||
// Our remote text write removes file URLs, so any files were copied later.
|
||||
// A failed write leaves ownership uncertain; retain the remote copy then.
|
||||
if (handedOff && (clipboard?.remote ? external.length > 0 : !sameFiles(external, clipboard?.paths)))
|
||||
@@ -600,12 +602,13 @@ async function readOsClipboard(): Promise<string[] | null> {
|
||||
}
|
||||
|
||||
export function canPaste(): boolean {
|
||||
return !!get().clipboard?.paths.length || osHasFiles;
|
||||
return !!get().clipboard?.paths.length || osHasFiles || osHasImage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paste: cut items go through the Move dialog, copied ones through the Copy
|
||||
* dialog (IDEA). Files copied in Explorer paste as copies.
|
||||
* dialog (IDEA). Files copied in Explorer paste as copies; an image (a
|
||||
* screenshot) is saved as `img.png`.
|
||||
*/
|
||||
export async function paste(target = targetFolder()) {
|
||||
if (!target || isReadOnlyPath(target)) return;
|
||||
@@ -626,6 +629,7 @@ export async function paste(target = targetFolder()) {
|
||||
if (!clipboard?.remote && external.length && !sameFiles(external, clipboard?.paths))
|
||||
return pasteFromOsClipboard(external, target);
|
||||
if (!clipboard?.paths.length) {
|
||||
if (await pasteImage(target)) return;
|
||||
notifications().notify({ level: "info", title: "No files on the clipboard" });
|
||||
return;
|
||||
}
|
||||
@@ -640,6 +644,39 @@ export async function paste(target = targetFolder()) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the clipboard's image in `target` as `img.png`, or `img_1.png`, `img_2.png`
|
||||
* … when taken, and selects it. False when the clipboard holds no image.
|
||||
*/
|
||||
async function pasteImage(target: string): Promise<boolean> {
|
||||
let png: Uint8Array | null;
|
||||
try {
|
||||
png = await osClipboard().image();
|
||||
} catch (e) {
|
||||
fail(tx("Cannot access the clipboard"), e);
|
||||
return true;
|
||||
}
|
||||
if (!png) return false;
|
||||
await loadDir(target);
|
||||
const taken = new Set((get().listings.get(target) ?? []).map((e) => e.name));
|
||||
for (let i = 0; ; i++) {
|
||||
const name = i ? `img_${i}.png` : "img.png";
|
||||
if (taken.has(name)) continue;
|
||||
const path = joinPath(target, name);
|
||||
try {
|
||||
await projectFs(get().root ?? target).createFileBytes(path, png);
|
||||
} catch (e) {
|
||||
// Created since the listing was read: take the next name.
|
||||
if (e instanceof IpcError && e.kind === "alreadyExists") continue;
|
||||
fail(tx`Cannot paste the image into ${name}`, e);
|
||||
return true;
|
||||
}
|
||||
await loadDir(target, true);
|
||||
revealPath(path, true);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Pasting from Explorer copies, whatever the source: never a move or a refactoring. */
|
||||
async function pasteFromOsClipboard(paths: string[], target: string) {
|
||||
osHasFiles = true;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"Cannot paste the image into {name}": "Cannot paste the image into {name}",
|
||||
"Worktree Conflict": "Worktree Conflict",
|
||||
"Branch '{branch}' is already checked out in a worktree at:\n{path}": "Branch '{branch}' is already checked out in a worktree at:\n{path}",
|
||||
"Checkout Anyway": "Checkout Anyway",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"Cannot paste the image into {name}": "无法将图片粘贴为 {name}",
|
||||
"Worktree Conflict": "工作树冲突",
|
||||
"Branch '{branch}' is already checked out in a worktree at:\n{path}": "分支 '{branch}' 已在以下位置的工作树中签出:\n{path}",
|
||||
"Checkout Anyway": "仍然签出",
|
||||
|
||||
Reference in new issue
Заблокувати користувача