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

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