feat(editor): paste and drop images into Markdown files (#489)

Pasting or dropping an image into a Markdown editor saves it beside the document in `assets/` and inserts a URL-encoded relative link as one undo step, in local and remote projects. Existing names get numbered suffixes; the folder and clipboard name pattern are in Settings › Editor › General. Non-Markdown editors, text paste and other dropped files keep their behavior.

Fixes #308
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-31 08:48:10 +08:00
1 джерело f592ff5b05
коміт df6ed5ccba
18 files changed
+498 -2

No files matched your search

+4
Переглянути файл
@@ -12,6 +12,8 @@ use std::collections::BTreeMap;
pub struct EditorGeneralSettings {
/// Change font size with ⌘ + mouse wheel.
pub wheel_zoom: bool,
pub markdown_image_folder: String,
pub markdown_image_name_pattern: String,
/// Drag-and-drop of selected text inside the editor.
pub drag_and_drop: bool,
/// When the caret moves out of view: `minimize` scrolling | `center` the caret line.
@@ -35,6 +37,8 @@ impl Default for EditorGeneralSettings {
fn default() -> Self {
Self {
wheel_zoom: false,
markdown_image_folder: "assets".into(),
markdown_image_name_pattern: "image-{timestamp}".into(),
drag_and_drop: true,
caret_scroll: "minimize".into(),
soft_wrap_files: "*.md; *.txt; *.rst; *.adoc".into(),
+4
Переглянути файл
@@ -49,6 +49,10 @@ pub fn create_file(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
}
}
pub async fn create_file_bytes(host: &Host, path: PathBuf, data: Vec<u8>) -> IdeResult<()> {
host.blocking(move || ide_vfs::create_file_bytes(&path, &data)).await
}
/// Toggle Read-only Attribute: sets the writable bit (and the open document's
/// state). Returns whether the file is read-only now.
pub fn set_writable(host: &Host, window: &str, path: PathBuf, writable: bool) -> IdeResult<bool> {
+3
Переглянути файл
@@ -168,6 +168,9 @@ impl Dispatcher {
.add("cancel_task", |c| async move { Ok(ide_core::task::cancel(c.arg::<TaskId>("id")?)) })
.add("fs_read_dir", |c| async move { ops::fs::read_dir(&c.host, &c.window, c.arg("path")?) })
.add("fs_create_file", |c| async move { ops::fs::create_file(&c.host, &c.window, c.arg("path")?) })
.add("fs_create_file_bytes", |c| async move {
ops::fs::create_file_bytes(&c.host, c.arg("path")?, c.arg("data")?).await
})
.add("fs_create_dir", |c| async move { ide_vfs::create_dir(&c.arg::<PathBuf>("path")?) })
.add("fs_rename", |c| async move { ide_vfs::rename(&c.arg::<PathBuf>("from")?, &c.arg::<PathBuf>("to")?) })
.add("fs_set_writable", |c| async move {
+6
Переглянути файл
@@ -232,6 +232,12 @@ pub fn save_text(path: &Path, text: &str, encoding: &str, has_bom: bool) -> IdeR
pub fn create_file(path: &Path) -> IdeResult<()> {
ops::create_file(path)
}
/// Creates a binary file exclusively, including missing parent directories.
pub fn create_file_bytes(path: &Path, data: &[u8]) -> IdeResult<()> {
ops::create_file_bytes(path, data)
}
/// Creates a directory and missing parents; `AlreadyExists` if present.
pub fn create_dir(path: &Path) -> IdeResult<()> {
ops::create_dir(path)
+11
Переглянути файл
@@ -35,6 +35,17 @@ pub(crate) fn create_file(path: &Path) -> IdeResult<()> {
Ok(())
}
pub(crate) fn create_file_bytes(path: &Path, data: &[u8]) -> IdeResult<()> {
use std::io::Write;
if exists(path) {
return Err(already_exists(path));
}
ensure_parent(path)?;
let mut file = fs::OpenOptions::new().write(true).create_new(true).open(path)?;
file.write_all(data)?;
Ok(())
}
/// IDEA's "Toggle Read-only Attribute": read-only clears every write bit,
/// writable adds the owner's.
pub(crate) fn set_writable(path: &Path, writable: bool) -> IdeResult<()> {
+31
Переглянути файл
@@ -367,3 +367,34 @@ fn worktree_and_nested_repository_local_excludes_are_reflected_in_listings() {
let entries = ide_vfs::read_dir(&nested, &[]).unwrap();
assert!(entries.iter().find(|e| e.name == "private").unwrap().ignored);
}
#[test]
fn create_file_bytes_is_exclusive() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("image.png");
let bytes = [0, 255, 128, 10];
ide_vfs::create_file_bytes(&path, &bytes).unwrap();
let error = ide_vfs::create_file_bytes(&path, b"replacement").unwrap_err();
assert_eq!(error.kind, ide_core::error::ErrorKind::AlreadyExists);
assert_eq!(std::fs::read(&path).unwrap(), bytes);
}
#[test]
fn create_file_bytes_creates_parents() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nested/assets/image.png");
ide_vfs::create_file_bytes(&path, &[0, 255]).unwrap();
assert_eq!(std::fs::read(path).unwrap(), [0, 255]);
}
#[cfg(unix)]
#[test]
fn create_file_bytes_does_not_follow_existing_symlink() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("target.png");
let link = dir.path().join("link.png");
std::os::unix::fs::symlink(&target, &link).unwrap();
let error = ide_vfs::create_file_bytes(&link, b"data").unwrap_err();
assert_eq!(error.kind, ide_core::error::ErrorKind::AlreadyExists);
assert!(!target.exists());
}