mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
feat(terminal): paste clipboard media as file paths (#880)
Pasting an image, audio or video from the clipboard into a local terminal saves the bytes to the per-user terminal cache (20 MB limit, files older than 24 h cleaned up on the next paste, private permissions) and inserts the shell-quoted path without running anything. Copied files paste their original paths, plain text pastes unchanged, and SSH sessions keep the existing remote image-paste route. Paths are quoted per shell (zsh/bash, fish, PowerShell, cmd.exe); fish quoting now escapes backslashes and single quotes. Fixes #838 Co-authored-by: kkMihai <59262001+kkMihai@users.noreply.github.com>
This commit is contained in:
@@ -1,6 +1,61 @@
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use std::path::Path;
|
||||
|
||||
const LIFETIME: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60);
|
||||
const DIRECTORY: &str = "terminal-images";
|
||||
|
||||
fn extension(mime: &str) -> IdeResult<&str> {
|
||||
match mime {
|
||||
"image/png" => Ok("png"),
|
||||
"image/jpeg" => Ok("jpg"),
|
||||
"image/gif" => Ok("gif"),
|
||||
"image/webp" => Ok("webp"),
|
||||
"image/bmp" => Ok("bmp"),
|
||||
"image/svg+xml" => Ok("svg"),
|
||||
"image/tiff" => Ok("tiff"),
|
||||
"image/x-icon" => Ok("ico"),
|
||||
"audio/wav" | "audio/x-wav" => Ok("wav"),
|
||||
"audio/mpeg" => Ok("mp3"),
|
||||
"audio/mp4" => Ok("m4a"),
|
||||
"audio/ogg" => Ok("ogg"),
|
||||
"audio/webm" | "video/webm" => Ok("webm"),
|
||||
"audio/flac" => Ok("flac"),
|
||||
"audio/aac" => Ok("aac"),
|
||||
"video/mp4" => Ok("mp4"),
|
||||
"video/ogg" => Ok("ogv"),
|
||||
"video/quicktime" => Ok("mov"),
|
||||
_ => Err(IdeError::invalid("unsupported clipboard media type")),
|
||||
}
|
||||
}
|
||||
|
||||
fn owned_name(name: &str) -> bool {
|
||||
let name = name.strip_prefix(".partial-").unwrap_or(name);
|
||||
let Some((id, ext)) = name.strip_prefix("image-").and_then(|s| s.split_once('.')) else { return false };
|
||||
id.len() == 32
|
||||
&& id.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
|
||||
&& matches!(
|
||||
ext,
|
||||
"png"
|
||||
| "jpg"
|
||||
| "gif"
|
||||
| "webp"
|
||||
| "bmp"
|
||||
| "svg"
|
||||
| "tiff"
|
||||
| "ico"
|
||||
| "wav"
|
||||
| "mp3"
|
||||
| "m4a"
|
||||
| "ogg"
|
||||
| "webm"
|
||||
| "flac"
|
||||
| "aac"
|
||||
| "mp4"
|
||||
| "ogv"
|
||||
| "mov"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
mod unix {
|
||||
use super::*;
|
||||
@@ -8,24 +63,7 @@ mod unix {
|
||||
use std::fs::File;
|
||||
use std::io::{self, Write};
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
const LIFETIME: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const DIRECTORY: &str = "terminal-images";
|
||||
|
||||
fn extension(mime: &str) -> IdeResult<&str> {
|
||||
match mime {
|
||||
"image/png" => Ok("png"),
|
||||
"image/jpeg" => Ok("jpg"),
|
||||
"image/gif" => Ok("gif"),
|
||||
"image/webp" => Ok("webp"),
|
||||
"image/bmp" => Ok("bmp"),
|
||||
"image/svg+xml" => Ok("svg"),
|
||||
"image/tiff" => Ok("tiff"),
|
||||
"image/x-icon" => Ok("ico"),
|
||||
_ => Err(IdeError::invalid("unsupported image type")),
|
||||
}
|
||||
}
|
||||
use std::time::SystemTime;
|
||||
|
||||
fn open_directory(data_dir: &Path) -> io::Result<File> {
|
||||
if !data_dir.is_absolute() {
|
||||
@@ -55,14 +93,6 @@ mod unix {
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
fn owned_name(name: &str) -> bool {
|
||||
let name = name.strip_prefix(".partial-").unwrap_or(name);
|
||||
let Some((id, ext)) = name.strip_prefix("image-").and_then(|s| s.split_once('.')) else { return false };
|
||||
id.len() == 32
|
||||
&& id.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
|
||||
&& matches!(ext, "png" | "jpg" | "gif" | "webp" | "bmp" | "svg" | "tiff" | "ico")
|
||||
}
|
||||
|
||||
fn cleanup(dir: &File, now: SystemTime) -> io::Result<()> {
|
||||
for entry in Dir::read_from(dir)? {
|
||||
let entry = entry?;
|
||||
@@ -140,6 +170,7 @@ mod unix {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::os::unix::fs::{PermissionsExt, symlink};
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn stores_bytes_with_private_modes_and_unpredictable_names() {
|
||||
@@ -211,10 +242,292 @@ mod unix {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod windows {
|
||||
use super::*;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{self, Write};
|
||||
use std::os::windows::fs::{MetadataExt, OpenOptionsExt};
|
||||
use std::time::SystemTime;
|
||||
use windows_sys::Win32::Storage::FileSystem::{
|
||||
FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_READ,
|
||||
FILE_SHARE_WRITE,
|
||||
};
|
||||
|
||||
fn open_directory(path: &Path) -> io::Result<File> {
|
||||
if !path.is_absolute() {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "data directory must be absolute"));
|
||||
}
|
||||
let dir = OpenOptions::new()
|
||||
.read(true)
|
||||
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE)
|
||||
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
|
||||
.open(path)?;
|
||||
let meta = dir.metadata()?;
|
||||
if !meta.is_dir() || meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"terminal media directory must not be a reparse point",
|
||||
));
|
||||
}
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
fn cleanup(path: &Path, now: SystemTime) -> io::Result<()> {
|
||||
for entry in fs::read_dir(path)? {
|
||||
let entry = entry?;
|
||||
if !entry.file_name().to_str().is_some_and(owned_name) {
|
||||
continue;
|
||||
}
|
||||
let file = match OpenOptions::new().read(true).custom_flags(FILE_FLAG_OPEN_REPARSE_POINT).open(entry.path())
|
||||
{
|
||||
Ok(file) => file,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let meta = file.metadata()?;
|
||||
if meta.is_file()
|
||||
&& meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0
|
||||
&& meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|time| now.duration_since(time).ok())
|
||||
.is_some_and(|age| age >= LIFETIME)
|
||||
{
|
||||
match fs::remove_file(entry.path()) {
|
||||
Ok(()) => (),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => (),
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn store_with(
|
||||
data_dir: &Path,
|
||||
data: &[u8],
|
||||
mime: &str,
|
||||
now: SystemTime,
|
||||
write: impl FnOnce(&mut File, &[u8]) -> io::Result<()>,
|
||||
) -> IdeResult<String> {
|
||||
let ext = extension(mime)?;
|
||||
let _parent = open_directory(data_dir)?;
|
||||
let path = data_dir.join(DIRECTORY);
|
||||
match fs::create_dir(&path) {
|
||||
Ok(()) => (),
|
||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => (),
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
let _dir = open_directory(&path)?;
|
||||
cleanup(&path, now)?;
|
||||
let name = format!("image-{}.{}", uuid::Uuid::new_v4().simple(), ext);
|
||||
let target = path.join(&name);
|
||||
let temporary = path.join(format!(".partial-{name}"));
|
||||
let mut file = OpenOptions::new().write(true).create_new(true).open(&temporary)?;
|
||||
let result = write(&mut file, data);
|
||||
drop(file);
|
||||
if let Err(error) = result {
|
||||
let _ = fs::remove_file(&temporary);
|
||||
return Err(error.into());
|
||||
}
|
||||
if let Err(error) = fs::hard_link(&temporary, &target) {
|
||||
let _ = fs::remove_file(&temporary);
|
||||
return Err(error.into());
|
||||
}
|
||||
let _ = fs::remove_file(&temporary);
|
||||
Ok(target.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
pub(in crate::ops::term) fn store(data_dir: &Path, data: &[u8], mime: &str) -> IdeResult<String> {
|
||||
store_with(data_dir, data, mime, SystemTime::now(), |file, bytes| file.write_all(bytes))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
fn junction(path: &Path, target: &Path) {
|
||||
let output = std::process::Command::new("powershell.exe")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
"New-Item -ItemType Junction -Path $env:OPENIDEA_TEST_JUNCTION -Target $env:OPENIDEA_TEST_TARGET -ErrorAction Stop | Out-Null",
|
||||
])
|
||||
.env("OPENIDEA_TEST_JUNCTION", path)
|
||||
.env("OPENIDEA_TEST_TARGET", target)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_reparse_data_and_storage_directories() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
let link = root.path().join("data");
|
||||
junction(&link, outside.path());
|
||||
assert!(store(&link, b"x", "image/png").is_err());
|
||||
assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 0);
|
||||
fs::remove_dir(&link).unwrap();
|
||||
let link = root.path().join(DIRECTORY);
|
||||
junction(&link, outside.path());
|
||||
assert!(store(root.path(), b"x", "image/png").is_err());
|
||||
assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 0);
|
||||
fs::remove_dir(&link).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expiry_only_removes_owned_regular_media_at_boundary() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
let path = root.path().join(DIRECTORY);
|
||||
fs::create_dir(&path).unwrap();
|
||||
let _dir = open_directory(&path).unwrap();
|
||||
let now = SystemTime::now();
|
||||
let names = [
|
||||
"image-00000000000000000000000000000001.png",
|
||||
"image-00000000000000000000000000000002.mp3",
|
||||
".partial-image-00000000000000000000000000000003.mp4",
|
||||
"image-00000000000000000000000000000004.webm",
|
||||
"image-00000000000000000000000000000005.wav",
|
||||
"unrelated.png",
|
||||
"image-00000000000000000000000000000006.exe",
|
||||
];
|
||||
for (name, time) in names.iter().zip([
|
||||
now - LIFETIME,
|
||||
now - LIFETIME,
|
||||
now - LIFETIME,
|
||||
now - LIFETIME + Duration::from_secs(1),
|
||||
now + Duration::from_secs(1),
|
||||
now - LIFETIME,
|
||||
now - LIFETIME,
|
||||
]) {
|
||||
let file = File::create(path.join(name)).unwrap();
|
||||
file.set_times(fs::FileTimes::new().set_modified(time)).unwrap();
|
||||
}
|
||||
let link = path.join("image-00000000000000000000000000000007.png");
|
||||
junction(&link, outside.path());
|
||||
let directory = path.join("image-00000000000000000000000000000008.png");
|
||||
fs::create_dir(&directory).unwrap();
|
||||
cleanup(&path, now).unwrap();
|
||||
for name in &names[..3] {
|
||||
assert!(!path.join(name).exists());
|
||||
}
|
||||
for name in &names[3..] {
|
||||
assert!(path.join(name).exists());
|
||||
}
|
||||
assert!(directory.is_dir());
|
||||
assert!(fs::symlink_metadata(&link).unwrap().file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0);
|
||||
fs::remove_dir(link).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_write_removes_partial_file() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let result = store_with(root.path(), b"partial", "audio/ogg", SystemTime::now(), |file, bytes| {
|
||||
file.write_all(bytes)?;
|
||||
Err(io::Error::other("simulated failure"))
|
||||
});
|
||||
assert!(result.is_err());
|
||||
assert_eq!(fs::read_dir(root.path().join(DIRECTORY)).unwrap().count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publication_preserves_existing_target() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let path = root.path().join(DIRECTORY);
|
||||
let result = store_with(root.path(), b"new", "video/mp4", SystemTime::now(), |file, bytes| {
|
||||
file.write_all(bytes)?;
|
||||
let temporary = fs::read_dir(&path)?.next().unwrap()?.file_name();
|
||||
let target = temporary.to_str().unwrap().strip_prefix(".partial-").unwrap();
|
||||
fs::write(path.join(target), b"existing")
|
||||
});
|
||||
assert!(result.is_err());
|
||||
let files: Vec<_> = fs::read_dir(&path).unwrap().map(|entry| entry.unwrap().path()).collect();
|
||||
assert_eq!(files.len(), 1);
|
||||
assert_eq!(fs::read(&files[0]).unwrap(), b"existing");
|
||||
assert!(!files[0].file_name().unwrap().to_str().unwrap().starts_with(".partial-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_handles_prevent_directory_replacement() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let path = root.path().join(DIRECTORY);
|
||||
let result = store_with(root.path(), b"x", "image/png", SystemTime::now(), |file, bytes| {
|
||||
assert!(fs::rename(&path, root.path().join("replaced")).is_err());
|
||||
file.write_all(bytes)
|
||||
});
|
||||
assert_eq!(fs::read(result.unwrap()).unwrap(), b"x");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(super) use unix::store;
|
||||
|
||||
#[cfg(not(unix))]
|
||||
#[cfg(windows)]
|
||||
pub(super) use windows::store;
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
pub(super) fn store(_data_dir: &Path, _data: &[u8], _mime: &str) -> IdeResult<String> {
|
||||
Err(IdeError::internal("private terminal image storage is unsupported on this platform"))
|
||||
}
|
||||
|
||||
#[cfg(all(test, any(unix, windows)))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn stores_supported_media_with_expected_extensions_and_unique_names() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let types = [
|
||||
("image/png", "png"),
|
||||
("image/jpeg", "jpg"),
|
||||
("image/gif", "gif"),
|
||||
("image/webp", "webp"),
|
||||
("image/bmp", "bmp"),
|
||||
("image/svg+xml", "svg"),
|
||||
("image/tiff", "tiff"),
|
||||
("image/x-icon", "ico"),
|
||||
("audio/wav", "wav"),
|
||||
("audio/x-wav", "wav"),
|
||||
("audio/mpeg", "mp3"),
|
||||
("audio/mp4", "m4a"),
|
||||
("audio/ogg", "ogg"),
|
||||
("audio/webm", "webm"),
|
||||
("audio/flac", "flac"),
|
||||
("audio/aac", "aac"),
|
||||
("video/mp4", "mp4"),
|
||||
("video/webm", "webm"),
|
||||
("video/ogg", "ogv"),
|
||||
("video/quicktime", "mov"),
|
||||
];
|
||||
let mut paths = std::collections::HashSet::new();
|
||||
for (mime, ext) in types {
|
||||
let path = store(root.path(), mime.as_bytes(), mime).unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), mime.as_bytes());
|
||||
assert_eq!(Path::new(&path).extension().unwrap(), ext);
|
||||
assert!(owned_name(Path::new(&path).file_name().unwrap().to_str().unwrap()));
|
||||
assert!(paths.insert(path));
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(root.path().join(DIRECTORY)).unwrap().count(), types.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_mime_before_creating_storage() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
for mime in ["text/plain", "application/octet-stream", "image/unknown", "audio/unknown", "video/unknown", ""] {
|
||||
let error = store(root.path(), b"x", mime).unwrap_err();
|
||||
assert_eq!(error.kind, ide_core::error::ErrorKind::InvalidInput);
|
||||
assert_eq!(error.message, "unsupported clipboard media type");
|
||||
}
|
||||
assert!(!root.path().join(DIRECTORY).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_relative_data_directory() {
|
||||
assert!(store(Path::new("relative-data"), b"x", "image/png").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#![cfg(unix)]
|
||||
#![cfg(any(unix, windows))]
|
||||
|
||||
use ide_core::{NullSink, TerminalId};
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
@@ -28,8 +28,8 @@ async fn storage_requires_a_registered_terminal() {
|
||||
.spawn(
|
||||
TerminalOptions {
|
||||
cwd: root.path().into(),
|
||||
shell: Some("/bin/sh".into()),
|
||||
args: vec![],
|
||||
shell: Some(if cfg!(windows) { "powershell.exe" } else { "/bin/sh" }.into()),
|
||||
args: if cfg!(windows) { vec!["-NoLogo".into(), "-NoProfile".into()] } else { vec![] },
|
||||
env: Default::default(),
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
@@ -40,9 +40,12 @@ async fn storage_requires_a_registered_terminal() {
|
||||
Box::new(|_| {}),
|
||||
)
|
||||
.unwrap();
|
||||
let path = ops::term::store_image(&host, terminal.id, vec![1, 2, 3], "image/png".into()).await.unwrap();
|
||||
assert_eq!(std::fs::read(path).unwrap(), [1, 2, 3]);
|
||||
for mime in ["image/png", "audio/mpeg", "video/mp4"] {
|
||||
let path = ops::term::store_image(&host, terminal.id, vec![1, 2, 3], mime.into()).await.unwrap();
|
||||
assert_eq!(std::fs::read(path).unwrap(), [1, 2, 3]);
|
||||
}
|
||||
assert!(ops::term::store_image(&host, terminal.id, vec![1], "application/octet-stream".into()).await.is_err());
|
||||
host.terminals.kill(terminal.id).unwrap();
|
||||
assert!(ops::term::store_image(&host, terminal.id, vec![4], "image/png".into()).await.is_err());
|
||||
assert_eq!(std::fs::read_dir(data_dir.join("terminal-images")).unwrap().count(), 1);
|
||||
assert_eq!(std::fs::read_dir(data_dir.join("terminal-images")).unwrap().count(), 3);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("local terminal stores clipboard media and pastes one shell-quoted argument", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const observed = await page.evaluate(async () => {
|
||||
const ipcPath = "/src/platform/ipc.ts";
|
||||
const sessionPath = "/src/features/terminal/session.ts";
|
||||
const { commands } = await import(ipcPath);
|
||||
const { TerminalSession } = await import(sessionPath);
|
||||
Object.defineProperty(window, "__TAURI_INTERNALS__", {
|
||||
configurable: true,
|
||||
value: { transformCallback: () => 1 },
|
||||
});
|
||||
const stored: { id: number; data: number[]; mime: string }[] = [];
|
||||
const writes: string[] = [];
|
||||
commands.termSpawn = async () => ({ status: "ok", data: { id: 838, title: "pwsh", pid: 1 } });
|
||||
commands.clipboardFileList = async () => ({ status: "ok", data: [] });
|
||||
commands.termStoreImage = async (id: number, data: number[], mime: string) => {
|
||||
stored.push({ id, data, mime });
|
||||
return { status: "ok", data: "C:\\Temp\\a'b c\\image.png" };
|
||||
};
|
||||
commands.termWrite = async (_id: number, data: string) => writes.push(data);
|
||||
commands.termResize = async () => undefined;
|
||||
commands.termKill = async () => undefined;
|
||||
const container = document.createElement("div");
|
||||
container.style.cssText = "position:fixed;inset:0;z-index:99999";
|
||||
document.body.append(container);
|
||||
const session = new TerminalSession({ key: "clipboard", cwd: "C:/Temp", onChange() {}, onFocus() {} });
|
||||
try {
|
||||
session.attach(container);
|
||||
await session.start();
|
||||
const transfer = new DataTransfer();
|
||||
transfer.items.add(new File([new Uint8Array([137, 80, 78, 71])], "image.png", { type: "image/png" }));
|
||||
const event = new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: transfer });
|
||||
container.querySelector("textarea")!.dispatchEvent(event);
|
||||
const deadline = Date.now() + 5000;
|
||||
while (!writes.length && Date.now() < deadline) await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
return { stored, writes, claimed: event.defaultPrevented };
|
||||
} finally {
|
||||
session.dispose();
|
||||
container.remove();
|
||||
}
|
||||
});
|
||||
expect(observed.claimed).toBe(true);
|
||||
expect(observed.stored).toEqual([{ id: 838, data: [137, 80, 78, 71], mime: "image/png" }]);
|
||||
expect(observed.writes).toEqual(["'C:\\Temp\\a''b c\\image.png'"]);
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { commands } from "@/platform/ipc";
|
||||
import { installTerminalPaste, pasteTerminalClipboard, type RemotePasteTarget } from "./paste";
|
||||
import { installTerminalPaste, pasteTerminalClipboard, type TerminalPasteTarget } from "./paste";
|
||||
|
||||
const notify = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/workbench/contributions", () => ({ notifications: () => ({ notify }) }));
|
||||
@@ -98,8 +98,8 @@ function imageEvent(blob: Blob, text = "") {
|
||||
return event;
|
||||
}
|
||||
|
||||
function remoteTarget(current = () => true): RemotePasteTarget {
|
||||
return { id: 42 as RemotePasteTarget["id"], supported: true, current };
|
||||
function remoteTarget(current = () => true): TerminalPasteTarget {
|
||||
return { id: 42 as TerminalPasteTarget["id"], supported: true, remote: true, shell: "sh", current };
|
||||
}
|
||||
|
||||
it.each(["event", "menu"])("rejects oversized %s images before reading or uploading", async (route) => {
|
||||
@@ -123,8 +123,8 @@ it.each(["event", "menu"])("rejects oversized %s images before reading or upload
|
||||
await vi.waitFor(() =>
|
||||
expect(notify).toHaveBeenCalledWith({
|
||||
level: "error",
|
||||
title: "Cannot paste image",
|
||||
body: "Image is too large to paste (limit 20 MB).",
|
||||
title: "Cannot paste clipboard media",
|
||||
body: "Clipboard media is too large to paste (limit 20 MB).",
|
||||
}),
|
||||
);
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
@@ -362,3 +362,166 @@ it("leaves local image and remote text events to existing paste handlers", () =>
|
||||
expect(store).not.toHaveBeenCalled();
|
||||
local();
|
||||
});
|
||||
|
||||
function localTarget(shell = "sh", current = () => true): TerminalPasteTarget {
|
||||
return { ...remoteTarget(current), remote: false, shell };
|
||||
}
|
||||
|
||||
it.each(["event", "menu"])("stores raw local clipboard media through %s paste", async (route) => {
|
||||
const store = vi.spyOn(commands, "termStoreImage").mockResolvedValue({ status: "ok", data: "/tmp/a'b c.png" });
|
||||
const host = document.createElement("div");
|
||||
const paste = vi.fn();
|
||||
const remove = installTerminalPaste(host, paste, () => localTarget());
|
||||
if (route === "event") {
|
||||
host.addEventListener("paste", () => paste("xterm"));
|
||||
const event = imageEvent(image());
|
||||
host.dispatchEvent(event);
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
} else {
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: { readText: async () => "", read: async () => [{ types: ["image/png"], getType: async () => image() }] },
|
||||
});
|
||||
await pasteTerminalClipboard(paste, () => localTarget());
|
||||
}
|
||||
await vi.waitFor(() => expect(paste).toHaveBeenCalledExactlyOnceWith("'/tmp/a'\\''b c.png'"));
|
||||
expect(store).toHaveBeenCalledWith(42, [1, 2], "image/png");
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
remove();
|
||||
});
|
||||
|
||||
it.each(["event", "menu"])(
|
||||
"keeps existing copied files on their original quoted paths during %s paste",
|
||||
async (route) => {
|
||||
const paths = ["/a b/photo.png", "/it's here/clip.mp4"];
|
||||
vi.spyOn(commands, "clipboardFileList").mockResolvedValue({ status: "ok", data: paths });
|
||||
const store = vi.spyOn(commands, "termStoreImage");
|
||||
const host = document.createElement("div");
|
||||
const paste = vi.fn();
|
||||
const remove = installTerminalPaste(host, paste, () => localTarget());
|
||||
if (route === "event") host.dispatchEvent(imageEvent(image(), paths.join("\n")));
|
||||
else {
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: {
|
||||
readText: async () => paths.join("\r\n"),
|
||||
read: async () => [{ types: ["image/png"], getType: async () => image() }],
|
||||
},
|
||||
});
|
||||
await pasteTerminalClipboard(paste, () => localTarget());
|
||||
}
|
||||
await vi.waitFor(() => expect(paste).toHaveBeenCalledExactlyOnceWith("'/a b/photo.png' '/it'\\''s here/clip.mp4'"));
|
||||
expect(store).not.toHaveBeenCalled();
|
||||
remove();
|
||||
},
|
||||
);
|
||||
|
||||
it("reads file URI lists without relying on a native file-list backend", async () => {
|
||||
const store = vi.spyOn(commands, "termStoreImage");
|
||||
const host = document.createElement("div");
|
||||
const paste = vi.fn();
|
||||
const remove = installTerminalPaste(host, paste, () => localTarget());
|
||||
const event = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, "clipboardData", {
|
||||
value: {
|
||||
files: [],
|
||||
types: ["text/uri-list"],
|
||||
getData: (type: string) =>
|
||||
type === "text/uri-list"
|
||||
? "# files\r\nfile:///tmp/a%20b.png\r\nhttps://example.com\r\nfile:///tmp/%E4%B8%AD%E6%96%87.mp4"
|
||||
: "",
|
||||
},
|
||||
});
|
||||
host.dispatchEvent(event);
|
||||
await vi.waitFor(() => expect(paste).toHaveBeenCalledExactlyOnceWith("'/tmp/a b.png' '/tmp/中文.mp4'"));
|
||||
expect(store).not.toHaveBeenCalled();
|
||||
remove();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ shell: "pwsh", path: "C:\\Users\\a'b $c\\image.png", expected: "'C:\\Users\\a''b $c\\image.png'" },
|
||||
{ shell: "powershell", path: "C:\\a b\\image.png", expected: "'C:\\a b\\image.png'" },
|
||||
{ shell: "zsh", path: "/tmp/a'b $中文.png", expected: "'/tmp/a'\\''b $中文.png'" },
|
||||
{ shell: "bash", path: "/tmp/a'b $中文.png", expected: "'/tmp/a'\\''b $中文.png'" },
|
||||
{ shell: "fish", path: "/tmp/a'b $中文.png", expected: "'/tmp/a\\'b $中文.png'" },
|
||||
{ shell: "fish", path: "/tmp/a\\\\b\\'中文.png", expected: "'/tmp/a\\\\\\\\b\\\\\\'中文.png'" },
|
||||
{ shell: "cmd", path: "C:\\a&b\\image.png", expected: '"C:\\a&b\\image.png"' },
|
||||
{ shell: "cmd.exe", path: "C:\\a b $中文\\image.png", expected: '"C:\\a b $中文\\image.png"' },
|
||||
])("quotes a stored path for $shell", async ({ shell, path, expected }) => {
|
||||
vi.spyOn(commands, "termStoreImage").mockResolvedValue({ status: "ok", data: path });
|
||||
const host = document.createElement("div");
|
||||
const paste = vi.fn();
|
||||
const remove = installTerminalPaste(host, paste, () => localTarget(shell));
|
||||
host.dispatchEvent(imageEvent(image()));
|
||||
await vi.waitFor(() => expect(paste).toHaveBeenCalledExactlyOnceWith(expected));
|
||||
remove();
|
||||
});
|
||||
|
||||
it.each(["audio/wav", "video/mp4"])("stores raw %s clipboard data", async (type) => {
|
||||
const store = vi.spyOn(commands, "termStoreImage").mockResolvedValue({ status: "ok", data: "/tmp/media" });
|
||||
const host = document.createElement("div");
|
||||
const paste = vi.fn();
|
||||
const remove = installTerminalPaste(host, paste, () => localTarget());
|
||||
const event = imageEvent({ ...image(), type } as Blob);
|
||||
host.dispatchEvent(event);
|
||||
await vi.waitFor(() => expect(paste).toHaveBeenCalledExactlyOnceWith("'/tmp/media'"));
|
||||
expect(store).toHaveBeenCalledWith(42, [1, 2], type);
|
||||
remove();
|
||||
});
|
||||
|
||||
it("ignores local media when its terminal is replaced during file recovery", async () => {
|
||||
let current = true;
|
||||
let finish!: (value: { status: "ok"; data: string[] }) => void;
|
||||
vi.spyOn(commands, "clipboardFileList").mockImplementation(() => new Promise((resolve) => (finish = resolve)));
|
||||
const store = vi.spyOn(commands, "termStoreImage");
|
||||
const host = document.createElement("div");
|
||||
const paste = vi.fn();
|
||||
const remove = installTerminalPaste(host, paste, () => localTarget("sh", () => current));
|
||||
host.dispatchEvent(imageEvent(image()));
|
||||
await vi.waitFor(() => expect(finish).toBeDefined());
|
||||
current = false;
|
||||
finish({ status: "ok", data: [] });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(store).not.toHaveBeenCalled();
|
||||
expect(paste).not.toHaveBeenCalled();
|
||||
remove();
|
||||
});
|
||||
|
||||
it.each(["event", "menu"])("rejects cmd expansion in copied file paths through %s paste", async (route) => {
|
||||
vi.spyOn(commands, "clipboardFileList").mockResolvedValue({ status: "ok", data: ["C:\\clips\\%OS%!clip!.png"] });
|
||||
const store = vi.spyOn(commands, "termStoreImage");
|
||||
const host = document.createElement("div");
|
||||
const paste = vi.fn();
|
||||
const remove = installTerminalPaste(host, paste, () => localTarget("cmd"));
|
||||
if (route === "event") host.dispatchEvent(imageEvent(image()));
|
||||
else {
|
||||
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { readText: async () => "" } });
|
||||
await pasteTerminalClipboard(paste, () => localTarget("cmd"));
|
||||
}
|
||||
await vi.waitFor(() =>
|
||||
expect(notify).toHaveBeenCalledWith({
|
||||
level: "error",
|
||||
title: "Cannot paste clipboard media",
|
||||
body: "Cannot paste this path safely in cmd.exe. Use PowerShell or a POSIX shell.",
|
||||
}),
|
||||
);
|
||||
expect(paste).not.toHaveBeenCalled();
|
||||
expect(store).not.toHaveBeenCalled();
|
||||
remove();
|
||||
});
|
||||
|
||||
it("recovers quoted local file paths when clipboard text permission is denied", async () => {
|
||||
vi.spyOn(commands, "clipboardFileList").mockResolvedValue({ status: "ok", data: ["C:\\a b\\image.png"] });
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: {
|
||||
readText: async () => {
|
||||
throw new Error("denied");
|
||||
},
|
||||
},
|
||||
});
|
||||
const paste = vi.fn();
|
||||
await pasteTerminalClipboard(paste, () => localTarget("pwsh"));
|
||||
expect(paste).toHaveBeenCalledExactlyOnceWith("'C:\\a b\\image.png'");
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -3,31 +3,75 @@ import { call, commands, type TerminalId } from "@/platform/ipc";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
|
||||
export interface RemotePasteTarget {
|
||||
export interface TerminalPasteTarget {
|
||||
id: TerminalId;
|
||||
supported: boolean;
|
||||
remote: boolean;
|
||||
shell: string;
|
||||
current: () => boolean;
|
||||
}
|
||||
|
||||
type Target = () => RemotePasteTarget | null;
|
||||
type Target = () => TerminalPasteTarget | null;
|
||||
|
||||
const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
|
||||
const MAX_MEDIA_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
async function pasteImage(image: Blob, target: RemotePasteTarget, paste: (text: string) => void) {
|
||||
function pathsText(paths: readonly string[], target: TerminalPasteTarget): string {
|
||||
const shell = target.shell.toLowerCase().replace(/\.exe$/, "");
|
||||
return paths
|
||||
.map((path) => {
|
||||
if (shell === "cmd") {
|
||||
if (/[%!"\r\n]/.test(path))
|
||||
throw new Error(tx("Cannot paste this path safely in cmd.exe. Use PowerShell or a POSIX shell."));
|
||||
return `"${path}"`;
|
||||
}
|
||||
if (shell === "powershell" || shell === "pwsh") return `'${path.replaceAll("'", "''")}'`;
|
||||
if (shell === "fish") return `'${path.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
|
||||
return `'${path.replaceAll("'", "'\\''")}'`;
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function isMedia(type: string): boolean {
|
||||
return /^(image|audio|video)\//.test(type);
|
||||
}
|
||||
|
||||
function pasteError(error: unknown, target: TerminalPasteTarget | null | undefined) {
|
||||
if (target?.current())
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: tx("Cannot paste clipboard media"),
|
||||
body: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
function filePaths(transfer: DataTransfer): string[] {
|
||||
const paths: string[] = [];
|
||||
for (const line of transfer.getData("text/uri-list").split(/\r?\n/)) {
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
try {
|
||||
const url = new URL(line);
|
||||
if (url.protocol !== "file:") continue;
|
||||
let path = decodeURIComponent(url.pathname);
|
||||
if (url.hostname && url.hostname !== "localhost") path = `//${url.hostname}${path}`;
|
||||
else if (/^\/[A-Za-z]:\//.test(path)) path = path.slice(1);
|
||||
paths.push(path);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
async function pasteMedia(media: Blob, target: TerminalPasteTarget, paste: (text: string) => void) {
|
||||
try {
|
||||
if (image.size > MAX_IMAGE_BYTES) throw new Error(tx`Image is too large to paste (limit ${20} MB).`);
|
||||
const data = Array.from(new Uint8Array(await image.arrayBuffer()));
|
||||
if (media.size > MAX_MEDIA_BYTES) throw new Error(tx`Clipboard media is too large to paste (limit ${20} MB).`);
|
||||
const data = Array.from(new Uint8Array(await media.arrayBuffer()));
|
||||
if (!target.current()) return;
|
||||
if (!target.supported) throw new Error(tx("Remote server does not support image paste."));
|
||||
const path = await call(commands.termStoreImage(target.id, data, image.type));
|
||||
if (target.current()) paste(`'${path.replaceAll("'", "'\\''")}'`);
|
||||
const path = await call(commands.termStoreImage(target.id, data, media.type));
|
||||
if (target.current()) paste(pathsText([path], target));
|
||||
} catch (error) {
|
||||
if (target.current())
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: tx("Cannot paste image"),
|
||||
body: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
pasteError(error, target);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,12 +80,29 @@ export function installTerminalPaste(host: HTMLElement, paste: (text: string) =>
|
||||
const onPaste = (event: ClipboardEvent) => {
|
||||
const transfer = event.clipboardData;
|
||||
const target = getTarget?.();
|
||||
if (!event.defaultPrevented && target && transfer && !transfer.getData("text/plain")) {
|
||||
const image = Array.from(transfer.files).find((file) => file.type.startsWith("image/"));
|
||||
if (image) {
|
||||
if (!event.defaultPrevented && target && transfer) {
|
||||
const media = Array.from(transfer.files).find((file) => isMedia(file.type));
|
||||
const files =
|
||||
transfer.files.length ||
|
||||
Array.from(transfer.types).some((type) => type === "Files" || type === "text/uri-list");
|
||||
if ((!target.remote && files) || (media && !transfer.getData("text/plain"))) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
void pasteImage(image, { ...target, current: () => alive && target.current() }, paste);
|
||||
const captured = { ...target, current: () => alive && target.current() };
|
||||
void (async () => {
|
||||
if (!captured.remote) {
|
||||
const paths = filePaths(transfer);
|
||||
const text = paths.length
|
||||
? pathsText(paths, captured)
|
||||
: await readClipboardText(transfer, (paths) => pathsText(paths, captured));
|
||||
if (!captured.current()) return;
|
||||
if (text) {
|
||||
paste(text);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (media) await pasteMedia(media, captured, paste);
|
||||
})().catch((error) => pasteError(error, captured));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -68,7 +129,9 @@ export async function pasteTerminalClipboard(paste: (text: string) => void, getT
|
||||
(error: unknown) => ({ error }),
|
||||
)
|
||||
: null;
|
||||
const text = target ? ((await navigator.clipboard?.readText().catch(() => "")) ?? "") : await readClipboardText();
|
||||
const text = target?.remote
|
||||
? ((await navigator.clipboard?.readText().catch(() => "")) ?? "")
|
||||
: await readClipboardText(undefined, target ? (paths) => pathsText(paths, target) : undefined);
|
||||
if (target && !target.current()) return;
|
||||
if (text) {
|
||||
paste(text);
|
||||
@@ -80,24 +143,19 @@ export async function pasteTerminalClipboard(paste: (text: string) => void, getT
|
||||
if ("error" in result) throw result.error;
|
||||
if (!target.current()) return;
|
||||
for (const item of result.items) {
|
||||
const mime = item.types.find((type) => type.startsWith("image/"));
|
||||
const mime = item.types.find(isMedia);
|
||||
if (mime) {
|
||||
const image = await item.getType(mime);
|
||||
if (target.current()) await pasteImage(image, target, paste);
|
||||
if (target.current()) await pasteMedia(image, target, paste);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
const paths = await readClipboardText();
|
||||
const paths = await readClipboardText(undefined, target.remote ? undefined : (paths) => pathsText(paths, target));
|
||||
if (!target.current()) return;
|
||||
if (paths) paste(paths);
|
||||
else if (!images) throw new Error(tx("Clipboard image reading is unavailable."));
|
||||
} catch (error) {
|
||||
if (target?.current())
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: tx("Cannot paste image"),
|
||||
body: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
pasteError(error, target);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,7 +286,7 @@ export class TerminalSession {
|
||||
}),
|
||||
);
|
||||
this.disposables.push(
|
||||
{ dispose: installTerminalPaste(this.host, (text) => this.paste(text), this.remotePasteTarget) },
|
||||
{ dispose: installTerminalPaste(this.host, (text) => this.paste(text), this.clipboardTarget) },
|
||||
{ dispose: installTerminalDrop(this.host, (text) => this.paste(text)) },
|
||||
);
|
||||
this.host.addEventListener("mousedown", this.onMiddleClick);
|
||||
@@ -298,14 +298,16 @@ export class TerminalSession {
|
||||
void this.pasteClipboard();
|
||||
};
|
||||
|
||||
private remotePasteTarget = () => {
|
||||
private clipboardTarget = () => {
|
||||
const remote = useRemote.getState().session;
|
||||
const id = this.ptyId;
|
||||
if (!remote || id === null || this.disposed) return null;
|
||||
if (id === null || this.disposed) return null;
|
||||
const generation = this.generation;
|
||||
return {
|
||||
id,
|
||||
supported: remote.commands.includes("term_store_image"),
|
||||
supported: !remote || remote.commands.includes("term_store_image"),
|
||||
remote: !!remote,
|
||||
shell: this.snapshot.title,
|
||||
current: () =>
|
||||
!this.disposed &&
|
||||
this.ptyId === id &&
|
||||
@@ -315,8 +317,8 @@ export class TerminalSession {
|
||||
};
|
||||
|
||||
pasteClipboard() {
|
||||
if (this.remotePasteTarget()) this.focus();
|
||||
return pasteTerminalClipboard((text) => this.paste(text), this.remotePasteTarget);
|
||||
if (this.clipboardTarget()) this.focus();
|
||||
return pasteTerminalClipboard((text) => this.paste(text), this.clipboardTarget);
|
||||
}
|
||||
/** Applies Settings › Tools › Terminal changes to a live session. */
|
||||
applySettings(t: TerminalSettingsValid) {
|
||||
|
||||
@@ -9,16 +9,19 @@ export async function readClipboardText(
|
||||
transfer?: DataTransfer | null,
|
||||
formatFiles: FormatFiles = rawPaths,
|
||||
): Promise<string> {
|
||||
const text = transfer ? transfer.getData("text/plain") : ((await navigator.clipboard?.readText()) ?? "");
|
||||
const text = transfer
|
||||
? transfer.getData("text/plain")
|
||||
: ((await navigator.clipboard?.readText().catch(() => "")) ?? "");
|
||||
if ((text && formatFiles === rawPaths) || (transfer && !hasClipboardFiles(transfer)) || !isTauri()) return text;
|
||||
let paths: string[];
|
||||
try {
|
||||
const paths = await call(commands.clipboardFileList());
|
||||
// Windows also supplies the file list as text; unrelated text still wins.
|
||||
if (text && text.replace(/\r\n/g, "\n").split("\n").map(ipcPath).join("\n") !== paths.join("\n")) return text;
|
||||
return formatFiles(paths);
|
||||
paths = await call(commands.clipboardFileList());
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
// Windows also supplies the file list as text; unrelated text still wins.
|
||||
if (text && text.replace(/\r\n/g, "\n").split("\n").map(ipcPath).join("\n") !== paths.join("\n")) return text;
|
||||
return formatFiles(paths);
|
||||
}
|
||||
|
||||
function hasClipboardFiles(transfer: DataTransfer): boolean {
|
||||
|
||||
@@ -5499,10 +5499,12 @@
|
||||
".NET launch profile '{profile_name}' is missing or is not a Project profile": ".NET 启动配置“{profile_name}”不存在或不是 Project 类型",
|
||||
".NET output resolution timed out": ".NET 构建产物路径查询超时",
|
||||
".NET build output is missing; run dotnet build before debugging": ".NET 构建产物不存在,请在调试前运行 dotnet build",
|
||||
"Cannot paste image": "无法粘贴图片",
|
||||
"Cannot paste clipboard media": "无法粘贴剪贴板媒体",
|
||||
"Cannot paste this path safely in cmd.exe. Use PowerShell or a POSIX shell.": "无法在 cmd.exe 中安全粘贴此路径。请使用 PowerShell 或 POSIX Shell。",
|
||||
"Remote server does not support image paste.": "远程服务器不支持粘贴图片。",
|
||||
"Clipboard image reading is unavailable.": "无法读取剪贴板图片。",
|
||||
"Image is too large to paste (limit {limit} MB).": "图片过大,无法粘贴(上限 {limit} MB)。",
|
||||
"Clipboard media is too large to paste (limit {limit} MB).": "剪贴板媒体过大,无法粘贴(上限 {limit} MB)。",
|
||||
"unsupported clipboard media type": "不支持的剪贴板媒体类型",
|
||||
"Show inline blame for the current line": "为当前行显示内联 Git Blame",
|
||||
"You, now · Uncommitted changes": "你,刚刚 · 未提交的更改",
|
||||
"now": "刚刚",
|
||||
|
||||
Reference in New Issue
Block a user