返回 DeepSeek-TUI-2026
attachment.rs
根目录 / crates / tui / src / commands / attachment.rs
1 //! Local media attachment commands.
2
3 use std::path::{Path, PathBuf};
4
5 use super::CommandResult;
6 use crate::tui::app::App;
7
8 pub fn attach(app: &mut App, arg: Option<&str>) -> CommandResult {
9 let Some(raw_path) = arg.map(str::trim).filter(|value| !value.is_empty()) else {
10 return CommandResult::error("Usage: /attach <image-or-video-path>");
11 };
12
13 let path = resolve_attachment_path(raw_path, &app.workspace);
14 let Ok(path) = path.canonicalize() else {
15 return CommandResult::error(format!("Attachment not found: {}", path.display()));
16 };
17 if !path.is_file() {
18 return CommandResult::error(format!("Attachment is not a file: {}", path.display()));
19 }
20
21 let Some(kind) = media_kind(&path) else {
22 return CommandResult::error(
23 "Unsupported attachment type. /attach is for image/video paths; use @path for text files or directories.",
24 );
25 };
26
27 app.insert_media_attachment(kind, &path, None);
28 CommandResult::message(format!("Attached {kind}: {}", path.display()))
29 }
30
31 fn resolve_attachment_path(raw_path: &str, workspace: &Path) -> PathBuf {
32 let unquoted = raw_path.trim().trim_matches('"').trim_matches('\'');
33 let path = expand_home(unquoted);
34 if path.is_absolute() {
35 path
36 } else {
37 workspace.join(path)
38 }
39 }
40
41 fn expand_home(path: &str) -> PathBuf {
42 if path == "~" {
43 if let Some(home) = std::env::var_os("HOME") {
44 return PathBuf::from(home);
45 }
46 } else if let Some(rest) = path.strip_prefix("~/")
47 && let Some(home) = std::env::var_os("HOME")
48 {
49 return PathBuf::from(home).join(rest);
50 }
51 PathBuf::from(path)
52 }
53
54 fn media_kind(path: &Path) -> Option<&'static str> {
55 let ext = path.extension()?.to_str()?.to_ascii_lowercase();
56 match ext.as_str() {
57 "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "tif" | "tiff" | "ppm" => Some("image"),
58 "mp4" | "mov" | "m4v" | "webm" | "avi" | "mkv" => Some("video"),
59 _ => None,
60 }
61 }
62
63 #[cfg(test)]
64 mod tests {
65 use super::*;
66 use crate::config::Config;
67 use crate::tui::app::TuiOptions;
68 use tempfile::TempDir;
69
70 fn app_with_workspace(tmpdir: &TempDir) -> App {
71 App::new(
72 TuiOptions {
73 model: "deepseek-v4-pro".to_string(),
74 workspace: tmpdir.path().to_path_buf(),
75 config_path: None,
76 config_profile: None,
77 allow_shell: false,
78 use_alt_screen: false,
79 use_mouse_capture: false,
80 use_bracketed_paste: true,
81 max_subagents: 1,
82 skills_dir: tmpdir.path().join("skills"),
83 memory_path: tmpdir.path().join("memory.md"),
84 notes_path: tmpdir.path().join("notes.txt"),
85 mcp_config_path: tmpdir.path().join("mcp.json"),
86 use_memory: false,
87 start_in_agent_mode: false,
88 skip_onboarding: true,
89 yolo: false,
90 resume_session_id: None,
91 initial_input: None,
92 },
93 &Config::default(),
94 )
95 }
96
97 #[test]
98 fn attach_inserts_image_reference() {
99 let tmpdir = TempDir::new().expect("tempdir");
100 let image_path = tmpdir.path().join("photo.png");
101 std::fs::write(&image_path, b"not actually decoded").expect("write image fixture");
102 let mut app = app_with_workspace(&tmpdir);
103
104 let result = attach(&mut app, Some("photo.png"));
105
106 assert!(result.message.expect("message").contains("Attached image"));
107 assert!(app.input.contains("[Attached image:"));
108 let canonical_path = image_path.canonicalize().expect("canonical image path");
109 assert!(app.input.contains(&canonical_path.display().to_string()));
110 }
111
112 #[test]
113 fn attach_rejects_unsupported_extension() {
114 let tmpdir = TempDir::new().expect("tempdir");
115 std::fs::write(tmpdir.path().join("notes.txt"), b"text").expect("write fixture");
116 let mut app = app_with_workspace(&tmpdir);
117
118 let result = attach(&mut app, Some("notes.txt"));
119
120 assert!(
121 result
122 .message
123 .expect("message")
124 .contains("Unsupported attachment type")
125 );
126 assert!(app.input.is_empty());
127 }
128 }
129
129 lines RUST