first commit

This commit is contained in:
2026-04-11 14:11:11 -06:00
commit ed34c56e31
6 changed files with 2819 additions and 0 deletions

2137
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

13
Cargo.toml Normal file
View File

@@ -0,0 +1,13 @@
[package]
name = "docmost-rust"
version = "0.1.0"
edition = "2024"
[dependencies]
ratatui = "0.26"
crossterm = "0.27"
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json", "cookies"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

105
src/api.rs Normal file
View File

@@ -0,0 +1,105 @@
use std::time::Duration;
use reqwest::Client;
use crate::app::{ApiList, LoginRequest, Page, SidebarPagesRequest, Space};
pub fn auth_client() -> Result<Client, String> {
Client::builder()
.timeout(Duration::from_secs(10))
.build()
.map_err(|e| e.to_string())
}
pub async fn do_login(base_url: &str, email: &str, password: &str) -> Result<String, String> {
let client = Client::builder()
.timeout(Duration::from_secs(10))
.cookie_store(true)
.build()
.map_err(|e| e.to_string())?;
let url = format!("{base_url}/auth/login");
let resp = client
.post(&url)
.json(&LoginRequest { email, password })
.send()
.await
.map_err(|e| format!("Connection error: {e}"))?;
let status = resp.status();
if !status.is_success() {
let body_text = resp.text().await.unwrap_or_default();
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&body_text) {
if let Some(msg) = v.get("message").and_then(|m| m.as_str()) {
return Err(msg.to_string());
}
}
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())
});
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)
.header("Cookie", format!("authToken={token}"))
.json(&serde_json::json!({}))
.send()
.await
.map_err(|e| format!("Connection error: {e}"))?;
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
if !status.is_success() {
return Err(format!("Spaces error {status}: {body}"));
}
let list: ApiList<Space> =
serde_json::from_str(&body).map_err(|e| format!("Parse error: {e}\n{body}"))?;
Ok(list.into_items())
}
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)
.header("Cookie", format!("authToken={token}"))
.json(&SidebarPagesRequest { space_id })
.send()
.await
.map_err(|e| format!("Connection error: {e}"))?;
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
if !status.is_success() {
return Err(format!("Pages error {status}: {body}"));
}
let list: ApiList<Page> =
serde_json::from_str(&body).map_err(|e| format!("Parse error: {e}\n{body}"))?;
Ok(list.into_items())
}

180
src/app.rs Normal file
View File

@@ -0,0 +1,180 @@
use serde::{Deserialize, Serialize};
// ─── Domain types ─────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Deserialize)]
pub struct Space {
pub id: String,
pub name: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Page {
pub id: String,
pub title: Option<String>,
}
#[derive(Deserialize)]
pub struct DataWrapper<T> {
pub items: Vec<T>,
}
#[derive(Deserialize)]
#[serde(untagged)]
pub enum ApiList<T> {
Wrapped { data: DataWrapper<T> },
Paginated { items: Vec<T> },
Plain(Vec<T>),
}
impl<T> ApiList<T> {
pub fn into_items(self) -> Vec<T> {
match self {
ApiList::Wrapped { data } => data.items,
ApiList::Paginated { items } => items,
ApiList::Plain(v) => v,
}
}
}
// ─── Login form ───────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum AppState {
Login,
Main,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum LoginField {
Url,
Email,
Password,
}
impl LoginField {
pub fn next(self) -> Self {
match self {
LoginField::Url => LoginField::Email,
LoginField::Email => LoginField::Password,
LoginField::Password => LoginField::Url,
}
}
pub fn prev(self) -> Self {
match self {
LoginField::Url => LoginField::Password,
LoginField::Email => LoginField::Url,
LoginField::Password => LoginField::Email,
}
}
}
pub struct LoginForm {
pub url: String,
pub email: String,
pub password: String,
pub active_field: LoginField,
pub error: Option<String>,
pub submitting: bool,
}
impl LoginForm {
pub fn new() -> Self {
Self {
url: String::from("https://docmost.nakano47.com"),
email: String::from("chamagua1@proton.me"),
password: String::new(),
active_field: LoginField::Email,
error: None,
submitting: false,
}
}
pub fn active_field_value_mut(&mut self) -> &mut String {
match self.active_field {
LoginField::Url => &mut self.url,
LoginField::Email => &mut self.email,
LoginField::Password => &mut self.password,
}
}
}
// ─── Main view ────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Panel {
Spaces,
Pages,
}
pub struct MainView {
pub spaces: Vec<Space>,
pub pages: Vec<Page>,
pub selected_space: usize,
pub selected_page: usize,
pub focus: Panel,
pub loading_spaces: bool,
pub loading_pages: bool,
pub error: Option<String>,
}
impl MainView {
pub fn new() -> Self {
Self {
spaces: vec![],
pages: vec![],
selected_space: 0,
selected_page: 0,
focus: Panel::Spaces,
loading_spaces: true,
loading_pages: false,
error: None,
}
}
}
// ─── Root app ─────────────────────────────────────────────────────────────────
pub struct App {
pub state: AppState,
pub login: LoginForm,
pub main: MainView,
pub base_url: String,
pub token: String,
}
impl App {
pub fn new() -> Self {
Self {
state: AppState::Login,
login: LoginForm::new(),
main: MainView::new(),
base_url: String::new(),
token: String::new(),
}
}
}
// ─── Messages ─────────────────────────────────────────────────────────────────
pub enum AppMsg {
LoginSuccess { token: String, base_url: String },
LoginError(String),
SpacesLoaded(Vec<Space>),
PagesLoaded(Vec<Page>),
ApiError(String),
}
// ─── Request DTOs ─────────────────────────────────────────────────────────────
#[derive(Serialize)]
pub struct LoginRequest<'a> {
pub email: &'a str,
pub password: &'a str,
}
#[derive(Serialize)]
pub struct SidebarPagesRequest<'a> {
#[serde(rename = "spaceId")]
pub space_id: &'a str,
}

