Порівняти коміти

...
1 Коміти
Автор SHA1 Повідомлення Дата
l0ng-ai 21338d557c fix(project-view): paste a clipboard image as img.png
Pasting into the Project view only handled files, so a screenshot on the
clipboard did nothing. With no files to paste, Paste now saves the clipboard
image in the target folder as img.png (img_1.png, img_2.png ... when taken)
and selects it; Paste is enabled while the clipboard holds an image.

The image is read natively: public.png, or public.tiff converted, on macOS;
the registered PNG format, or CF_DIB (24/32-bit) encoded as PNG, on Windows;
GTK on Linux as before.

Fixes #3486
2026-11-15 17:09:42 +08:00
14 змінених файлів з 372 додано та 27 видалено

No files matched your search

Створено
+1
Переглянути файл
@@ -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]]
+9 -3
Переглянути файл
@@ -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"
+222 -3
Переглянути файл
@@ -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(&gtk::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());
}
}
+1
Переглянути файл
@@ -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"
+1
Переглянути файл
@@ -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",
+17 -9
Переглянути файл
@@ -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))
}
}
+1
Переглянути файл
@@ -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,
+8 -3
Переглянути файл
@@ -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")),
/**
+35 -4
Переглянути файл
@@ -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;
}
+13
Переглянути файл
@@ -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);
+20
Переглянути файл
@@ -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 () => {
+42 -5
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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": "仍然签出",