page editor
This commit is contained in:
248
src/main.rs
248
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<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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user