search bar

This commit is contained in:
2026-04-11 15:28:22 -06:00
parent 2725f73c7d
commit 316de93781
6 changed files with 381 additions and 7 deletions

View File

@@ -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 ---

View File

@@ -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, String> {
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<Vec<SearchResult>, 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<SearchResult> =
serde_json::from_value(items_val).map_err(|e| format!("Parse results error: {e}"))?;
info!("search_pages: {} results", results.len());
Ok(results)
}

View File

@@ -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<String>,
pub highlight: Option<String>,
pub space: Option<SearchResultSpace>,
}
pub struct SearchView {
pub query: String,
pub results: Vec<SearchResult>,
pub selected: usize,
pub loading: bool,
pub error: Option<String>,
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<SearchResult>),
SearchError(String),
ApiError(String),
}

View File

@@ -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<dyn std::error::Error>> {
async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<()> {
let mut app = App::new();
let (tx, rx) = mpsc::channel::<AppMsg>();
// The TextArea lives here so it's independent of app state lifetime
let mut editor_textarea: Option<TextArea<'static>> = None;
// Debounce: fire search 300 ms after the last keystroke
let mut search_debounce: Option<Instant> = None;
loop {
// ── Drain async messages ──────────────────────────────────────────
@@ -143,16 +144,51 @@ async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> 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<CrosstermBackend<io::Stdout>>) -> 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<CrosstermBackend<io::Stdout>>) -> 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<CrosstermBackend<io::Stdout>>) -> 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

114
src/ui.rs
View File

@@ -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<ListItem> = 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("<b>", "**")
.replace("</b>", "**")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&amp;", "&");
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 {

View File

@@ -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.