first commit
This commit is contained in:
2137
Cargo.lock
generated
Normal file
2137
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
13
Cargo.toml
Normal file
13
Cargo.toml
Normal 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
105
src/api.rs
Normal 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
180
src/app.rs
Normal 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
213
src/main.rs
Normal 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
171
src/ui.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user