Skip to main content

codetether_agent/tui/app/
file_picker.rs

1//! File picker UI for browsing and attaching files (including images).
2//!
3//! Supports:
4//! - Navigating directories
5//! - Selecting text files (attached as code snippets)
6//! - Selecting image files (attached as base64 image attachments)
7
8use std::path::{Path, PathBuf};
9
10use crate::tui::app::state::App;
11use crate::tui::chat::message::{ChatMessage, MessageType};
12
13/// A single entry in the file picker listing.
14#[derive(Debug, Clone)]
15pub struct FilePickerEntry {
16    pub path: PathBuf,
17    pub is_dir: bool,
18    pub name: String,
19}
20
21/// Supported image file extensions.
22const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg"];
23
24/// Check if a file path has an image extension.
25pub 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
32/// Open the file picker at the given working directory.
33pub 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
45/// Handle Enter key in file picker — navigate into directories or select files.
46pub 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        // Navigate into directory
59        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    // File selected — attach it
68    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
79/// Handle Backspace in the filter input.
80pub fn file_picker_filter_backspace(app: &mut App) {
81    app.state.file_picker_filter.pop();
82    rescan_with_filter(app);
83}
84
85/// Push a character to the filter.
86pub 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
91/// Scan the current directory and populate entries.
92fn scan_directory(dir: &Path) -> Vec<FilePickerEntry> {
93    let mut entries: Vec<FilePickerEntry> = Vec::new();
94
95    // Add parent directory entry (..) if not at root
96    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        // Skip hidden files/dirs
120        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    // Sort: directories first, then files, alphabetically
141    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
149/// Re-scan with current filter applied.
150fn 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
167/// Attach an image file from the file picker.
168fn 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
193/// Select previous entry.
194pub 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
200/// Select next entry.
201pub 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
207/// Render the file picker view.
208pub 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}