Skip to main content

okf_studio/
lib.rs

1//! # okf-studio: an interactive terminal studio for OKF bundles
2//!
3//! `okf studio` is the *resident* form of the okf engine: one process that
4//! holds a live, continuously re-validated model of a bundle and lets you
5//! navigate, audit, and refactor it interactively.
6//!
7//! The crate is a library with a single entry point, [`run`]; the `okf` CLI's
8//! `studio` subcommand (and `cargo okf studio`) are thin wrappers around it.
9//!
10//! Architecture: Elm-style unidirectional flow. The UI thread owns
11//! [`app::App`] and renders from an immutable [`snapshot::Snapshot`]; a
12//! worker thread performs every load and write; a polling watcher reports
13//! external edits. See the workspace design document for the full picture.
14
15#![forbid(unsafe_code)]
16#![warn(missing_docs)]
17#![warn(clippy::pedantic, clippy::nursery)]
18
19pub mod app;
20pub mod graph;
21pub mod keymap;
22pub mod markdown;
23pub mod search;
24pub mod snapshot;
25pub mod theme;
26pub mod ui;
27pub mod watch;
28pub mod worker;
29
30use app::{App, Command, Msg};
31use crossterm::event::{self, Event};
32use okf_core::Date;
33use std::io::Write as _;
34use std::path::PathBuf;
35use std::process::ExitCode;
36use std::sync::mpsc::channel;
37use std::time::{Duration, Instant};
38
39/// The four studio workspaces.
40#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
41pub enum Tab {
42    /// Tree → document → inspector.
43    #[default]
44    Explorer,
45    /// The link graph.
46    Graph,
47    /// Mission control: trust / staleness / lifecycle.
48    Trust,
49    /// The computations playground.
50    Computations,
51}
52
53impl std::str::FromStr for Tab {
54    type Err = String;
55    fn from_str(s: &str) -> Result<Self, Self::Err> {
56        match s.trim().to_ascii_lowercase().as_str() {
57            "explorer" => Ok(Self::Explorer),
58            "graph" => Ok(Self::Graph),
59            "trust" => Ok(Self::Trust),
60            "computations" | "compute" => Ok(Self::Computations),
61            other => Err(format!(
62                "unknown tab {other:?} (expected explorer|graph|trust|computations)"
63            )),
64        }
65    }
66}
67
68/// Launch options for the studio.
69#[derive(Clone, Debug)]
70pub struct StudioOptions {
71    /// Bundle directory (defaults to `.`, same as every other subcommand).
72    pub root: PathBuf,
73    /// Pin "today" for deterministic staleness display (mirrors `--today`).
74    pub today: Option<Date>,
75    /// Disable the file watcher (single snapshot; useful over slow FS/NFS).
76    pub no_watch: bool,
77    /// Start on a specific workspace tab.
78    pub initial_tab: Option<Tab>,
79    /// Author identity for verification stamps / log entries
80    /// (defaults to [`okf_core::default_author`]).
81    pub author: Option<String>,
82}
83
84impl Default for StudioOptions {
85    fn default() -> Self {
86        Self {
87            root: PathBuf::from("."),
88            today: None,
89            no_watch: false,
90            initial_tab: None,
91            author: None,
92        }
93    }
94}
95
96/// Runs the studio over the bundle at `options.root`. Returns the process
97/// exit code.
98///
99/// # Errors
100///
101/// Returns an [`std::io::Error`] when the terminal cannot be initialized or
102/// drawn to. Bundle problems are *not* errors here: they render inside the
103/// studio, which is the whole point of a permissive loader.
104pub fn run(options: StudioOptions) -> std::io::Result<ExitCode> {
105    let (msg_tx, msg_rx) = channel::<Msg>();
106
107    let mut app = App::new(&options);
108    let _watcher = if options.no_watch {
109        None
110    } else {
111        Some(watch::spawn(
112            options.root.clone(),
113            Duration::from_millis(500),
114            msg_tx.clone(),
115        ))
116    };
117    let worker_tx = worker::spawn(
118        worker::WorkerConfig {
119            root: options.root,
120            today: options.today,
121            author: app.author.clone(),
122        },
123        msg_tx,
124    );
125
126    // Terminal teardown is guaranteed: a panic hook restores the terminal
127    // before the panic message prints (extending the CLI's hook pattern).
128    let default_hook = std::panic::take_hook();
129    std::panic::set_hook(Box::new(move |info| {
130        ratatui::restore();
131        default_hook(info);
132    }));
133    let mut terminal = ratatui::try_init()?;
134
135    let tick = Duration::from_millis(250);
136    let mut last_tick = Instant::now();
137    // Rendering is on-demand: draw after every message (plus the tick), not
138    // on a fixed frame rate. An idle studio consumes ~0% CPU.
139    let mut dirty = true;
140    let result = loop {
141        // Drain worker / watcher messages.
142        while let Ok(msg) = msg_rx.try_recv() {
143            app.update(msg);
144            dirty = true;
145        }
146        if last_tick.elapsed() >= tick {
147            last_tick = Instant::now();
148            app.update(Msg::Tick);
149            dirty = true;
150        }
151
152        // Dispatch requested side effects.
153        for command in app.pending_commands.drain(..) {
154            let _ = worker_tx.send(command);
155        }
156        if let Some(path) = app.editor_request.take() {
157            open_in_editor(&mut terminal, &path)?;
158            app.update(Msg::FilesChanged);
159            dirty = true;
160        }
161        if let Some(text) = app.copy_request.take() {
162            osc52_copy(&text);
163        }
164        if app.should_quit {
165            break Ok(ExitCode::SUCCESS);
166        }
167
168        if dirty {
169            terminal.draw(|frame| ui::shell::draw(frame, &app))?;
170            dirty = false;
171        }
172
173        // Event-driven with a bounded poll so ticks and worker messages
174        // still land promptly; an idle studio costs ~0% CPU.
175        if event::poll(Duration::from_millis(100))? {
176            match event::read()? {
177                Event::Key(key) if key.is_press() => app.update(Msg::Key(key)),
178                Event::Mouse(mouse) => app.update(Msg::Mouse(mouse)),
179                Event::Resize(_, _) => app.update(Msg::Resize),
180                _ => {}
181            }
182            dirty = true;
183        }
184    };
185    let _ = worker_tx.send(Command::Shutdown);
186    ratatui::restore();
187    result
188}
189
190/// Suspends the TUI, runs `$EDITOR` (fallback `vi`) on `path`, and resumes.
191/// This is the studio's escape hatch for free-form body edits.
192fn open_in_editor(
193    terminal: &mut ratatui::DefaultTerminal,
194    path: &std::path::Path,
195) -> std::io::Result<()> {
196    let editor = std::env::var("VISUAL")
197        .or_else(|_| std::env::var("EDITOR"))
198        .unwrap_or_else(|_| "vi".to_string());
199    ratatui::restore();
200    let status = std::process::Command::new(&editor).arg(path).status();
201    // Re-enter the alternate screen whatever the editor did.
202    crossterm::terminal::enable_raw_mode()?;
203    crossterm::execute!(std::io::stdout(), crossterm::terminal::EnterAlternateScreen)?;
204    terminal.clear()?;
205    if let Err(e) = status {
206        // Reported after the terminal is back, so it is visible in-app.
207        return Err(std::io::Error::other(format!(
208            "could not launch editor {editor:?}: {e}"
209        )));
210    }
211    Ok(())
212}
213
214/// Copies text to the system clipboard through the OSC 52 escape sequence —
215/// the handoff artifact for whoever *does* execute a computation.
216fn osc52_copy(text: &str) {
217    let encoded = base64(text.as_bytes());
218    let mut stdout = std::io::stdout();
219    let _ = write!(stdout, "\x1b]52;c;{encoded}\x07");
220    let _ = stdout.flush();
221}
222
223/// Minimal std-only base64 (standard alphabet, padded).
224fn base64(input: &[u8]) -> String {
225    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
226    let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
227    for chunk in input.chunks(3) {
228        let b = [
229            chunk[0],
230            chunk.get(1).copied().unwrap_or(0),
231            chunk.get(2).copied().unwrap_or(0),
232        ];
233        let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
234        out.push(ALPHABET[(n >> 18) as usize & 63] as char);
235        out.push(ALPHABET[(n >> 12) as usize & 63] as char);
236        out.push(if chunk.len() > 1 {
237            ALPHABET[(n >> 6) as usize & 63] as char
238        } else {
239            '='
240        });
241        out.push(if chunk.len() > 2 {
242            ALPHABET[n as usize & 63] as char
243        } else {
244            '='
245        });
246    }
247    out
248}