authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-04 21:17:27-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-04 21:53:44-07:00
logc2174276845a26013191ed67398382758312ac8f
tree1f19b0c6ceb9679db8438c5a67be3032f101fe88
parent206bd8fba08030b8e592fdf5adcc4743005bd83a
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

fix: paste into fields and bound file uploads

- Paste plain text into focused input fields, including dialogs and search. - Reject oversized files before reading them, import accepted files off the frame thread, and bound Live Share uploads and encoding. - Keep Unicode search matches on character boundaries. - Insert attachments from the title into the page body so they save correctly. Assisted-by: gpt-6.1-sol

18 files changed, 497 insertions(+), 90 deletions(-)

crates/canvas/src/editor.rs+3
...@@ -2503,6 +2503,9 @@ impl CanvasEditor {...@@ -2503,6 +2503,9 @@ impl CanvasEditor {
2503 engine: &mut TextEngine,2503 engine: &mut TextEngine,
2504 mut file: onestore::page::Attachment,2504 mut file: onestore::page::Attachment,
2505 ) -> Result<(), EditorError> {2505 ) -> Result<(), EditorError> {
2506 if self.active_outline().title && self.leave_title_for_body(engine)? {
2507 return self.insert_in_flow(engine, ParagraphContent::Attachment(file));
2508 }
2506 if let Focus::Caret { outline, .. } = &self.active {2509 if let Focus::Caret { outline, .. } = &self.active {
2507 [file.layout.x, file.layout.y] = outline.origin().map(Some);2510 [file.layout.x, file.layout.y] = outline.origin().map(Some);
2508 let layout = Box::new(crate::outline::page_file(engine, &file)?);2511 let layout = Box::new(crate::outline::page_file(engine, &file)?);
crates/canvas/src/search.rs+1-5
...@@ -516,11 +516,7 @@ pub fn page_matches(editor: &CanvasEditor, query: &Query) -> Vec<PageMatch> {...@@ -516,11 +516,7 @@ pub fn page_matches(editor: &CanvasEditor, query: &Query) -> Vec<PageMatch> {
516 start = span.end;516 start = span.end;
517 }517 }
518 for hit in query.find(&shown) {518 for hit in query.find(&shown) {
519 let end = source[hit.end - 1]519 let end = source[hit.end - 1] + 1;
520 + paragraph.text()[source[hit.end - 1]..]
521 .chars()
522 .next()
523 .map_or(0, char::len_utf8);
524 let (Ok(from), Ok(to)) = (520 let (Ok(from), Ok(to)) = (
525 paragraph.utf16_offset(source[hit.start]),521 paragraph.utf16_offset(source[hit.start]),
526 paragraph.utf16_offset(end),522 paragraph.utf16_offset(end),
crates/canvas/tests/attachment_insert.rs+76-2
...@@ -7,8 +7,8 @@...@@ -7,8 +7,8 @@
7use canvas::{editor::CanvasEditor, layout::TextEngine};7use canvas::{editor::CanvasEditor, layout::TextEngine};
8use draw::edit::Movement;8use draw::edit::Movement;
9use onestore::{9use onestore::{
10 Arena, Section, Store,10 Arena, PageCreation, Section, Store,
11 op::{Edit, Op},11 op::{Edit, Op, SectionOp},
12 page::{Attachment, PageObject, ParagraphContent},12 page::{Attachment, PageObject, ParagraphContent},
13};13};
14use std::sync::Arc;14use std::sync::Arc;
...@@ -62,6 +62,80 @@ fn file(name: &str, bytes: Vec<u8>, preview: Option<Arc<[u8]>>) -> Attachment {...@@ -62,6 +62,80 @@ fn file(name: &str, bytes: Vec<u8>, preview: Option<Arc<[u8]>>) -> Attachment {
62 }62 }
63}63}
6464
65#[test]
66fn a_file_attached_from_the_title_stores_in_the_body() {
67 let mut image = onestore::create_section("files.one", "First page", "Author").unwrap();
68 let arena = Arena::default();
69 let mut section = Section::open(&arena, image.clone()).unwrap();
70 section
71 .apply(
72 "Author",
73 &Edit {
74 at: 133_000_000_000_000_000,
75 ops: vec![Op::Section(SectionOp::Create(
76 PageCreation::new(None, Some("Attachment from title"), "Author").unwrap(),
77 ))],
78 },
79 )
80 .unwrap();
81 let (space, ..) = section.pages().unwrap()[1].clone();
82 let mut engine = TextEngine::default();
83 let mut editor = CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap();
84 let title = editor
85 .outlines()
86 .iter()
87 .find(|outline| outline.title)
88 .unwrap()
89 .id;
90 editor.focus_outline(title).unwrap();
91 let attached = file(
92 "attachment.txt",
93 b"Snowbound bounded import fixture\n".to_vec(),
94 Some(native_icon()),
95 );
96 editor
97 .insert_attachment(&mut engine, attached.clone())
98 .unwrap();
99 assert!(!editor.active_outline().title);
100 let ops = editor
101 .take_ops()
102 .unwrap()
103 .into_iter()
104 .map(|op| Op::Page { space, op })
105 .collect();
106 section
107 .apply(
108 "Author",
109 &Edit {
110 at: 133_000_000_010_000_000,
111 ops,
112 },
113 )
114 .unwrap();
115 section.seal().unwrap().unwrap().apply(&mut image).unwrap();
116 let arena = Arena::default();
117 let mut reopened = Section::open(&arena, image.clone()).unwrap();
118 assert_eq!(reopened.pages().unwrap()[1].1, "Attachment from title");
119 let page = reopened.page(space).unwrap();
120 let files: Vec<_> = page
121 .objects
122 .iter()
123 .filter_map(|object| match object {
124 PageObject::Outline(outline) if !outline.title => Some(&outline.paragraphs),
125 _ => None,
126 })
127 .flatten()
128 .filter_map(|paragraph| match &paragraph.content {
129 ParagraphContent::Attachment(file) => Some(file),
130 _ => None,
131 })
132 .collect();
133 assert_eq!(files, [&attached]);
134 if let Some(path) = std::env::var_os("CANVAS_TITLE_ATTACHMENT_EXPORT") {
135 std::fs::write(path, image).unwrap();
136 }
137}
138
65#[test]139#[test]
66fn attached_files_store_their_bytes_between_the_text_around_them() {140fn attached_files_store_their_bytes_between_the_text_around_them() {
67 let source = onestore::create_section("files.one", "Before the file", "Author").unwrap();141 let source = onestore::create_section("files.one", "Before the file", "Author").unwrap();
crates/canvas/tests/search.rs+21
...@@ -135,6 +135,27 @@ fn find_on_page_counts_what_onenote_counts() {...@@ -135,6 +135,27 @@ fn find_on_page_counts_what_onenote_counts() {
135 assert!(page_matches(&editor, &Query::new("zzq")).is_empty());135 assert!(page_matches(&editor, &Query::new("zzq")).is_empty());
136}136}
137137
138#[test]
139fn find_on_page_keeps_unicode_matches_on_character_boundaries() {
140 let mut engine = TextEngine::default();
141 let page = page(&mut engine, "Unicode", "雪 ☃ 🦀 café");
142 let editor = CanvasEditor::from_page(page, &mut engine).unwrap();
143 for (query, offsets) in [
144 ("雪", [0, 1]),
145 ("☃", [2, 3]),
146 ("🦀", [4, 6]),
147 ("cafe", [7, 11]),
148 ] {
149 let matches = page_matches(&editor, &Query::new(query));
150 assert_eq!(matches.len(), 1, "{query}");
151 assert_eq!(
152 matches[0].1.positions.map(|at| at.offset),
153 offsets,
154 "{query}"
155 );
156 }
157}
158
138#[test]159#[test]
139fn folding_ignores_case_and_diacritics() {160fn folding_ignores_case_and_diacritics() {
140 assert_eq!(fold("Crème BRÛLÉE"), "creme brulee");161 assert_eq!(fold("Crème BRÛLÉE"), "creme brulee");
crates/notebook/src/bin/onestore-diagnostic.rs+1-1
...@@ -83,7 +83,7 @@ fn run(args: &[std::ffi::OsString]) -> Result<Value, Box<dyn std::error::Error>>...@@ -83,7 +83,7 @@ fn run(args: &[std::ffi::OsString]) -> Result<Value, Box<dyn std::error::Error>>
83 &mut notebook::discover::Local::open(root)?,83 &mut notebook::discover::Local::open(root)?,
84 notebook::discover::Limits {84 notebook::discover::Limits {
85 entries: 100_000,85 entries: 100_000,
86 bytes_per_file: 256 * 1024 * 1024,86 bytes_per_file: notebook::MAX_FILE_BYTES,
87 depth: 64,87 depth: 64,
88 },88 },
89 )?;89 )?;
crates/notebook/src/fs.rs+45
...@@ -20,7 +20,52 @@ mod web;...@@ -20,7 +20,52 @@ mod web;
20#[cfg(target_arch = "wasm32")]20#[cfg(target_arch = "wasm32")]
21pub use web::*;21pub use web::*;
2222
23/// Reads a regular file within `limit`, including when it grows during the read.
24pub fn read_limited(path: impl AsRef<std::path::Path>, limit: usize) -> std::io::Result<Vec<u8>> {
25 use std::io::Read;
26 let file = File::open(path)?;
27 let metadata = file.metadata()?;
28 if !metadata.is_file() {
29 return Err(std::io::ErrorKind::InvalidInput.into());
30 }
31 if metadata.len() > limit as u64 {
32 return Err(std::io::ErrorKind::FileTooLarge.into());
33 }
34 let mut bytes = Vec::new();
35 file.take((limit as u64).saturating_add(1))
36 .read_to_end(&mut bytes)?;
37 if bytes.len() > limit {
38 return Err(std::io::ErrorKind::FileTooLarge.into());
39 }
40 Ok(bytes)
41}
42
23#[cfg(not(target_arch = "wasm32"))]43#[cfg(not(target_arch = "wasm32"))]
24pub async fn durable() -> std::io::Result<()> {44pub async fn durable() -> std::io::Result<()> {
25 Ok(())45 Ok(())
26}46}
47
48#[cfg(all(test, not(target_arch = "wasm32")))]
49mod tests {
50 use super::*;
51
52 #[test]
53 fn bounded_reads_refuse_an_eight_gib_file() {
54 let file = tempfile::NamedTempFile::new().unwrap();
55 write(file.path(), b"hello").unwrap();
56 assert_eq!(read_limited(file.path(), 5).unwrap(), b"hello");
57 assert_eq!(
58 read_limited(file.path(), 4).unwrap_err().kind(),
59 std::io::ErrorKind::FileTooLarge
60 );
61 file.as_file().set_len(8 << 30).unwrap();
62 assert_eq!(
63 read_limited(file.path(), crate::MAX_FILE_BYTES)
64 .unwrap_err()
65 .kind(),
66 std::io::ErrorKind::FileTooLarge
67 );
68 let folder = tempfile::tempdir().unwrap();
69 assert!(read_limited(folder.path(), crate::MAX_FILE_BYTES).is_err());
70 }
71}
crates/notebook/src/lib.rs+3
...@@ -49,6 +49,9 @@ mod working;...@@ -49,6 +49,9 @@ mod working;
49pub use smb::SmbRemote;49pub use smb::SmbRemote;
50pub use worker::SyncWorker;50pub use worker::SyncWorker;
5151
52/// The largest file a notebook session or Live Share reads or writes whole.
53pub const MAX_FILE_BYTES: usize = 256 << 20;
54
52#[derive(Debug, thiserror::Error)]55#[derive(Debug, thiserror::Error)]
53pub enum Error {56pub enum Error {
54 #[error(transparent)]57 #[error(transparent)]
crates/notebook/src/live/share.rs+89-13
...@@ -42,8 +42,7 @@ const CHUNK: usize = 128 << 10;...@@ -42,8 +42,7 @@ const CHUNK: usize = 128 << 10;
42const WINDOW: usize = 512 << 10;42const WINDOW: usize = 512 << 10;
43/// How long a request waits for its reply.43/// How long a request waits for its reply.
44const TIMEOUT: Duration = Duration::from_secs(60);44const TIMEOUT: Duration = Duration::from_secs(60);
45/// The largest file read or written whole.45use crate::MAX_FILE_BYTES as LIMIT;
46const LIMIT: usize = 256 << 20;
47/// Snapshots of files being read, per guest, and how long one is kept unread.46/// Snapshots of files being read, per guest, and how long one is kept unread.
48const SNAPSHOTS: usize = 8;47const SNAPSHOTS: usize = 8;
49const SNAPSHOT_AGE: Duration = Duration::from_secs(120);48const SNAPSHOT_AGE: Duration = Duration::from_secs(120);
...@@ -501,14 +500,28 @@ impl Served {...@@ -501,14 +500,28 @@ impl Served {
501 let handle = request.handle.unwrap_or_default();500 let handle = request.handle.unwrap_or_default();
502 let bytes = request.bytes.unwrap_or_default();501 let bytes = request.bytes.unwrap_or_default();
503 let mut puts = self.puts.lock().unwrap();502 let mut puts = self.puts.lock().unwrap();
504 let held = puts.entry((*peer, handle)).or_default();503 let key = (*peer, handle);
505 if request.offset != Some(held.len() as u64) || held.len() + bytes.len() > LIMIT {504 let length = puts.get(&key).map_or(0, Vec::len);
505 if request.offset != Some(length as u64) || bytes.is_empty() {
506 puts.remove(&key);
506 return Err(refused(507 return Err(refused(
507 io::ErrorKind::InvalidInput,508 io::ErrorKind::InvalidInput,
508 "An upload out of order",509 "An upload out of order",
509 ));510 ));
510 }511 }
511 held.extend_from_slice(&bytes);512 let held: usize = puts
513 .iter()
514 .filter(|((guest, _), _)| guest == peer)
515 .map(|(_, bytes)| bytes.len())
516 .sum();
517 if bytes.len() > LIMIT.saturating_sub(held) {
518 puts.remove(&key);
519 return Err(refused(
520 io::ErrorKind::FileTooLarge,
521 "An upload is too large",
522 ));
523 }
524 puts.entry(key).or_default().extend_from_slice(&bytes);
512 done525 done
513 }526 }
514 kind::COMMIT => {527 kind::COMMIT => {
...@@ -1228,6 +1241,9 @@ impl Guest {...@@ -1228,6 +1241,9 @@ impl Guest {
1228 request: &mut Request,1241 request: &mut Request,
1229 bytes: Vec<u8>,1242 bytes: Vec<u8>,
1230 ) -> std::result::Result<(), Failed> {1243 ) -> std::result::Result<(), Failed> {
1244 if bytes.len() > LIMIT {
1245 return Err(Failed::Unsent(io::ErrorKind::FileTooLarge.into()));
1246 }
1231 if bytes.len() <= CHUNK {1247 if bytes.len() <= CHUNK {
1232 request.bytes = Some(bytes);1248 request.bytes = Some(bytes);
1233 return Ok(());1249 return Ok(());
...@@ -1436,16 +1452,19 @@ impl Guest {...@@ -1436,16 +1452,19 @@ impl Guest {
1436 edits: &[crate::PendingEdit],1452 edits: &[crate::PendingEdit],
1437 revisions: &BTreeMap<onestore::ExGuid, onestore::ExGuid>,1453 revisions: &BTreeMap<onestore::ExGuid, onestore::ExGuid>,
1438 ) -> std::result::Result<(), CommitError> {1454 ) -> std::result::Result<(), CommitError> {
1439 let bytes = serde_json::to_vec(&batch::Edits {1455 let bytes = batch::encode(
1440 edits: edits1456 &batch::Edits {
1441 .iter()1457 edits: edits
1442 .map(|edit| (edit.author.clone(), edit.edit.clone()))1458 .iter()
1443 .collect(),1459 .map(|edit| (edit.author.clone(), edit.edit.clone()))
1444 revisions: revisions.clone(),1460 .collect(),
1445 })1461 revisions: revisions.clone(),
1462 },
1463 LIMIT,
1464 )
1446 .map_err(|error| CommitError {1465 .map_err(|error| CommitError {
1447 state: CommitState::NotCommitted,1466 state: CommitState::NotCommitted,
1448 error: io::Error::other(error),1467 error,
1449 })?;1468 })?;
1450 let mut request = Request {1469 let mut request = Request {
1451 path: path.to_owned(),1470 path: path.to_owned(),
...@@ -1877,6 +1896,9 @@ impl Storage for Hosted {...@@ -1877,6 +1896,9 @@ impl Storage for Hosted {
1877 }1896 }
18781897
1879 fn create(&self, path: &str, bytes: &[u8]) -> Result<()> {1898 fn create(&self, path: &str, bytes: &[u8]) -> Result<()> {
1899 if bytes.len() > LIMIT {
1900 return Err(io::Error::from(io::ErrorKind::FileTooLarge).into());
1901 }
1880 let mut request = Request {1902 let mut request = Request {
1881 path: path.to_owned(),1903 path: path.to_owned(),
1882 ..Request::default()1904 ..Request::default()
...@@ -1954,6 +1976,60 @@ impl Storage for Hosted {...@@ -1954,6 +1976,60 @@ impl Storage for Hosted {
1954mod tests {1976mod tests {
1955 use super::*;1977 use super::*;
19561978
1979 #[test]
1980 fn refused_uploads_release_their_bytes_and_share_one_guest_budget() {
1981 let directory = tempfile::tempdir().unwrap();
1982 std::fs::create_dir(directory.path().join("notebook")).unwrap();
1983 let served = Arc::new(Served {
1984 storage: crate::session::Notebook::open(
1985 directory.path().join("notebook"),
1986 directory.path().join("cache"),
1987 )
1988 .unwrap()
1989 .into_storage(),
1990 images: Mutex::default(),
1991 snapshots: Mutex::default(),
1992 puts: Mutex::default(),
1993 guests: Mutex::default(),
1994 writers: Mutex::default(),
1995 host: Mutex::default(),
1996 room: Mutex::default(),
1997 });
1998 let peer = [1; 16];
1999 let put = |handle, offset, bytes| {
2000 served.answer(
2001 &peer,
2002 kind::PUT,
2003 Request {
2004 handle: Some(handle),
2005 offset: Some(offset),
2006 bytes: Some(bytes),
2007 ..Request::default()
2008 },
2009 )
2010 };
2011 put(1, 0, vec![1; 3]).unwrap();
2012 put(2, 0, vec![2; 2]).unwrap();
2013 assert!(put(1, 2, vec![3]).is_err());
2014 assert_eq!(
2015 served.puts.lock().unwrap().get(&(peer, 2)).unwrap(),
2016 &[2; 2]
2017 );
2018 assert!(!served.puts.lock().unwrap().contains_key(&(peer, 1)));
2019 put(1, 0, vec![4]).unwrap();
2020 assert!(put(1, 1, Vec::new()).is_err());
2021 served
2022 .puts
2023 .lock()
2024 .unwrap()
2025 .insert((peer, 3), vec![0; LIMIT - 2]);
2026 assert!(put(2, 2, vec![5]).is_err());
2027 assert!(!served.puts.lock().unwrap().contains_key(&(peer, 2)));
2028 assert!(put(3, (LIMIT - 2) as u64, vec![6; 3]).is_err());
2029 assert!(served.puts.lock().unwrap().is_empty());
2030 put(4, 0, vec![7]).unwrap();
2031 }
2032
1957 /// A commit's writes, found by comparing images, rebuild the image after it from the one2033 /// A commit's writes, found by comparing images, rebuild the image after it from the one
1958 /// before, and a change touching most of the file is left to be read whole.2034 /// before, and a change touching most of the file is left to be read whole.
1959 #[test]2035 #[test]
crates/notebook/src/live/share/batch.rs+90
...@@ -10,6 +10,37 @@ pub(super) struct Edits {...@@ -10,6 +10,37 @@ pub(super) struct Edits {
10 pub revisions: BTreeMap<ExGuid, ExGuid>,10 pub revisions: BTreeMap<ExGuid, ExGuid>,
11}11}
1212
13pub(super) fn encode(edits: &Edits, limit: usize) -> io::Result<Vec<u8>> {
14 struct Buffer {
15 bytes: Vec<u8>,
16 limit: usize,
17 }
18 impl io::Write for Buffer {
19 fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
20 if bytes.len() > self.limit.saturating_sub(self.bytes.len()) {
21 return Err(io::ErrorKind::FileTooLarge.into());
22 }
23 self.bytes.extend_from_slice(bytes);
24 Ok(bytes.len())
25 }
26
27 fn flush(&mut self) -> io::Result<()> {
28 Ok(())
29 }
30 }
31 let mut buffer = Buffer {
32 bytes: Vec::new(),
33 limit,
34 };
35 serde_json::to_writer(&mut buffer, edits).map_err(|error| {
36 io::Error::new(
37 error.io_error_kind().unwrap_or(io::ErrorKind::InvalidData),
38 error,
39 )
40 })?;
41 Ok(buffer.bytes)
42}
43
13pub(super) struct Waiting {44pub(super) struct Waiting {
14 peer: [u8; 16],45 peer: [u8; 16],
15 request: Request,46 request: Request,
...@@ -262,3 +293,62 @@ impl Served {...@@ -262,3 +293,62 @@ impl Served {
262 Ok(transaction)293 Ok(transaction)
263 }294 }
264}295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300 use onestore::{
301 op::{Op, PageOp},
302 page::{Attachment, PageObject},
303 };
304
305 #[test]
306 fn attachment_encoding_stops_at_the_upload_budget() {
307 let id = ExGuid {
308 guid: [1; 16],
309 n: 1,
310 };
311 let bytes: Arc<[u8]> = (0..4096)
312 .map(|at| (at % 251) as u8)
313 .collect::<Vec<_>>()
314 .into();
315 let edits = Edits {
316 edits: vec![(
317 "Ada".into(),
318 Edit {
319 at: 0,
320 ops: vec![Op::Page {
321 space: id,
322 op: PageOp::Add {
323 object: PageObject::Attachment(Attachment {
324 id,
325 filename: "payload.bin".into(),
326 source_path: None,
327 size: None,
328 layout: Default::default(),
329 bytes: Some(Arc::clone(&bytes)),
330 preview: None,
331 recording: None,
332 tags: Vec::new(),
333 }),
334 before: None,
335 },
336 }],
337 },
338 )],
339 revisions: BTreeMap::new(),
340 };
341 let encoded = serde_json::to_vec(&edits).unwrap();
342 assert_eq!(encode(&edits, encoded.len()).unwrap(), encoded);
343 let decoded: Edits = serde_json::from_slice(&encoded).unwrap();
344 assert_eq!(decoded.edits, edits.edits);
345 assert_eq!(
346 encode(&edits, encoded.len() - 1).unwrap_err().kind(),
347 io::ErrorKind::FileTooLarge
348 );
349 assert_eq!(
350 encode(&edits, 128).unwrap_err().kind(),
351 io::ErrorKind::FileTooLarge
352 );
353 }
354}
crates/notebook/src/session.rs+2-2
...@@ -326,7 +326,7 @@ impl Storage for Share {...@@ -326,7 +326,7 @@ impl Storage for Share {
326 fn read(&self, path: &str) -> Result<Vec<u8>> {326 fn read(&self, path: &str) -> Result<Vec<u8>> {
327 Ok(self327 Ok(self
328 .client328 .client
329 .read_storage(&self.path(path), 256 * 1024 * 1024)?)329 .read_storage(&self.path(path), crate::MAX_FILE_BYTES)?)
330 }330 }
331331
332 fn read_file(&self, path: &str, limit: usize) -> Result<Vec<u8>> {332 fn read_file(&self, path: &str, limit: usize) -> Result<Vec<u8>> {
...@@ -403,7 +403,7 @@ impl Storage for Share {...@@ -403,7 +403,7 @@ impl Storage for Share {
403403
404pub(crate) const LIMITS: discover::Limits = discover::Limits {404pub(crate) const LIMITS: discover::Limits = discover::Limits {
405 entries: 100_000,405 entries: 100_000,
406 bytes_per_file: 256 * 1024 * 1024,406 bytes_per_file: crate::MAX_FILE_BYTES,
407 depth: 64,407 depth: 64,
408};408};
409409
crates/onestore/src/page/mod.rs+11-5
...@@ -22,17 +22,23 @@ pub use math::Math;...@@ -22,17 +22,23 @@ pub use math::Math;
22/// Picture and attachment payloads travel with the model (queued intents replay them),22/// Picture and attachment payloads travel with the model (queued intents replay them),
23/// as base64 text.23/// as base64 text.
24mod payload {24mod payload {
25 use base64::Engine;25 use base64::{Engine, display::Base64Display, engine::general_purpose::STANDARD};
26 use std::sync::Arc;26 use std::sync::Arc;
2727
28 struct Encoded<'a>(&'a [u8]);
29
30 impl serde::Serialize for Encoded<'_> {
31 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
32 serializer.collect_str(&Base64Display::new(self.0, &STANDARD))
33 }
34 }
35
28 pub fn serialize<S: serde::Serializer>(36 pub fn serialize<S: serde::Serializer>(
29 bytes: &Option<Arc<[u8]>>,37 bytes: &Option<Arc<[u8]>>,
30 serializer: S,38 serializer: S,
31 ) -> Result<S::Ok, S::Error> {39 ) -> Result<S::Ok, S::Error> {
32 match bytes {40 match bytes {
33 Some(bytes) => {41 Some(bytes) => serializer.serialize_some(&Encoded(bytes)),
34 serializer.serialize_some(&base64::engine::general_purpose::STANDARD.encode(bytes))
35 }
36 None => serializer.serialize_none(),42 None => serializer.serialize_none(),
37 }43 }
38 }44 }
...@@ -42,7 +48,7 @@ mod payload {...@@ -42,7 +48,7 @@ mod payload {
42 ) -> Result<Option<Arc<[u8]>>, D::Error> {48 ) -> Result<Option<Arc<[u8]>>, D::Error> {
43 let text: Option<String> = serde::Deserialize::deserialize(deserializer)?;49 let text: Option<String> = serde::Deserialize::deserialize(deserializer)?;
44 text.map(|text| {50 text.map(|text| {
45 base64::engine::general_purpose::STANDARD51 STANDARD
46 .decode(text)52 .decode(text)
47 .map(Arc::from)53 .map(Arc::from)
48 .map_err(serde::de::Error::custom)54 .map_err(serde::de::Error::custom)
crates/snowbound/src/attachment.rs+76-34
...@@ -2,44 +2,76 @@...@@ -2,44 +2,76 @@
22
3use crate::{State, platform};3use crate::{State, platform};
4use onestore::page::Attachment;4use onestore::page::Attachment;
5use std::{error::Error, path::Path};5use std::{
6 error::Error,
7 path::{Path, PathBuf},
8};
69
7impl State {10impl State {
8 /// Attach File, or a file dropped at `at`, a window point: a copy of the file's bytes11 /// Inserts a file at the caret or window point `at`, as a picture when requested.
9 /// at the caret or at `at`, with the icon the system shows for it; audio and video are12 pub(crate) fn import_file(&mut self, path: &Path, at: Option<[f32; 2]>, picture: bool) {
10 /// recordings, as OneNote attaches them.
11 pub(crate) fn attach(
12 &mut self,
13 path: &Path,
14 at: Option<[f32; 2]>,
15 ) -> Result<(), Box<dyn Error>> {
16 if self.session.as_ref().is_some_and(crate::Session::read_only) {13 if self.session.as_ref().is_some_and(crate::Session::read_only) {
17 return Ok(());14 return;
18 }15 }
19 let (Some(name), Ok(bytes)) = (16 let loading = self.loading;
20 path.file_name().and_then(|name| name.to_str()),17 let reply = self.reply(
21 notebook::fs::read(path),18 move |state, (path, read): (PathBuf, std::io::Result<Vec<u8>>)| {
22 ) else {19 let bytes = match read {
23 platform::alert("Couldn't attach the file", "Choose a file you can open.");20 Ok(bytes) => bytes,
24 return Ok(());21 Err(error) if error.kind() == std::io::ErrorKind::FileTooLarge => {
25 };22 too_large();
26 let file = Attachment {23 return Ok(());
27 id: onestore::page::text::new_id()?,24 }
28 filename: name.to_owned(),25 Err(_) => {
29 source_path: path.to_str().map(str::to_owned),26 platform::alert("Couldn't insert the file", "Choose a file you can open.");
30 size: Some(canvas::gpu::page::ICON_SIZE),27 return Ok(());
31 layout: Default::default(),28 }
32 preview: platform::file_icon(path).map(Into::into),29 };
33 recording: canvas::recording::attached(name, &bytes),30 if state.loading != loading {
34 bytes: Some(bytes.into()),31 platform::alert(
35 tags: Vec::new(),32 "File wasn't inserted",
36 };33 "Return to the page and insert the file again.",
37 let response = match at {34 );
38 Some(point) => self.view.drop_attachment(self.page_point(point), file)?,35 return Ok(());
39 None => self.view.insert_attachment(file)?,36 }
40 };37 if state
41 self.respond(response);38 .session
42 Ok(())39 .as_ref()
40 .is_some_and(crate::Session::read_only)
41 {
42 return Ok(());
43 }
44 if picture && draw::RasterImage::measure(&bytes).is_ok() {
45 return state.insert_picture(bytes, at);
46 }
47 let name = path
48 .file_name()
49 .and_then(|name| name.to_str())
50 .ok_or("No file name")?;
51 let file = Attachment {
52 id: onestore::page::text::new_id()?,
53 filename: name.to_owned(),
54 source_path: path.to_str().map(str::to_owned),
55 size: Some(canvas::gpu::page::ICON_SIZE),
56 layout: Default::default(),
57 preview: platform::file_icon(&path).map(Into::into),
58 recording: canvas::recording::attached(name, &bytes),
59 bytes: Some(bytes.into()),
60 tags: Vec::new(),
61 };
62 let response = match at {
63 Some(point) => state.view.drop_attachment(state.page_point(point), file)?,
64 None => state.view.insert_attachment(file)?,
65 };
66 state.respond(response);
67 Ok(())
68 },
69 );
70 let path = path.to_owned();
71 crate::spawn(move || {
72 let bytes = notebook::fs::read_limited(&path, notebook::MAX_FILE_BYTES);
73 reply.send((path, bytes));
74 });
43 }75 }
4476
45 /// Open: a recording plays; another file opens as a copy, in a folder of its own, with77 /// Open: a recording plays; another file opens as a copy, in a folder of its own, with
...@@ -89,6 +121,16 @@ impl State {...@@ -89,6 +121,16 @@ impl State {
89 }121 }
90}122}
91123
124pub(crate) fn too_large() {
125 platform::alert(
126 "File is too large",
127 &format!(
128 "Choose files totaling at most {} MiB.",
129 notebook::MAX_FILE_BYTES >> 20
130 ),
131 );
132}
133
92/// A file another program stored beside the section rather than in it.134/// A file another program stored beside the section rather than in it.
93fn unstored() {135fn unstored() {
94 platform::alert(136 platform::alert(
crates/snowbound/src/commands.rs+16-2
...@@ -1247,7 +1247,7 @@ impl State {...@@ -1247,7 +1247,7 @@ impl State {
1247 Id::Undo | Id::Redo => enabled(writable && !field && self.can_step(id == Id::Redo)),1247 Id::Undo | Id::Redo => enabled(writable && !field && self.can_step(id == Id::Redo)),
1248 Id::Cut => enabled(writable && selected),1248 Id::Cut => enabled(writable && selected),
1249 Id::Copy => enabled(selected),1249 Id::Copy => enabled(selected),
1250 Id::Paste => enabled(text && !field),1250 Id::Paste => enabled(field || text),
1251 Id::SelectAll => enabled(field || page),1251 Id::SelectAll => enabled(field || page),
1252 Id::Back | Id::Forward => {1252 Id::Back | Id::Forward => {
1253 enabled(!modal && self.can_travel()[usize::from(id == Id::Forward)])1253 enabled(!modal && self.can_travel()[usize::from(id == Id::Forward)])
...@@ -1573,6 +1573,17 @@ impl State {...@@ -1573,6 +1573,17 @@ impl State {
1573 self.respond(response);1573 self.respond(response);
1574 return Ok(());1574 return Ok(());
1575 }1575 }
1576 Id::Paste if field.is_some() => {
1577 let text = match &mut self.clipboard {
1578 crate::Clipboard::System(clipboard) => clipboard.get_text().ok(),
1579 crate::Clipboard::Memory(text, _) => Some(text.clone()),
1580 };
1581 if let Some(text) = text {
1582 self.ui
1583 .event(ui::Event::Ime(winit::event::Ime::Commit(text)));
1584 }
1585 return Ok(());
1586 }
1576 Id::Paste => Work::Page(Request::Paste),1587 Id::Paste => Work::Page(Request::Paste),
1577 Id::FormatPainter => {1588 Id::FormatPainter => {
1578 self.painter = match self.painter {1589 self.painter = match self.painter {
...@@ -1659,7 +1670,10 @@ impl State {...@@ -1659,7 +1670,10 @@ impl State {
1659 return Ok(());1670 return Ok(());
1660 }1671 }
1661 Id::Attachment => {1672 Id::Attachment => {
1662 let reply = self.reply(|state, path: std::path::PathBuf| state.attach(&path, None));1673 let reply = self.reply(|state, path: std::path::PathBuf| {
1674 state.import_file(&path, None, false);
1675 Ok(())
1676 });
1663 platform::pick_file("Attach File", &[], reply);1677 platform::pick_file("Attach File", &[], reply);
1664 return Ok(());1678 return Ok(());
1665 }1679 }
crates/snowbound/src/main.rs+1-1
...@@ -6630,7 +6630,7 @@ impl ApplicationHandler<UserEvent> for App {...@@ -6630,7 +6630,7 @@ impl ApplicationHandler<UserEvent> for App {
6630 WindowEvent::Ime(ime) => state.input(ui::Event::Ime(ime)),6630 WindowEvent::Ime(ime) => state.input(ui::Event::Ime(ime)),
6631 WindowEvent::DroppedFile(path) => {6631 WindowEvent::DroppedFile(path) => {
6632 let at = platform::drop_point(&state.window);6632 let at = platform::drop_point(&state.window);
6633 state.place_file(&path, at)?;6633 state.import_file(&path, at, true);
6634 state.window.request_redraw();6634 state.window.request_redraw();
6635 }6635 }
6636 WindowEvent::PinchGesture { delta, .. } => state.pinch(1.0 + delta as f32)?,6636 WindowEvent::PinchGesture { delta, .. } => state.pinch(1.0 + delta as f32)?,
crates/snowbound/src/paste.rs+2-20
...@@ -3,10 +3,7 @@...@@ -3,10 +3,7 @@
33
4use crate::{State, platform};4use crate::{State, platform};
5use canvas::editor::{Clip, Piece};5use canvas::editor::{Clip, Piece};
6use std::{6use std::{error::Error, path::PathBuf};
7 error::Error,
8 path::{Path, PathBuf},
9};
107
11/// Copy offers text and HTML, or a picture as PNG, beside Snowbound's lossless clip.8/// Copy offers text and HTML, or a picture as PNG, beside Snowbound's lossless clip.
12pub(crate) struct Copied {9pub(crate) struct Copied {
...@@ -123,7 +120,7 @@ impl State {...@@ -123,7 +120,7 @@ impl State {
123 match self.clipboard.pasted() {120 match self.clipboard.pasted() {
124 Some(Pasted::Files(paths)) => {121 Some(Pasted::Files(paths)) => {
125 for path in paths {122 for path in paths {
126 self.place_file(&path, None)?;123 self.import_file(&path, None, true);
127 }124 }
128 }125 }
129 Some(Pasted::Clip(clip)) => {126 Some(Pasted::Clip(clip)) => {
...@@ -180,21 +177,6 @@ impl State {...@@ -180,21 +177,6 @@ impl State {
180 Ok(())177 Ok(())
181 }178 }
182179
183 /// A pasted file, or one dropped at `at`, a window point: a picture file as its picture,
184 /// as OneNote 2010 pastes one (lab, 2026-09-30), and any other file attached.
185 pub(crate) fn place_file(
186 &mut self,
187 path: &Path,
188 at: Option<[f32; 2]>,
189 ) -> Result<(), Box<dyn Error>> {
190 match notebook::fs::read(path) {
191 Ok(bytes) if draw::RasterImage::measure(&bytes).is_ok() => {
192 self.insert_picture(bytes, at)
193 }
194 _ => self.attach(path, at),
195 }
196 }
197
198 /// Puts an encoded picture at the caret, or where it is dropped at `at`, a window point,180 /// Puts an encoded picture at the caret, or where it is dropped at `at`, a window point,
199 /// at the size its resolution gives it, as OneNote 2010 inserts and pastes one.181 /// at the size its resolution gives it, as OneNote 2010 inserts and pastes one.
200 pub(crate) fn insert_picture(182 pub(crate) fn insert_picture(
crates/snowbound/src/web.rs+12-1
...@@ -1376,6 +1376,16 @@ pub fn appearance_changed(dark: bool) {...@@ -1376,6 +1376,16 @@ pub fn appearance_changed(dark: bool) {
1376 send(UserEvent::Appearance);1376 send(UserEvent::Appearance);
1377}1377}
13781378
1379/// Rejects a selection before JavaScript reads its files.
1380#[wasm_bindgen]
1381pub fn accepts_files(size: f64) -> bool {
1382 let accepted = size.is_finite() && size >= 0.0 && size <= notebook::MAX_FILE_BYTES as f64;
1383 if !accepted {
1384 crate::attachment::too_large();
1385 }
1386 accepted
1387}
1388
1379/// Files chosen or dropped, as `[name, bytes]` pairs, for `purpose`: `open` opens them as1389/// Files chosen or dropped, as `[name, bytes]` pairs, for `purpose`: `open` opens them as
1380/// notebooks, sections or packages, `place` puts them at the caret.1390/// notebooks, sections or packages, `place` puts them at the caret.
1381#[wasm_bindgen]1391#[wasm_bindgen]
...@@ -1388,7 +1398,8 @@ pub fn files(purpose: &str, files: js_sys::Array) {...@@ -1388,7 +1398,8 @@ pub fn files(purpose: &str, files: js_sys::Array) {
1388 _ => {1398 _ => {
1389 for path in keep(files, CHOSEN) {1399 for path in keep(files, CHOSEN) {
1390 send(UserEvent::Then(Box::new(move |state| {1400 send(UserEvent::Then(Box::new(move |state| {
1391 state.place_file(&path, None)1401 state.import_file(&path, None, true);
1402 Ok(())
1392 })));1403 })));
1393 }1404 }
1394 }1405 }
crates/snowbound/tests/replay.rs+42
...@@ -606,6 +606,48 @@ fn the_find_bar_keeps_room_for_the_query() {...@@ -606,6 +606,48 @@ fn the_find_bar_keeps_room_for_the_query() {
606 );606 );
607}607}
608608
609#[test]
610fn paste_replaces_a_focused_input_without_editing_the_page() {
611 let scratch = Scratch::new("paste-field");
612 let notebook =
613 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate");
614 let steps = [
615 "move 900 600",
616 "press",
617 "release",
618 "type 雪 paste ☃",
619 "settle",
620 "modifiers command shift",
621 "key Left",
622 "modifiers command",
623 "key c",
624 "modifiers",
625 "settle",
626 "modifiers command",
627 "key f",
628 "modifiers",
629 "settle",
630 "type replace me",
631 "settle",
632 "modifiers command",
633 "key a",
634 "modifiers",
635 "settle",
636 "modifiers command",
637 "key v",
638 "modifiers",
639 "accessibility pasted",
640 ];
641 let [pasted] = replay(&scratch, Some(&notebook), &steps)
642 .try_into()
643 .unwrap();
644 assert!(
645 pasted.contains(r#"SearchInput "Find on Page" = "雪 paste ☃" [focused]"#),
646 "{pasted}"
647 );
648 assert_eq!(pasted.matches("雪 paste ☃").count(), 2, "{pasted}");
649}
650
609/// A launch shows the page each notebook was left on, as the settings recall them: the651/// A launch shows the page each notebook was left on, as the settings recall them: the
610/// notebook shown at once, and another as its section is opened.652/// notebook shown at once, and another as its section is opened.
611#[test]653#[test]
crates/snowbound/web/glue.js+6-4
...@@ -611,10 +611,12 @@ function modifiers(event) {...@@ -611,10 +611,12 @@ function modifiers(event) {
611 );611 );
612}612}
613613
614function read(list) {614async function read(list) {
615 return Promise.all(615 const files = [...list];
616 [...list].map(async (file) => [file.name, new Uint8Array(await file.arrayBuffer())]),616 if (!wasm.accepts_files(files.reduce((size, file) => size + file.size, 0))) return [];
617 );617 const read = [];
618 for (const file of files) read.push([file.name, new Uint8Array(await file.arrayBuffer())]);
619 return read;
618}620}
619621
620const NOTEBOOK_FILES = /\.(one|onetoc2|onepkg)$/i;622const NOTEBOOK_FILES = /\.(one|onetoc2|onepkg)$/i;