notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

session_picker.rs (13639B)


      1 //! UI component for selecting resumable Claude sessions.
      2 
      3 use crate::path_utils::abbreviate_path;
      4 use crate::session_discovery::{discover_sessions, format_relative_time, ResumableSession};
      5 use crate::ui::keybind_hint::paint_keybind_hint;
      6 use egui::{RichText, Vec2};
      7 use std::path::{Path, PathBuf};
      8 
      9 /// Maximum number of sessions to display
     10 const MAX_SESSIONS_DISPLAYED: usize = 10;
     11 
     12 /// Actions that can be triggered from the session picker
     13 #[derive(Debug, Clone)]
     14 pub enum SessionPickerAction {
     15     /// User selected a session to resume
     16     ResumeSession {
     17         cwd: PathBuf,
     18         session_id: String,
     19         title: String,
     20         /// Path to the JSONL file for archive conversion
     21         file_path: PathBuf,
     22     },
     23     /// User wants to start a new session (no resume)
     24     NewSession { cwd: PathBuf },
     25     /// User cancelled and wants to go back to directory picker
     26     BackToDirectoryPicker,
     27 }
     28 
     29 /// State for the session picker modal
     30 pub struct SessionPicker {
     31     /// The working directory we're showing sessions for
     32     cwd: Option<PathBuf>,
     33     /// Cached list of resumable sessions
     34     sessions: Vec<ResumableSession>,
     35     /// Whether the picker is currently open
     36     pub is_open: bool,
     37 }
     38 
     39 impl Default for SessionPicker {
     40     fn default() -> Self {
     41         Self::new()
     42     }
     43 }
     44 
     45 impl SessionPicker {
     46     pub fn new() -> Self {
     47         Self {
     48             cwd: None,
     49             sessions: Vec::new(),
     50             is_open: false,
     51         }
     52     }
     53 
     54     /// Open the picker for a specific working directory
     55     pub fn open(&mut self, cwd: PathBuf) {
     56         self.sessions = discover_sessions(&cwd);
     57         self.cwd = Some(cwd);
     58         self.is_open = true;
     59     }
     60 
     61     /// Close the picker
     62     pub fn close(&mut self) {
     63         self.is_open = false;
     64         self.cwd = None;
     65         self.sessions.clear();
     66     }
     67 
     68     /// Check if there are sessions available to resume
     69     pub fn has_sessions(&self) -> bool {
     70         !self.sessions.is_empty()
     71     }
     72 
     73     /// Get the current working directory
     74     pub fn cwd(&self) -> Option<&Path> {
     75         self.cwd.as_deref()
     76     }
     77 
     78     /// Render the session picker as a full-panel overlay
     79     pub fn overlay_ui(&mut self, ui: &mut egui::Ui) -> Option<SessionPickerAction> {
     80         let cwd = self.cwd.clone()?;
     81 
     82         let mut action = None;
     83         let is_narrow = notedeck::ui::is_narrow(ui.ctx());
     84         let ctrl_held = ui.input(|i| i.modifiers.ctrl);
     85 
     86         // Handle keyboard shortcuts for sessions (Ctrl+1-9)
     87         // Only trigger when Ctrl is held to avoid intercepting TextEdit input
     88         if ctrl_held {
     89             for (idx, session) in self.sessions.iter().take(9).enumerate() {
     90                 let key = match idx {
     91                     0 => egui::Key::Num1,
     92                     1 => egui::Key::Num2,
     93                     2 => egui::Key::Num3,
     94                     3 => egui::Key::Num4,
     95                     4 => egui::Key::Num5,
     96                     5 => egui::Key::Num6,
     97                     6 => egui::Key::Num7,
     98                     7 => egui::Key::Num8,
     99                     8 => egui::Key::Num9,
    100                     _ => continue,
    101                 };
    102                 if ui.input(|i| i.key_pressed(key)) {
    103                     return Some(SessionPickerAction::ResumeSession {
    104                         cwd,
    105                         session_id: session.session_id.clone(),
    106                         title: session.summary.clone(),
    107                         file_path: session.file_path.clone(),
    108                     });
    109                 }
    110             }
    111         }
    112 
    113         // Handle Ctrl+N key for new session
    114         // Only trigger when Ctrl is held to avoid intercepting TextEdit input
    115         if ctrl_held && ui.input(|i| i.key_pressed(egui::Key::N)) {
    116             return Some(SessionPickerAction::NewSession { cwd });
    117         }
    118 
    119         // Handle Escape key or Ctrl+B to go back
    120         // B key requires Ctrl to avoid intercepting TextEdit input
    121         if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape))
    122             || (ctrl_held && ui.input(|i| i.key_pressed(egui::Key::B)))
    123         {
    124             return Some(SessionPickerAction::BackToDirectoryPicker);
    125         }
    126 
    127         // Full panel frame
    128         egui::Frame::new()
    129             .fill(ui.visuals().panel_fill)
    130             .inner_margin(egui::Margin::symmetric(if is_narrow { 16 } else { 40 }, 20))
    131             .show(ui, |ui| {
    132                 // Header
    133                 ui.horizontal(|ui| {
    134                     if ui.button("< Back").clicked() {
    135                         action = Some(SessionPickerAction::BackToDirectoryPicker);
    136                     }
    137                     ui.add_space(16.0);
    138                     ui.heading("Resume Session");
    139                 });
    140 
    141                 ui.add_space(8.0);
    142 
    143                 // Show the cwd
    144                 ui.label(RichText::new(abbreviate_path(&cwd)).monospace().weak());
    145 
    146                 ui.add_space(16.0);
    147 
    148                 // Centered content
    149                 let max_content_width = if is_narrow {
    150                     ui.available_width()
    151                 } else {
    152                     600.0
    153                 };
    154                 let available_height = ui.available_height();
    155 
    156                 ui.allocate_ui_with_layout(
    157                     egui::vec2(max_content_width, available_height),
    158                     egui::Layout::top_down(egui::Align::LEFT),
    159                     |ui| {
    160                         // New session button at top
    161                         ui.horizontal(|ui| {
    162                             let new_button = egui::Button::new(
    163                                 RichText::new("+ New Session").size(if is_narrow {
    164                                     16.0
    165                                 } else {
    166                                     14.0
    167                                 }),
    168                             )
    169                             .min_size(Vec2::new(
    170                                 if is_narrow {
    171                                     ui.available_width() - 28.0
    172                                 } else {
    173                                     150.0
    174                                 },
    175                                 if is_narrow { 48.0 } else { 36.0 },
    176                             ));
    177 
    178                             let response = ui.add(new_button);
    179 
    180                             // Show keybind hint when Ctrl is held
    181                             if ctrl_held {
    182                                 let hint_center =
    183                                     response.rect.right_center() + egui::vec2(14.0, 0.0);
    184                                 paint_keybind_hint(ui, hint_center, "N", 18.0);
    185                             }
    186 
    187                             if response
    188                                 .on_hover_text("Start a new conversation (N)")
    189                                 .clicked()
    190                             {
    191                                 action = Some(SessionPickerAction::NewSession { cwd: cwd.clone() });
    192                             }
    193                         });
    194 
    195                         ui.add_space(16.0);
    196                         ui.separator();
    197                         ui.add_space(12.0);
    198 
    199                         // Sessions list
    200                         if self.sessions.is_empty() {
    201                             ui.label(
    202                                 RichText::new("No previous sessions found for this directory.")
    203                                     .weak(),
    204                             );
    205                         } else {
    206                             ui.label(RichText::new("Recent Sessions").strong());
    207                             ui.add_space(8.0);
    208 
    209                             let scroll_height = if is_narrow {
    210                                 (ui.available_height() - 80.0).max(100.0)
    211                             } else {
    212                                 400.0
    213                             };
    214 
    215                             egui::ScrollArea::vertical()
    216                                 .max_height(scroll_height)
    217                                 .show(ui, |ui| {
    218                                     for (idx, session) in self
    219                                         .sessions
    220                                         .iter()
    221                                         .take(MAX_SESSIONS_DISPLAYED)
    222                                         .enumerate()
    223                                     {
    224                                         let button_height = if is_narrow { 64.0 } else { 50.0 };
    225                                         let hint_width =
    226                                             if ctrl_held && idx < 9 { 24.0 } else { 0.0 };
    227                                         let button_width = ui.available_width() - hint_width - 4.0;
    228 
    229                                         ui.horizontal(|ui| {
    230                                             // Create a frame for the session button
    231                                             let response = ui.add(
    232                                                 egui::Button::new("")
    233                                                     .min_size(Vec2::new(
    234                                                         button_width,
    235                                                         button_height,
    236                                                     ))
    237                                                     .fill(
    238                                                         ui.visuals().widgets.inactive.weak_bg_fill,
    239                                                     ),
    240                                             );
    241 
    242                                             // Draw the content over the button
    243                                             let rect = response.rect;
    244                                             let painter = ui.painter();
    245 
    246                                             // Summary text (truncated)
    247                                             let summary_text = &session.summary;
    248                                             let text_color = ui.visuals().text_color();
    249                                             painter.text(
    250                                                 rect.left_top() + egui::vec2(8.0, 8.0),
    251                                                 egui::Align2::LEFT_TOP,
    252                                                 summary_text,
    253                                                 egui::FontId::proportional(13.0),
    254                                                 text_color,
    255                                             );
    256 
    257                                             // Metadata line (time + message count)
    258                                             let meta_text = format!(
    259                                                 "{} • {} messages",
    260                                                 format_relative_time(&session.last_timestamp),
    261                                                 session.message_count
    262                                             );
    263                                             painter.text(
    264                                                 rect.left_bottom() + egui::vec2(8.0, -8.0),
    265                                                 egui::Align2::LEFT_BOTTOM,
    266                                                 meta_text,
    267                                                 egui::FontId::proportional(11.0),
    268                                                 ui.visuals().weak_text_color(),
    269                                             );
    270 
    271                                             // Show keybind hint when Ctrl is held
    272                                             if ctrl_held && idx < 9 {
    273                                                 let hint_text = format!("{}", idx + 1);
    274                                                 let hint_center = response.rect.right_center()
    275                                                     + egui::vec2(hint_width / 2.0 + 2.0, 0.0);
    276                                                 paint_keybind_hint(
    277                                                     ui,
    278                                                     hint_center,
    279                                                     &hint_text,
    280                                                     18.0,
    281                                                 );
    282                                             }
    283 
    284                                             if response.clicked() {
    285                                                 action = Some(SessionPickerAction::ResumeSession {
    286                                                     cwd: cwd.clone(),
    287                                                     session_id: session.session_id.clone(),
    288                                                     title: session.summary.clone(),
    289                                                     file_path: session.file_path.clone(),
    290                                                 });
    291                                             }
    292                                         });
    293 
    294                                         ui.add_space(4.0);
    295                                     }
    296 
    297                                     if self.sessions.len() > MAX_SESSIONS_DISPLAYED {
    298                                         ui.add_space(8.0);
    299                                         ui.label(
    300                                             RichText::new(format!(
    301                                                 "... and {} more sessions",
    302                                                 self.sessions.len() - MAX_SESSIONS_DISPLAYED
    303                                             ))
    304                                             .weak(),
    305                                         );
    306                                     }
    307                                 });
    308                         }
    309                     },
    310                 );
    311             });
    312 
    313         action
    314     }
    315 }