search bar
This commit is contained in:
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 {
|
||||
|
||||
Reference in New Issue
Block a user