feat(chat): colle les fichiers du presse-papiers via Ctrl+V dans le composer (#181)

This commit is contained in:
2026-08-11 15:57:30 +02:00
parent ce2640c9af
commit 419987d890
4 changed files with 296 additions and 3 deletions

View File

@ -110,6 +110,7 @@ impl ImportChatAttachments {
.await?
}
ImportChatAttachmentSource::Bytes { bytes, .. } => {
validate_attachment_size(bytes.len() as u64)?;
self.store
.import_from_bytes(
&input.project.root,
@ -174,6 +175,20 @@ fn validate_attachment_filename(filename: &str) -> Result<(), AppError> {
Ok(())
}
fn validate_attachment_size(size_bytes: u64) -> Result<(), AppError> {
if size_bytes == 0 {
return Err(AppError::Invalid(
"chat attachment must not be empty".to_owned(),
));
}
if size_bytes > CHAT_ATTACHMENT_MAX_BYTES {
return Err(AppError::Invalid(format!(
"chat attachment exceeds {CHAT_ATTACHMENT_MAX_BYTES} bytes"
)));
}
Ok(())
}
fn sanitize_mime(raw: Option<&str>, filename: &str) -> Result<String, AppError> {
let mime = raw
.map(str::trim)

View File

@ -12,7 +12,7 @@ use uuid::Uuid;
use application::{
ImportChatAttachmentItem, ImportChatAttachmentSource, ImportChatAttachments,
ImportChatAttachmentsInput,
ImportChatAttachmentsInput, CHAT_ATTACHMENT_MAX_BYTES,
};
#[derive(Default)]
@ -220,6 +220,109 @@ async fn import_chat_attachments_routes_clipboard_bytes_to_store_port() {
assert_eq!(calls[0].1.filename, "clipboard.png");
}
#[tokio::test]
async fn import_chat_attachments_accepts_generic_clipboard_file_bytes() {
let store = Arc::new(FakeChatAttachments::default());
let usecase = ImportChatAttachments::new(
Arc::clone(&store) as Arc<dyn ChatAttachmentStore>,
Arc::new(FixedIds),
Arc::new(FixedClock),
);
let output = usecase
.execute(ImportChatAttachmentsInput {
project: project(),
agent_id: AgentId::new_random(),
session_id: SessionId::new_random(),
attachments: vec![ImportChatAttachmentItem {
source: ImportChatAttachmentSource::Bytes {
filename: "clipboard.pdf".to_owned(),
bytes: b"%PDF-1.7".to_vec(),
},
mime: Some("application/pdf".to_owned()),
source_kind: ChatAttachmentSourceKind::Clipboard,
}],
})
.await
.unwrap();
assert_eq!(output.attachments[0].filename, "clipboard.pdf");
assert_eq!(output.attachments[0].mime, "application/pdf");
assert_eq!(
output.attachments[0].source_kind,
ChatAttachmentSourceKind::Clipboard
);
let calls = store.bytes_calls.lock().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0, b"%PDF-1.7");
assert_eq!(calls[0].1.filename, "clipboard.pdf");
assert_eq!(calls[0].1.mime, "application/pdf");
}
#[tokio::test]
async fn import_chat_attachments_rejects_oversized_clipboard_bytes_before_store_call() {
let store = Arc::new(FakeChatAttachments::default());
let usecase = ImportChatAttachments::new(
Arc::clone(&store) as Arc<dyn ChatAttachmentStore>,
Arc::new(FixedIds),
Arc::new(FixedClock),
);
let err = usecase
.execute(ImportChatAttachmentsInput {
project: project(),
agent_id: AgentId::new_random(),
session_id: SessionId::new_random(),
attachments: vec![ImportChatAttachmentItem {
source: ImportChatAttachmentSource::Bytes {
filename: "too-big.pdf".to_owned(),
bytes: vec![0; (CHAT_ATTACHMENT_MAX_BYTES + 1) as usize],
},
mime: Some("application/pdf".to_owned()),
source_kind: ChatAttachmentSourceKind::Clipboard,
}],
})
.await
.unwrap_err();
assert_eq!(err.code(), "INVALID");
assert!(err.to_string().contains("chat attachment exceeds"));
assert!(store.path_calls.lock().unwrap().is_empty());
assert!(store.bytes_calls.lock().unwrap().is_empty());
}
#[tokio::test]
async fn import_chat_attachments_rejects_empty_clipboard_bytes_before_store_call() {
let store = Arc::new(FakeChatAttachments::default());
let usecase = ImportChatAttachments::new(
Arc::clone(&store) as Arc<dyn ChatAttachmentStore>,
Arc::new(FixedIds),
Arc::new(FixedClock),
);
let err = usecase
.execute(ImportChatAttachmentsInput {
project: project(),
agent_id: AgentId::new_random(),
session_id: SessionId::new_random(),
attachments: vec![ImportChatAttachmentItem {
source: ImportChatAttachmentSource::Bytes {
filename: "empty.txt".to_owned(),
bytes: Vec::new(),
},
mime: Some("text/plain".to_owned()),
source_kind: ChatAttachmentSourceKind::Clipboard,
}],
})
.await
.unwrap_err();
assert_eq!(err.code(), "INVALID");
assert!(err.to_string().contains("chat attachment must not be empty"));
assert!(store.path_calls.lock().unwrap().is_empty());
assert!(store.bytes_calls.lock().unwrap().is_empty());
}
#[tokio::test]
async fn import_chat_attachments_rejects_executable_sources_before_store_call() {
let store = Arc::new(FakeChatAttachments::default());