213
src/main.rs Normal file
View File

@@ -0,0 +1,213 @@
mod api;
mod app;
mod ui;
use std::{io, sync::mpsc, time::Duration};
use crossterm::{
event::{self, Event, KeyCode, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
use api::{do_login, fetch_pages, fetch_spaces};
use app::{App, AppMsg, AppState, LoginField, Panel};
use ui::{draw_login, draw_main};
// ─── Entry point ──────────────────────────────────────────────────────────────
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let res = run_app(&mut terminal).await;
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{err:?}");
}
Ok(())
}
// ─── App loop ─────────────────────────────────────────────────────────────────
async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<()> {
let mut app = App::new();
let (tx, rx) = mpsc::channel::<AppMsg>();
loop {
// Drain async messages
while let Ok(msg) = rx.try_recv() {
match msg {
AppMsg::LoginSuccess { token, base_url } => {
app.token = token.clone();
app.base_url = base_url.clone();
app.state = AppState::Main;
app.login.submitting = false;
let tx2 = tx.clone();
tokio::spawn(async move {
let msg = match fetch_spaces(&base_url, &token).await {
Ok(spaces) => AppMsg::SpacesLoaded(spaces),
Err(e) => AppMsg::ApiError(e),
};
let _ = tx2.send(msg);
});
}
AppMsg::LoginError(e) => {
app.login.error = Some(e);
app.login.submitting = false;
}
AppMsg::SpacesLoaded(spaces) => {
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() {
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);
});
}
}
AppMsg::PagesLoaded(pages) => {
app.main.loading_pages = false;
app.main.pages = pages;
app.main.selected_page = 0;
}
AppMsg::ApiError(e) => {
app.main.loading_spaces = false;
app.main.loading_pages = false;
app.main.error = Some(e);
}
}
}
terminal.draw(|f| match app.state {
AppState::Login => draw_login(f, &app.login),
AppState::Main => draw_main(f, &app.main),
})?;
if !event::poll(Duration::from_millis(50))? {
continue;
}
if let Event::Key(key) = event::read()? {
match app.state {
AppState::Login => {
if app.login.submitting {
continue;
}
match key.code {
KeyCode::Esc => return Ok(()),
KeyCode::Tab => app.login.active_field = app.login.active_field.next(),
KeyCode::BackTab => app.login.active_field = app.login.active_field.prev(),
KeyCode::Up => app.login.active_field = app.login.active_field.prev(),
KeyCode::Down => app.login.active_field = app.login.active_field.next(),
KeyCode::Enter => {
if app.login.active_field != LoginField::Password {
app.login.active_field = app.login.active_field.next();
} else {
app.login.error = None;
app.login.submitting = true;
let url = app.login.url.trim_end_matches('/').to_string();
let email = app.login.email.clone();
let password = app.login.password.clone();
let tx2 = tx.clone();
tokio::spawn(async move {
let msg = match do_login(&url, &email, &password).await {
Ok(token) => AppMsg::LoginSuccess { token, base_url: url },
Err(e) => AppMsg::LoginError(e),
};
let _ = tx2.send(msg);
});
}
}
KeyCode::Backspace => {
app.login.active_field_value_mut().pop();
}
KeyCode::Char(c) => {
if key.modifiers.contains(KeyModifiers::CONTROL) && c == 'c' {
return Ok(());
}
app.login.active_field_value_mut().push(c);
}
_ => {}
}
}
AppState::Main => 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;
}
}
},
_ => {}
},
}
}
}
}
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);
});
}
}

171
src/ui.rs Normal file
View File

@@ -0,0 +1,171 @@
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
Frame,
};
use crate::app::{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);
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);
let inner = Rect {
x: dialog.x + 2,
y: dialog.y + 1,
width: dialog.width.saturating_sub(4),
height: dialog.height.saturating_sub(2),
};
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(1),
Constraint::Length(3),
])
.split(inner);
render_field(f, "Server URL", &login.url, false, login.active_field == LoginField::Url, rows[0]);
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(
format!(" {err}"),
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)]))
.alignment(Alignment::Left)
} else {
Paragraph::new(Line::from(vec![Span::styled(
" Tab/↑↓ switch field · Enter submit · Esc quit",
Style::default().fg(Color::DarkGray),
)]))
.alignment(Alignment::Left)
};
f.render_widget(bottom, rows[4]);
}
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 {
Style::default().fg(Color::Cyan)
} 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);
}
// ─── Main screen ──────────────────────────────────────────────────────────────
pub fn draw_main(f: &mut Frame, main: &MainView) {
let size = f.size();
let layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
.split(size);
let left_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(layout[0]);
// ── 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)))]
} else {
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));
// ── 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)))]
} else {
main.pages
.iter()
.map(|p| ListItem::new(p.title.as_deref().unwrap_or("Untitled")))
.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));
// ── Right panel ──
let right_content = 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")
};
let right = Paragraph::new(right_content)
.block(Block::default().title("Detail").borders(Borders::ALL));
f.render_widget(right, layout[1]);
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
pub fn list_state(selected: usize) -> ratatui::widgets::ListState {
let mut state = ratatui::widgets::ListState::default();
state.select(Some(selected));
state
}
pub fn centered_rect(width: u16, height: u16, r: Rect) -> Rect {
Rect {
x: r.x + r.width.saturating_sub(width) / 2,
y: r.y + r.height.saturating_sub(height) / 2,
width: width.min(r.width),
height: height.min(r.height),
}
}