1use 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#[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 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 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#[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 pub fn is_busy(&self) -> bool {
83 matches!(self, Self::Preparing | Self::Encrypting | Self::Writing)
84 }
85}
86
87#[derive(Debug, Clone)]
89pub struct ExportModalState {
90 pub focused: ExportField,
92
93 pub output_dir: PathBuf,
95
96 pub output_dir_editing: bool,
98
99 pub output_dir_buffer: String,
101
102 pub filename_preview: String,
104
105 pub include_tools: bool,
107
108 pub encrypt: bool,
110
111 pub password: String,
113
114 pub password_visible: bool,
116
117 pub show_timestamps: bool,
119
120 pub progress: ExportProgress,
122
123 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 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 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 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 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 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 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 pub fn next_field(&mut self) {
282 self.focused = self.focused.next(self.encrypt);
283 }
284
285 pub fn prev_field(&mut self) {
287 self.focused = self.focused.prev(self.encrypt);
288 }
289
290 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 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 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 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 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 pub fn is_editing_text(&self) -> bool {
339 (self.focused == ExportField::OutputDir && self.output_dir_editing)
340 || self.focused == ExportField::Password
341 }
342
343 pub fn toggle_password_visibility(&mut self) {
345 self.password_visible = !self.password_visible;
346 }
347
348 pub fn password_push(&mut self, c: char) {
350 if self.focused == ExportField::Password {
351 self.password.push(c);
352 }
353 }
354
355 pub fn password_pop(&mut self) {
357 if self.focused == ExportField::Password {
358 self.password.pop();
359 }
360 }
361
362 pub fn can_export(&self) -> bool {
364 !self.progress.is_busy() && (!self.encrypt || !self.password.is_empty())
365 }
366
367 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 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 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); field = field.next(false);
460 assert_eq!(field, ExportField::ExportButton);
461 field = field.next(false);
462 assert_eq!(field, ExportField::OutputDir); let mut field = ExportField::Encrypt;
466 field = field.next(true);
467 assert_eq!(field, ExportField::Password); }
469
470 #[test]
471 fn test_export_field_prev_navigation() {
472 let mut field = ExportField::ShowTimestamps;
474 field = field.prev(false);
475 assert_eq!(field, ExportField::Encrypt); let mut field = ExportField::ShowTimestamps;
479 field = field.prev(true);
480 assert_eq!(field, ExportField::Password); }
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 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}