mod api; mod app; mod ui; use std::fs::OpenOptions; use std::{ io, sync::mpsc, time::{Duration, Instant}, }; use crossterm::{ event::{self, Event, KeyCode, KeyModifiers}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use ratatui::{backend::CrosstermBackend, Terminal}; use tui_textarea::TextArea; use tracing::info; use tracing_subscriber::fmt; use api::{ create_page, create_space, delete_page, delete_space, do_login, fetch_page_content, fetch_pages, fetch_spaces, save_page, search_pages, }; use app::{ App, AppMsg, AppState, DeleteConfirmDialog, DeleteTarget, EditorFocus, EditorStatus, EditorView, LoginField, NewPageDialog, NewSpaceDialog, Panel, SearchView, }; use ui::{draw_editor, draw_login, draw_main, draw_search}; // ─── Entry point ────────────────────────────────────────────────────────────── #[tokio::main] async fn main() -> Result<(), Box> { // Log to file so it doesn't interfere with the TUI. // Run: tail -f docmost.log in another terminal to follow logs. let log_file = OpenOptions::new() .create(true) .append(true) .open("docmost.log")?; fmt() .with_writer(log_file) .with_ansi(false) .with_env_filter("docmost_rust=debug") .init(); info!("--- startup ---"); enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; let res = run_app(&mut terminal).await; disable_raw_mode()?; execute!(terminal.backend_mut(), LeaveAlternateScreen)?; terminal.show_cursor()?; if let Err(err) = res { println!("{err:?}"); } Ok(()) } // ─── App loop ───────────────────────────────────────────────────────────────── async fn run_app(terminal: &mut Terminal>) -> io::Result<()> { let mut app = App::new(); let (tx, rx) = mpsc::channel::(); 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 ────────────────────────────────────────── while let Ok(msg) = rx.try_recv() { match msg { AppMsg::LoginSuccess { token, base_url } => { info!("login success, fetching spaces from {base_url}"); app.token = token.clone(); app.base_url = base_url.clone(); app.state = AppState::Main; app.login.submitting = false; let tx2 = tx.clone(); tokio::spawn(async move { let msg = match fetch_spaces(&base_url, &token).await { Ok(spaces) => AppMsg::SpacesLoaded(spaces), Err(e) => AppMsg::ApiError(e), }; let _ = tx2.send(msg); }); } AppMsg::LoginError(e) => { info!("login error: {e}"); app.login.error = Some(e); app.login.submitting = false; } AppMsg::SpacesLoaded(spaces) => { info!("spaces loaded: {} spaces", spaces.len()); app.main.loading_spaces = false; app.main.spaces = spaces; app.main.selected_space = 0; app.main.pages = vec![]; if let Some(space) = app.main.spaces.first() { info!( "auto-loading pages for space '{}' ({})", space.name, space.id ); app.main.loading_pages = true; spawn_fetch_pages_msg(&app.base_url, &app.token, &space.id, &tx); } } AppMsg::PagesLoaded(pages) => { info!("pages loaded: {} pages", pages.len()); app.main.loading_pages = false; app.main.pages = pages; app.main.selected_page = 0; } AppMsg::PageContentLoaded { page_id, title, content, } => { info!( "page content loaded: id={page_id} title={title:?} content_len={}", content.len() ); info!("content preview: {:?}", &content[..content.len().min(200)]); app.main.loading_page_content = false; app.editor = EditorView::new(page_id, title); let lines: Vec = content.lines().map(|l| l.to_string()).collect(); info!("textarea lines: {}", lines.len()); let ta = if lines.is_empty() { TextArea::default() } else { TextArea::from(lines) }; editor_textarea = Some(ta); app.state = AppState::Editor; info!("state → Editor"); } AppMsg::PageSaved => { info!("page saved ok"); app.editor.saving = false; app.editor.status = Some(EditorStatus::Saved); // Update the title in the pages list immediately let saved_id = app.editor.page_id.clone(); let saved_title = app.editor.page_title.clone(); if let Some(page) = app.main.pages.iter_mut().find(|p| p.id == saved_id) { page.title = Some(saved_title); } // Refresh pages from server in the background spawn_fetch_pages(&app, &tx); } AppMsg::SaveError(e) => { info!("save error: {e}"); app.editor.saving = false; app.editor.status = Some(EditorStatus::Error(e)); } AppMsg::PageCreated { page_id, title } => { info!("page created: id={page_id} title={title:?}"); app.main.new_page_dialog = None; // Refresh pages list then open the new page in the editor spawn_fetch_pages(&app, &tx); app.main.loading_page_content = true; let base_url = app.base_url.clone(); let token = app.token.clone(); 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); }); } AppMsg::CreateError(e) => { info!("create error: {e}"); if let Some(d) = app.main.new_page_dialog.as_mut() { d.creating = false; d.error = Some(e); } } AppMsg::SpaceDeleted(space_id) => { info!("space deleted: {space_id}"); app.main.delete_dialog = None; app.main.spaces.retain(|s| s.id != space_id); app.main.selected_space = 0; app.main.pages = vec![]; if let Some(space) = app.main.spaces.first() { app.main.loading_pages = true; spawn_fetch_pages_msg(&app.base_url, &app.token, &space.id, &tx); } } AppMsg::PageDeleted(page_id) => { info!("page deleted: {page_id}"); app.main.delete_dialog = None; app.main.pages.retain(|p| p.id != page_id); if app.main.selected_page >= app.main.pages.len() { app.main.selected_page = app.main.pages.len().saturating_sub(1); } } AppMsg::DeleteError(e) => { info!("delete error: {e}"); if let Some(d) = app.main.delete_dialog.as_mut() { d.deleting = false; d.error = Some(e); } } AppMsg::SpaceCreated(space) => { info!("space created: id={} name={:?}", space.id, space.name); app.main.new_space_dialog = None; // Add to list and select it app.main.spaces.push(space); app.main.selected_space = app.main.spaces.len() - 1; app.main.pages = vec![]; app.main.loading_pages = false; } AppMsg::CreateSpaceError(e) => { info!("create space error: {e}"); if let Some(d) = app.main.new_space_dialog.as_mut() { d.creating = false; d.error = Some(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), AppState::Main => draw_main(f, &app.main), AppState::Editor => { if let Some(ta) = editor_textarea.as_mut() { draw_editor(f, &app.editor, ta); } } AppState::Search => draw_search(f, &app.search), })?; if !event::poll(Duration::from_millis(200))? { continue; } if let Event::Key(key) = event::read()? { match app.state { // ── Login keys ──────────────────────────────────────────── AppState::Login => { if app.login.submitting { continue; } match key.code { KeyCode::Esc => return Ok(()), KeyCode::Tab => app.login.active_field = app.login.active_field.next(), KeyCode::BackTab => app.login.active_field = app.login.active_field.prev(), KeyCode::Up => app.login.active_field = app.login.active_field.prev(), KeyCode::Down => app.login.active_field = app.login.active_field.next(), KeyCode::Enter => { if app.login.active_field != LoginField::Password { app.login.active_field = app.login.active_field.next(); } else { app.login.error = None; app.login.submitting = true; let url = app.login.url.trim_end_matches('/').to_string(); let email = app.login.email.clone(); let password = app.login.password.clone(); let tx2 = tx.clone(); tokio::spawn(async move { let msg = match do_login(&url, &email, &password).await { Ok(token) => AppMsg::LoginSuccess { token, base_url: url, }, Err(e) => AppMsg::LoginError(e), }; let _ = tx2.send(msg); }); } } KeyCode::Backspace => { app.login.active_field_value_mut().pop(); } KeyCode::Char(c) => { if key.modifiers.contains(KeyModifiers::CONTROL) && c == 'c' { return Ok(()); } app.login.active_field_value_mut().push(c); } _ => {} } } // ── Main keys ───────────────────────────────────────────── AppState::Main => { if app.main.loading_page_content { continue; } // ── Delete confirm dialog intercepts all keys when open ── if let Some(dialog) = app.main.delete_dialog.as_mut() { if dialog.deleting { continue; } match key.code { KeyCode::Esc => { app.main.delete_dialog = None; } KeyCode::Char('d') => { dialog.deleting = true; dialog.error = None; let base_url = app.base_url.clone(); let token = app.token.clone(); let target = dialog.target.clone(); let tx2 = tx.clone(); tokio::spawn(async move { let msg = match &target { DeleteTarget::Space { id, .. } => { match delete_space(&base_url, &token, id).await { Ok(()) => AppMsg::SpaceDeleted(id.clone()), Err(e) => AppMsg::DeleteError(e), } } DeleteTarget::Page { id, .. } => { match delete_page(&base_url, &token, id).await { Ok(()) => AppMsg::PageDeleted(id.clone()), Err(e) => AppMsg::DeleteError(e), } } }; let _ = tx2.send(msg); }); } _ => {} } continue; } // ── New-space dialog intercepts all keys when open ── if let Some(dialog) = app.main.new_space_dialog.as_mut() { if dialog.creating { continue; } match key.code { KeyCode::Esc => { app.main.new_space_dialog = None; } KeyCode::Backspace => { dialog.name.pop(); } KeyCode::Enter => { if dialog.name.trim().is_empty() { dialog.error = Some("Name cannot be empty.".into()); } else { dialog.creating = true; dialog.error = None; let base_url = app.base_url.clone(); let token = app.token.clone(); let name = dialog.name.trim().to_string(); let tx2 = tx.clone(); tokio::spawn(async move { let msg = match create_space(&base_url, &token, &name).await { Ok(space) => AppMsg::SpaceCreated(space), Err(e) => AppMsg::CreateSpaceError(e), }; let _ = tx2.send(msg); }); } } KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { dialog.error = None; dialog.name.push(c); } _ => {} } continue; } // ── New-page dialog intercepts all keys when open ── if let Some(dialog) = app.main.new_page_dialog.as_mut() { if dialog.creating { continue; } match key.code { KeyCode::Esc => { app.main.new_page_dialog = None; } KeyCode::Backspace => { dialog.title.pop(); } KeyCode::Enter => { if dialog.title.trim().is_empty() { dialog.error = Some("Title cannot be empty.".into()); } else { dialog.creating = true; dialog.error = None; let base_url = app.base_url.clone(); let token = app.token.clone(); let title = dialog.title.trim().to_string(); let space_id = app .main .spaces .get(app.main.selected_space) .map(|s| s.id.clone()) .unwrap_or_default(); let tx2 = tx.clone(); tokio::spawn(async move { let msg = match create_page(&base_url, &token, &space_id, &title) .await { Ok((page_id, title)) => { AppMsg::PageCreated { page_id, title } } Err(e) => AppMsg::CreateError(e), }; let _ = tx2.send(msg); }); } } KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { dialog.error = None; dialog.title.push(c); } _ => {} } continue; } match key.code { KeyCode::Char('q') | KeyCode::Esc => return Ok(()), KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { return Ok(()) } KeyCode::Tab => { app.main.focus = match app.main.focus { Panel::Spaces => Panel::Pages, Panel::Pages => Panel::Spaces, }; } KeyCode::Down | KeyCode::Char('j') => match app.main.focus { Panel::Spaces => { if app.main.selected_space + 1 < app.main.spaces.len() { app.main.selected_space += 1; spawn_fetch_pages(&app, &tx); } } Panel::Pages => { if app.main.selected_page + 1 < app.main.pages.len() { app.main.selected_page += 1; } } }, KeyCode::Up | KeyCode::Char('k') => match app.main.focus { Panel::Spaces => { if app.main.selected_space > 0 { app.main.selected_space -= 1; spawn_fetch_pages(&app, &tx); } } Panel::Pages => { if app.main.selected_page > 0 { app.main.selected_page -= 1; } } }, // d → delete focused item KeyCode::Char('d') => { let target = match app.main.focus { Panel::Spaces => { app.main.spaces.get(app.main.selected_space).map(|s| { DeleteTarget::Space { id: s.id.clone(), name: s.name.clone(), } }) } Panel::Pages => { app.main.pages.get(app.main.selected_page).map(|p| { DeleteTarget::Page { id: p.id.clone(), title: p .title .clone() .unwrap_or_else(|| "Untitled".into()), } }) } }; if let Some(target) = target { app.main.delete_dialog = Some(DeleteConfirmDialog::new(target)); } } // Ctrl+N → new space KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => { app.main.new_space_dialog = Some(NewSpaceDialog::new()); } // n → new page in current space KeyCode::Char('n') => { if !app.main.spaces.is_empty() { app.main.new_page_dialog = Some(NewPageDialog::new()); } } // / 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 { if let Some(page) = app.main.pages.get(app.main.selected_page) { app.main.loading_page_content = true; app.main.error = None; let base_url = app.base_url.clone(); let token = app.token.clone(); let page_id = page.id.clone(); let title = page.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); }); } } } _ => {} } } // ── 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 if matches!(app.editor.status, Some(EditorStatus::Saved)) { app.editor.status = None; } match (key.code, key.modifiers) { // Esc → back to main view (KeyCode::Esc, _) => { app.state = AppState::Main; editor_textarea = None; } // Ctrl+S → save both title and content (KeyCode::Char('s'), m) if m.contains(KeyModifiers::CONTROL) => { if !app.editor.saving { app.editor.saving = true; app.editor.status = None; let base_url = app.base_url.clone(); let token = app.token.clone(); let page_id = app.editor.page_id.clone(); let title = app.editor.page_title.clone(); let content = editor_textarea .as_ref() .map(|ta| ta.lines().join("\n")) .unwrap_or_default(); let tx2 = tx.clone(); tokio::spawn(async move { let msg = match save_page( &base_url, &token, &page_id, &title, &content, ) .await { Ok(()) => AppMsg::PageSaved, Err(e) => AppMsg::SaveError(e), }; let _ = tx2.send(msg); }); } } // Tab → toggle focus between title and content (KeyCode::Tab, _) => { app.editor.focus = match app.editor.focus { EditorFocus::Title => EditorFocus::Content, EditorFocus::Content => EditorFocus::Title, }; } // Title field keys (KeyCode::Backspace, _) if app.editor.focus == EditorFocus::Title => { app.editor.page_title.pop(); } (KeyCode::Char(c), m) if app.editor.focus == EditorFocus::Title && !m.contains(KeyModifiers::CONTROL) => { app.editor.page_title.push(c); } // All other keys → delegate to content textarea _ => { if app.editor.focus == EditorFocus::Content { if let Some(ta) = editor_textarea.as_mut() { ta.input(key); } } } } } } } } } // ─── Helpers ────────────────────────────────────────────────────────────────── fn spawn_fetch_pages(app: &App, tx: &mpsc::Sender) { if let Some(space) = app.main.spaces.get(app.main.selected_space) { spawn_fetch_pages_msg(&app.base_url, &app.token, &space.id, tx); } } fn spawn_fetch_pages_msg(base_url: &str, token: &str, space_id: &str, tx: &mpsc::Sender) { let base_url = base_url.to_string(); let token = token.to_string(); let space_id = space_id.to_string(); let tx2 = tx.clone(); tokio::spawn(async move { let msg = match fetch_pages(&base_url, &token, &space_id).await { Ok(pages) => AppMsg::PagesLoaded(pages), Err(e) => AppMsg::ApiError(e), }; let _ = tx2.send(msg); }); }