From d126a7e7f7977dddae598486dd99aba79ce1f5f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 12 Oct 2026 07:24:15 -0400 Subject: [PATCH] documents: dropped files and pasted pictures go to the owner; insert puts text at the caret Co-Authored-By: Claude Opus 5.5 (1M context) --- src/documents/document/attach.rs | 62 +++++++++++++++++++++ src/documents/document/mod.rs | 2 + src/documents/document/tests/attach.rs | 76 ++++++++++++++++++++++++++ src/documents/document/tests/mod.rs | 1 + src/documents/document/visual.rs | 28 ++++++++-- src/documents/mod.rs | 3 +- src/editor/mod.rs | 2 +- src/forms/mod.rs | 4 +- src/forms/text/mod.rs | 3 +- 9 files changed, 171 insertions(+), 10 deletions(-) create mode 100644 src/documents/document/attach.rs create mode 100644 src/documents/document/tests/attach.rs diff --git a/src/documents/document/attach.rs b/src/documents/document/attach.rs new file mode 100644 index 0000000..f02eac9 --- /dev/null +++ b/src/documents/document/attach.rs @@ -0,0 +1,62 @@ +//! Dropped files and pasted pictures go to the owner. + +use std::path::PathBuf; + +use gpui::{ClipboardEntry, Context, EventEmitter, Image}; + +use super::MarkdownDocument; +use crate::documents::MarkdownMode; + +/// What was dropped on or pasted into the document. +#[derive(Clone, Debug, PartialEq)] +pub enum Attachment { + /// Files dropped, as they lie on disk. + Files(Vec), + /// A picture pasted with no text beside it. + Picture(Image), +} + +impl EventEmitter for MarkdownDocument {} + +impl MarkdownDocument { + /// A pasted picture goes to the owner; false leaves it. + pub(super) fn paste_picture(&mut self, cx: &mut Context) -> bool { + let Some(item) = cx.read_from_clipboard() else { + return false; + }; + let entries = item.entries(); + if entries + .iter() + .any(|entry| matches!(entry, ClipboardEntry::String(_))) + { + return false; + } + let Some(picture) = entries.iter().find_map(|entry| match entry { + ClipboardEntry::Image(image) => Some(image.clone()), + _ => None, + }) else { + return false; + }; + log::info!("markdown document: a picture pasted"); + cx.emit(Attachment::Picture(picture)); + true + } + + /// `text` at the caret, as typed; false in preview. + pub fn insert(&mut self, text: &str, cx: &mut Context) -> bool { + if self.mode == MarkdownMode::Preview { + return false; + } + if let Some(open) = &self.open { + open.input.update(cx, |input, cx| input.insert(text, cx)); + return true; + } + let caret = self.editor.read(cx).primary().range(); + let after = caret.start + text.len(); + self.editor.update(cx, |editor, cx| { + editor.edit([(caret, text.to_string())], cx); + editor.select(std::iter::once(after..after), cx); + }); + true + } +} diff --git a/src/documents/document/mod.rs b/src/documents/document/mod.rs index 79456ea..57b9a22 100644 --- a/src/documents/document/mod.rs +++ b/src/documents/document/mod.rs @@ -1,3 +1,4 @@ +mod attach; mod edit; mod finding; mod lists; @@ -17,6 +18,7 @@ use gpui::{ ListState, Subscription, Task, Window, }; +pub use attach::Attachment; pub use parse::DocumentHeading; use parse::{Parsed, alike, parsed}; pub use tables::{TableEdit, TableRefusal}; diff --git a/src/documents/document/tests/attach.rs b/src/documents/document/tests/attach.rs new file mode 100644 index 0000000..b5a38da --- /dev/null +++ b/src/documents/document/tests/attach.rs @@ -0,0 +1,76 @@ +use std::{cell::RefCell, path::PathBuf, rc::Rc}; + +use gpui::{ + ClipboardItem, Entity, ExternalPaths, FileDropEvent, Image, ImageFormat, Modifiers, + TestAppContext, VisualTestContext, point, px, +}; + +use super::{MarkdownDocument, MarkdownMode, open, opened, press, text}; +use crate::documents::Attachment; + +/// Attachments the document hands its owner. +fn heard( + document: &Entity, + cx: &mut VisualTestContext, +) -> Rc>> { + let heard = Rc::new(RefCell::new(Vec::new())); + let kept = heard.clone(); + cx.update(|_, cx| { + cx.subscribe(document, move |_, attached: &Attachment, _| { + kept.borrow_mut().push(attached.clone()) + }) + .detach() + }); + heard +} + +fn picture() -> Image { + Image::from_bytes(ImageFormat::Png, vec![1, 2, 3]) +} + +#[gpui::test] +fn dropped_files_go_to_the_owner(cx: &mut TestAppContext) { + let (document, cx) = open("a\n", MarkdownMode::Visual, cx); + let heard = heard(&document, cx); + let at = point(px(100.0), px(100.0)); + let paths = ExternalPaths(vec![PathBuf::from("shot.png")].into()); + cx.simulate_event(FileDropEvent::Entered { + position: at, + paths, + }); + cx.simulate_event(FileDropEvent::Submit { position: at }); + cx.run_until_parked(); + assert_eq!( + *heard.borrow(), + [Attachment::Files(vec![PathBuf::from("shot.png")])] + ); +} + +#[gpui::test] +fn a_pasted_picture_goes_to_the_owner_and_text_pastes(cx: &mut TestAppContext) { + let (document, cx) = open("a\n", MarkdownMode::Visual, cx); + let heard = heard(&document, cx); + press(0, Modifiers::none(), cx); + cx.write_to_clipboard(ClipboardItem::new_image(&picture())); + cx.simulate_keystrokes("secondary-v"); + assert_eq!(*heard.borrow(), [Attachment::Picture(picture())]); + assert_eq!(opened(&document, cx), "a", "the picture typed nothing"); + cx.write_to_clipboard(ClipboardItem::new_string("b".into())); + cx.simulate_keystrokes("secondary-v"); + assert_eq!(opened(&document, cx), "ab", "text pastes as text"); +} + +#[gpui::test] +fn text_goes_in_at_the_caret_but_not_in_preview(cx: &mut TestAppContext) { + let (document, cx) = open("ab\n", MarkdownMode::Source, cx); + document.update(cx, |document, cx| { + document.set_caret(0, 1, cx); + assert!(document.insert("X", cx)); + }); + assert_eq!(text(&document, cx), "aXb\n"); + document.update_in(cx, |document, window, cx| { + document.set_mode(MarkdownMode::Preview, window, cx) + }); + assert!(!document.update(cx, |document, cx| document.insert("Y", cx))); + assert_eq!(text(&document, cx), "aXb\n"); +} diff --git a/src/documents/document/tests/mod.rs b/src/documents/document/tests/mod.rs index 50f8d9b..7ca716c 100644 --- a/src/documents/document/tests/mod.rs +++ b/src/documents/document/tests/mod.rs @@ -365,6 +365,7 @@ fn a_press_after_a_write_back_waits_for_the_long_read(cx: &mut TestAppContext) { assert_eq!(opened(&document, cx), "B", "once read, B opens whole"); } +mod attach; mod history; mod links; mod place; diff --git a/src/documents/document/visual.rs b/src/documents/document/visual.rs index e46c0e2..5e826ca 100644 --- a/src/documents/document/visual.rs +++ b/src/documents/document/visual.rs @@ -1,7 +1,7 @@ use gpui::{ - AnyElement, App, ClickEvent, Context, FontWeight, InteractiveElement, IntoElement, KeyBinding, - MouseButton, MouseDownEvent, ParentElement, Render, StatefulInteractiveElement, Styled, Window, - actions, div, list, + AnyElement, App, ClickEvent, Context, ExternalPaths, FontWeight, InteractiveElement, + IntoElement, KeyBinding, MouseButton, MouseDownEvent, ParentElement, Render, + StatefulInteractiveElement, Styled, Window, actions, div, list, }; use super::MarkdownDocument; @@ -12,7 +12,7 @@ use crate::{ theme::{ActiveTheme, Radius, TextSize}, }; -actions!(markdown_document, [Undo, Redo, Indent, Outdent]); +actions!(markdown_document, [Undo, Redo, Indent, Outdent, Paste]); const CONTEXT: &str = "MarkdownDocument"; @@ -23,6 +23,7 @@ pub(crate) fn bind_keys(cx: &mut App) { let mut bindings = vec![ KeyBinding::new("secondary-z", Undo, context), KeyBinding::new("secondary-shift-z", Redo, context), + KeyBinding::new("secondary-v", Paste, context), KeyBinding::new("tab", Indent, block), KeyBinding::new("shift-tab", Outdent, block), ]; @@ -144,10 +145,19 @@ impl Render for MarkdownDocument { let root = div() .size_full() .bg(colors.surface) - .font_weight(FontWeight::NORMAL); + .font_weight(FontWeight::NORMAL) + .on_drop(cx.listener(|_, dropped: &ExternalPaths, _, cx| { + log::info!("markdown document: {} files dropped", dropped.paths().len()); + cx.emit(super::Attachment::Files(dropped.paths().to_vec())); + })); let blocks = list(self.list.clone(), cx.processor(Self::item)).size_full(); match self.mode { MarkdownMode::Source => root + .capture_action(cx.listener(|document, _: &crate::editor::Paste, _, cx| { + if document.paste_picture(cx) { + cx.stop_propagation(); + } + })) .capture_action(cx.listener(|document, _: &crate::editor::Newline, _, cx| { if document.source_newline(cx) { cx.stop_propagation(); @@ -184,6 +194,14 @@ impl Render for MarkdownDocument { cx.stop_propagation(); document.history(true, window, cx); })) + .capture_action(cx.listener(|document, _: &forms::Paste, _, cx| { + if document.paste_picture(cx) { + cx.stop_propagation(); + } + })) + .on_action(cx.listener(|document, _: &Paste, _, cx| { + document.paste_picture(cx); + })) .capture_action(cx.listener(|document, _: &forms::Enter, _, cx| { if document.block_newline(cx) { cx.stop_propagation(); diff --git a/src/documents/mod.rs b/src/documents/mod.rs index af4ebe5..4a18ba3 100644 --- a/src/documents/mod.rs +++ b/src/documents/mod.rs @@ -35,7 +35,8 @@ pub use contents::outline; pub use counts::{ReadingTime, WordCount}; pub use diagram::Diagram; pub use document::{ - DocumentEvent, DocumentHeading, LARGEST_READ, MarkdownDocument, TableEdit, TableRefusal, + Attachment, DocumentEvent, DocumentHeading, LARGEST_READ, MarkdownDocument, TableEdit, + TableRefusal, }; pub use epub::{Chapter, EpubReader}; pub use format::{Format, format}; diff --git a/src/editor/mod.rs b/src/editor/mod.rs index 7f20615..85bd6ad 100644 --- a/src/editor/mod.rs +++ b/src/editor/mod.rs @@ -48,7 +48,7 @@ pub use find::{FindOptions, FindWidget, find_all}; pub use hints::{CompletionItem, CompletionMenu, Signature, SignatureHelp, completions}; pub use hover::HoverInfo; pub use keybindings::{KeySource, Keybinding, KeybindingsEditor, conflicts}; -pub(crate) use keys::{Newline, bind_keys}; +pub(crate) use keys::{Newline, Paste, bind_keys}; pub(crate) use layout::stack; pub use preview::ThemePreview; pub use problems::{Problem, ProblemsPanel}; diff --git a/src/forms/mod.rs b/src/forms/mod.rs index a37bf61..83ca20b 100644 --- a/src/forms/mod.rs +++ b/src/forms/mod.rs @@ -116,8 +116,8 @@ pub use switch::Switch; pub(crate) use syntax::{Syntax, syntax_fenced, syntax_of}; pub use tags::TagInput; pub(crate) use text::{ - Backspace, Copy, Cut, Delete, DeleteWordLeft, Down, Enter, Redo, Submit, Undo, Up, bind_keys, - from_utf16, to_utf16, + Backspace, Copy, Cut, Delete, DeleteWordLeft, Down, Enter, Paste, Redo, Submit, Undo, Up, + bind_keys, from_utf16, to_utf16, }; pub use text::{Highlight, History, InputEvent, TextInput}; pub use transfer::TransferList; diff --git a/src/forms/text/mod.rs b/src/forms/text/mod.rs index 5655c9a..6f09452 100644 --- a/src/forms/text/mod.rs +++ b/src/forms/text/mod.rs @@ -13,7 +13,8 @@ use gpui::{ }; pub(crate) use actions::{ - Backspace, Copy, Cut, Delete, DeleteWordLeft, Down, Enter, Redo, Submit, Undo, Up, bind_keys, + Backspace, Copy, Cut, Delete, DeleteWordLeft, Down, Enter, Paste, Redo, Submit, Undo, Up, + bind_keys, }; pub use edit::History; use edit::Snapshot;