Skip to main content

onenote_cli/tui/
mod.rs

1//! Interactive notebook browser backed by the same desktop or SSH profile as the CLI.
2mod editor;
3mod view;
4
5use crate::{cli::PageArgs, config::Profile, desktop, error::Error, model, write};
6use editor::Editor;
7use ratatui::{
8    Terminal,
9    backend::CrosstermBackend,
10    crossterm::{
11        cursor::Show,
12        event::{
13            self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyEvent,
14            KeyEventKind, KeyModifiers,
15        },
16        execute,
17        terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
18    },
19};
20use serde_json::{Value, json};
21use std::{
22    collections::HashMap,
23    io::{self, IsTerminal},
24    time::Duration,
25};
26use tokio::task::JoinHandle;
27
28const PAGE_SIZE: u16 = 50;
29
30struct TerminalGuard;
31impl TerminalGuard {
32    fn enter() -> Result<Self, Error> {
33        enable_raw_mode()?;
34        let guard = Self;
35        execute!(io::stdout(), EnterAlternateScreen, EnableBracketedPaste)?;
36        Ok(guard)
37    }
38}
39impl Drop for TerminalGuard {
40    fn drop(&mut self) {
41        let _ = disable_raw_mode();
42        let _ = execute!(
43            io::stdout(),
44            DisableBracketedPaste,
45            LeaveAlternateScreen,
46            Show
47        );
48    }
49}
50
51#[derive(Clone, Debug, PartialEq, Eq)]
52struct Entry {
53    id: String,
54    name: String,
55    path: String,
56    locked: bool,
57    modified: Option<String>,
58}
59impl Entry {
60    fn from_value(value: &Value) -> Option<Self> {
61        Some(Self {
62            id: value["id"].as_str()?.to_owned(),
63            name: value["name"].as_str().unwrap_or("Untitled").to_owned(),
64            path: value["path"].as_str().unwrap_or("").to_owned(),
65            locked: value["locked"].as_bool().unwrap_or(false),
66            modified: value["last_modified"].as_str().map(str::to_owned),
67        })
68    }
69}
70#[derive(Clone, Debug, PartialEq, Eq)]
71enum Location {
72    Notebooks,
73    Sections(Entry),
74    Pages { notebook: Entry, section: Entry },
75    Search { query: String, scan: bool },
76}
77impl Location {
78    fn key(&self) -> String {
79        match self {
80            Self::Notebooks => "notebooks".into(),
81            Self::Sections(book) => format!("sections:{}", book.id),
82            Self::Pages { section, .. } => format!("pages:{}", section.id),
83            Self::Search { query, scan } => format!("search:{scan}:{query}"),
84        }
85    }
86    fn label(&self) -> String {
87        match self {
88            Self::Notebooks => "Notebooks".into(),
89            Self::Sections(book) => format!("{}  /  Sections", book.name),
90            Self::Pages { notebook, section } => format!("{}  /  {}", notebook.name, section.name),
91            Self::Search { query, scan } => format!(
92                "{} search  /  {query}",
93                if *scan { "Literal" } else { "Indexed" }
94            ),
95        }
96    }
97    fn heading(&self) -> &'static str {
98        match self {
99            Self::Notebooks => "NOTEBOOKS",
100            Self::Sections(_) => "SECTIONS",
101            Self::Pages { .. } => "PAGES",
102            Self::Search { .. } => "SEARCH RESULTS",
103        }
104    }
105}
106#[derive(Clone)]
107enum Request {
108    List {
109        location: Location,
110        offset: u32,
111    },
112    Read {
113        page: Entry,
114    },
115    Write {
116        kind: WriteKind,
117        target: String,
118        title: Option<String>,
119        text: String,
120    },
121}
122#[derive(Clone, Copy, Debug, PartialEq, Eq)]
123enum WriteKind {
124    Create,
125    Append,
126}
127impl WriteKind {
128    fn operation(self) -> &'static str {
129        match self {
130            Self::Create => "create",
131            Self::Append => "append",
132        }
133    }
134}
135enum Response {
136    List {
137        rows: Vec<Entry>,
138        total: usize,
139        next: Option<u32>,
140        warning: Option<String>,
141    },
142    Read(Value),
143    Written(Value),
144}
145struct Job {
146    request: Request,
147    task: JoinHandle<Result<Response, Error>>,
148}
149impl Drop for Job {
150    fn drop(&mut self) {
151        self.task.abort();
152    }
153}
154
155#[derive(Clone, Copy, PartialEq, Eq)]
156enum InputKind {
157    Indexed,
158    Literal,
159    Filter,
160}
161struct Compose {
162    kind: WriteKind,
163    target: String,
164    target_name: String,
165    title: Editor,
166    body: Editor,
167    title_focus: bool,
168    review_scroll: u16,
169}
170impl Compose {
171    fn new(
172        kind: WriteKind,
173        target: String,
174        target_name: String,
175        old: Option<(String, String)>,
176    ) -> Self {
177        let (title, body) = old.unwrap_or_default();
178        Self {
179            kind,
180            target,
181            target_name,
182            title: Editor::new(title, false),
183            body: Editor::new(body, true),
184            title_focus: kind == WriteKind::Create,
185            review_scroll: 0,
186        }
187    }
188    fn active(&mut self) -> &mut Editor {
189        if self.title_focus {
190            &mut self.title
191        } else {
192            &mut self.body
193        }
194    }
195    fn draft(&self) -> (String, String) {
196        (self.title.text().to_owned(), self.body.text().to_owned())
197    }
198}
199enum Overlay {
200    None,
201    Help,
202    Input { kind: InputKind, editor: Editor },
203    Compose(Compose),
204    Review(Compose),
205}
206struct App {
207    profile_name: String,
208    profile: Profile,
209    color: bool,
210    location: Location,
211    return_to: Option<Location>,
212    rows: Vec<Entry>,
213    selected: usize,
214    filter: String,
215    total: usize,
216    next: Option<u32>,
217    page: Option<Value>,
218    page_scroll: u16,
219    page_max_scroll: u16,
220    full_page: bool,
221    loading: bool,
222    writing: bool,
223    notice: String,
224    error: Option<String>,
225    uncertain: bool,
226    drafts: HashMap<String, (String, String)>,
227    selections: HashMap<String, String>,
228    overlay: Overlay,
229}
230impl App {
231    fn new(profile: Profile, name: Option<&str>, color: bool) -> Self {
232        Self {
233            profile_name: name.unwrap_or("default").into(),
234            profile,
235            color,
236            location: Location::Notebooks,
237            return_to: None,
238            rows: vec![],
239            selected: 0,
240            filter: String::new(),
241            total: 0,
242            next: None,
243            page: None,
244            page_scroll: 0,
245            page_max_scroll: 0,
246            full_page: false,
247            loading: false,
248            writing: false,
249            notice: "Choose a notebook · press ? for keys".into(),
250            error: None,
251            uncertain: false,
252            drafts: HashMap::new(),
253            selections: HashMap::new(),
254            overlay: Overlay::None,
255        }
256    }
257    fn selected_entry(&self) -> Option<&Entry> {
258        self.rows.get(self.selected)
259    }
260    fn visible_indices(&self) -> Vec<usize> {
261        let needle = self.filter.to_lowercase();
262        self.rows
263            .iter()
264            .enumerate()
265            .filter(|(_, row)| {
266                needle.is_empty()
267                    || row.name.to_lowercase().contains(&needle)
268                    || row.path.to_lowercase().contains(&needle)
269            })
270            .map(|(i, _)| i)
271            .collect()
272    }
273    fn move_selection(&mut self, delta: isize) -> Action {
274        let visible = self.visible_indices();
275        if visible.is_empty() {
276            return Action::None;
277        }
278        let current = visible
279            .iter()
280            .position(|i| *i == self.selected)
281            .unwrap_or(0);
282        let next = (current as isize + delta).clamp(0, visible.len() as isize - 1) as usize;
283        if self.selected == visible[next] {
284            return Action::None;
285        }
286        self.selected = visible[next];
287        self.page = None;
288        self.full_page = false;
289        self.page_scroll = 0;
290        self.read_request().map_or(Action::None, Action::Fetch)
291    }
292    fn read_request(&self) -> Option<Request> {
293        if matches!(
294            self.location,
295            Location::Pages { .. } | Location::Search { .. }
296        ) {
297            self.selected_entry()
298                .cloned()
299                .map(|page| Request::Read { page })
300        } else {
301            None
302        }
303    }
304    fn navigate(&mut self, location: Location) -> Action {
305        if let Some(row) = self.selected_entry() {
306            self.selections.insert(self.location.key(), row.id.clone());
307        }
308        self.location = location.clone();
309        self.rows.clear();
310        self.selected = 0;
311        self.total = 0;
312        self.next = None;
313        self.page = None;
314        self.page_scroll = 0;
315        self.page_max_scroll = 0;
316        self.full_page = false;
317        self.filter.clear();
318        self.error = None;
319        Action::Fetch(Request::List {
320            location,
321            offset: 0,
322        })
323    }
324    fn back(&mut self) -> Action {
325        if self.full_page {
326            self.full_page = false;
327            return Action::None;
328        }
329        match &self.location {
330            Location::Notebooks => Action::None,
331            Location::Sections(_) => self.navigate(Location::Notebooks),
332            Location::Pages { notebook, .. } => self.navigate(Location::Sections(notebook.clone())),
333            Location::Search { .. } => {
334                let location = self.return_to.take().unwrap_or(Location::Notebooks);
335                self.navigate(location)
336            }
337        }
338    }
339    fn enter(&mut self) -> Action {
340        let Some(row) = self.selected_entry().cloned() else {
341            return Action::None;
342        };
343        match &self.location {
344            Location::Notebooks => self.navigate(Location::Sections(row)),
345            Location::Sections(book) => {
346                if row.locked {
347                    self.notice = "Section is locked · unlock it in OneNote desktop".into();
348                    Action::None
349                } else {
350                    self.navigate(Location::Pages {
351                        notebook: book.clone(),
352                        section: row,
353                    })
354                }
355            }
356            Location::Pages { .. } | Location::Search { .. } => {
357                self.full_page = true;
358                if self.page.as_ref().and_then(|v| v["id"].as_str()) == Some(row.id.as_str()) {
359                    Action::None
360                } else {
361                    Action::Fetch(Request::Read { page: row })
362                }
363            }
364        }
365    }
366    fn complete(&mut self, request: &Request, result: Result<Response, Error>) -> Option<Request> {
367        self.loading = false;
368        self.writing = false;
369        let response = match result {
370            Ok(response) => {
371                self.error = None;
372                response
373            }
374            Err(error) => {
375                self.error = Some(error.message);
376                if matches!(request, Request::Write { .. }) {
377                    self.uncertain = true;
378                    self.notice =
379                        "Write result unknown · inspect OneNote before another write".into();
380                } else {
381                    self.notice = "Request failed · r retry".into();
382                }
383                return None;
384            }
385        };
386        match (request, response) {
387            (
388                Request::List { location, offset },
389                Response::List {
390                    rows,
391                    total,
392                    next,
393                    warning,
394                },
395            ) if self.location == *location => {
396                if *offset == 0 {
397                    self.rows.clear();
398                    self.selected = 0;
399                    self.page = None;
400                }
401                for row in rows {
402                    if !self.rows.iter().any(|old| old.id == row.id) {
403                        self.rows.push(row);
404                    }
405                }
406                self.total = total;
407                self.next = next;
408                if *offset == 0
409                    && let Some(saved) = self.selections.get(&location.key())
410                    && let Some(index) = self.rows.iter().position(|row| &row.id == saved)
411                {
412                    self.selected = index;
413                }
414                self.notice = warning.unwrap_or_else(|| {
415                    format!(
416                        "{} of {} {} loaded",
417                        self.rows.len(),
418                        total,
419                        location.heading().to_lowercase()
420                    )
421                });
422                if *offset == 0 {
423                    return self.read_request();
424                }
425            }
426            (Request::Read { page }, Response::Read(value))
427                if self.selected_entry().is_some_and(|row| row.id == page.id) =>
428            {
429                self.page = Some(value);
430                self.page_scroll = 0;
431                self.page_max_scroll = 0;
432                self.notice = format!("{} · ready", page.name);
433            }
434            (
435                Request::Write {
436                    kind,
437                    target,
438                    title,
439                    ..
440                },
441                Response::Written(value),
442            ) => {
443                self.drafts
444                    .remove(&format!("{}:{target}", kind.operation()));
445                if *kind == WriteKind::Create
446                    && let Some(row) = Entry::from_value(&value)
447                {
448                    self.rows.retain(|old| old.id != row.id);
449                    self.rows.insert(0, row);
450                    self.selected = 0;
451                    self.total += 1;
452                    self.filter.clear();
453                }
454                if *kind == WriteKind::Append
455                    && let Some(index) = self.rows.iter().position(|row| row.id == *target)
456                {
457                    self.selected = index;
458                }
459                self.page = Some(value);
460                self.page_scroll = 0;
461                self.page_max_scroll = 0;
462                self.notice = match kind {
463                    WriteKind::Create => format!(
464                        "Created {} · verified in OneNote",
465                        title.as_deref().unwrap_or("page")
466                    ),
467                    WriteKind::Append => "Append verified in OneNote".into(),
468                };
469            }
470            _ => {}
471        }
472        None
473    }
474    fn key(&mut self, key: KeyEvent) -> Action {
475        if self.writing {
476            return Action::None;
477        }
478        if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
479            return Action::Quit;
480        }
481        match std::mem::replace(&mut self.overlay, Overlay::None) {
482            Overlay::Help => return Action::None,
483            Overlay::Input { kind, mut editor } => {
484                if key.code == KeyCode::Esc {
485                    return Action::None;
486                }
487                if key.code == KeyCode::Enter {
488                    let text = editor.text().trim().to_owned();
489                    match kind {
490                        InputKind::Filter => {
491                            self.filter = text;
492                            if let Some(first) = self.visible_indices().first() {
493                                self.selected = *first;
494                                self.page = None;
495                                return self.read_request().map_or(Action::None, Action::Fetch);
496                            }
497                            return Action::None;
498                        }
499                        InputKind::Indexed | InputKind::Literal if !text.is_empty() => {
500                            if !matches!(self.location, Location::Search { .. }) {
501                                self.return_to = Some(self.location.clone());
502                            }
503                            return self.navigate(Location::Search {
504                                query: text,
505                                scan: kind == InputKind::Literal,
506                            });
507                        }
508                        _ => {}
509                    }
510                }
511                editor.key(key);
512                self.overlay = Overlay::Input { kind, editor };
513                return Action::None;
514            }
515            Overlay::Compose(mut draft) => {
516                if key.code == KeyCode::Esc {
517                    self.drafts.insert(
518                        format!("{}:{}", draft.kind.operation(), draft.target),
519                        draft.draft(),
520                    );
521                    self.notice = "Draft kept for this session".into();
522                    return Action::None;
523                }
524                if key.code == KeyCode::Tab {
525                    draft.title_focus = !draft.title_focus && draft.kind == WriteKind::Create;
526                    self.overlay = Overlay::Compose(draft);
527                    return Action::None;
528                }
529                if key.modifiers.contains(KeyModifiers::CONTROL)
530                    && matches!(key.code, KeyCode::Char('s') | KeyCode::Enter)
531                {
532                    if draft.kind == WriteKind::Create
533                        && let Err(e) = write::validate(draft.title.text(), true)
534                    {
535                        self.error = Some(e.message);
536                        self.overlay = Overlay::Compose(draft);
537                        return Action::None;
538                    }
539                    if let Err(e) = write::validate(draft.body.text(), false) {
540                        self.error = Some(e.message);
541                        self.overlay = Overlay::Compose(draft);
542                        return Action::None;
543                    }
544                    self.error = None;
545                    self.overlay = Overlay::Review(draft);
546                    return Action::None;
547                }
548                draft.active().key(key);
549                self.error = None;
550                self.overlay = Overlay::Compose(draft);
551                return Action::None;
552            }
553            Overlay::Review(mut draft) => {
554                match key.code {
555                    KeyCode::Esc => {
556                        self.overlay = Overlay::Compose(draft);
557                        return Action::None;
558                    }
559                    KeyCode::Enter => {
560                        let title = if draft.kind == WriteKind::Create {
561                            Some(draft.title.text().to_owned())
562                        } else {
563                            None
564                        };
565                        let text = draft.body.text().to_owned();
566                        self.drafts.insert(
567                            format!("{}:{}", draft.kind.operation(), draft.target),
568                            draft.draft(),
569                        );
570                        return Action::Fetch(Request::Write {
571                            kind: draft.kind,
572                            target: draft.target,
573                            title,
574                            text,
575                        });
576                    }
577                    KeyCode::Down | KeyCode::Char('j') => {
578                        draft.review_scroll = draft.review_scroll.saturating_add(1)
579                    }
580                    KeyCode::Up | KeyCode::Char('k') => {
581                        draft.review_scroll = draft.review_scroll.saturating_sub(1)
582                    }
583                    KeyCode::PageDown => {
584                        draft.review_scroll = draft.review_scroll.saturating_add(8)
585                    }
586                    KeyCode::PageUp => draft.review_scroll = draft.review_scroll.saturating_sub(8),
587                    KeyCode::Home => draft.review_scroll = 0,
588                    KeyCode::End => draft.review_scroll = u16::MAX,
589                    _ => {}
590                }
591                self.overlay = Overlay::Review(draft);
592                return Action::None;
593            }
594            Overlay::None => {}
595        }
596        match key.code {
597            KeyCode::Char('q') => Action::Quit,
598            KeyCode::Char('?') => {
599                self.overlay = Overlay::Help;
600                Action::None
601            }
602            KeyCode::Char('j') | KeyCode::Down => self.move_selection(1),
603            KeyCode::Char('k') | KeyCode::Up => self.move_selection(-1),
604            KeyCode::Enter | KeyCode::Right => self.enter(),
605            KeyCode::Esc | KeyCode::Left | KeyCode::Backspace => {
606                if self.full_page {
607                    self.back()
608                } else if !self.filter.is_empty() {
609                    self.filter.clear();
610                    Action::None
611                } else {
612                    self.back()
613                }
614            }
615            KeyCode::PageDown => {
616                self.page_scroll = self.page_scroll.saturating_add(8).min(self.page_max_scroll);
617                Action::None
618            }
619            KeyCode::PageUp => {
620                self.page_scroll = self.page_scroll.saturating_sub(8);
621                Action::None
622            }
623            KeyCode::Home => {
624                self.page_scroll = 0;
625                Action::None
626            }
627            KeyCode::End => {
628                self.page_scroll = self.page_max_scroll;
629                Action::None
630            }
631            KeyCode::Char('r') => Action::Fetch(Request::List {
632                location: self.location.clone(),
633                offset: 0,
634            }),
635            KeyCode::Char('n') if self.next.is_some() && !self.loading => {
636                Action::Fetch(Request::List {
637                    location: self.location.clone(),
638                    offset: self.next.unwrap(),
639                })
640            }
641            KeyCode::Char('/') => {
642                self.overlay = Overlay::Input {
643                    kind: InputKind::Indexed,
644                    editor: Editor::new(String::new(), false),
645                };
646                Action::None
647            }
648            KeyCode::Char('s') => {
649                self.overlay = Overlay::Input {
650                    kind: InputKind::Literal,
651                    editor: Editor::new(String::new(), false),
652                };
653                Action::None
654            }
655            KeyCode::Char('f') => {
656                self.overlay = Overlay::Input {
657                    kind: InputKind::Filter,
658                    editor: Editor::new(self.filter.clone(), false),
659                };
660                Action::None
661            }
662            KeyCode::Char('b') => self.navigate(Location::Notebooks),
663            KeyCode::Char('c') if !self.profile.read_only && !self.uncertain => {
664                if let Location::Pages { section, .. } = &self.location {
665                    let target = section.id.clone();
666                    let name = section.name.clone();
667                    let old = self.drafts.remove(&format!("create:{target}"));
668                    self.overlay =
669                        Overlay::Compose(Compose::new(WriteKind::Create, target, name, old));
670                } else {
671                    self.notice = "Open a section to create a page".into();
672                }
673                Action::None
674            }
675            KeyCode::Char('a') if !self.profile.read_only && !self.uncertain => {
676                if let Some(page) = self
677                    .selected_entry()
678                    .filter(|_| {
679                        matches!(
680                            self.location,
681                            Location::Pages { .. } | Location::Search { .. }
682                        )
683                    })
684                    .cloned()
685                {
686                    let target = page.id.clone();
687                    let old = self.drafts.remove(&format!("append:{target}"));
688                    self.overlay =
689                        Overlay::Compose(Compose::new(WriteKind::Append, target, page.name, old));
690                } else {
691                    self.notice = "Select a page to append text".into();
692                }
693                Action::None
694            }
695            KeyCode::Char('c' | 'a') if self.profile.read_only => {
696                self.notice = "This profile is read-only".into();
697                Action::None
698            }
699            KeyCode::Char('c' | 'a') if self.uncertain => {
700                self.notice = "Write outcome unknown · inspect OneNote before another write".into();
701                Action::None
702            }
703            _ => Action::None,
704        }
705    }
706    fn paste(&mut self, text: &str) {
707        let truncated = match &mut self.overlay {
708            Overlay::Input { editor, .. } => editor.paste(text),
709            Overlay::Compose(draft) => draft.active().paste(text),
710            _ => false,
711        };
712        if truncated {
713            self.error =
714                Some("Paste reached the editor limit; review the text before continuing".into());
715        }
716    }
717}
718#[allow(clippy::large_enum_variant)] // A navigation request carries two small hierarchy records.
719enum Action {
720    None,
721    Fetch(Request),
722    Quit,
723}
724
725fn request_start(profile: Profile, request: Request, app: &mut App) -> Job {
726    app.loading = true;
727    app.writing = matches!(request, Request::Write { .. });
728    app.error = None;
729    app.notice = match &request {
730        Request::List { .. } => "Loading from OneNote…",
731        Request::Read { .. } => "Reading page…",
732        Request::Write { .. } => "Writing to OneNote · please wait…",
733    }
734    .into();
735    let task_request = request.clone();
736    let task = tokio::spawn(async move {
737        match task_request {
738            Request::List { location, offset } => {
739                let page = PageArgs {
740                    limit: PAGE_SIZE,
741                    offset,
742                    fields: vec![],
743                };
744                let value = match &location {
745                    Location::Notebooks => model::collection(
746                        &desktop::run(&profile, json!({"operation":"notebooks"})).await?,
747                        "Notebook",
748                        &page,
749                    )?,
750                    Location::Sections(book) => model::collection(
751                        &desktop::run(&profile, json!({"operation":"sections","scope":book.id}))
752                            .await?,
753                        "Section",
754                        &page,
755                    )?,
756                    Location::Pages { section, .. } => model::collection(
757                        &desktop::run(&profile, json!({"operation":"pages","scope":section.id}))
758                            .await?,
759                        "Page",
760                        &page,
761                    )?,
762                    Location::Search { query, scan: false } => model::search(
763                        &desktop::run(&profile, json!({"operation":"search","query":query}))
764                            .await?,
765                        &page,
766                    )?,
767                    Location::Search { query, scan: true } => {
768                        let batch = desktop::scan(&profile, json!({"operation":"scan","scope":null,"scan_limit":100,"scan_offset":0})).await?;
769                        model::scan(&batch, query, &page)?
770                    }
771                };
772                let rows = value["items"]
773                    .as_array()
774                    .map(|v| v.iter().filter_map(Entry::from_value).collect())
775                    .unwrap_or_default();
776                let warning = if value["indexing_pending"] == true {
777                    Some(format!(
778                        "Search index is still updating · {} pages not indexed",
779                        value["unindexed_count"]
780                    ))
781                } else if value["incomplete"] == true {
782                    Some(
783                        "Literal scan incomplete · refine your query or use the CLI for more"
784                            .into(),
785                    )
786                } else {
787                    None
788                };
789                Ok(Response::List {
790                    rows,
791                    total: value["total"].as_u64().unwrap_or(0) as usize,
792                    next: value["next_offset"].as_u64().map(|n| n as u32),
793                    warning,
794                })
795            }
796            Request::Read { page } => {
797                let xml = desktop::run(&profile, json!({"operation":"read","id":page.id})).await?;
798                Ok(Response::Read(model::read(&xml, &page.id, false)?))
799            }
800            Request::Write {
801                kind,
802                target,
803                title,
804                text,
805            } => {
806                let args = crate::cli::ContentArgs {
807                    text: Some(text),
808                    file: None,
809                    dry_run: false,
810                };
811                let payload = write::prepare(kind.operation(), &target, title.as_deref(), &args)?;
812                let value = desktop::write(&profile, payload).await?;
813                let id = value["id"].as_str().ok_or_else(|| {
814                    Error::new("write_uncertain", "Write response has no page ID")
815                })?;
816                let xml = value["xml"].as_str().ok_or_else(|| {
817                    Error::new("write_uncertain", "Write response has no page content")
818                })?;
819                if value["verified"] != true {
820                    return Err(Error::new(
821                        "write_uncertain",
822                        "OneNote did not verify the write",
823                    ));
824                }
825                Ok(Response::Written(
826                    model::read(xml, id, false)
827                        .map_err(|e| Error::new("write_uncertain", e.message))?,
828                ))
829            }
830        }
831    });
832    Job { request, task }
833}
834
835pub async fn run(profile: Profile, name: Option<&str>, color: bool) -> Result<(), Error> {
836    if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
837        return Err(Error::invalid("`onenote tui` needs a terminal"));
838    }
839    let color = color
840        && std::env::var_os("NO_COLOR").is_none()
841        && std::env::var("TERM").as_deref() != Ok("dumb");
842    let mut app = App::new(profile.clone(), name, color);
843    let _guard = TerminalGuard::enter()?;
844    let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?;
845    let mut job = Some(request_start(
846        profile.clone(),
847        Request::List {
848            location: Location::Notebooks,
849            offset: 0,
850        },
851        &mut app,
852    ));
853    let mut dirty = true;
854    loop {
855        if job.as_ref().is_some_and(|j| j.task.is_finished()) {
856            let mut done = job.take().unwrap();
857            let result = (&mut done.task)
858                .await
859                .unwrap_or_else(|e| Err(Error::desktop(format!("Request stopped: {e}"))));
860            if let Some(next) = app.complete(&done.request, result) {
861                job = Some(request_start(profile.clone(), next, &mut app));
862            }
863            dirty = true;
864        }
865        if dirty {
866            terminal.draw(|frame| view::draw(frame, &mut app))?;
867            dirty = false;
868        }
869        if event::poll(Duration::from_millis(40))? {
870            dirty = true;
871            let action = match event::read()? {
872                Event::Key(key)
873                    if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) =>
874                {
875                    app.key(key)
876                }
877                Event::Paste(text) => {
878                    app.paste(&text);
879                    Action::None
880                }
881                _ => Action::None,
882            };
883            match action {
884                Action::Quit => break,
885                Action::None => {}
886                Action::Fetch(request) => {
887                    drop(job.take());
888                    job = Some(request_start(profile.clone(), request, &mut app));
889                }
890            }
891        }
892        tokio::task::yield_now().await;
893    }
894    Ok(())
895}
896
897#[cfg(test)]
898mod tests {
899    use super::*;
900    use ratatui::{Terminal, backend::TestBackend};
901    fn row(id: &str, name: &str) -> Entry {
902        Entry {
903            id: id.into(),
904            name: name.into(),
905            path: name.into(),
906            locked: false,
907            modified: None,
908        }
909    }
910    fn key(code: KeyCode) -> KeyEvent {
911        KeyEvent::new(code, KeyModifiers::NONE)
912    }
913    #[test]
914    fn drill_down_and_back_preserve_hierarchy() {
915        let mut app = App::new(Profile::default(), None, false);
916        app.rows = vec![row("book", "Notebook")];
917        assert!(matches!(
918            app.key(key(KeyCode::Enter)),
919            Action::Fetch(Request::List {
920                location: Location::Sections(_),
921                ..
922            })
923        ));
924        app.rows = vec![row("first", "First"), row("section", "Ideas")];
925        app.selected = 1;
926        assert!(matches!(
927            app.key(key(KeyCode::Enter)),
928            Action::Fetch(Request::List {
929                location: Location::Pages { .. },
930                ..
931            })
932        ));
933        assert!(matches!(
934            app.key(key(KeyCode::Esc)),
935            Action::Fetch(Request::List {
936                location: Location::Sections(_),
937                ..
938            })
939        ));
940        let location = app.location.clone();
941        app.complete(
942            &Request::List {
943                location,
944                offset: 0,
945            },
946            Ok(Response::List {
947                rows: vec![row("first", "First"), row("section", "Ideas")],
948                total: 2,
949                next: None,
950                warning: None,
951            }),
952        );
953        assert_eq!(
954            app.selected_entry().map(|row| row.id.as_str()),
955            Some("section")
956        );
957    }
958    #[test]
959    fn writes_keep_captured_target_and_require_review() {
960        let mut app = App::new(Profile::default(), None, false);
961        app.location = Location::Pages {
962            notebook: row("book", "Notebook"),
963            section: row("section", "Ideas"),
964        };
965        app.rows = vec![row("page-1", "First"), row("page-2", "Second")];
966        app.key(key(KeyCode::Char('a')));
967        app.selected = 1;
968        app.paste("Safe note");
969        app.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
970        assert!(matches!(app.overlay, Overlay::Review(_)));
971        assert!(
972            matches!(app.key(key(KeyCode::Enter)), Action::Fetch(Request::Write { target, text, .. }) if target == "page-1" && text == "Safe note")
973        );
974    }
975    #[test]
976    fn read_only_blocks_compose() {
977        let profile = Profile {
978            read_only: true,
979            ..Profile::default()
980        };
981        let mut app = App::new(profile, None, false);
982        app.location = Location::Pages {
983            notebook: row("book", "Notebook"),
984            section: row("section", "Ideas"),
985        };
986        app.rows.push(row("page", "First"));
987        app.key(key(KeyCode::Char('a')));
988        assert!(matches!(app.overlay, Overlay::None));
989    }
990    #[test]
991    fn verified_create_is_visible_and_uncertain_write_blocks_retries() {
992        let mut app = App::new(Profile::default(), None, false);
993        app.location = Location::Pages {
994            notebook: row("book", "Notebook"),
995            section: row("section", "Ideas"),
996        };
997        app.complete(
998            &Request::Write {
999                kind: WriteKind::Create,
1000                target: "section".into(),
1001                title: Some("New".into()),
1002                text: "body".into(),
1003            },
1004            Ok(Response::Written(
1005                json!({"id":"page","name":"New","path":"Notebook / Ideas / New","markdown":"body"}),
1006            )),
1007        );
1008        assert_eq!(
1009            app.selected_entry().map(|row| row.name.as_str()),
1010            Some("New")
1011        );
1012        assert_eq!(app.notice, "Created New · verified in OneNote");
1013        app.complete(
1014            &Request::Write {
1015                kind: WriteKind::Append,
1016                target: "page".into(),
1017                title: None,
1018                text: "again".into(),
1019            },
1020            Err(Error::new("write_uncertain", "Connection lost")),
1021        );
1022        app.key(key(KeyCode::Char('a')));
1023        assert!(app.uncertain);
1024        assert!(matches!(app.overlay, Overlay::None));
1025    }
1026    #[test]
1027    fn layouts_render_at_compact_medium_and_wide_sizes() {
1028        let mut app = App::new(Profile::default(), None, false);
1029        app.rows.push(row("book", "Notebook"));
1030        app.total = 1;
1031        for (width, height) in [(42, 12), (80, 24), (120, 35)] {
1032            let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
1033            terminal.draw(|frame| view::draw(frame, &mut app)).unwrap();
1034            let screen: String = terminal
1035                .backend()
1036                .buffer()
1037                .content()
1038                .iter()
1039                .map(|cell| cell.symbol())
1040                .collect();
1041            assert!(screen.contains("ONENOTE"));
1042            assert!(screen.contains("Notebook"));
1043        }
1044    }
1045    #[test]
1046    fn long_write_review_can_reach_the_final_lines() {
1047        let mut app = App::new(Profile::default(), None, false);
1048        let mut draft = Compose::new(WriteKind::Append, "page".into(), "Page".into(), None);
1049        draft.body.paste(
1050            &(1..=35)
1051                .map(|n| format!("Line {n:02}"))
1052                .collect::<Vec<_>>()
1053                .join("\n"),
1054        );
1055        app.overlay = Overlay::Review(draft);
1056        app.key(key(KeyCode::End));
1057        let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
1058        terminal.draw(|frame| view::draw(frame, &mut app)).unwrap();
1059        let screen: String = terminal
1060            .backend()
1061            .buffer()
1062            .content()
1063            .iter()
1064            .map(|cell| cell.symbol())
1065            .collect();
1066        assert!(screen.contains("Line 35"));
1067        assert!(
1068            matches!(app.key(key(KeyCode::Enter)), Action::Fetch(Request::Write { text, .. }) if text.ends_with("Line 35"))
1069        );
1070    }
1071    #[test]
1072    fn wrapped_editor_keeps_cursor_visible_on_compact_terminal() {
1073        let mut app = App::new(Profile::default(), None, false);
1074        let mut draft = Compose::new(WriteKind::Append, "page".into(), "Page".into(), None);
1075        draft.body.paste(&"long word ".repeat(45));
1076        app.overlay = Overlay::Compose(draft);
1077        let mut terminal = Terminal::new(TestBackend::new(42, 12)).unwrap();
1078        terminal.draw(|frame| view::draw(frame, &mut app)).unwrap();
1079        let screen: String = terminal
1080            .backend()
1081            .buffer()
1082            .content()
1083            .iter()
1084            .map(|cell| cell.symbol())
1085            .collect();
1086        assert!(screen.contains('▏'));
1087    }
1088    #[test]
1089    fn escape_leaves_full_page_before_clearing_filter() {
1090        let mut app = App::new(Profile::default(), None, false);
1091        app.location = Location::Pages {
1092            notebook: row("book", "Notebook"),
1093            section: row("section", "Ideas"),
1094        };
1095        app.rows.push(row("page", "Page"));
1096        app.filter = "Page".into();
1097        app.full_page = true;
1098        assert!(matches!(app.key(key(KeyCode::Esc)), Action::None));
1099        assert!(!app.full_page);
1100        assert_eq!(app.filter, "Page");
1101    }
1102}