codetether_agent/tui/app/
file_picker.rs1use std::path::{Path, PathBuf};
9
10use crate::tui::app::state::App;
11use crate::tui::chat::message::{ChatMessage, MessageType};
12
13#[derive(Debug, Clone)]
15pub struct FilePickerEntry {
16 pub path: PathBuf,
17 pub is_dir: bool,
18 pub name: String,
19}
20
21const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg"];
23
24pub fn is_image_file(path: &Path) -> bool {
26 path.extension()
27 .and_then(|ext| ext.to_str())
28 .map(|ext| ext.to_ascii_lowercase())
29 .is_some_and(|ext| IMAGE_EXTENSIONS.contains(&ext.as_str()))
30}
31
32pub fn open_file_picker(app: &mut App, cwd: &Path) {
34 let dir = cwd.to_path_buf();
35 let entries = scan_directory(&dir);
36 app.state.file_picker_active = true;
37 app.state.file_picker_dir = dir;
38 app.state.file_picker_entries = entries;
39 app.state.file_picker_selected = 0;
40 app.state.file_picker_filter.clear();
41 app.state.view_mode = crate::tui::models::ViewMode::FilePicker;
42 app.state.status = "File picker — Enter to select, Esc to cancel".to_string();
43}
44
45pub fn file_picker_enter(app: &mut App, cwd: &Path) {
47 let entry = app
48 .state
49 .file_picker_entries
50 .get(app.state.file_picker_selected)
51 .cloned();
52
53 let Some(entry) = entry else {
54 return;
55 };
56
57 if entry.is_dir {
58 let entries = scan_directory(&entry.path);
60 app.state.file_picker_dir = entry.path;
61 app.state.file_picker_entries = entries;
62 app.state.file_picker_selected = 0;
63 app.state.file_picker_filter.clear();
64 return;
65 }
66
67 let path = entry.path;
69 if is_image_file(&path) {
70 attach_image_from_picker(app, cwd, &path);
71 } else {
72 crate::tui::app::file_share::attach_file_to_input(app, cwd, &path);
73 }
74
75 app.state.file_picker_active = false;
76 app.state.view_mode = crate::tui::models::ViewMode::Chat;
77}
78
79pub fn file_picker_filter_backspace(app: &mut App) {
81 app.state.file_picker_filter.pop();
82 rescan_with_filter(app);
83}
84
85pub fn file_picker_filter_push(app: &mut App, c: char) {
87 app.state.file_picker_filter.push(c);
88 rescan_with_filter(app);
89}
90
91fn scan_directory(dir: &Path) -> Vec<FilePickerEntry> {
93 let mut entries: Vec<FilePickerEntry> = Vec::new();
94
95 if dir.parent().is_some() {
97 entries.push(FilePickerEntry {
98 path: dir.parent().unwrap().to_path_buf(),
99 is_dir: true,
100 name: "../".to_string(),
101 });
102 }
103
104 let Ok(read_dir) = std::fs::read_dir(dir) else {
105 return entries;
106 };
107
108 let mut dir_entries: Vec<FilePickerEntry> = Vec::new();
109 let mut file_entries: Vec<FilePickerEntry> = Vec::new();
110
111 for entry in read_dir.flatten() {
112 let path = entry.path();
113 let name = path
114 .file_name()
115 .and_then(|n| n.to_str())
116 .unwrap_or("?")
117 .to_string();
118
119 if name.starts_with('.') {
121 continue;
122 }
123
124 if path.is_dir() {
125 dir_entries.push(FilePickerEntry {
126 path,
127 is_dir: true,
128 name: format!("{name}/"),
129 });
130 } else {
131 let indicator = if is_image_file(&path) { " 📷" } else { "" };
132 file_entries.push(FilePickerEntry {
133 path,
134 is_dir: false,
135 name: format!("{name}{indicator}"),
136 });
137 }
138 }
139
140 dir_entries.sort_by(|a, b| a.name.cmp(&b.name));
142 file_entries.sort_by(|a, b| a.name.cmp(&b.name));
143
144 entries.extend(dir_entries);
145 entries.extend(file_entries);
146 entries
147}
148
149fn rescan_with_filter(app: &mut App) {
151 let filter = app.state.file_picker_filter.to_ascii_lowercase();
152 let all_entries = scan_directory(&app.state.file_picker_dir);
153
154 if filter.is_empty() {
155 app.state.file_picker_entries = all_entries;
156 } else {
157 app.state.file_picker_entries = all_entries
158 .into_iter()
159 .filter(|e| {
160 e.name.to_ascii_lowercase().contains(&filter) || e.is_dir && e.name == "../"
161 })
162 .collect();
163 }
164 app.state.file_picker_selected = 0;
165}
166
167fn attach_image_from_picker(app: &mut App, _cwd: &Path, path: &Path) {
169 match crate::tui::app::input::attach_image_file(path) {
170 Ok(attachment) => {
171 let display = path.display();
172 app.state.pending_images.push(attachment);
173 let count = app.state.pending_images.len();
174 app.state.status =
175 format!("📷 Attached {display}. {count} image(s) pending. Press Enter to send.");
176 app.state.messages.push(ChatMessage::new(
177 MessageType::System,
178 format!("📷 Image attached: {display}. Type a message and press Enter to send."),
179 ));
180 app.state.scroll_to_bottom();
181 }
182 Err(msg) => {
183 app.state.messages.push(ChatMessage::new(
184 MessageType::Error,
185 format!("Failed to attach image: {msg}"),
186 ));
187 app.state.status = "Failed to attach image".to_string();
188 app.state.scroll_to_bottom();
189 }
190 }
191}
192
193pub fn file_picker_select_prev(app: &mut App) {
195 if app.state.file_picker_selected > 0 {
196 app.state.file_picker_selected -= 1;
197 }
198}
199
200pub fn file_picker_select_next(app: &mut App) {
202 if app.state.file_picker_selected + 1 < app.state.file_picker_entries.len() {
203 app.state.file_picker_selected += 1;
204 }
205}
206
207pub fn render_file_picker(f: &mut ratatui::Frame, area: ratatui::prelude::Rect, app: &App) {
209 use ratatui::prelude::*;
210 use ratatui::widgets::*;
211
212 let dir = app.state.file_picker_dir.display().to_string();
213 let filter = &app.state.file_picker_filter;
214
215 let items: Vec<ListItem> = app
216 .state
217 .file_picker_entries
218 .iter()
219 .enumerate()
220 .map(|(i, entry)| {
221 let style = if i == app.state.file_picker_selected {
222 Style::default().fg(Color::Cyan).bold()
223 } else if entry.is_dir {
224 Style::default().fg(Color::Blue)
225 } else if is_image_file(&entry.path) {
226 Style::default().fg(Color::Magenta)
227 } else {
228 Style::default().dim()
229 };
230 ListItem::new(Line::from(Span::styled(&entry.name, style)))
231 })
232 .collect();
233
234 let title = if filter.is_empty() {
235 format!(" {dir} ")
236 } else {
237 format!(" {dir} [filter: {filter}] ")
238 };
239
240 let list = List::new(items).block(
241 Block::default()
242 .borders(Borders::ALL)
243 .title(title)
244 .border_style(Style::default().fg(Color::DarkGray)),
245 );
246
247 f.render_widget(list, area);
248}