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

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);
});
}