| author | |
| committer | |
| log | c2174276845a26013191ed67398382758312ac8f |
| tree | 1f19b0c6ceb9679db8438c5a67be3032f101fe88 |
| parent | 206bd8fba08030b8e592fdf5adcc4743005bd83a |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
- 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-sol18 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 @@ |
| 7 | use canvas::{editor::CanvasEditor, layout::TextEngine}; | 7 | use canvas::{editor::CanvasEditor, layout::TextEngine}; |
| 8 | use draw::edit::Movement; | 8 | use draw::edit::Movement; |
| 9 | use onestore::{ | 9 | use 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 | }; |
| 14 | use std::sync::Arc; | 14 | use 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 | } |
| 64 | 64 | ||
| 65 | #[test] | ||
| 66 | fn 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] |
| 66 | fn attached_files_store_their_bytes_between_the_text_around_them() { | 140 | fn 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 | } |
| 137 | 137 | ||
| 138 | #[test] | ||
| 139 | fn 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] |
| 139 | fn folding_ignores_case_and_diacritics() { | 160 | fn 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")] |
| 21 | pub use web::*; | 21 | pub use web::*; |
| 22 | 22 | ||
| 23 | /// Reads a regular file within `limit`, including when it grows during the read. | ||
| 24 | pub 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"))] |
| 24 | pub async fn durable() -> std::io::Result<()> { | 44 | pub async fn durable() -> std::io::Result<()> { |
| 25 | Ok(()) | 45 | Ok(()) |
| 26 | } | 46 | } |
| 47 | |||
| 48 | #[cfg(all(test, not(target_arch = "wasm32")))] | ||
| 49 | mod 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; |
| 49 | pub use smb::SmbRemote; | 49 | pub use smb::SmbRemote; |
| 50 | pub use worker::SyncWorker; | 50 | pub use worker::SyncWorker; |
| 51 | 51 | ||
| 52 | /// The largest file a notebook session or Live Share reads or writes whole. | ||
| 53 | pub const MAX_FILE_BYTES: usize = 256 << 20; | ||
| 54 | |||
| 52 | #[derive(Debug, thiserror::Error)] | 55 | #[derive(Debug, thiserror::Error)] |
| 53 | pub enum Error { | 56 | pub 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; |
| 42 | const WINDOW: usize = 512 << 10; | 42 | const WINDOW: usize = 512 << 10; |
| 43 | /// How long a request waits for its reply. | 43 | /// How long a request waits for its reply. |
| 44 | const TIMEOUT: Duration = Duration::from_secs(60); | 44 | const TIMEOUT: Duration = Duration::from_secs(60); |
| 45 | /// The largest file read or written whole. | 45 | use crate::MAX_FILE_BYTES as LIMIT; |
| 46 | const 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. |
| 48 | const SNAPSHOTS: usize = 8; | 47 | const SNAPSHOTS: usize = 8; |
| 49 | const SNAPSHOT_AGE: Duration = Duration::from_secs(120); | 48 | const 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 | done | 525 | 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: edits | 1456 | &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 | } |
| 1878 | 1897 | ||
| 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 { |
| 1954 | mod tests { | 1976 | mod tests { |
| 1955 | use super::*; | 1977 | use super::*; |
| 1956 | 1978 | ||
| 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 one | 2033 | /// 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 | } |
| 12 | 12 | ||
| 13 | pub(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 | |||
| 13 | pub(super) struct Waiting { | 44 | pub(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)] | ||
| 298 | mod 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(self | 327 | Ok(self |
| 328 | .client | 328 | .client |
| 329 | .read_storage(&self.path(path), 256 * 1024 * 1024)?) | 329 | .read_storage(&self.path(path), crate::MAX_FILE_BYTES)?) |
| 330 | } | 330 | } |
| 331 | 331 | ||
| 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 { |
| 403 | 403 | ||
| 404 | pub(crate) const LIMITS: discover::Limits = discover::Limits { | 404 | pub(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 | }; |
| 409 | 409 |
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. |
| 24 | mod payload { | 24 | mod 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; |
| 27 | 27 | ||
| 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::STANDARD | 51 | 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 @@ |
| 2 | 2 | ||
| 3 | use crate::{State, platform}; | 3 | use crate::{State, platform}; |
| 4 | use onestore::page::Attachment; | 4 | use onestore::page::Attachment; |
| 5 | use std::{error::Error, path::Path}; | 5 | use std::{ |
| 6 | error::Error, | ||
| 7 | path::{Path, PathBuf}, | ||
| 8 | }; | ||
| 6 | 9 | ||
| 7 | impl State { | 10 | impl State { |
| 8 | /// Attach File, or a file dropped at `at`, a window point: a copy of the file's bytes | 11 | /// 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 are | 12 | 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 | } |
| 44 | 76 | ||
| 45 | /// Open: a recording plays; another file opens as a copy, in a folder of its own, with | 77 | /// 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 | } |
| 91 | 123 | ||
| 124 | pub(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. |
| 93 | fn unstored() { | 135 | fn 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 @@ |
| 3 | 3 | ||
| 4 | use crate::{State, platform}; | 4 | use crate::{State, platform}; |
| 5 | use canvas::editor::{Clip, Piece}; | 5 | use canvas::editor::{Clip, Piece}; |
| 6 | use std::{ | 6 | use std::{error::Error, path::PathBuf}; |
| 7 | error::Error, | ||
| 8 | path::{Path, PathBuf}, | ||
| 9 | }; | ||
| 10 | 7 | ||
| 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. |
| 12 | pub(crate) struct Copied { | 9 | pub(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 | } |
| 182 | 179 | ||
| 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 | } |
| 1378 | 1378 | ||
| 1379 | /// Rejects a selection before JavaScript reads its files. | ||
| 1380 | #[wasm_bindgen] | ||
| 1381 | pub 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 as | 1389 | /// 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 | } |
| 608 | 608 | ||
| 609 | #[test] | ||
| 610 | fn 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: the | 651 | /// 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 | } |
| 613 | 613 | ||
| 614 | function read(list) { | 614 | async 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 | } |
| 619 | 621 | ||
| 620 | const NOTEBOOK_FILES = /\.(one|onetoc2|onepkg)$/i; | 622 | const NOTEBOOK_FILES = /\.(one|onetoc2|onepkg)$/i; |