From 316de9378131fa45e465233ae3f6e6ee1470e02b Mon Sep 17 00:00:00 2001 From: Nino Date: Sat, 11 Apr 2026 15:28:22 -0600 Subject: [PATCH] search bar --- docmost.log | 31 ++++++++++++++ src/api.rs | 51 ++++++++++++++++++++++- src/app.rs | 42 +++++++++++++++++++ src/main.rs | 111 +++++++++++++++++++++++++++++++++++++++++++++++--- src/ui.rs | 114 +++++++++++++++++++++++++++++++++++++++++++++++++++- todo.txt | 39 ++++++++++++++++++ 6 files changed, 381 insertions(+), 7 deletions(-) diff --git a/docmost.log b/docmost.log index aa82515..06edb6a 100644 --- a/docmost.log +++ b/docmost.log @@ -109,3 +109,34 @@ 2026-04-11T21:11:45.173549Z INFO docmost_rust: content preview: "- [ ] Hacer limpieza en sala y en cuarto\n- [ ] comprar soda cΓ‘ustica para baΓ±o\n- [x] poner dock de steam deck\n- [ ] Agregar CV a nextcloudmodificar CV ponerle los tΓ­tulos de los puestos\n- [x] Hacer" 2026-04-11T21:11:45.173643Z INFO docmost_rust: textarea lines: 51 2026-04-11T21:11:45.173671Z INFO docmost_rust: state β†’ Editor +2026-04-11T21:27:04.832611Z INFO docmost_rust: --- startup --- +2026-04-11T21:27:10.169061Z INFO docmost_rust: login success, fetching spaces from https://docmost.nakano47.com/api +2026-04-11T21:27:10.524392Z INFO docmost_rust: spaces loaded: 5 spaces +2026-04-11T21:27:10.524467Z INFO docmost_rust: auto-loading pages for space 'Curso Cobol Platzi' (019b7051-6cde-7cfe-90cf-62787f33a2b0) +2026-04-11T21:27:10.881217Z INFO docmost_rust: pages loaded: 1 pages +2026-04-11T21:27:11.404613Z INFO docmost_rust: pages loaded: 0 pages +2026-04-11T21:27:11.647222Z INFO docmost_rust: pages loaded: 8 pages +2026-04-11T21:27:11.889697Z INFO docmost_rust: pages loaded: 2 pages +2026-04-11T21:27:12.696622Z INFO docmost_rust: pages loaded: 8 pages +2026-04-11T21:27:14.444842Z INFO docmost_rust::api: fetch_page_content: POST https://docmost.nakano47.com/api/pages/info page_id=019aaef8-9c8c-7eb0-9449-a202ac683818 +2026-04-11T21:27:14.728347Z INFO docmost_rust::api: fetch_page_content: status=200 OK body_len=1223 +2026-04-11T21:27:14.728442Z INFO docmost_rust::api: fetch_page_content: raw body = {"data":{"id":"019aaef8-9c8c-7eb0-9449-a202ac683818","slugId":"5Nb5FoU1P8","title":"Lista de proyectos pendientes'","icon":"πŸ”¨","coverPhoto":null,"position":"a04Ha","parentPageId":null,"creatorId":"019a8369-7137-75b6-b171-cb7e11111fa7","lastUpdatedById":"019a8369-7137-75b6-b171-cb7e11111fa7","spaceId":"019a8369-7142-7c46-89c4-5f3a65b16942","workspaceId":"019a8369-713a-7aee-b3d9-45fb478c1f81","isLocked":false,"createdAt":"2025-11-23T04:28:39.947Z","updatedAt":"2026-04-11T20:54:20.006Z","deletedAt":null,"contributorIds":["019a8369-7137-75b6-b171-cb7e11111fa7"],"content":"- [ ] hay que encriptar las carpetas de immich\n- [ ] Aprender sobre los servidores MCP\n- [ ] Pedir pants y ropa para hacer ejercicio en la market place\n- [ ] El editor de docmost esta recio","creator":{"id":"019a8369-7137-75b6-b171-cb7e11111fa7","name":"arthur","avatarUrl":null},"lastUpdatedBy":{"id":"019a8369-7137-75b6-b171-cb7e11111fa7","name":"arthur","avatarUrl":null},"contributors":[{"id":"019a8369-7137-75b6-b171-cb7e11111fa7","name":"arthur","avatarUrl":null}],"space":{"id":"019a8369-7142-7c46-89c4-5f3a65b16942","name":"General","slug":"general"},"permissions":{"canEdit":true,"hasRestriction":false}},"success":true,"status":200} +2026-04-11T21:27:14.728745Z INFO docmost_rust::api: fetch_page_content: top-level keys = Some(["data", "status", "success"]) +2026-04-11T21:27:14.728798Z INFO docmost_rust::api: fetch_page_content: extracted content len=189 +2026-04-11T21:27:14.751352Z INFO docmost_rust: page content loaded: id=019aaef8-9c8c-7eb0-9449-a202ac683818 title="Lista de proyectos pendientes'" content_len=189 +2026-04-11T21:27:14.751445Z INFO docmost_rust: content preview: "- [ ] hay que encriptar las carpetas de immich\n- [ ] Aprender sobre los servidores MCP\n- [ ] Pedir pants y ropa para hacer ejercicio en la market place\n- [ ] El editor de docmost esta recio" +2026-04-11T21:27:14.751502Z INFO docmost_rust: textarea lines: 4 +2026-04-11T21:27:14.751527Z INFO docmost_rust: state β†’ Editor +2026-04-11T21:27:38.495172Z INFO docmost_rust::api: search_pages: query="Lista" +2026-04-11T21:27:38.764547Z INFO docmost_rust::api: search_pages: status=200 OK body_len=939 +2026-04-11T21:27:38.764862Z INFO docmost_rust::api: search_pages: 2 results +2026-04-11T21:27:38.812170Z INFO docmost_rust: search results: 2 items +2026-04-11T21:27:42.146653Z INFO docmost_rust::api: search_pages: query="S" +2026-04-11T21:27:42.397197Z INFO docmost_rust::api: search_pages: status=200 OK body_len=49 +2026-04-11T21:27:42.397314Z INFO docmost_rust::api: search_pages: 0 results +2026-04-11T21:27:42.403503Z INFO docmost_rust: search results: 0 items +2026-04-11T21:27:43.331068Z INFO docmost_rust::api: search_pages: query="Soda" +2026-04-11T21:27:43.593222Z INFO docmost_rust::api: search_pages: status=200 OK body_len=583 +2026-04-11T21:27:43.593415Z INFO docmost_rust::api: search_pages: 1 results +2026-04-11T21:27:43.650468Z INFO docmost_rust: search results: 1 items +2026-04-11T21:28:04.431585Z INFO docmost_rust: --- startup --- diff --git a/src/api.rs b/src/api.rs index 695dfe4..e2c83d7 100644 --- a/src/api.rs +++ b/src/api.rs @@ -4,7 +4,7 @@ use reqwest::Client; use serde::Serialize; use tracing::info; -use crate::app::{ApiList, LoginRequest, Page, SidebarPagesRequest, Space}; +use crate::app::{ApiList, LoginRequest, Page, SearchResult, SidebarPagesRequest, Space}; pub fn auth_client() -> Result { Client::builder() @@ -205,3 +205,52 @@ pub async fn save_page( Ok(()) } + +#[derive(Serialize)] +struct SearchRequest<'a> { + query: &'a str, + limit: u32, +} + +pub async fn search_pages( + base_url: &str, + token: &str, + query: &str, +) -> Result, String> { + let client = auth_client()?; + + info!("search_pages: query={query:?}"); + + let resp = client + .post(format!("{base_url}/search")) + .header("Cookie", format!("authToken={token}")) + .json(&SearchRequest { query, limit: 25 }) + .send() + .await + .map_err(|e| format!("Connection error: {e}"))?; + + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + + info!("search_pages: status={status} body_len={}", body.len()); + + if !status.is_success() { + return Err(format!("Search error {status}: {body}")); + } + + let v: serde_json::Value = + serde_json::from_str(&body).map_err(|e| format!("Parse error: {e}"))?; + + // Response: { "items": [...] } or { "data": { "items": [...] } } + let items_val = v + .pointer("/items") + .or_else(|| v.pointer("/data/items")) + .cloned() + .unwrap_or(serde_json::Value::Array(vec![])); + + let results: Vec = + serde_json::from_value(items_val).map_err(|e| format!("Parse results error: {e}"))?; + + info!("search_pages: {} results", results.len()); + Ok(results) +} diff --git a/src/app.rs b/src/app.rs index d793669..591ff35 100644 --- a/src/app.rs +++ b/src/app.rs @@ -44,6 +44,7 @@ pub enum AppState { Login, Main, Editor, + Search, } // ─── Login form ─────────────────────────────────────────────────────────────── @@ -172,6 +173,43 @@ impl EditorView { } } +// ─── Search view ───────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Deserialize)] +pub struct SearchResultSpace { + pub name: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SearchResult { + pub id: String, + pub title: Option, + pub highlight: Option, + pub space: Option, +} + +pub struct SearchView { + pub query: String, + pub results: Vec, + pub selected: usize, + pub loading: bool, + pub error: Option, + pub opening_page: bool, +} + +impl SearchView { + pub fn new() -> Self { + Self { + query: String::new(), + results: vec![], + selected: 0, + loading: false, + error: None, + opening_page: false, + } + } +} + // ─── Root app ───────────────────────────────────────────────────────────────── pub struct App { @@ -179,6 +217,7 @@ pub struct App { pub login: LoginForm, pub main: MainView, pub editor: EditorView, + pub search: SearchView, pub base_url: String, pub token: String, } @@ -190,6 +229,7 @@ impl App { login: LoginForm::new(), main: MainView::new(), editor: EditorView::new(String::new(), String::new()), + search: SearchView::new(), base_url: String::new(), token: String::new(), } @@ -206,6 +246,8 @@ pub enum AppMsg { PageContentLoaded { page_id: String, title: String, content: String }, PageSaved, SaveError(String), + SearchResults(Vec), + SearchError(String), ApiError(String), } diff --git a/src/main.rs b/src/main.rs index 8b75b00..c33a851 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,7 @@ mod api; mod app; mod ui; -use std::{io, sync::mpsc, time::Duration}; +use std::{io, sync::mpsc, time::{Duration, Instant}}; use std::fs::OpenOptions; use crossterm::{ @@ -16,9 +16,9 @@ use tui_textarea::TextArea; use tracing::info; use tracing_subscriber::fmt; -use api::{do_login, fetch_page_content, fetch_pages, fetch_spaces, save_page}; -use app::{App, AppMsg, AppState, EditorFocus, EditorStatus, EditorView, LoginField, Panel}; -use ui::{draw_editor, draw_login, draw_main}; +use api::{do_login, fetch_page_content, fetch_pages, fetch_spaces, save_page, search_pages}; +use app::{App, AppMsg, AppState, EditorFocus, EditorStatus, EditorView, LoginField, Panel, SearchView}; +use ui::{draw_editor, draw_login, draw_main, draw_search}; // ─── Entry point ────────────────────────────────────────────────────────────── @@ -61,8 +61,9 @@ async fn main() -> Result<(), Box> { async fn run_app(terminal: &mut Terminal>) -> io::Result<()> { let mut app = App::new(); let (tx, rx) = mpsc::channel::(); - // The TextArea lives here so it's independent of app state lifetime let mut editor_textarea: Option> = None; + // Debounce: fire search 300 ms after the last keystroke + let mut search_debounce: Option = None; loop { // ── Drain async messages ────────────────────────────────────────── @@ -143,16 +144,51 @@ async fn run_app(terminal: &mut Terminal>) -> io::R app.editor.saving = false; app.editor.status = Some(EditorStatus::Error(e)); } + AppMsg::SearchResults(results) => { + info!("search results: {} items", results.len()); + app.search.loading = false; + app.search.results = results; + app.search.selected = 0; + app.search.error = None; + } + AppMsg::SearchError(e) => { + info!("search error: {e}"); + app.search.loading = false; + app.search.error = Some(e); + } AppMsg::ApiError(e) => { info!("api error: {e}"); app.main.loading_spaces = false; app.main.loading_pages = false; app.main.loading_page_content = false; + app.search.opening_page = false; app.main.error = Some(e); } } } + // ── Debounce: fire search after 300 ms of silence ───────────────── + if let Some(t) = search_debounce { + if t.elapsed() >= Duration::from_millis(300) { + search_debounce = None; + if !app.search.query.is_empty() { + app.search.loading = true; + app.search.error = None; + let base_url = app.base_url.clone(); + let token = app.token.clone(); + let query = app.search.query.clone(); + let tx2 = tx.clone(); + tokio::spawn(async move { + let msg = match search_pages(&base_url, &token, &query).await { + Ok(results) => AppMsg::SearchResults(results), + Err(e) => AppMsg::SearchError(e), + }; + let _ = tx2.send(msg); + }); + } + } + } + // ── Draw ────────────────────────────────────────────────────────── terminal.draw(|f| match app.state { AppState::Login => draw_login(f, &app.login), @@ -162,6 +198,7 @@ async fn run_app(terminal: &mut Terminal>) -> io::R draw_editor(f, &app.editor, ta); } } + AppState::Search => draw_search(f, &app.search), })?; if !event::poll(Duration::from_millis(50))? { @@ -255,6 +292,15 @@ async fn run_app(terminal: &mut Terminal>) -> io::R } } }, + // / or Ctrl+F β†’ open search + KeyCode::Char('/') | KeyCode::Char('f') + if key.code == KeyCode::Char('/') + || key.modifiers.contains(KeyModifiers::CONTROL) => + { + app.search = SearchView::new(); + search_debounce = None; + app.state = AppState::Search; + } // Enter on a page β†’ open editor KeyCode::Enter => { if app.main.focus == Panel::Pages { @@ -280,6 +326,61 @@ async fn run_app(terminal: &mut Terminal>) -> io::R } } + // ── Search keys ────────────────────────────────────────── + AppState::Search => { + if app.search.opening_page { + continue; + } + match (key.code, key.modifiers) { + (KeyCode::Esc, _) => { + app.state = AppState::Main; + search_debounce = None; + } + (KeyCode::Backspace, _) => { + app.search.query.pop(); + search_debounce = Some(Instant::now()); + if app.search.query.is_empty() { + app.search.results.clear(); + app.search.loading = false; + } + } + (KeyCode::Down, _) | (KeyCode::Char('j'), _) => { + if app.search.selected + 1 < app.search.results.len() { + app.search.selected += 1; + } + } + (KeyCode::Up, _) | (KeyCode::Char('k'), _) => { + if app.search.selected > 0 { + app.search.selected -= 1; + } + } + // Enter β†’ open selected result in editor + (KeyCode::Enter, _) => { + if let Some(result) = app.search.results.get(app.search.selected) { + app.search.opening_page = true; + let base_url = app.base_url.clone(); + let token = app.token.clone(); + let page_id = result.id.clone(); + let title = result.title.clone().unwrap_or_else(|| "Untitled".into()); + let tx2 = tx.clone(); + tokio::spawn(async move { + let msg = match fetch_page_content(&base_url, &token, &page_id).await { + Ok(content) => AppMsg::PageContentLoaded { page_id, title, content }, + Err(e) => AppMsg::ApiError(e), + }; + let _ = tx2.send(msg); + }); + } + } + // Any printable char β†’ append to query + (KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => { + app.search.query.push(c); + search_debounce = Some(Instant::now()); + } + _ => {} + } + } + // ── Editor keys ─────────────────────────────────────────── AppState::Editor => { // Clear "Saved!" status on any keypress diff --git a/src/ui.rs b/src/ui.rs index 95c72f6..db4c078 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -7,7 +7,7 @@ use ratatui::{ }; use tui_textarea::TextArea; -use crate::app::{EditorFocus, EditorStatus, EditorView, LoginField, LoginForm, MainView, Panel}; +use crate::app::{EditorFocus, EditorStatus, EditorView, LoginField, LoginForm, MainView, Panel, SearchView}; // ─── Login screen ───────────────────────────────────────────────────────────── @@ -246,6 +246,118 @@ pub fn draw_editor(f: &mut Frame, editor: &EditorView, textarea: &mut TextArea<' f.render_widget(Paragraph::new(status_line), layout[2]); } +// ─── Search screen ─────────────────────────────────────────────────────────── + +pub fn draw_search(f: &mut Frame, search: &SearchView) { + let size = f.size(); + + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // query input + Constraint::Min(1), // results + preview + Constraint::Length(1), // hint bar + ]) + .split(size); + + // ── Query input ── + let query_display = format!("{}β–Œ", search.query); + let query_title = if search.loading { " Search (searching…) " } else { " Search " }; + f.render_widget( + Paragraph::new(query_display).block( + Block::default() + .title(query_title) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)), + ), + layout[0], + ); + + // ── Results + preview (side by side) ── + let mid = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(40), Constraint::Percentage(60)]) + .split(layout[1]); + + // Results list + let result_items: Vec = if search.results.is_empty() && !search.loading { + let msg = if search.query.is_empty() { "Type to search…" } else { "No results" }; + vec![ListItem::new(Span::styled(msg, Style::default().fg(Color::DarkGray)))] + } else { + search + .results + .iter() + .map(|r| { + let title = r.title.as_deref().unwrap_or("Untitled"); + let space = r.space.as_ref().map(|s| s.name.as_str()).unwrap_or(""); + ListItem::new(Line::from(vec![ + Span::raw(title), + Span::styled(format!(" [{space}]"), Style::default().fg(Color::DarkGray)), + ])) + }) + .collect() + }; + + f.render_stateful_widget( + List::new(result_items) + .block( + Block::default() + .title(" Results ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::White)), + ) + .highlight_style(Style::default().bg(Color::Blue).add_modifier(Modifier::BOLD)) + .highlight_symbol("β–Ά "), + mid[0], + &mut list_state(search.selected), + ); + + // ── Preview panel ── + let preview_text = if search.opening_page { + "Opening page…".to_string() + } else if let Some(err) = &search.error { + format!("Error: {err}") + } else if let Some(result) = search.results.get(search.selected) { + let title = result.title.as_deref().unwrap_or("Untitled"); + let space = result.space.as_ref().map(|s| s.name.as_str()).unwrap_or("-"); + let highlight = result + .highlight + .as_deref() + .unwrap_or("(no preview)") + .replace("", "**") + .replace("", "**") + .replace("<", "<") + .replace(">", ">") + .replace("&", "&"); + format!("{title}\n[{space}]\n\n{highlight}") + } else { + String::new() + }; + + f.render_widget( + Paragraph::new(preview_text) + .wrap(ratatui::widgets::Wrap { trim: false }) + .block( + Block::default() + .title(" Preview ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::White)), + ), + mid[1], + ); + + // ── Hint bar ── + let hint = if search.opening_page { + Line::from(Span::styled(" Opening…", Style::default().fg(Color::Yellow))) + } else { + Line::from(Span::styled( + " ↑↓: navigate Β· Enter: open Β· Esc: back", + Style::default().fg(Color::DarkGray), + )) + }; + f.render_widget(Paragraph::new(hint), layout[2]); +} + // ─── Helpers ────────────────────────────────────────────────────────────────── fn panel_border(focused: bool) -> Style { diff --git a/todo.txt b/todo.txt index e69de29..2f90281 100644 --- a/todo.txt +++ b/todo.txt @@ -0,0 +1,39 @@ +can we implement Full-text search across pages ? + + Yes, totally doable. Here's the honest breakdown: + + What the Docmost API offers + + Looking at the sidebar-pages endpoint pattern, Docmost likely has a + search endpoint β€” probably something like POST /search with a { + query: string, spaceId?: string } body. This would be the right + approach rather than fetching all pages and filtering client-side. + + TUI implementation + + A search screen would work like this: + + β”Œβ”€ Search ─────────────────────────────────┐ + β”‚ queryβ–Œ β”‚ ← type to search + β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ + β”‚ β–Ά Result page title Space name β”‚ + β”‚ Another matching page Other space β”‚ + β”‚ ... β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + Enter: open Β· Esc: back + + - Trigger it with / or Ctrl+F from the main view + - Each keypress fires a debounced API call (e.g. wait 300ms after + last keystroke) + - Results show page title + space name + - Enter opens the selected result in the editor + - Esc goes back + + The debounce part is the only tricky bit in the async loop β€” you'd + track a last_typed: Instant and only fire the search if elapsed() > + 300ms without new input. + + Caveat: if Docmost's API doesn't expose a search endpoint, you'd + have to fetch all pages across all spaces and do client-side string + matching, which is slow and not full-text. Worth checking the API + docs first to confirm the endpoint exists before building it.