notedeck

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

session_picker.rs (13378B)


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