ElyMark: dropped and pasted pictures copy into assets and link at the caret (C14.10)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
72e9441399
commit
b74f4e4e72
12 files changed
+357
-63
No files matched your search
@@ -0,0 +1,299 @@
|
||||
//! Pictures dropped or pasted, copied beside the file (MARK-07).
|
||||
|
||||
use std::{
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use ely_gpui_component::documents::Attachment;
|
||||
use ely_ui::i18n::text;
|
||||
use gpui::{App, Context, ImageFormat};
|
||||
|
||||
use crate::view::{Failed, MarkView, Mode};
|
||||
|
||||
const PICTURES: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"];
|
||||
|
||||
/// What goes into `assets/`: a file, or pasted bytes.
|
||||
enum Source {
|
||||
File(PathBuf),
|
||||
Bytes(String, Vec<u8>),
|
||||
}
|
||||
|
||||
fn said(key: &str, cx: &App) -> Failed {
|
||||
Failed(text(cx, key, &[]).to_string())
|
||||
}
|
||||
|
||||
impl MarkView {
|
||||
/// Copies pictures off the window, then links them.
|
||||
pub(crate) fn attach(&mut self, attached: &Attachment, cx: &mut Context<Self>) {
|
||||
if self.mode == Mode::Preview {
|
||||
return cx.emit(said("mark.attach.preview", cx));
|
||||
}
|
||||
let Some(folder) = self.folder.clone() else {
|
||||
return cx.emit(said("mark.attach.untitled", cx));
|
||||
};
|
||||
let sources: Vec<Source> = match attached {
|
||||
Attachment::Files(paths) => {
|
||||
let (pictures, others): (Vec<&PathBuf>, Vec<&PathBuf>) =
|
||||
paths.iter().partition(|path| picture(path));
|
||||
if !others.is_empty() {
|
||||
cx.emit(said("mark.attach.images", cx));
|
||||
}
|
||||
pictures.into_iter().cloned().map(Source::File).collect()
|
||||
}
|
||||
Attachment::Picture(image) => {
|
||||
let millis = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("the clock is past 1970")
|
||||
.as_millis();
|
||||
let name = format!("pasted-{millis}.{}", extension(image.format()));
|
||||
vec![Source::Bytes(name, image.bytes().to_vec())]
|
||||
}
|
||||
};
|
||||
if sources.is_empty() {
|
||||
return;
|
||||
}
|
||||
log::info!("mark: {} pictures copy in", sources.len());
|
||||
let assets = folder.join("assets");
|
||||
cx.spawn(async move |view, cx| {
|
||||
let copied = cx
|
||||
.background_executor()
|
||||
.spawn(async move {
|
||||
sources
|
||||
.into_iter()
|
||||
.map(|source| match source {
|
||||
Source::File(path) => ely_files::import_attachment(&path, &assets),
|
||||
Source::Bytes(name, bytes) => {
|
||||
ely_files::write_attachment(&name, &bytes, &assets)
|
||||
}
|
||||
})
|
||||
.collect::<io::Result<Vec<PathBuf>>>()
|
||||
})
|
||||
.await;
|
||||
view.update(cx, |view, cx| view.attached(folder, copied, cx))
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Links the copies, unless the file moved meanwhile.
|
||||
fn attached(
|
||||
&mut self,
|
||||
folder: PathBuf,
|
||||
copied: io::Result<Vec<PathBuf>>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let copied = match copied {
|
||||
Ok(copied) => copied,
|
||||
Err(error) => {
|
||||
log::error!("mark: a picture did not copy: {error}");
|
||||
let said = text(cx, "office.error.engine", &[("error", &error.to_string())]);
|
||||
return cx.emit(Failed(said.to_string()));
|
||||
}
|
||||
};
|
||||
if self.folder.as_ref() != Some(&folder) {
|
||||
log::info!("mark: the file moved while pictures copied");
|
||||
let assets = folder.join("assets").display().to_string();
|
||||
let said = text(cx, "mark.attach.moved", &[("folder", &assets)]);
|
||||
return cx.emit(Failed(said.to_string()));
|
||||
}
|
||||
let links: Vec<String> = copied.iter().map(|copy| link(&folder, copy)).collect();
|
||||
let written = self
|
||||
.document()
|
||||
.clone()
|
||||
.update(cx, |document, cx| document.insert(&links.join("\n"), cx));
|
||||
if !written {
|
||||
cx.emit(said("mark.attach.preview", cx));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn picture(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.is_some_and(|ext| PICTURES.contains(&ext.to_string_lossy().to_ascii_lowercase().as_str()))
|
||||
}
|
||||
|
||||
fn extension(format: ImageFormat) -> &'static str {
|
||||
match format {
|
||||
ImageFormat::Png => "png",
|
||||
ImageFormat::Jpeg => "jpg",
|
||||
ImageFormat::Gif => "gif",
|
||||
ImageFormat::Webp => "webp",
|
||||
ImageFormat::Svg => "svg",
|
||||
ImageFormat::Bmp => "bmp",
|
||||
ImageFormat::Tiff => "tiff",
|
||||
ImageFormat::Ico => "ico",
|
||||
ImageFormat::Pnm => "pnm",
|
||||
}
|
||||
}
|
||||
|
||||
/// The Markdown link to `copy`, relative to `folder`.
|
||||
fn link(folder: &Path, copy: &Path) -> String {
|
||||
let relative = copy.strip_prefix(folder).expect("copied under the folder");
|
||||
let address: Vec<String> = relative
|
||||
.components()
|
||||
.map(|part| escaped(&part.as_os_str().to_string_lossy()))
|
||||
.collect();
|
||||
let stem = copy.file_stem().unwrap_or_default().to_string_lossy();
|
||||
let alt = stem
|
||||
.replace('\\', "\\\\")
|
||||
.replace('[', "\\[")
|
||||
.replace(']', "\\]");
|
||||
format!("", address.join("/"))
|
||||
}
|
||||
|
||||
/// A path part, every byte but plain ones escaped.
|
||||
fn escaped(part: &str) -> String {
|
||||
part.bytes()
|
||||
.map(|byte| match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
|
||||
(byte as char).to_string()
|
||||
}
|
||||
other => format!("%{other:02X}"),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{cell::RefCell, path::Path, rc::Rc};
|
||||
|
||||
use ely_gpui_component::documents::Attachment;
|
||||
use gpui::{Entity, Image, ImageFormat, TestAppContext, VisualTestContext};
|
||||
|
||||
use super::link;
|
||||
use crate::desk::{
|
||||
Desk,
|
||||
tests::{open, start},
|
||||
};
|
||||
use crate::view::{Failed, MarkView};
|
||||
|
||||
/// What the view says, in order.
|
||||
type Said = Rc<RefCell<Vec<String>>>;
|
||||
|
||||
/// A saved file open in ElyMark, and what it says.
|
||||
fn saved(
|
||||
cx: &mut TestAppContext,
|
||||
) -> (
|
||||
Entity<MarkView>,
|
||||
&'static mut VisualTestContext,
|
||||
[tempfile::TempDir; 2],
|
||||
Said,
|
||||
) {
|
||||
let (data, files) = (
|
||||
tempfile::tempdir().expect("data"),
|
||||
tempfile::tempdir().expect("files"),
|
||||
);
|
||||
let path = files.path().join("a.md");
|
||||
std::fs::write(
|
||||
&path, "a
|
||||
",
|
||||
)
|
||||
.expect("a file");
|
||||
let (desk, cx) = start(cx, data.path());
|
||||
open(&desk, &path, cx);
|
||||
let view = desk.read_with(cx, |desk: &Desk, _| desk.active_view().expect("a tab"));
|
||||
let said = Rc::new(RefCell::new(Vec::new()));
|
||||
let heard = said.clone();
|
||||
cx.update(|_, cx| {
|
||||
cx.subscribe(&view, move |_, failed: &Failed, _| {
|
||||
heard.borrow_mut().push(failed.0.clone())
|
||||
})
|
||||
.detach()
|
||||
});
|
||||
(view, cx, [data, files], said)
|
||||
}
|
||||
|
||||
fn attach(view: &Entity<MarkView>, attached: Attachment, cx: &mut VisualTestContext) {
|
||||
let document = view.read_with(cx, |view, _| view.document().clone());
|
||||
document.update(cx, |_, cx| cx.emit(attached));
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
fn current(view: &Entity<MarkView>, cx: &mut VisualTestContext) -> String {
|
||||
view.read_with(cx, |view, cx| view.document().read(cx).text(cx))
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_dropped_picture_is_copied_beside_and_linked(cx: &mut TestAppContext) {
|
||||
let (view, cx, [_, files], said) = saved(cx);
|
||||
let shot = files.path().join("my shot.png");
|
||||
std::fs::write(&shot, "png").expect("a picture");
|
||||
attach(&view, Attachment::Files(vec![shot]), cx);
|
||||
assert!(files.path().join("assets/my shot.png").exists());
|
||||
assert!(
|
||||
current(&view, cx).contains(""),
|
||||
"{}",
|
||||
current(&view, cx)
|
||||
);
|
||||
assert!(said.borrow().is_empty());
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_pasted_picture_is_written_and_linked(cx: &mut TestAppContext) {
|
||||
let (view, cx, [_, files], _) = saved(cx);
|
||||
attach(
|
||||
&view,
|
||||
Attachment::Picture(Image::from_bytes(ImageFormat::Png, vec![1])),
|
||||
cx,
|
||||
);
|
||||
let assets: Vec<_> = std::fs::read_dir(files.path().join("assets"))
|
||||
.expect("assets")
|
||||
.collect();
|
||||
assert_eq!(assets.len(), 1);
|
||||
assert!(current(&view, cx).contains("](assets/pasted-"));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn other_files_and_untitled_text_take_no_picture(cx: &mut TestAppContext) {
|
||||
let (view, cx, [_, files], said) = saved(cx);
|
||||
let note = files.path().join("note.txt");
|
||||
std::fs::write(¬e, "text").expect("a file");
|
||||
attach(&view, Attachment::Files(vec![note]), cx);
|
||||
assert!(!files.path().join("assets").exists());
|
||||
assert_eq!(said.borrow().len(), 1, "other files are refused");
|
||||
view.update(cx, |view, _| view.folder = None);
|
||||
attach(
|
||||
&view,
|
||||
Attachment::Picture(Image::from_bytes(ImageFormat::Png, vec![1])),
|
||||
cx,
|
||||
);
|
||||
assert_eq!(said.borrow().len(), 2, "untitled text is refused");
|
||||
assert_eq!(
|
||||
current(&view, cx),
|
||||
"a
|
||||
"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_file_moved_while_copying_gets_no_link(cx: &mut TestAppContext) {
|
||||
let (view, cx, [_, files], said) = saved(cx);
|
||||
let document = view.read_with(cx, |view, _| view.document().clone());
|
||||
document.update(cx, |_, cx| {
|
||||
cx.emit(Attachment::Picture(Image::from_bytes(
|
||||
ImageFormat::Png,
|
||||
vec![1],
|
||||
)))
|
||||
});
|
||||
let elsewhere = files.path().join("elsewhere");
|
||||
view.update(cx, |view, cx| view.moved(elsewhere, cx));
|
||||
cx.run_until_parked();
|
||||
assert_eq!(
|
||||
current(&view, cx),
|
||||
"a
|
||||
"
|
||||
);
|
||||
assert_eq!(said.borrow().len(), 1, "the move is said");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_escape_their_address_and_alt() {
|
||||
let folder = Path::new("/notes");
|
||||
let copy = folder.join("assets").join("a b[1].png");
|
||||
assert_eq!(
|
||||
link(folder, ©),
|
||||
"![a b\\[1\\]](assets/a%20b%5B1%5D.png)"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user