page editor

This commit is contained in:
2026-04-11 14:58:15 -06:00
parent ed34c56e31
commit 5dbba44e10
8 changed files with 599 additions and 120 deletions

130
Cargo.lock generated
View File

@@ -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"

View File

@@ -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"] }

0
docmost.log Normal file
View File

View File

@@ -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<Str
return Err(format!("{status}: {body_text}"));
}
let cookie = resp
.headers()
.get_all("set-cookie")
.iter()
.find_map(|v| {
let s = v.to_str().ok()?;
s.split(';')
.next()
.and_then(|pair| pair.strip_prefix("authToken="))
.map(|t| t.trim().to_string())
});
let cookie = resp.headers().get_all("set-cookie").iter().find_map(|v| {
let s = v.to_str().ok()?;
s.split(';')
.next()
.and_then(|pair| pair.strip_prefix("authToken="))
.map(|t| t.trim().to_string())
});
cookie.ok_or_else(|| "authToken cookie not found in response.".into())
}
pub async fn fetch_spaces(base_url: &str, token: &str) -> Result<Vec<Space>, 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<Vec<Space>, Str
pub async fn fetch_pages(base_url: &str, token: &str, space_id: &str) -> Result<Vec<Page>, 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<String, String> {
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::<Vec<_>>()));
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::<serde_json::Value>(&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(())
}

View File

@@ -37,14 +37,17 @@ impl<T> ApiList<T> {
}
}
// ─── 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<String>,
}
@@ -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<EditorStatus>,
}
#[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<Space>),
PagesLoaded(Vec<Page>),
PageContentLoaded { page_id: String, title: String, content: String },
PageSaved,
SaveError(String),
ApiError(String),
}

View File

@@ -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<dyn std::error::Error>> {
// 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<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;
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<CrosstermBackend<io::Stdout>>) -> 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<String> = 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<CrosstermBackend<io::Stdout>>) -> 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<CrosstermBackend<io::Stdout>>) -> 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<AppMsg>) {
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<AppMsg>) {
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);
});
}

173
src/ui.rs
View File

@@ -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<ListItem> = 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<ListItem> = 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));

0
todo.txt Normal file
View File