search bar
This commit is contained in:
31
docmost.log
31
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.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.173643Z INFO docmost_rust: textarea lines: 51
|
||||||
2026-04-11T21:11:45.173671Z INFO docmost_rust: state → Editor
|
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 ---
|
||||||
|
|||||||
51
src/api.rs
51
src/api.rs
@@ -4,7 +4,7 @@ use reqwest::Client;
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tracing::info;
|
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> {
|
pub fn auth_client() -> Result<Client, String> {
|
||||||
Client::builder()
|
Client::builder()
|
||||||
@@ -205,3 +205,52 @@ pub async fn save_page(
|
|||||||
|
|
||||||
Ok(())
|
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)
|
||||||
|
}
|
||||||
|
|||||||
42
src/app.rs
42
src/app.rs
@@ -44,6 +44,7 @@ pub enum AppState {
|
|||||||
Login,
|
Login,
|
||||||
Main,
|
Main,
|
||||||
Editor,
|
Editor,
|
||||||
|
Search,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Login form ───────────────────────────────────────────────────────────────
|
// ─── 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 ─────────────────────────────────────────────────────────────────
|
// ─── Root app ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
@@ -179,6 +217,7 @@ pub struct App {
|
|||||||
pub login: LoginForm,
|
pub login: LoginForm,
|
||||||
pub main: MainView,
|
pub main: MainView,
|
||||||
pub editor: EditorView,
|
pub editor: EditorView,
|
||||||
|
pub search: SearchView,
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
pub token: String,
|
pub token: String,
|
||||||
}
|
}
|
||||||
@@ -190,6 +229,7 @@ impl App {
|
|||||||
login: LoginForm::new(),
|
login: LoginForm::new(),
|
||||||
main: MainView::new(),
|
main: MainView::new(),
|
||||||
editor: EditorView::new(String::new(), String::new()),
|
editor: EditorView::new(String::new(), String::new()),
|
||||||
|
search: SearchView::new(),
|
||||||
base_url: String::new(),
|
base_url: String::new(),
|
||||||
token: String::new(),
|
token: String::new(),
|
||||||
}
|
}
|
||||||
@@ -206,6 +246,8 @@ pub enum AppMsg {
|
|||||||
PageContentLoaded { page_id: String, title: String, content: String },
|
PageContentLoaded { page_id: String, title: String, content: String },
|
||||||
PageSaved,
|
PageSaved,
|
||||||
SaveError(String),
|
SaveError(String),
|
||||||
|
SearchResults(Vec<SearchResult>),
|
||||||
|
SearchError(String),
|
||||||
ApiError(String),
|
ApiError(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
111
src/main.rs
111
src/main.rs
@@ -2,7 +2,7 @@ mod api;
|
|||||||
mod app;
|
mod app;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
|
||||||
use std::{io, sync::mpsc, time::Duration};
|
use std::{io, sync::mpsc, time::{Duration, Instant}};
|
||||||
use std::fs::OpenOptions;
|
use std::fs::OpenOptions;
|
||||||
|
|
||||||
use crossterm::{
|
use crossterm::{
|
||||||
@@ -16,9 +16,9 @@ use tui_textarea::TextArea;
|
|||||||
use tracing::info;
|
use tracing::info;
|
||||||
use tracing_subscriber::fmt;
|
use tracing_subscriber::fmt;
|
||||||
|
|
||||||
use api::{do_login, fetch_page_content, fetch_pages, fetch_spaces, save_page};
|
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};
|
use app::{App, AppMsg, AppState, EditorFocus, EditorStatus, EditorView, LoginField, Panel, SearchView};
|
||||||
use ui::{draw_editor, draw_login, draw_main};
|
use ui::{draw_editor, draw_login, draw_main, draw_search};
|
||||||
|
|
||||||
// ─── Entry point ──────────────────────────────────────────────────────────────
|
// ─── 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<()> {
|
async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<()> {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
let (tx, rx) = mpsc::channel::<AppMsg>();
|
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;
|
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 {
|
loop {
|
||||||
// ── Drain async messages ──────────────────────────────────────────
|
// ── 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.saving = false;
|
||||||
app.editor.status = Some(EditorStatus::Error(e));
|
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) => {
|
AppMsg::ApiError(e) => {
|
||||||
info!("api error: {e}");
|
info!("api error: {e}");
|
||||||
app.main.loading_spaces = false;
|
app.main.loading_spaces = false;
|
||||||
app.main.loading_pages = false;
|
app.main.loading_pages = false;
|
||||||
app.main.loading_page_content = false;
|
app.main.loading_page_content = false;
|
||||||
|
app.search.opening_page = false;
|
||||||
app.main.error = Some(e);
|
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 ──────────────────────────────────────────────────────────
|
// ── Draw ──────────────────────────────────────────────────────────
|
||||||
terminal.draw(|f| match app.state {
|
terminal.draw(|f| match app.state {
|
||||||
AppState::Login => draw_login(f, &app.login),
|
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);
|
draw_editor(f, &app.editor, ta);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AppState::Search => draw_search(f, &app.search),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if !event::poll(Duration::from_millis(50))? {
|
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
|
// Enter on a page → open editor
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
if app.main.focus == Panel::Pages {
|
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 ───────────────────────────────────────────
|
// ── Editor keys ───────────────────────────────────────────
|
||||||
AppState::Editor => {
|
AppState::Editor => {
|
||||||
// Clear "Saved!" status on any keypress
|
// Clear "Saved!" status on any keypress
|
||||||
|
|||||||
114
src/ui.rs
114
src/ui.rs
@@ -7,7 +7,7 @@ use ratatui::{
|
|||||||
};
|
};
|
||||||
use tui_textarea::TextArea;
|
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 ─────────────────────────────────────────────────────────────
|
// ─── 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]);
|
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("<", "<")
|
||||||
|
.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 ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn panel_border(focused: bool) -> Style {
|
fn panel_border(focused: bool) -> Style {
|
||||||
|
|||||||
39
todo.txt
39
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user