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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ use std::{
|
||||
convert::Infallible,
|
||||
hash::{DefaultHasher, Hash as _, Hasher as _},
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
path::Path,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
@@ -36,12 +36,6 @@ impl Text {
|
||||
|
||||
pub type Doc = Arc<Mutex<Text>>;
|
||||
|
||||
/// A load: the file as read, and where it is.
|
||||
pub struct Opened {
|
||||
pub source: Source,
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
/// The history mark: this text's length and hash.
|
||||
pub fn mark(text: &str) -> String {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
@@ -56,10 +50,10 @@ impl Engine for Mark {
|
||||
type Worker = ();
|
||||
type Doc = Doc;
|
||||
type View = MarkView;
|
||||
type Loaded = Opened;
|
||||
type Loaded = Source;
|
||||
type Import = Infallible;
|
||||
|
||||
fn load(_: &mut (), from: Load<Infallible>) -> io::Result<Opened> {
|
||||
fn load(_: &mut (), from: Load<Infallible>) -> io::Result<Source> {
|
||||
let path = match from {
|
||||
Load::File(path, _) => path,
|
||||
Load::Import(_, never) => match *never {},
|
||||
@@ -69,30 +63,19 @@ impl Engine for Mark {
|
||||
io::ErrorKind::PermissionDenied => io::Error::other(error),
|
||||
_ => error,
|
||||
})?;
|
||||
Ok(Opened {
|
||||
source: Source::decode(bytes)?,
|
||||
path: path.to_path_buf(),
|
||||
})
|
||||
Source::decode(bytes)
|
||||
}
|
||||
|
||||
fn open(
|
||||
_: &Arc<Mutex<()>>,
|
||||
loaded: Opened,
|
||||
loaded: Source,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> io::Result<(Doc, Entity<MarkView>, String)> {
|
||||
let Opened {
|
||||
source: loaded,
|
||||
path,
|
||||
} = loaded;
|
||||
let folder = path
|
||||
.parent()
|
||||
.expect("a file sits in a folder")
|
||||
.to_path_buf();
|
||||
let mark = mark(&loaded.text);
|
||||
let doc = Arc::new(Mutex::new(Text::new(loaded)));
|
||||
let saved = mark.clone();
|
||||
let view = cx.new(|cx| MarkView::new(Arc::clone(&doc), saved, folder, window, cx));
|
||||
let view = cx.new(|cx| MarkView::new(Arc::clone(&doc), saved, window, cx));
|
||||
Ok((doc, view, mark))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Release builds open no console on Windows.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod attach;
|
||||
mod autosave;
|
||||
mod compare;
|
||||
mod desk;
|
||||
|
||||
@@ -74,8 +74,8 @@ pub struct MarkView {
|
||||
/// The mark at the last load or save.
|
||||
saved: String,
|
||||
pub mode: Mode,
|
||||
/// The file's folder: pictures resolve here.
|
||||
pub(crate) folder: PathBuf,
|
||||
/// A saved file's folder: pictures resolve and go here.
|
||||
pub(crate) folder: Option<PathBuf>,
|
||||
/// The mode first took input (PERF-07).
|
||||
pub(crate) shown: bool,
|
||||
pub focus: FocusHandle,
|
||||
@@ -85,13 +85,7 @@ impl EventEmitter<Reported> for MarkView {}
|
||||
impl EventEmitter<Failed> for MarkView {}
|
||||
|
||||
impl MarkView {
|
||||
pub fn new(
|
||||
doc: Doc,
|
||||
saved: String,
|
||||
folder: PathBuf,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
pub fn new(doc: Doc, saved: String, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let text = doc.lock().expect("the text lock").current.clone();
|
||||
let mode = match text.len() > LARGEST_READ {
|
||||
true => Mode::Source,
|
||||
@@ -100,13 +94,14 @@ impl MarkView {
|
||||
let view = cx.weak_entity();
|
||||
let document = cx.new(|cx| {
|
||||
let mut document = MarkdownDocument::new(text, mode.shown(), window, cx);
|
||||
document.set_base(folder.as_path(), cx);
|
||||
document.on_link(move |url, _, cx| {
|
||||
view.update(cx, |view, cx| view.follow(url, cx)).ok();
|
||||
});
|
||||
document
|
||||
});
|
||||
cx.subscribe_in(&document, window, Self::heard).detach();
|
||||
cx.subscribe(&document, |view, _, attached, cx| view.attach(attached, cx))
|
||||
.detach();
|
||||
// The shell hears the load.
|
||||
cx.defer_in(window, |view, _, cx| {
|
||||
view.show_once(cx);
|
||||
@@ -125,7 +120,7 @@ impl MarkView {
|
||||
changed: 0,
|
||||
saved,
|
||||
mode,
|
||||
folder,
|
||||
folder: None,
|
||||
shown: false,
|
||||
focus,
|
||||
}
|
||||
@@ -251,14 +246,14 @@ impl MarkView {
|
||||
}
|
||||
}
|
||||
|
||||
/// The file moved; pictures follow it.
|
||||
/// The saved file's folder; pictures follow it.
|
||||
pub fn moved(&mut self, folder: PathBuf, cx: &mut Context<Self>) {
|
||||
if folder == self.folder {
|
||||
if self.folder.as_ref() == Some(&folder) {
|
||||
return;
|
||||
}
|
||||
self.document
|
||||
.update(cx, |document, cx| document.set_base(folder.as_path(), cx));
|
||||
self.folder = folder;
|
||||
self.folder = Some(folder);
|
||||
}
|
||||
|
||||
fn heard(
|
||||
|
||||
@@ -18,9 +18,7 @@ fn open<'a>(
|
||||
let source = Source::decode(text.as_bytes().to_vec()).expect("reads");
|
||||
let saved = mark(&source.text);
|
||||
let doc = Arc::new(Mutex::new(Text::new(source)));
|
||||
let folder = std::env::temp_dir();
|
||||
let (view, cx) =
|
||||
cx.add_window_view(move |window, cx| MarkView::new(doc, saved, folder, window, cx));
|
||||
let (view, cx) = cx.add_window_view(move |window, cx| MarkView::new(doc, saved, window, cx));
|
||||
cx.update(|window, _| window.activate_window());
|
||||
cx.run_until_parked();
|
||||
(view, cx)
|
||||
@@ -166,8 +164,7 @@ fn the_shell_hears_a_load_it_subscribed_to(cx: &mut TestAppContext) {
|
||||
let source = Source::decode(b"# Opened\n".to_vec()).expect("reads");
|
||||
let saved = mark(&source.text);
|
||||
let doc = Arc::new(Mutex::new(Text::new(source)));
|
||||
let folder = std::env::temp_dir();
|
||||
let view = cx.new(|cx| MarkView::new(doc, saved, folder, window, cx));
|
||||
let view = cx.new(|cx| MarkView::new(doc, saved, window, cx));
|
||||
// As the shell: subscribed after the view is made.
|
||||
let count = count.clone();
|
||||
cx.subscribe(&view, move |_, _, _: &Reported, _| {
|
||||
|
||||
Reference in new issue
Block a user