diff --git a/Cargo.lock b/Cargo.lock index f7122e4..16e7483 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -198,6 +207,9 @@ dependencies = [ "serde", "serde_json", "tokio", + "tracing", + "tracing-subscriber", + "tui-textarea", ] [[package]] @@ -683,6 +695,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -737,6 +755,15 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "memchr" version = "2.8.0" @@ -789,6 +816,15 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-conv" version = "0.2.1" @@ -986,6 +1022,23 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "reqwest" version = "0.12.28" @@ -1199,6 +1252,15 @@ dependencies = [ "serde", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1373,6 +1435,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" version = "0.3.47" @@ -1527,9 +1598,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -1537,6 +1620,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -1545,6 +1658,17 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tui-textarea" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e38ced1f941a9cfc923fbf2fe6858443c42cc5220bfd35bdd3648371e7bd8e" +dependencies = [ + "crossterm", + "ratatui", + "unicode-width", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1604,6 +1728,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" diff --git a/Cargo.toml b/Cargo.toml index 48b6f39..864d881 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,8 +6,11 @@ edition = "2024" [dependencies] ratatui = "0.26" crossterm = "0.27" +tui-textarea = { version = "0.4", features = ["crossterm"] } tokio = { version = "1", features = ["full"] } reqwest = { version = "0.12", features = ["json", "cookies"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/docmost.log b/docmost.log new file mode 100644 index 0000000..e69de29 diff --git a/src/api.rs b/src/api.rs index 04be23b..ca84091 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,6 +1,8 @@ use std::time::Duration; use reqwest::Client; +use serde::Serialize; +use tracing::info; use crate::app::{ApiList, LoginRequest, Page, SidebarPagesRequest, Space}; @@ -39,27 +41,22 @@ pub async fn do_login(base_url: &str, email: &str, password: &str) -> Result Result, String> { let client = auth_client()?; - let url = format!("{base_url}/spaces"); let resp = client - .post(&url) + .post(format!("{base_url}/spaces")) .header("Cookie", format!("authToken={token}")) .json(&serde_json::json!({})) .send() @@ -81,10 +78,9 @@ pub async fn fetch_spaces(base_url: &str, token: &str) -> Result, Str pub async fn fetch_pages(base_url: &str, token: &str, space_id: &str) -> Result, String> { let client = auth_client()?; - let url = format!("{base_url}/pages/sidebar-pages"); let resp = client - .post(&url) + .post(format!("{base_url}/pages/sidebar-pages")) .header("Cookie", format!("authToken={token}")) .json(&SidebarPagesRequest { space_id }) .send() @@ -103,3 +99,104 @@ pub async fn fetch_pages(base_url: &str, token: &str, space_id: &str) -> Result< Ok(list.into_items()) } + +#[derive(Serialize)] +struct PageInfoRequest<'a> { + #[serde(rename = "pageId")] + page_id: &'a str, + #[serde(rename = "includeContent")] + include_content: bool, + format: &'a str, +} + +pub async fn fetch_page_content( + base_url: &str, + token: &str, + page_id: &str, +) -> Result { + let client = auth_client()?; + + info!("fetch_page_content: POST {base_url}/pages/info page_id={page_id}"); + + let resp = client + .post(format!("{base_url}/pages/info")) + .header("Cookie", format!("authToken={token}")) + .json(&PageInfoRequest { + page_id, + include_content: true, + format: "markdown", + }) + .send() + .await + .map_err(|e| format!("Connection error: {e}"))?; + + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + + info!("fetch_page_content: status={status} body_len={}", body.len()); + info!("fetch_page_content: raw body = {body}"); + + if !status.is_success() { + return Err(format!("Page error {status}: {body}")); + } + + let v: serde_json::Value = + serde_json::from_str(&body).map_err(|e| format!("Parse error: {e}"))?; + + info!("fetch_page_content: top-level keys = {:?}", v.as_object().map(|o| o.keys().collect::>())); + + let content = v + .pointer("/data/content") + .and_then(|c| c.as_str()) + .unwrap_or("") + .to_string(); + + info!("fetch_page_content: extracted content len={}", content.len()); + + Ok(content) +} + +#[derive(Serialize)] +struct PageUpdateRequest<'a> { + #[serde(rename = "pageId")] + page_id: &'a str, + content: &'a str, + operation: &'a str, + format: &'a str, +} + +pub async fn save_page( + base_url: &str, + token: &str, + page_id: &str, + content: &str, +) -> Result<(), String> { + let client = auth_client()?; + + let resp = client + .post(format!("{base_url}/pages/update")) + .header("Cookie", format!("authToken={token}")) + .json(&PageUpdateRequest { + page_id, + content, + operation: "replace", + format: "markdown", + }) + .send() + .await + .map_err(|e| format!("Connection error: {e}"))?; + + let status = resp.status(); + + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + if let Ok(v) = serde_json::from_str::(&body) { + if let Some(msg) = v.get("message").and_then(|m| m.as_str()) { + return Err(msg.to_string()); + } + } + return Err(format!("Save error {status}: {body}")); + } + + Ok(()) +} diff --git a/src/app.rs b/src/app.rs index c3accf5..77a5412 100644 --- a/src/app.rs +++ b/src/app.rs @@ -37,14 +37,17 @@ impl ApiList { } } -// ─── Login form ─────────────────────────────────────────────────────────────── +// ─── App state ──────────────────────────────────────────────────────────────── #[derive(Debug, Clone, Copy, PartialEq)] pub enum AppState { Login, Main, + Editor, } +// ─── Login form ─────────────────────────────────────────────────────────────── + #[derive(Debug, Clone, Copy, PartialEq)] pub enum LoginField { Url, @@ -115,6 +118,7 @@ pub struct MainView { pub focus: Panel, pub loading_spaces: bool, pub loading_pages: bool, + pub loading_page_content: bool, pub error: Option, } @@ -128,17 +132,45 @@ impl MainView { focus: Panel::Spaces, loading_spaces: true, loading_pages: false, + loading_page_content: false, error: None, } } } +// ─── Editor view ───────────────────────────────────────────────────────────── + +pub struct EditorView { + pub page_id: String, + pub page_title: String, + pub saving: bool, + pub status: Option, +} + +#[derive(Debug, Clone)] +pub enum EditorStatus { + Saved, + Error(String), +} + +impl EditorView { + pub fn new(page_id: String, page_title: String) -> Self { + Self { + page_id, + page_title, + saving: false, + status: None, + } + } +} + // ─── Root app ───────────────────────────────────────────────────────────────── pub struct App { pub state: AppState, pub login: LoginForm, pub main: MainView, + pub editor: EditorView, pub base_url: String, pub token: String, } @@ -149,6 +181,7 @@ impl App { state: AppState::Login, login: LoginForm::new(), main: MainView::new(), + editor: EditorView::new(String::new(), String::new()), base_url: String::new(), token: String::new(), } @@ -162,6 +195,9 @@ pub enum AppMsg { LoginError(String), SpacesLoaded(Vec), PagesLoaded(Vec), + PageContentLoaded { page_id: String, title: String, content: String }, + PageSaved, + SaveError(String), ApiError(String), } diff --git a/src/main.rs b/src/main.rs index db1d6db..ecb7a69 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod app; mod ui; use std::{io, sync::mpsc, time::Duration}; +use std::fs::OpenOptions; use crossterm::{ event::{self, Event, KeyCode, KeyModifiers}, @@ -10,15 +11,32 @@ use crossterm::{ terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use ratatui::{backend::CrosstermBackend, Terminal}; +use tui_textarea::TextArea; -use api::{do_login, fetch_pages, fetch_spaces}; -use app::{App, AppMsg, AppState, LoginField, Panel}; -use ui::{draw_login, draw_main}; +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, EditorStatus, EditorView, LoginField, Panel}; +use ui::{draw_editor, draw_login, draw_main}; // ─── 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)?; @@ -43,12 +61,15 @@ async fn main() -> Result<(), Box> { async fn run_app(terminal: &mut Terminal>) -> io::Result<()> { let mut app = App::new(); let (tx, rx) = mpsc::channel::(); + // The TextArea lives here so it's independent of app state lifetime + let mut editor_textarea: Option> = None; loop { - // Drain async messages + // ── 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; @@ -63,45 +84,76 @@ async fn run_app(terminal: &mut Terminal>) -> io::R }); } 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; - let tx2 = tx.clone(); - let base_url = app.base_url.clone(); - let token = app.token.clone(); - let space_id = space.id.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); - }); + 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); + } + AppMsg::SaveError(e) => { + info!("save error: {e}"); + app.editor.saving = false; + app.editor.status = Some(EditorStatus::Error(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.main.error = Some(e); } } } + // ── 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); + } + } })?; if !event::poll(Duration::from_millis(50))? { @@ -110,6 +162,7 @@ async fn run_app(terminal: &mut Terminal>) -> io::R if let Event::Key(key) = event::read()? { match app.state { + // ── Login keys ──────────────────────────────────────────── AppState::Login => { if app.login.submitting { continue; @@ -152,62 +205,139 @@ async fn run_app(terminal: &mut Terminal>) -> io::R } } - AppState::Main => match key.code { - KeyCode::Char('q') | KeyCode::Esc => return Ok(()), - KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { - return Ok(()) + // ── Main keys ───────────────────────────────────────────── + AppState::Main => { + if app.main.loading_page_content { + continue; } - KeyCode::Tab => { - app.main.focus = match app.main.focus { - Panel::Spaces => Panel::Pages, - Panel::Pages => Panel::Spaces, - }; + 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; + } + } + }, + // 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); + }); + } + } + } + _ => {} } - 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); + } + + // ── Editor keys ─────────────────────────────────────────── + AppState::Editor => { + // Clear 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 + (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 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, &content).await { + Ok(()) => AppMsg::PageSaved, + Err(e) => AppMsg::SaveError(e), + }; + let _ = tx2.send(msg); + }); } } - Panel::Pages => { - if app.main.selected_page + 1 < app.main.pages.len() { - app.main.selected_page += 1; + // All other keys → delegate to textarea + _ => { + if let Some(ta) = editor_textarea.as_mut() { + ta.input(key); } } - }, - 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; - } - } - }, - _ => {} - }, + } + } } } } } +// ─── Helpers ────────────────────────────────────────────────────────────────── + fn spawn_fetch_pages(app: &App, tx: &mpsc::Sender) { if let Some(space) = app.main.spaces.get(app.main.selected_space) { - let base_url = app.base_url.clone(); - let token = app.token.clone(); - let space_id = space.id.clone(); - 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); - }); + 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); + }); +} diff --git a/src/ui.rs b/src/ui.rs index 5ec316d..dfbe68b 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -5,26 +5,28 @@ use ratatui::{ widgets::{Block, Borders, Clear, List, ListItem, Paragraph}, Frame, }; +use tui_textarea::TextArea; -use crate::app::{LoginField, LoginForm, MainView, Panel}; +use crate::app::{EditorStatus, EditorView, LoginField, LoginForm, MainView, Panel}; // ─── Login screen ───────────────────────────────────────────────────────────── pub fn draw_login(f: &mut Frame, login: &LoginForm) { let size = f.size(); - let bg = Block::default().style(Style::default().bg(Color::Black)); - f.render_widget(bg, size); + f.render_widget(Block::default().style(Style::default().bg(Color::Black)), size); let dialog = centered_rect(50, 18, size); f.render_widget(Clear, dialog); let title = if login.submitting { "Logging in…" } else { "Login — Docmost" }; - let outer = Block::default() - .title(title) - .borders(Borders::ALL) - .style(Style::default().fg(Color::Cyan)); - f.render_widget(outer, dialog); + f.render_widget( + Block::default() + .title(title) + .borders(Borders::ALL) + .style(Style::default().fg(Color::Cyan)), + dialog, + ); let inner = Rect { x: dialog.x + 2, @@ -48,23 +50,30 @@ pub fn draw_login(f: &mut Frame, login: &LoginForm) { render_field(f, "Email", &login.email, false, login.active_field == LoginField::Email, rows[1]); render_field(f, "Password", &login.password, true, login.active_field == LoginField::Password, rows[2]); - let bottom = if let Some(err) = &login.error { - Paragraph::new(Line::from(vec![Span::styled( + let hint = if let Some(err) = &login.error { + Paragraph::new(Line::from(Span::styled( format!(" {err}"), Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), - )])) + ))) .alignment(Alignment::Left) } else { - Paragraph::new(Line::from(vec![Span::styled( + Paragraph::new(Line::from(Span::styled( " Tab/↑↓ switch field · Enter submit · Esc quit", Style::default().fg(Color::DarkGray), - )])) + ))) .alignment(Alignment::Left) }; - f.render_widget(bottom, rows[4]); + f.render_widget(hint, rows[4]); } -fn render_field(f: &mut Frame, label: &'static str, value: &str, masked: bool, active: bool, area: Rect) { +fn render_field( + f: &mut Frame, + label: &'static str, + value: &str, + masked: bool, + active: bool, + area: Rect, +) { let display = if masked { "*".repeat(value.len()) } else { value.to_string() }; let content = format!("{display}{}", if active { "▌" } else { "" }); let border_style = if active { @@ -72,9 +81,11 @@ fn render_field(f: &mut Frame, label: &'static str, value: &str, masked: bool, a } else { Style::default().fg(Color::DarkGray) }; - let widget = Paragraph::new(content) - .block(Block::default().title(label).borders(Borders::ALL).border_style(border_style)); - f.render_widget(widget, area); + f.render_widget( + Paragraph::new(content) + .block(Block::default().title(label).borders(Borders::ALL).border_style(border_style)), + area, + ); } // ─── Main screen ────────────────────────────────────────────────────────────── @@ -95,11 +106,6 @@ pub fn draw_main(f: &mut Frame, main: &MainView) { // ── Spaces panel (top-left) ── let spaces_focused = main.focus == Panel::Spaces; let spaces_title = if main.loading_spaces { "Spaces (loading…)" } else { "Spaces" }; - let spaces_border = if spaces_focused { - Style::default().fg(Color::Cyan) - } else { - Style::default().fg(Color::White) - }; let space_items: Vec = if main.spaces.is_empty() && !main.loading_spaces { vec![ListItem::new(Span::styled("No spaces", Style::default().fg(Color::DarkGray)))] @@ -107,21 +113,23 @@ pub fn draw_main(f: &mut Frame, main: &MainView) { main.spaces.iter().map(|s| ListItem::new(s.name.as_str())).collect() }; - let spaces_list = List::new(space_items) - .block(Block::default().title(spaces_title).borders(Borders::ALL).border_style(spaces_border)) - .highlight_style(Style::default().bg(Color::Blue).add_modifier(Modifier::BOLD)) - .highlight_symbol("▶ "); - - f.render_stateful_widget(spaces_list, left_layout[0], &mut list_state(main.selected_space)); + f.render_stateful_widget( + List::new(space_items) + .block( + Block::default() + .title(spaces_title) + .borders(Borders::ALL) + .border_style(panel_border(spaces_focused)), + ) + .highlight_style(Style::default().bg(Color::Blue).add_modifier(Modifier::BOLD)) + .highlight_symbol("▶ "), + left_layout[0], + &mut list_state(main.selected_space), + ); // ── Pages panel (bottom-left) ── let pages_focused = main.focus == Panel::Pages; let pages_title = if main.loading_pages { "Pages (loading…)" } else { "Pages" }; - let pages_border = if pages_focused { - Style::default().fg(Color::Cyan) - } else { - Style::default().fg(Color::White) - }; let page_items: Vec = if main.pages.is_empty() && !main.loading_pages { vec![ListItem::new(Span::styled("No pages", Style::default().fg(Color::DarkGray)))] @@ -132,29 +140,104 @@ pub fn draw_main(f: &mut Frame, main: &MainView) { .collect() }; - let pages_list = List::new(page_items) - .block(Block::default().title(pages_title).borders(Borders::ALL).border_style(pages_border)) - .highlight_style(Style::default().bg(Color::Blue).add_modifier(Modifier::BOLD)) - .highlight_symbol("▶ "); - - f.render_stateful_widget(pages_list, left_layout[1], &mut list_state(main.selected_page)); + f.render_stateful_widget( + List::new(page_items) + .block( + Block::default() + .title(pages_title) + .borders(Borders::ALL) + .border_style(panel_border(pages_focused)), + ) + .highlight_style(Style::default().bg(Color::Blue).add_modifier(Modifier::BOLD)) + .highlight_symbol("▶ "), + left_layout[1], + &mut list_state(main.selected_page), + ); // ── Right panel ── - let right_content = if let Some(err) = &main.error { + let right_content = if main.loading_page_content { + "Loading page content…".to_string() + } else if let Some(err) = &main.error { format!("Error: {err}") } else { let space_name = main.spaces.get(main.selected_space).map(|s| s.name.as_str()).unwrap_or("-"); let page_title = main.pages.get(main.selected_page).and_then(|p| p.title.as_deref()).unwrap_or("-"); - format!("Space: {space_name}\nPage: {page_title}\n\nTab to switch panel\n↑↓/j k to navigate\nq/Esc to quit") + format!( + "Space: {space_name}\nPage: {page_title}\n\nEnter open editor\nTab switch panel\n↑↓/j k navigate\nq/Esc quit" + ) }; - let right = Paragraph::new(right_content) - .block(Block::default().title("Detail").borders(Borders::ALL)); - f.render_widget(right, layout[1]); + f.render_widget( + Paragraph::new(right_content).block(Block::default().title("Info").borders(Borders::ALL)), + layout[1], + ); +} + +// ─── Editor screen ──────────────────────────────────────────────────────────── + +pub fn draw_editor(f: &mut Frame, editor: &EditorView, textarea: &mut TextArea<'static>) { + let size = f.size(); + + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(3), Constraint::Length(1)]) + .split(size); + + // Set textarea block with title + let border_style = if editor.saving { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::Cyan) + }; + + let title = if editor.saving { + format!(" Saving: {} ", editor.page_title) + } else { + format!(" Edit: {} ", editor.page_title) + }; + + textarea.set_block( + Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(border_style), + ); + textarea.set_line_number_style(Style::default().fg(Color::DarkGray)); + textarea.set_cursor_line_style(Style::default().add_modifier(Modifier::UNDERLINED)); + + f.render_widget(textarea.widget(), layout[0]); + + // Status bar + let status_line = match &editor.status { + Some(EditorStatus::Saved) => Line::from(vec![ + Span::styled(" Saved! ", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), + Span::styled(" Ctrl+S: Save · Esc: Back", Style::default().fg(Color::DarkGray)), + ]), + Some(EditorStatus::Error(e)) => Line::from(vec![ + Span::styled( + format!(" Error: {e} "), + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ), + ]), + None => Line::from(Span::styled( + " Ctrl+S: Save · Esc: Back to list", + Style::default().fg(Color::DarkGray), + )), + }; + + f.render_widget(Paragraph::new(status_line), layout[1]); } // ─── Helpers ────────────────────────────────────────────────────────────────── +fn panel_border(focused: bool) -> Style { + if focused { + Style::default().fg(Color::Cyan) + } else { + Style::default().fg(Color::White) + } +} + pub fn list_state(selected: usize) -> ratatui::widgets::ListState { let mut state = ratatui::widgets::ListState::default(); state.select(Some(selected)); diff --git a/todo.txt b/todo.txt new file mode 100644 index 0000000..e69de29