Skip to main content

coding_agent_search/ui/components/
export_modal.rs

1//! Export modal component for HTML session export.
2//!
3//! Provides a beautiful, keyboard-navigable modal for configuring HTML export options.
4//! Features progressive disclosure, smart defaults, and instant visual feedback.
5//!
6//! State and logic live here; rendering is done in [`super::super::app::CassApp::render_export_overlay`]
7//! using ftui widgets.
8
9use std::path::PathBuf;
10
11use crate::html_export::{
12    ExportOptions, FilenameMetadata, FilenameOptions, generate_filepath, get_downloads_dir,
13    unique_filename,
14};
15use crate::search::query::SearchHit;
16use crate::ui::data::ConversationView;
17
18/// Focus field in the export modal.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20pub enum ExportField {
21    #[default]
22    OutputDir,
23    IncludeTools,
24    Encrypt,
25    Password,
26    ShowTimestamps,
27    ExportButton,
28}
29
30impl ExportField {
31    /// Get next field (Tab navigation).
32    pub fn next(self, encrypt_enabled: bool) -> Self {
33        match self {
34            Self::OutputDir => Self::IncludeTools,
35            Self::IncludeTools => Self::Encrypt,
36            Self::Encrypt => {
37                if encrypt_enabled {
38                    Self::Password
39                } else {
40                    Self::ShowTimestamps
41                }
42            }
43            Self::Password => Self::ShowTimestamps,
44            Self::ShowTimestamps => Self::ExportButton,
45            Self::ExportButton => Self::OutputDir,
46        }
47    }
48
49    /// Get previous field (Shift+Tab navigation).
50    pub fn prev(self, encrypt_enabled: bool) -> Self {
51        match self {
52            Self::OutputDir => Self::ExportButton,
53            Self::IncludeTools => Self::OutputDir,
54            Self::Encrypt => Self::IncludeTools,
55            Self::Password => Self::Encrypt,
56            Self::ShowTimestamps => {
57                if encrypt_enabled {
58                    Self::Password
59                } else {
60                    Self::Encrypt
61                }
62            }
63            Self::ExportButton => Self::ShowTimestamps,
64        }
65    }
66}
67
68/// Export progress states.
69#[derive(Debug, Clone, Default)]
70pub enum ExportProgress {
71    #[default]
72    Idle,
73    Preparing,
74    Encrypting,
75    Writing,
76    Complete(PathBuf),
77    Error(String),
78}
79
80impl ExportProgress {
81    /// Check if export is in progress.
82    pub fn is_busy(&self) -> bool {
83        matches!(self, Self::Preparing | Self::Encrypting | Self::Writing)
84    }
85}
86
87/// State for the export modal.
88#[derive(Debug, Clone)]
89pub struct ExportModalState {
90    /// Currently focused field.
91    pub focused: ExportField,
92
93    /// Output directory (defaults to cwd).
94    pub output_dir: PathBuf,
95
96    /// User is editing the output directory path.
97    pub output_dir_editing: bool,
98
99    /// Temporary edit buffer for output directory.
100    pub output_dir_buffer: String,
101
102    /// Generated filename preview.
103    pub filename_preview: String,
104
105    /// Include tool calls in export.
106    pub include_tools: bool,
107
108    /// Enable encryption.
109    pub encrypt: bool,
110
111    /// Password for encryption (only used if encrypt is true).
112    pub password: String,
113
114    /// Show password characters (toggle visibility).
115    pub password_visible: bool,
116
117    /// Show message timestamps.
118    pub show_timestamps: bool,
119
120    /// Export progress state.
121    pub progress: ExportProgress,
122
123    /// Session metadata for display.
124    pub agent_name: String,
125    pub workspace: String,
126    pub timestamp: String,
127    pub message_count: usize,
128    pub title_preview: String,
129}
130
131impl Default for ExportModalState {
132    fn default() -> Self {
133        let output_dir = get_downloads_dir();
134        let output_dir_buffer = output_dir.display().to_string();
135        Self {
136            focused: ExportField::default(),
137            output_dir,
138            output_dir_editing: false,
139            output_dir_buffer,
140            filename_preview: String::new(),
141            include_tools: true,
142            encrypt: false,
143            password: String::new(),
144            password_visible: false,
145            show_timestamps: true,
146            progress: ExportProgress::default(),
147            agent_name: String::new(),
148            workspace: String::new(),
149            timestamp: String::new(),
150            message_count: 0,
151            title_preview: String::new(),
152        }
153    }
154}
155
156fn timestamp_to_utc(ts: i64) -> Option<chrono::DateTime<chrono::Utc>> {
157    if ts.unsigned_abs() >= 10_000_000_000 {
158        chrono::DateTime::<chrono::Utc>::from_timestamp_millis(ts)
159    } else {
160        chrono::DateTime::<chrono::Utc>::from_timestamp(ts, 0)
161    }
162}
163
164impl ExportModalState {
165    /// Create new export modal state from a search hit and conversation view.
166    pub fn from_hit(hit: &SearchHit, view: &ConversationView) -> Self {
167        let agent = if view.convo.agent_slug.trim().is_empty() {
168            hit.agent.trim().to_string()
169        } else {
170            view.convo.agent_slug.trim().to_string()
171        };
172        let workspace = view
173            .workspace
174            .as_ref()
175            .map(|ws| ws.path.display().to_string())
176            .or_else(|| {
177                view.convo
178                    .workspace
179                    .as_ref()
180                    .map(|path| path.display().to_string())
181            })
182            .filter(|workspace| !workspace.trim().is_empty())
183            .unwrap_or_else(|| hit.workspace.trim().to_string());
184        let started_at = view
185            .convo
186            .started_at
187            .or_else(|| view.messages.iter().filter_map(|m| m.created_at).min())
188            .or(hit.created_at);
189        let message_count = view.messages.len();
190
191        // Prefer stable session metadata over first-message text so export titles and
192        // filenames do not drift when the indexed conversation already has a real title.
193        let title_preview = view
194            .convo
195            .title
196            .as_deref()
197            .map(str::trim)
198            .filter(|title| !title.is_empty())
199            .map(str::to_string)
200            .or_else(|| {
201                let hit_title = hit.title.trim();
202                (!hit_title.is_empty()).then(|| hit_title.to_string())
203            })
204            .or_else(|| {
205                view.messages.first().map(|m| {
206                    let content = m.content.trim();
207                    // Use char_indices to safely truncate at UTF-8 boundary (57 chars + "...")
208                    if content.chars().count() > 60 {
209                        let end_idx = content
210                            .char_indices()
211                            .nth(56)
212                            .map(|(idx, _)| idx)
213                            .unwrap_or(content.len());
214                        format!("{}...", &content[..end_idx])
215                    } else {
216                        content.to_string()
217                    }
218                })
219            })
220            .filter(|title| !title.trim().is_empty())
221            .unwrap_or_else(|| "Untitled Session".to_string());
222
223        // Format date for filename
224        let started_dt = started_at.and_then(timestamp_to_utc);
225        let date_str = started_dt.map(|dt| dt.format("%Y-%m-%d").to_string());
226
227        // Generate filename preview
228        let metadata = FilenameMetadata {
229            agent: (!agent.is_empty()).then(|| agent.clone()),
230            date: date_str,
231            project: (!workspace.is_empty()).then(|| workspace.clone()),
232            topic: Some(title_preview.clone()),
233            title: None,
234        };
235        let options = FilenameOptions {
236            include_date: true,
237            include_agent: true,
238            include_project: true,
239            include_topic: true,
240            ..Default::default()
241        };
242        let downloads = get_downloads_dir();
243        let filepath = generate_filepath(&downloads, &metadata, &options);
244        let base_filename = filepath
245            .file_name()
246            .and_then(|name| name.to_str())
247            .unwrap_or("session.html");
248        let filename_preview = unique_filename(&downloads, base_filename)
249            .file_name()
250            .map(|name| name.to_string_lossy().to_string())
251            .unwrap_or_else(|| base_filename.to_string());
252
253        // Format timestamp for display
254        let timestamp = started_at
255            .and_then(timestamp_to_utc)
256            .map(|dt| dt.format("%b %d, %Y at %I:%M %p").to_string())
257            .unwrap_or_else(|| "Unknown date".to_string());
258
259        let output_dir_buffer = downloads.display().to_string();
260        Self {
261            output_dir: downloads,
262            output_dir_editing: false,
263            output_dir_buffer,
264            filename_preview,
265            include_tools: true,
266            encrypt: false,
267            password: String::new(),
268            password_visible: false,
269            show_timestamps: true,
270            focused: ExportField::default(),
271            progress: ExportProgress::default(),
272            agent_name: agent.clone(),
273            workspace: workspace.clone(),
274            timestamp,
275            message_count,
276            title_preview,
277        }
278    }
279
280    /// Navigate to next field.
281    pub fn next_field(&mut self) {
282        self.focused = self.focused.next(self.encrypt);
283    }
284
285    /// Navigate to previous field.
286    pub fn prev_field(&mut self) {
287        self.focused = self.focused.prev(self.encrypt);
288    }
289
290    /// Toggle the current checkbox field or start editing text fields.
291    pub fn toggle_current(&mut self) {
292        match self.focused {
293            ExportField::OutputDir => {
294                self.output_dir_editing = !self.output_dir_editing;
295                if self.output_dir_editing {
296                    self.output_dir_buffer = self.output_dir.display().to_string();
297                } else {
298                    // Commit the edit
299                    self.commit_output_dir();
300                }
301            }
302            ExportField::IncludeTools => self.include_tools = !self.include_tools,
303            ExportField::Encrypt => {
304                self.encrypt = !self.encrypt;
305                if !self.encrypt {
306                    self.password.clear();
307                }
308            }
309            ExportField::ShowTimestamps => self.show_timestamps = !self.show_timestamps,
310            _ => {}
311        }
312    }
313
314    /// Commit the output directory edit buffer.
315    fn commit_output_dir(&mut self) {
316        let path = PathBuf::from(&self.output_dir_buffer);
317        if path.is_dir() || !path.exists() {
318            self.output_dir = path;
319        }
320        self.output_dir_editing = false;
321    }
322
323    /// Add character to output directory buffer.
324    pub fn output_dir_push(&mut self, c: char) {
325        if self.focused == ExportField::OutputDir && self.output_dir_editing {
326            self.output_dir_buffer.push(c);
327        }
328    }
329
330    /// Remove last character from output directory buffer.
331    pub fn output_dir_pop(&mut self) {
332        if self.focused == ExportField::OutputDir && self.output_dir_editing {
333            self.output_dir_buffer.pop();
334        }
335    }
336
337    /// Check if currently editing a text field.
338    pub fn is_editing_text(&self) -> bool {
339        (self.focused == ExportField::OutputDir && self.output_dir_editing)
340            || self.focused == ExportField::Password
341    }
342
343    /// Toggle password visibility.
344    pub fn toggle_password_visibility(&mut self) {
345        self.password_visible = !self.password_visible;
346    }
347
348    /// Add character to password.
349    pub fn password_push(&mut self, c: char) {
350        if self.focused == ExportField::Password {
351            self.password.push(c);
352        }
353    }
354
355    /// Remove last character from password.
356    pub fn password_pop(&mut self) {
357        if self.focused == ExportField::Password {
358            self.password.pop();
359        }
360    }
361
362    /// Check if export is ready (valid configuration).
363    pub fn can_export(&self) -> bool {
364        !self.progress.is_busy() && (!self.encrypt || !self.password.is_empty())
365    }
366
367    /// Get export options from current state.
368    pub fn to_export_options(&self) -> ExportOptions {
369        ExportOptions {
370            title: Some(self.title_preview.clone()),
371            include_cdn: true,
372            syntax_highlighting: true,
373            include_search: true,
374            include_theme_toggle: true,
375            encrypt: self.encrypt,
376            print_styles: true,
377            agent_name: Some(self.agent_name.clone()),
378            show_timestamps: self.show_timestamps,
379            show_tool_calls: self.include_tools,
380        }
381    }
382
383    /// Get the full output path.
384    pub fn output_path(&self) -> PathBuf {
385        self.output_dir.join(&self.filename_preview)
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use crate::model::types::{Conversation, Message, MessageRole};
393    use crate::search::query::MatchType;
394    use crate::ui::data::ConversationView;
395    use std::path::PathBuf;
396
397    fn make_hit(created_at: Option<i64>) -> SearchHit {
398        SearchHit {
399            title: "t".to_string(),
400            snippet: "s".to_string(),
401            content: "content".to_string(),
402            content_hash: 1,
403            conversation_id: None,
404            score: 1.0,
405            source_path: "/tmp/session.jsonl".to_string(),
406            agent: "codex".to_string(),
407            workspace: "/tmp/ws".to_string(),
408            workspace_original: None,
409            created_at,
410            line_number: Some(1),
411            match_type: MatchType::Exact,
412            source_id: "local".to_string(),
413            origin_kind: "local".to_string(),
414            origin_host: None,
415        }
416    }
417
418    fn make_view(started_at: Option<i64>, message_ts: Option<i64>) -> ConversationView {
419        ConversationView {
420            convo: Conversation {
421                id: Some(1),
422                agent_slug: "codex".to_string(),
423                workspace: Some(PathBuf::from("/tmp/ws")),
424                external_id: Some("ext-1".to_string()),
425                title: Some("session".to_string()),
426                source_path: PathBuf::from("/tmp/session.jsonl"),
427                started_at,
428                ended_at: started_at,
429                approx_tokens: None,
430                metadata_json: serde_json::json!({}),
431                messages: Vec::new(),
432                source_id: "local".to_string(),
433                origin_host: None,
434            },
435            messages: vec![Message {
436                id: Some(1),
437                idx: 0,
438                role: MessageRole::User,
439                author: Some("user".to_string()),
440                created_at: message_ts,
441                content: "hello export".to_string(),
442                extra_json: serde_json::json!({}),
443                snippets: Vec::new(),
444            }],
445            workspace: None,
446        }
447    }
448
449    #[test]
450    fn test_export_field_navigation() {
451        // Test Tab navigation without encryption
452        let mut field = ExportField::OutputDir;
453        field = field.next(false);
454        assert_eq!(field, ExportField::IncludeTools);
455        field = field.next(false);
456        assert_eq!(field, ExportField::Encrypt);
457        field = field.next(false);
458        assert_eq!(field, ExportField::ShowTimestamps); // Skips password
459        field = field.next(false);
460        assert_eq!(field, ExportField::ExportButton);
461        field = field.next(false);
462        assert_eq!(field, ExportField::OutputDir); // Wraps
463
464        // Test Tab navigation with encryption
465        let mut field = ExportField::Encrypt;
466        field = field.next(true);
467        assert_eq!(field, ExportField::Password); // Includes password
468    }
469
470    #[test]
471    fn test_export_field_prev_navigation() {
472        // Test Shift+Tab without encryption
473        let mut field = ExportField::ShowTimestamps;
474        field = field.prev(false);
475        assert_eq!(field, ExportField::Encrypt); // Skips password
476
477        // Test Shift+Tab with encryption
478        let mut field = ExportField::ShowTimestamps;
479        field = field.prev(true);
480        assert_eq!(field, ExportField::Password); // Includes password
481    }
482
483    #[test]
484    fn test_can_export() {
485        let state = ExportModalState::default();
486        assert!(state.can_export());
487
488        let state = ExportModalState {
489            encrypt: true,
490            ..Default::default()
491        };
492        assert!(!state.can_export());
493
494        let state = ExportModalState {
495            encrypt: true,
496            password: "secret".to_string(),
497            ..Default::default()
498        };
499        assert!(state.can_export());
500    }
501
502    #[test]
503    fn test_toggle_encryption_clears_password() {
504        let mut state = ExportModalState {
505            encrypt: true,
506            password: "secret".to_string(),
507            focused: ExportField::Encrypt,
508            ..Default::default()
509        };
510
511        // Toggling encryption off should clear password
512        state.toggle_current();
513        assert!(!state.encrypt);
514        assert!(state.password.is_empty());
515    }
516
517    #[test]
518    fn from_hit_prefers_conversation_agent_and_workspace_metadata() {
519        let mut hit = make_hit(None);
520        hit.agent = "stale-agent".to_string();
521        hit.workspace = "/stale/ws".to_string();
522
523        let mut view = make_view(None, Some(1_700_000_000));
524        view.convo.agent_slug = "cursor".to_string();
525        view.convo.workspace = Some(PathBuf::from("/canonical/ws"));
526
527        let state = ExportModalState::from_hit(&hit, &view);
528
529        assert_eq!(state.agent_name, "cursor");
530        assert_eq!(state.workspace, "/canonical/ws");
531        assert!(
532            state.filename_preview.contains("cursor") || state.filename_preview.contains("Cursor"),
533            "filename should use canonical agent metadata"
534        );
535        assert!(
536            state.filename_preview.contains("canonical-ws")
537                || state.filename_preview.contains("canonical_ws")
538                || state.filename_preview.contains("canonical"),
539            "filename should use canonical workspace metadata"
540        );
541    }
542
543    #[test]
544    fn from_hit_prefers_conversation_title_for_title_preview() {
545        let hit = make_hit(None);
546        let mut view = make_view(None, Some(1_700_000_000));
547        view.convo.title = Some("Canonical Session Title".to_string());
548        view.messages[0].content = "hello export".to_string();
549
550        let state = ExportModalState::from_hit(&hit, &view);
551
552        assert_eq!(state.title_preview, "Canonical Session Title");
553        assert!(
554            state.filename_preview.contains("canonical-session-title")
555                || state.filename_preview.contains("Canonical-Session-Title")
556                || state.filename_preview.contains("canonical_session_title"),
557            "filename should derive from the canonical conversation title"
558        );
559    }
560
561    #[test]
562    fn from_hit_trims_whitespace_hit_agent_and_workspace_when_view_metadata_missing() {
563        let mut hit = make_hit(None);
564        hit.agent = "   codex   ".to_string();
565        hit.workspace = "   /tmp/ws   ".to_string();
566
567        let mut view = make_view(None, Some(1_700_000_000));
568        view.convo.agent_slug.clear();
569        view.convo.workspace = None;
570        view.workspace = None;
571
572        let state = ExportModalState::from_hit(&hit, &view);
573
574        assert_eq!(state.agent_name, "codex");
575        assert_eq!(state.workspace, "/tmp/ws");
576    }
577
578    #[test]
579    fn from_hit_falls_back_to_search_hit_title_when_conversation_title_missing() {
580        let mut hit = make_hit(None);
581        hit.title = "Search Hit Title".to_string();
582        let mut view = make_view(None, Some(1_700_000_000));
583        view.convo.title = None;
584        view.messages[0].content = "first user message body".to_string();
585
586        let state = ExportModalState::from_hit(&hit, &view);
587
588        assert_eq!(state.title_preview, "Search Hit Title");
589    }
590
591    #[test]
592    fn from_hit_ignores_whitespace_first_message_when_deriving_title_preview() {
593        let mut hit = make_hit(None);
594        hit.title = "   ".to_string();
595        let mut view = make_view(None, Some(1_700_000_000));
596        view.convo.title = None;
597        view.messages[0].content = "   
598	  "
599        .to_string();
600
601        let state = ExportModalState::from_hit(&hit, &view);
602
603        assert_eq!(state.title_preview, "Untitled Session");
604    }
605
606    #[test]
607    fn from_hit_uses_message_timestamp_when_conversation_start_missing() {
608        let hit = make_hit(None);
609        let view = make_view(None, Some(1_700_000_000));
610        let state = ExportModalState::from_hit(&hit, &view);
611
612        assert_ne!(state.timestamp, "Unknown date");
613        assert!(!state.filename_preview.contains("1970"));
614    }
615
616    #[test]
617    fn from_hit_with_no_timestamps_does_not_fabricate_epoch_date() {
618        let hit = make_hit(None);
619        let mut view = make_view(None, None);
620        view.messages.clear();
621        let state = ExportModalState::from_hit(&hit, &view);
622
623        assert_eq!(state.timestamp, "Unknown date");
624        assert!(!state.filename_preview.contains("1970"));
625    }
626}