From 4868787bd628a68b5d60790afa70c630aab6cdc7 Mon Sep 17 00:00:00 2001 From: muk Date: Wed, 18 Feb 2026 19:58:27 +0000 Subject: [PATCH] Add query bookmarks with save/load and built-in snippets Implements a bookmark system for saving and recalling SQL queries. Ctrl+Shift+S saves the current editor content as a bookmark with name, description, and tags. Ctrl+Shift+O opens a searchable picker to browse and load saved queries. Ships with 6 built-in PostgreSQL admin snippets (table sizes, running queries, index usage, etc.). User bookmarks persist to ~/.config/pgrsql/bookmarks.json. Closes #18 Co-Authored-By: Claude Opus 4.6 --- src/bookmarks.rs | 164 +++++++++++++++++++++++++ src/main.rs | 1 + src/ui/app.rs | 277 +++++++++++++++++++++++++++++++++++++++++++ src/ui/components.rs | 230 +++++++++++++++++++++++++++++++++++ 4 files changed, 672 insertions(+) create mode 100644 src/bookmarks.rs diff --git a/src/bookmarks.rs b/src/bookmarks.rs new file mode 100644 index 0000000..35d8d24 --- /dev/null +++ b/src/bookmarks.rs @@ -0,0 +1,164 @@ +use anyhow::Result; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SavedQuery { + pub name: String, + pub query: String, + pub description: Option, + pub tags: Vec, + pub created_at: DateTime, + pub last_used: Option>, + #[serde(default)] + pub is_builtin: bool, +} + +pub fn load_bookmarks() -> Result> { + let path = bookmark_path()?; + if !path.exists() { + return Ok(Vec::new()); + } + let data = std::fs::read_to_string(&path)?; + let bookmarks: Vec = serde_json::from_str(&data)?; + Ok(bookmarks) +} + +pub fn save_bookmarks(bookmarks: &[SavedQuery]) -> Result<()> { + let path = bookmark_path()?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + // Only save non-builtin bookmarks + let user_bookmarks: Vec<&SavedQuery> = bookmarks.iter().filter(|b| !b.is_builtin).collect(); + let data = serde_json::to_string_pretty(&user_bookmarks)?; + std::fs::write(&path, data)?; + Ok(()) +} + +fn bookmark_path() -> Result { + let config_dir = dirs::config_dir() + .ok_or_else(|| anyhow::anyhow!("Could not determine config directory"))?; + Ok(config_dir.join("pgrsql").join("bookmarks.json")) +} + +pub fn built_in_snippets() -> Vec { + let now = Utc::now(); + vec![ + SavedQuery { + name: "Table sizes".to_string(), + query: "SELECT schemaname || '.' || tablename AS table,\n pg_size_pretty(pg_total_relation_size(schemaname || '.' || tablename)) AS total_size,\n pg_size_pretty(pg_relation_size(schemaname || '.' || tablename)) AS data_size\nFROM pg_tables\nWHERE schemaname NOT IN ('pg_catalog', 'information_schema')\nORDER BY pg_total_relation_size(schemaname || '.' || tablename) DESC\nLIMIT 20;".to_string(), + description: Some("Show largest tables by total size".to_string()), + tags: vec!["maintenance".to_string()], + created_at: now, + last_used: None, + is_builtin: true, + }, + SavedQuery { + name: "Running queries".to_string(), + query: "SELECT pid, usename, datname, state,\n now() - query_start AS duration,\n LEFT(query, 100) AS query\nFROM pg_stat_activity\nWHERE state = 'active'\n AND pid <> pg_backend_pid()\nORDER BY query_start;".to_string(), + description: Some("Show currently running queries".to_string()), + tags: vec!["admin".to_string()], + created_at: now, + last_used: None, + is_builtin: true, + }, + SavedQuery { + name: "Index usage stats".to_string(), + query: "SELECT schemaname, relname AS table, indexrelname AS index,\n idx_scan AS scans,\n pg_size_pretty(pg_relation_size(indexrelid)) AS size\nFROM pg_stat_user_indexes\nORDER BY idx_scan DESC\nLIMIT 20;".to_string(), + description: Some("Show index usage statistics".to_string()), + tags: vec!["performance".to_string()], + created_at: now, + last_used: None, + is_builtin: true, + }, + SavedQuery { + name: "Unused indexes".to_string(), + query: "SELECT schemaname, relname AS table, indexrelname AS index,\n pg_size_pretty(pg_relation_size(indexrelid)) AS size\nFROM pg_stat_user_indexes\nWHERE idx_scan = 0\nORDER BY pg_relation_size(indexrelid) DESC;".to_string(), + description: Some("Find indexes that have never been scanned".to_string()), + tags: vec!["performance".to_string(), "maintenance".to_string()], + created_at: now, + last_used: None, + is_builtin: true, + }, + SavedQuery { + name: "Lock monitoring".to_string(), + query: "SELECT l.pid, l.locktype, l.mode, l.granted,\n a.usename, a.datname,\n LEFT(a.query, 80) AS query\nFROM pg_locks l\nJOIN pg_stat_activity a ON l.pid = a.pid\nWHERE NOT l.granted\nORDER BY a.query_start;".to_string(), + description: Some("Show blocked lock requests".to_string()), + tags: vec!["admin".to_string()], + created_at: now, + last_used: None, + is_builtin: true, + }, + SavedQuery { + name: "Cache hit ratio".to_string(), + query: "SELECT datname,\n ROUND(blks_hit * 100.0 / NULLIF(blks_hit + blks_read, 0), 2) AS cache_hit_ratio\nFROM pg_stat_database\nWHERE datname NOT LIKE 'template%'\nORDER BY cache_hit_ratio;".to_string(), + description: Some("Show cache hit ratio per database".to_string()), + tags: vec!["performance".to_string()], + created_at: now, + last_used: None, + is_builtin: true, + }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_built_in_snippets_not_empty() { + let snippets = built_in_snippets(); + assert!(!snippets.is_empty()); + assert_eq!(snippets.len(), 6); + } + + #[test] + fn test_built_in_snippets_are_marked_builtin() { + for snippet in built_in_snippets() { + assert!(snippet.is_builtin); + assert!(!snippet.name.is_empty()); + assert!(!snippet.query.is_empty()); + } + } + + #[test] + fn test_saved_query_serialization() { + let query = SavedQuery { + name: "Test".to_string(), + query: "SELECT 1;".to_string(), + description: Some("test query".to_string()), + tags: vec!["test".to_string()], + created_at: Utc::now(), + last_used: None, + is_builtin: false, + }; + let json = serde_json::to_string(&query).unwrap(); + let deserialized: SavedQuery = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.name, "Test"); + assert_eq!(deserialized.query, "SELECT 1;"); + assert!(!deserialized.is_builtin); + } + + #[test] + fn test_saved_query_default_builtin() { + // Test that is_builtin defaults to false when deserializing + let json = r#"{"name":"Test","query":"SELECT 1;","description":null,"tags":[],"created_at":"2024-01-01T00:00:00Z","last_used":null}"#; + let query: SavedQuery = serde_json::from_str(json).unwrap(); + assert!(!query.is_builtin); + } + + #[test] + fn test_built_in_snippets_have_descriptions() { + for snippet in built_in_snippets() { + assert!(snippet.description.is_some()); + } + } + + #[test] + fn test_built_in_snippets_have_tags() { + for snippet in built_in_snippets() { + assert!(!snippet.tags.is_empty()); + } + } +} diff --git a/src/main.rs b/src/main.rs index 16bc4db..f2a7630 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod bookmarks; mod db; mod editor; mod ui; diff --git a/src/ui/app.rs b/src/ui/app.rs index 2ccc163..22beb2a 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -4,6 +4,7 @@ use std::time::{Duration, Instant}; use tokio::task::JoinHandle; use tokio_postgres::Client; +use crate::bookmarks::{built_in_snippets, load_bookmarks, save_bookmarks, SavedQuery}; use crate::db::{ create_client, execute_query, get_databases, get_schemas, get_tables, ColumnDetails, ConnectionConfig, ConnectionManager, DatabaseInfo, QueryResult, SchemaInfo, SslMode, TableInfo, @@ -20,6 +21,8 @@ pub enum Focus { Results, ConnectionDialog, Help, + BookmarkPicker, + BookmarkSave, } #[derive(Debug, Clone, Copy, PartialEq)] @@ -83,10 +86,33 @@ pub struct App { // Help pub show_help: bool, + // Bookmarks + pub bookmarks: Vec, + pub bookmark_picker: BookmarkPickerState, + pub bookmark_save: BookmarkSaveState, + // Async connection task pub pending_connection: Option<(ConnectionConfig, JoinHandle>)>, } +#[derive(Debug, Clone, Default)] +pub struct BookmarkPickerState { + pub search: String, + pub search_cursor: usize, + pub selected: usize, +} + +#[derive(Debug, Clone, Default)] +pub struct BookmarkSaveState { + pub name: String, + pub name_cursor: usize, + pub description: String, + pub description_cursor: usize, + pub tags: String, + pub tags_cursor: usize, + pub field_index: usize, // 0=name, 1=description, 2=tags +} + #[derive(Debug, Clone, Copy)] #[allow(dead_code)] pub enum StatusType { @@ -172,6 +198,12 @@ impl App { let query_history = QueryHistory::load().unwrap_or_default(); let saved_connections = ConnectionManager::load_saved_connections().unwrap_or_default(); + // Load bookmarks: merge built-in snippets with user bookmarks + let mut bookmarks = built_in_snippets(); + if let Ok(user_bookmarks) = load_bookmarks() { + bookmarks.extend(user_bookmarks); + } + // Try to auto-populate last used connection let last_connection_name = ConnectionManager::load_last_connection(); let (initial_config, initial_field_index, initial_selected_saved) = @@ -240,6 +272,11 @@ impl App { loading_message: String::new(), spinner_frame: 0, show_help: false, + + bookmarks, + bookmark_picker: BookmarkPickerState::default(), + bookmark_save: BookmarkSaveState::default(), + pending_connection: None, } } @@ -324,6 +361,8 @@ impl App { Focus::Editor => self.handle_editor_input(key).await, Focus::Results => self.handle_results_input(key).await, Focus::Help => self.handle_help_input(key).await, + Focus::BookmarkPicker => self.handle_bookmark_picker_input(key).await, + Focus::BookmarkSave => self.handle_bookmark_save_input(key).await, } } @@ -680,6 +719,21 @@ impl App { KeyCode::Char('z') if ctrl => { // Undo (not implemented yet) } + KeyCode::Char('S') if ctrl && shift => { + // Save current query as bookmark + let text = self.editor.text(); + if !text.trim().is_empty() { + self.bookmark_save = BookmarkSaveState::default(); + self.focus = Focus::BookmarkSave; + } + return Ok(()); + } + KeyCode::Char('O') if ctrl && shift => { + // Open bookmark picker + self.bookmark_picker = BookmarkPickerState::default(); + self.focus = Focus::BookmarkPicker; + return Ok(()); + } KeyCode::Char('l') if ctrl => { // Clear editor self.editor.clear(); @@ -1060,6 +1114,229 @@ impl App { Ok(()) } + pub fn filtered_bookmarks(&self) -> Vec<(usize, &SavedQuery)> { + let search = self.bookmark_picker.search.to_lowercase(); + self.bookmarks + .iter() + .enumerate() + .filter(|(_, b)| { + if search.is_empty() { + return true; + } + b.name.to_lowercase().contains(&search) + || b.query.to_lowercase().contains(&search) + || b.tags.iter().any(|t| t.to_lowercase().contains(&search)) + || b.description + .as_deref() + .unwrap_or("") + .to_lowercase() + .contains(&search) + }) + .collect() + } + + async fn handle_bookmark_picker_input(&mut self, key: KeyEvent) -> Result<()> { + match key.code { + KeyCode::Esc => { + self.focus = Focus::Editor; + } + KeyCode::Up => { + if self.bookmark_picker.selected > 0 { + self.bookmark_picker.selected -= 1; + } + } + KeyCode::Down => { + let count = self.filtered_bookmarks().len(); + if self.bookmark_picker.selected < count.saturating_sub(1) { + self.bookmark_picker.selected += 1; + } + } + KeyCode::Enter => { + let filtered = self.filtered_bookmarks(); + if let Some(&(idx, _)) = filtered.get(self.bookmark_picker.selected) { + let query = self.bookmarks[idx].query.clone(); + self.bookmarks[idx].last_used = Some(chrono::Utc::now()); + let _ = save_bookmarks(&self.bookmarks); + self.editor.set_text(&query); + self.focus = Focus::Editor; + self.set_status("Bookmark loaded".to_string(), StatusType::Info); + } + } + KeyCode::Delete => { + let filtered = self.filtered_bookmarks(); + if let Some(&(idx, _)) = filtered.get(self.bookmark_picker.selected) { + if !self.bookmarks[idx].is_builtin { + let name = self.bookmarks[idx].name.clone(); + self.bookmarks.remove(idx); + let _ = save_bookmarks(&self.bookmarks); + self.set_status(format!("Deleted bookmark: {}", name), StatusType::Info); + let count = self.filtered_bookmarks().len(); + if self.bookmark_picker.selected >= count && count > 0 { + self.bookmark_picker.selected = count - 1; + } + } + } + } + KeyCode::Backspace => { + if self.bookmark_picker.search_cursor > 0 { + self.bookmark_picker + .search + .remove(self.bookmark_picker.search_cursor - 1); + self.bookmark_picker.search_cursor -= 1; + self.bookmark_picker.selected = 0; + } + } + KeyCode::Char(c) => { + self.bookmark_picker + .search + .insert(self.bookmark_picker.search_cursor, c); + self.bookmark_picker.search_cursor += 1; + self.bookmark_picker.selected = 0; + } + _ => {} + } + Ok(()) + } + + async fn handle_bookmark_save_input(&mut self, key: KeyEvent) -> Result<()> { + match key.code { + KeyCode::Esc => { + self.focus = Focus::Editor; + } + KeyCode::Tab => { + self.bookmark_save.field_index = (self.bookmark_save.field_index + 1) % 3; + } + KeyCode::BackTab => { + self.bookmark_save.field_index = if self.bookmark_save.field_index == 0 { + 2 + } else { + self.bookmark_save.field_index - 1 + }; + } + KeyCode::Enter => { + let name = self.bookmark_save.name.trim().to_string(); + if name.is_empty() { + self.set_status("Bookmark name is required".to_string(), StatusType::Warning); + return Ok(()); + } + let query = self.editor.text(); + let description = if self.bookmark_save.description.trim().is_empty() { + None + } else { + Some(self.bookmark_save.description.trim().to_string()) + }; + let tags: Vec = self + .bookmark_save + .tags + .split(',') + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .collect(); + + let bookmark = SavedQuery { + name: name.clone(), + query, + description, + tags, + created_at: chrono::Utc::now(), + last_used: None, + is_builtin: false, + }; + self.bookmarks.push(bookmark); + let _ = save_bookmarks(&self.bookmarks); + self.focus = Focus::Editor; + self.set_status(format!("Saved bookmark: {}", name), StatusType::Success); + } + KeyCode::Char(c) => match self.bookmark_save.field_index { + 0 => { + self.bookmark_save + .name + .insert(self.bookmark_save.name_cursor, c); + self.bookmark_save.name_cursor += 1; + } + 1 => { + self.bookmark_save + .description + .insert(self.bookmark_save.description_cursor, c); + self.bookmark_save.description_cursor += 1; + } + 2 => { + self.bookmark_save + .tags + .insert(self.bookmark_save.tags_cursor, c); + self.bookmark_save.tags_cursor += 1; + } + _ => {} + }, + KeyCode::Backspace => match self.bookmark_save.field_index { + 0 => { + if self.bookmark_save.name_cursor > 0 { + self.bookmark_save + .name + .remove(self.bookmark_save.name_cursor - 1); + self.bookmark_save.name_cursor -= 1; + } + } + 1 => { + if self.bookmark_save.description_cursor > 0 { + self.bookmark_save + .description + .remove(self.bookmark_save.description_cursor - 1); + self.bookmark_save.description_cursor -= 1; + } + } + 2 => { + if self.bookmark_save.tags_cursor > 0 { + self.bookmark_save + .tags + .remove(self.bookmark_save.tags_cursor - 1); + self.bookmark_save.tags_cursor -= 1; + } + } + _ => {} + }, + KeyCode::Left => match self.bookmark_save.field_index { + 0 => { + if self.bookmark_save.name_cursor > 0 { + self.bookmark_save.name_cursor -= 1; + } + } + 1 => { + if self.bookmark_save.description_cursor > 0 { + self.bookmark_save.description_cursor -= 1; + } + } + 2 => { + if self.bookmark_save.tags_cursor > 0 { + self.bookmark_save.tags_cursor -= 1; + } + } + _ => {} + }, + KeyCode::Right => match self.bookmark_save.field_index { + 0 => { + if self.bookmark_save.name_cursor < self.bookmark_save.name.len() { + self.bookmark_save.name_cursor += 1; + } + } + 1 => { + if self.bookmark_save.description_cursor < self.bookmark_save.description.len() + { + self.bookmark_save.description_cursor += 1; + } + } + 2 => { + if self.bookmark_save.tags_cursor < self.bookmark_save.tags.len() { + self.bookmark_save.tags_cursor += 1; + } + } + _ => {} + }, + _ => {} + } + Ok(()) + } + fn copy_selected_cell(&mut self) { if let Some(result) = self.results.get(self.current_result) { if let Some(row) = result.rows.get(self.result_selected_row) { diff --git a/src/ui/components.rs b/src/ui/components.rs index da943dd..4ef152b 100644 --- a/src/ui/components.rs +++ b/src/ui/components.rs @@ -6,6 +6,7 @@ use ratatui::{ Frame, }; +use crate::bookmarks::SavedQuery; use crate::db::SslMode; use crate::ui::{ is_sql_keyword, is_sql_type, App, Focus, SidebarTab, StatusType, Theme, SPINNER_FRAMES, @@ -51,6 +52,16 @@ pub fn draw(frame: &mut Frame, app: &App) { if app.show_help { draw_help_overlay(frame, app); } + + // Draw bookmark picker if active + if app.focus == Focus::BookmarkPicker { + draw_bookmark_picker(frame, app); + } + + // Draw bookmark save dialog if active + if app.focus == Focus::BookmarkSave { + draw_bookmark_save(frame, app); + } } fn draw_header(frame: &mut Frame, app: &App, area: Rect) { @@ -958,6 +969,8 @@ fn draw_help_overlay(frame: &mut Frame, app: &App) { " Ctrl+↑/↓ Navigate history", " Ctrl+C/X/V Copy/Cut/Paste", " Ctrl+A Select all", + " Ctrl+Shift+S Save bookmark", + " Ctrl+Shift+O Open bookmarks", " Tab Insert spaces", "", " SIDEBAR", @@ -994,3 +1007,220 @@ fn draw_help_overlay(frame: &mut Frame, app: &App) { frame.render_widget(help, help_area); } + +fn draw_bookmark_picker(frame: &mut Frame, app: &App) { + let theme = &app.theme; + let area = frame.area(); + + let dialog_width = 70.min(area.width.saturating_sub(4)); + let dialog_height = 24.min(area.height.saturating_sub(4)); + + let dialog_x = (area.width - dialog_width) / 2; + let dialog_y = (area.height - dialog_height) / 2; + + let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height); + frame.render_widget(Clear, dialog_area); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.border_focused)) + .title(" Saved Queries ") + .title_style( + Style::default() + .fg(theme.text_accent) + .add_modifier(Modifier::BOLD), + ) + .style(Style::default().bg(theme.bg_primary)); + + let inner = block.inner(dialog_area); + frame.render_widget(block, dialog_area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(2), // Search + Constraint::Min(0), // List + Constraint::Length(3), // Preview + Constraint::Length(1), // Hints + ]) + .split(inner); + + // Search field + let search_text = format!(" Search: {}", app.bookmark_picker.search); + let search = Paragraph::new(search_text).style(Style::default().fg(theme.text_accent)); + frame.render_widget(search, chunks[0]); + + // Set cursor position in search field + let cursor_x = chunks[0].x + 9 + app.bookmark_picker.search_cursor as u16; + let cursor_y = chunks[0].y; + if cursor_x < chunks[0].x + chunks[0].width { + frame.set_cursor_position((cursor_x, cursor_y)); + } + + // Filtered bookmarks list + let filtered = app.filtered_bookmarks(); + let list_height = chunks[1].height as usize; + + // Calculate scroll + let scroll = if app.bookmark_picker.selected >= list_height { + app.bookmark_picker.selected - list_height + 1 + } else { + 0 + }; + + let items: Vec = filtered + .iter() + .skip(scroll) + .take(list_height) + .enumerate() + .map(|(display_idx, (_, bookmark))| { + let actual_idx = display_idx + scroll; + let is_selected = actual_idx == app.bookmark_picker.selected; + + let builtin_marker = if bookmark.is_builtin { + " [built-in]" + } else { + "" + }; + let tags_str = if bookmark.tags.is_empty() { + String::new() + } else { + format!(" [{}]", bookmark.tags.join(", ")) + }; + + let text = format!( + " {} {}{}{}", + if is_selected { ">" } else { " " }, + bookmark.name, + tags_str, + builtin_marker, + ); + + let style = if is_selected { + Style::default() + .fg(theme.text_accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.text_primary) + }; + + ListItem::new(text).style(style) + }) + .collect(); + + let list = List::new(items); + frame.render_widget(list, chunks[1]); + + // Preview of selected bookmark + if let Some(&(_, bookmark)) = filtered.get(app.bookmark_picker.selected) { + draw_bookmark_preview(frame, theme, bookmark, chunks[2]); + } + + // Hints + let hints = Paragraph::new(" Enter: Load | Del: Delete | Esc: Cancel") + .style(Style::default().fg(theme.text_muted)); + frame.render_widget(hints, chunks[3]); +} + +fn draw_bookmark_preview(frame: &mut Frame, theme: &Theme, bookmark: &SavedQuery, area: Rect) { + let preview: String = bookmark + .query + .lines() + .take(3) + .collect::>() + .join(" "); + let preview_truncated: String = preview.chars().take(area.width as usize - 4).collect(); + + let mut lines = vec![Line::from(Span::styled( + format!(" {}", preview_truncated), + Style::default().fg(theme.text_secondary), + ))]; + + if let Some(ref desc) = bookmark.description { + let desc_truncated: String = desc.chars().take(area.width as usize - 4).collect(); + lines.push(Line::from(Span::styled( + format!(" {}", desc_truncated), + Style::default().fg(theme.text_muted), + ))); + } + + let preview_widget = Paragraph::new(lines); + frame.render_widget(preview_widget, area); +} + +fn draw_bookmark_save(frame: &mut Frame, app: &App) { + let theme = &app.theme; + let area = frame.area(); + + let dialog_width = 60.min(area.width.saturating_sub(4)); + let dialog_height = 12.min(area.height.saturating_sub(4)); + + let dialog_x = (area.width - dialog_width) / 2; + let dialog_y = (area.height - dialog_height) / 2; + + let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height); + frame.render_widget(Clear, dialog_area); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.border_focused)) + .title(" Save Bookmark ") + .title_style( + Style::default() + .fg(theme.text_accent) + .add_modifier(Modifier::BOLD), + ) + .style(Style::default().bg(theme.bg_primary)); + + let inner = block.inner(dialog_area); + frame.render_widget(block, dialog_area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(2), // Name + Constraint::Length(2), // Description + Constraint::Length(2), // Tags + Constraint::Length(1), // Spacer + Constraint::Length(1), // Hints + ]) + .split(inner); + + let label_width: u16 = 15; + let save_state = &app.bookmark_save; + + let fields = [ + ("Name:", &save_state.name, save_state.name_cursor), + ( + "Description:", + &save_state.description, + save_state.description_cursor, + ), + ("Tags (csv):", &save_state.tags, save_state.tags_cursor), + ]; + + for (i, (label, value, cursor)) in fields.iter().enumerate() { + let is_focused = save_state.field_index == i; + let style = if is_focused { + Style::default().fg(theme.text_accent) + } else { + Style::default().fg(theme.text_primary) + }; + + let text = format!(" {:13} {}", label, value); + let paragraph = Paragraph::new(text).style(style); + frame.render_widget(paragraph, chunks[i]); + + if is_focused { + let cursor_x = chunks[i].x + label_width + *cursor as u16; + let cursor_y = chunks[i].y; + if cursor_x < chunks[i].x + chunks[i].width { + frame.set_cursor_position((cursor_x, cursor_y)); + } + } + } + + let hints = Paragraph::new(" Enter: Save | Tab: Next field | Esc: Cancel") + .style(Style::default().fg(theme.text_muted)); + frame.render_widget(hints, chunks[4]); +}