page editor
This commit is contained in:
130
Cargo.lock
generated
130
Cargo.lock
generated
@@ -2,6 +2,15 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
@@ -198,6 +207,9 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"tui-textarea",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -683,6 +695,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
@@ -737,6 +755,15 @@ dependencies = [
|
||||
"hashbrown 0.15.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
|
||||
dependencies = [
|
||||
"regex-automata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
@@ -789,6 +816,15 @@ dependencies = [
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.1"
|
||||
@@ -986,6 +1022,23 @@ dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
@@ -1199,6 +1252,15 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
@@ -1373,6 +1435,15 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.47"
|
||||
@@ -1527,9 +1598,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
@@ -1537,6 +1620,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"valuable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-log"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
|
||||
dependencies = [
|
||||
"matchers",
|
||||
"nu-ansi-term",
|
||||
"once_cell",
|
||||
"regex-automata",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
"tracing",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1545,6 +1658,17 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "tui-textarea"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3e38ced1f941a9cfc923fbf2fe6858443c42cc5220bfd35bdd3648371e7bd8e"
|
||||
dependencies = [
|
||||
"crossterm",
|
||||
"ratatui",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
@@ -1604,6 +1728,12 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
|
||||
@@ -6,8 +6,11 @@ edition = "2024"
|
||||
[dependencies]
|
||||
ratatui = "0.26"
|
||||
crossterm = "0.27"
|
||||
tui-textarea = { version = "0.4", features = ["crossterm"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
reqwest = { version = "0.12", features = ["json", "cookies"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
|
||||
0
docmost.log
Normal file
0
docmost.log
Normal file
115
src/api.rs
115
src/api.rs
@@ -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,11 +41,7 @@ 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 cookie = resp.headers().get_all("set-cookie").iter().find_map(|v| {
|
||||
let s = v.to_str().ok()?;
|
||||
s.split(';')
|
||||
.next()
|
||||
@@ -56,10 +54,9 @@ pub async fn do_login(base_url: &str, email: &str, password: &str) -> Result<Str
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
38
src/app.rs
38
src/app.rs
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
172
src/main.rs
172
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,7 +205,12 @@ async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::R
|
||||
}
|
||||
}
|
||||
|
||||
AppState::Main => match key.code {
|
||||
// ── Main keys ─────────────────────────────────────────────
|
||||
AppState::Main => {
|
||||
if app.main.loading_page_content {
|
||||
continue;
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
|
||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
return Ok(())
|
||||
@@ -189,18 +247,91 @@ async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::R
|
||||
}
|
||||
}
|
||||
},
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
// All other keys → delegate to textarea
|
||||
_ => {
|
||||
if let Some(ta) = editor_textarea.as_mut() {
|
||||
ta.input(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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();
|
||||
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 {
|
||||
@@ -210,4 +341,3 @@ fn spawn_fetch_pages(app: &App, tx: &mpsc::Sender<AppMsg>) {
|
||||
let _ = tx2.send(msg);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
165
src/ui.rs
165
src/ui.rs
@@ -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()
|
||||
f.render_widget(
|
||||
Block::default()
|
||||
.title(title)
|
||||
.borders(Borders::ALL)
|
||||
.style(Style::default().fg(Color::Cyan));
|
||||
f.render_widget(outer, dialog);
|
||||
.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))
|
||||
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("▶ ");
|
||||
|
||||
f.render_stateful_widget(spaces_list, left_layout[0], &mut list_state(main.selected_space));
|
||||
.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))
|
||||
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("▶ ");
|
||||
|
||||
f.render_stateful_widget(pages_list, left_layout[1], &mut list_state(main.selected_page));
|
||||
.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));
|
||||
|
||||
Reference in New Issue
Block a user