751 lines
36 KiB
Rust
751 lines
36 KiB
Rust
mod api;
|
|
mod app;
|
|
mod ui;
|
|
|
|
use std::fs::OpenOptions;
|
|
use std::{
|
|
io,
|
|
sync::mpsc,
|
|
time::{Duration, Instant},
|
|
};
|
|
|
|
use crossterm::{
|
|
event::{self, Event, KeyCode, KeyModifiers},
|
|
execute,
|
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
|
};
|
|
use ratatui::{backend::CrosstermBackend, Terminal};
|
|
use tui_textarea::TextArea;
|
|
|
|
use tracing::info;
|
|
use tracing_subscriber::fmt;
|
|
|
|
use api::{
|
|
create_page, create_space, delete_page, delete_space, do_login, fetch_page_content,
|
|
fetch_pages, fetch_spaces, save_page, search_pages,
|
|
};
|
|
use app::{
|
|
App, AppMsg, AppState, DeleteConfirmDialog, DeleteTarget, EditorFocus, EditorStatus,
|
|
EditorView, LoginField, NewPageDialog, NewSpaceDialog, Panel, SearchView,
|
|
};
|
|
use ui::{draw_editor, draw_login, draw_main, draw_search};
|
|
|
|
// ─── 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)?;
|
|
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>();
|
|
let mut editor_textarea: Option<TextArea<'static>> = None;
|
|
// Debounce: fire search 300 ms after the last keystroke
|
|
let mut search_debounce: Option<Instant> = None;
|
|
|
|
loop {
|
|
// ── 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;
|
|
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) => {
|
|
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;
|
|
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);
|
|
// Update the title in the pages list immediately
|
|
let saved_id = app.editor.page_id.clone();
|
|
let saved_title = app.editor.page_title.clone();
|
|
if let Some(page) = app.main.pages.iter_mut().find(|p| p.id == saved_id) {
|
|
page.title = Some(saved_title);
|
|
}
|
|
// Refresh pages from server in the background
|
|
spawn_fetch_pages(&app, &tx);
|
|
}
|
|
AppMsg::SaveError(e) => {
|
|
info!("save error: {e}");
|
|
app.editor.saving = false;
|
|
app.editor.status = Some(EditorStatus::Error(e));
|
|
}
|
|
AppMsg::PageCreated { page_id, title } => {
|
|
info!("page created: id={page_id} title={title:?}");
|
|
app.main.new_page_dialog = None;
|
|
// Refresh pages list then open the new page in the editor
|
|
spawn_fetch_pages(&app, &tx);
|
|
app.main.loading_page_content = true;
|
|
let base_url = app.base_url.clone();
|
|
let token = app.token.clone();
|
|
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);
|
|
});
|
|
}
|
|
AppMsg::CreateError(e) => {
|
|
info!("create error: {e}");
|
|
if let Some(d) = app.main.new_page_dialog.as_mut() {
|
|
d.creating = false;
|
|
d.error = Some(e);
|
|
}
|
|
}
|
|
AppMsg::SpaceDeleted(space_id) => {
|
|
info!("space deleted: {space_id}");
|
|
app.main.delete_dialog = None;
|
|
app.main.spaces.retain(|s| s.id != space_id);
|
|
app.main.selected_space = 0;
|
|
app.main.pages = vec![];
|
|
if let Some(space) = app.main.spaces.first() {
|
|
app.main.loading_pages = true;
|
|
spawn_fetch_pages_msg(&app.base_url, &app.token, &space.id, &tx);
|
|
}
|
|
}
|
|
AppMsg::PageDeleted(page_id) => {
|
|
info!("page deleted: {page_id}");
|
|
app.main.delete_dialog = None;
|
|
app.main.pages.retain(|p| p.id != page_id);
|
|
if app.main.selected_page >= app.main.pages.len() {
|
|
app.main.selected_page = app.main.pages.len().saturating_sub(1);
|
|
}
|
|
}
|
|
AppMsg::DeleteError(e) => {
|
|
info!("delete error: {e}");
|
|
if let Some(d) = app.main.delete_dialog.as_mut() {
|
|
d.deleting = false;
|
|
d.error = Some(e);
|
|
}
|
|
}
|
|
AppMsg::SpaceCreated(space) => {
|
|
info!("space created: id={} name={:?}", space.id, space.name);
|
|
app.main.new_space_dialog = None;
|
|
// Add to list and select it
|
|
app.main.spaces.push(space);
|
|
app.main.selected_space = app.main.spaces.len() - 1;
|
|
app.main.pages = vec![];
|
|
app.main.loading_pages = false;
|
|
}
|
|
AppMsg::CreateSpaceError(e) => {
|
|
info!("create space error: {e}");
|
|
if let Some(d) = app.main.new_space_dialog.as_mut() {
|
|
d.creating = false;
|
|
d.error = Some(e);
|
|
}
|
|
}
|
|
AppMsg::SearchResults(results) => {
|
|
info!("search results: {} items", results.len());
|
|
app.search.loading = false;
|
|
app.search.results = results;
|
|
app.search.selected = 0;
|
|
app.search.error = None;
|
|
}
|
|
AppMsg::SearchError(e) => {
|
|
info!("search error: {e}");
|
|
app.search.loading = false;
|
|
app.search.error = Some(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.search.opening_page = false;
|
|
app.main.error = Some(e);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Debounce: fire search after 300 ms of silence ─────────────────
|
|
if let Some(t) = search_debounce {
|
|
if t.elapsed() >= Duration::from_millis(300) {
|
|
search_debounce = None;
|
|
if !app.search.query.is_empty() {
|
|
app.search.loading = true;
|
|
app.search.error = None;
|
|
let base_url = app.base_url.clone();
|
|
let token = app.token.clone();
|
|
let query = app.search.query.clone();
|
|
let tx2 = tx.clone();
|
|
tokio::spawn(async move {
|
|
let msg = match search_pages(&base_url, &token, &query).await {
|
|
Ok(results) => AppMsg::SearchResults(results),
|
|
Err(e) => AppMsg::SearchError(e),
|
|
};
|
|
let _ = tx2.send(msg);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 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);
|
|
}
|
|
}
|
|
AppState::Search => draw_search(f, &app.search),
|
|
})?;
|
|
|
|
if !event::poll(Duration::from_millis(200))? {
|
|
continue;
|
|
}
|
|
|
|
if let Event::Key(key) = event::read()? {
|
|
match app.state {
|
|
// ── Login keys ────────────────────────────────────────────
|
|
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);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
// ── Main keys ─────────────────────────────────────────────
|
|
AppState::Main => {
|
|
if app.main.loading_page_content {
|
|
continue;
|
|
}
|
|
|
|
// ── Delete confirm dialog intercepts all keys when open ──
|
|
if let Some(dialog) = app.main.delete_dialog.as_mut() {
|
|
if dialog.deleting {
|
|
continue;
|
|
}
|
|
match key.code {
|
|
KeyCode::Esc => {
|
|
app.main.delete_dialog = None;
|
|
}
|
|
KeyCode::Char('d') => {
|
|
dialog.deleting = true;
|
|
dialog.error = None;
|
|
let base_url = app.base_url.clone();
|
|
let token = app.token.clone();
|
|
let target = dialog.target.clone();
|
|
let tx2 = tx.clone();
|
|
tokio::spawn(async move {
|
|
let msg = match &target {
|
|
DeleteTarget::Space { id, .. } => {
|
|
match delete_space(&base_url, &token, id).await {
|
|
Ok(()) => AppMsg::SpaceDeleted(id.clone()),
|
|
Err(e) => AppMsg::DeleteError(e),
|
|
}
|
|
}
|
|
DeleteTarget::Page { id, .. } => {
|
|
match delete_page(&base_url, &token, id).await {
|
|
Ok(()) => AppMsg::PageDeleted(id.clone()),
|
|
Err(e) => AppMsg::DeleteError(e),
|
|
}
|
|
}
|
|
};
|
|
let _ = tx2.send(msg);
|
|
});
|
|
}
|
|
_ => {}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// ── New-space dialog intercepts all keys when open ──
|
|
if let Some(dialog) = app.main.new_space_dialog.as_mut() {
|
|
if dialog.creating {
|
|
continue;
|
|
}
|
|
match key.code {
|
|
KeyCode::Esc => {
|
|
app.main.new_space_dialog = None;
|
|
}
|
|
KeyCode::Backspace => {
|
|
dialog.name.pop();
|
|
}
|
|
KeyCode::Enter => {
|
|
if dialog.name.trim().is_empty() {
|
|
dialog.error = Some("Name cannot be empty.".into());
|
|
} else {
|
|
dialog.creating = true;
|
|
dialog.error = None;
|
|
let base_url = app.base_url.clone();
|
|
let token = app.token.clone();
|
|
let name = dialog.name.trim().to_string();
|
|
let tx2 = tx.clone();
|
|
tokio::spawn(async move {
|
|
let msg = match create_space(&base_url, &token, &name).await
|
|
{
|
|
Ok(space) => AppMsg::SpaceCreated(space),
|
|
Err(e) => AppMsg::CreateSpaceError(e),
|
|
};
|
|
let _ = tx2.send(msg);
|
|
});
|
|
}
|
|
}
|
|
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
dialog.error = None;
|
|
dialog.name.push(c);
|
|
}
|
|
_ => {}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// ── New-page dialog intercepts all keys when open ──
|
|
if let Some(dialog) = app.main.new_page_dialog.as_mut() {
|
|
if dialog.creating {
|
|
continue;
|
|
}
|
|
match key.code {
|
|
KeyCode::Esc => {
|
|
app.main.new_page_dialog = None;
|
|
}
|
|
KeyCode::Backspace => {
|
|
dialog.title.pop();
|
|
}
|
|
KeyCode::Enter => {
|
|
if dialog.title.trim().is_empty() {
|
|
dialog.error = Some("Title cannot be empty.".into());
|
|
} else {
|
|
dialog.creating = true;
|
|
dialog.error = None;
|
|
let base_url = app.base_url.clone();
|
|
let token = app.token.clone();
|
|
let title = dialog.title.trim().to_string();
|
|
let space_id = app
|
|
.main
|
|
.spaces
|
|
.get(app.main.selected_space)
|
|
.map(|s| s.id.clone())
|
|
.unwrap_or_default();
|
|
let tx2 = tx.clone();
|
|
tokio::spawn(async move {
|
|
let msg =
|
|
match create_page(&base_url, &token, &space_id, &title)
|
|
.await
|
|
{
|
|
Ok((page_id, title)) => {
|
|
AppMsg::PageCreated { page_id, title }
|
|
}
|
|
Err(e) => AppMsg::CreateError(e),
|
|
};
|
|
let _ = tx2.send(msg);
|
|
});
|
|
}
|
|
}
|
|
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
dialog.error = None;
|
|
dialog.title.push(c);
|
|
}
|
|
_ => {}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
},
|
|
// d → delete focused item
|
|
KeyCode::Char('d') => {
|
|
let target = match app.main.focus {
|
|
Panel::Spaces => {
|
|
app.main.spaces.get(app.main.selected_space).map(|s| {
|
|
DeleteTarget::Space {
|
|
id: s.id.clone(),
|
|
name: s.name.clone(),
|
|
}
|
|
})
|
|
}
|
|
Panel::Pages => {
|
|
app.main.pages.get(app.main.selected_page).map(|p| {
|
|
DeleteTarget::Page {
|
|
id: p.id.clone(),
|
|
title: p
|
|
.title
|
|
.clone()
|
|
.unwrap_or_else(|| "Untitled".into()),
|
|
}
|
|
})
|
|
}
|
|
};
|
|
if let Some(target) = target {
|
|
app.main.delete_dialog = Some(DeleteConfirmDialog::new(target));
|
|
}
|
|
}
|
|
// Ctrl+N → new space
|
|
KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
app.main.new_space_dialog = Some(NewSpaceDialog::new());
|
|
}
|
|
// n → new page in current space
|
|
KeyCode::Char('n') => {
|
|
if !app.main.spaces.is_empty() {
|
|
app.main.new_page_dialog = Some(NewPageDialog::new());
|
|
}
|
|
}
|
|
// / or Ctrl+F → open search
|
|
KeyCode::Char('/') | KeyCode::Char('f')
|
|
if key.code == KeyCode::Char('/')
|
|
|| key.modifiers.contains(KeyModifiers::CONTROL) =>
|
|
{
|
|
app.search = SearchView::new();
|
|
search_debounce = None;
|
|
app.state = AppState::Search;
|
|
}
|
|
// 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);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
// ── Search keys ──────────────────────────────────────────
|
|
AppState::Search => {
|
|
if app.search.opening_page {
|
|
continue;
|
|
}
|
|
match (key.code, key.modifiers) {
|
|
(KeyCode::Esc, _) => {
|
|
app.state = AppState::Main;
|
|
search_debounce = None;
|
|
}
|
|
(KeyCode::Backspace, _) => {
|
|
app.search.query.pop();
|
|
search_debounce = Some(Instant::now());
|
|
if app.search.query.is_empty() {
|
|
app.search.results.clear();
|
|
app.search.loading = false;
|
|
}
|
|
}
|
|
(KeyCode::Down, _) | (KeyCode::Char('j'), _) => {
|
|
if app.search.selected + 1 < app.search.results.len() {
|
|
app.search.selected += 1;
|
|
}
|
|
}
|
|
(KeyCode::Up, _) | (KeyCode::Char('k'), _) => {
|
|
if app.search.selected > 0 {
|
|
app.search.selected -= 1;
|
|
}
|
|
}
|
|
// Enter → open selected result in editor
|
|
(KeyCode::Enter, _) => {
|
|
if let Some(result) = app.search.results.get(app.search.selected) {
|
|
app.search.opening_page = true;
|
|
let base_url = app.base_url.clone();
|
|
let token = app.token.clone();
|
|
let page_id = result.id.clone();
|
|
let title =
|
|
result.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);
|
|
});
|
|
}
|
|
}
|
|
// Any printable char → append to query
|
|
(KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
|
|
app.search.query.push(c);
|
|
search_debounce = Some(Instant::now());
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
// ── Editor keys ───────────────────────────────────────────
|
|
AppState::Editor => {
|
|
// Clear "Saved!" 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 both title and content
|
|
(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 title = app.editor.page_title.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, &title, &content,
|
|
)
|
|
.await
|
|
{
|
|
Ok(()) => AppMsg::PageSaved,
|
|
Err(e) => AppMsg::SaveError(e),
|
|
};
|
|
let _ = tx2.send(msg);
|
|
});
|
|
}
|
|
}
|
|
// Tab → toggle focus between title and content
|
|
(KeyCode::Tab, _) => {
|
|
app.editor.focus = match app.editor.focus {
|
|
EditorFocus::Title => EditorFocus::Content,
|
|
EditorFocus::Content => EditorFocus::Title,
|
|
};
|
|
}
|
|
// Title field keys
|
|
(KeyCode::Backspace, _) if app.editor.focus == EditorFocus::Title => {
|
|
app.editor.page_title.pop();
|
|
}
|
|
(KeyCode::Char(c), m)
|
|
if app.editor.focus == EditorFocus::Title
|
|
&& !m.contains(KeyModifiers::CONTROL) =>
|
|
{
|
|
app.editor.page_title.push(c);
|
|
}
|
|
// All other keys → delegate to content textarea
|
|
_ => {
|
|
if app.editor.focus == EditorFocus::Content {
|
|
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) {
|
|
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);
|
|
});
|
|
}
|