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.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 ---
|
||||
|
||||
51
src/api.rs
51
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, 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)
|
||||
}
|
||||
|
||||
42
src/app.rs
42
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<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),
|
||||
}
|
||||
|
||||
|
||||
111
src/main.rs
111
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<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
114
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<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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
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