edamame 0.1.2

A fast TUI Markdown editor and viewer
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
//! External-editor lifecycle extracted from `app.rs` in Step 2 of
//! `refactor-app.md`.
//!
//! Owns:
//! - [`ExternalEditorOutcome`] reporting whether the editor process
//!   actually ran.
//! - The shared suspend/resume dance in [`App::run_external_editor`].
//! - Two App entry points, one for `config.toml` and one for the
//!   current buffer's file, that wrap the shared helper with
//!   pre-launch save and post-exit reload semantics.
//! - [`App::spawn_open_worker`] for OS-handler fallbacks (URLs and
//!   non-Markdown local files).

use std::io::Stdout;
use std::path::Path;
use std::sync::atomic::Ordering;
use std::sync::mpsc;
use std::time::Duration;

use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;

use crate::app::modal;
use crate::config;
use crate::config::{Config, KeyMap, Theme};
use crate::terminal::ColorDepth;
use crate::ui::ModalKind;

use super::flash::MessageKind;
use super::theme_fallback;
use super::{App, AppEvent};

/// Result of [`App::run_external_editor`].  Tells the caller whether
/// the editor actually ran so it can decide whether a post-exit
/// reload is appropriate.
pub(super) enum ExternalEditorOutcome {
    /// `$VISUAL` / `$EDITOR` was unset; the path was handed to the
    /// OS handler via `open::that`.  No suspend happened.
    OsHandler,
    /// The TUI couldn't be suspended.  An error was already flashed.
    SuspendFailed,
    /// The editor process ran (or failed to launch) — here's the
    /// outcome.
    Exited(std::io::Result<std::process::ExitStatus>),
}

impl App {
    /// Open `config.toml` in the user's text editor and reload the
    /// config when the editor exits.  Prefers `$VISUAL` over
    /// `$EDITOR` (the modern shell convention); falls back to
    /// `open::that` (which delegates to the OS GUI handler) when
    /// neither variable is set.
    ///
    /// When a shell editor is invoked we need to surrender the
    /// terminal entirely: leave the alternate screen, drop raw mode,
    /// disable mouse capture, etc., so the editor can talk to the
    /// real TTY.  Once the editor exits we re-enter the TUI and
    /// force a full redraw.
    ///
    /// `terminal` is borrowed mutably so we can call
    /// [`Terminal::clear`] after re-entry — without this, ratatui's
    /// in-memory buffer thinks the screen still holds whatever it
    /// drew before suspension and skips redrawing unchanged cells.
    pub(super) fn open_config_in_editor(
        &mut self,
        terminal: &mut Terminal<CrosstermBackend<Stdout>>,
        rx: &mpsc::Receiver<AppEvent>,
    ) {
        // `--no-config` rules the config directory out of this session
        // in both directions, and this one action would cross it twice:
        // the seeding save below writes `config.toml`, and the reload
        // after the editor exits would pull the user's real settings
        // into a run started specifically to exclude them.  Refuse the
        // whole flow rather than half-honour it.
        if !config::config_writes_allowed() {
            self.notify(
                "The config file is not in use while --no-config is in effect",
                ModalKind::Warning,
            );
            return;
        }
        let Some(path) = Config::config_path() else {
            self.notify("No config directory available", ModalKind::Error);
            return;
        };
        // Make sure the file exists before we hand it to an editor
        // that might fail on a missing path.  `Config::save`
        // serialises the in-memory config — same content the user
        // would see if they navigated to the file via the file
        // manager.
        if !path.exists() {
            if let Err(e) = self.config.save() {
                tracing::warn!(error = %e, "failed to seed config.toml before editor launch");
                self.notify(format!("Config save failed: {e}"), ModalKind::Error);
                return;
            }
        }

        let outcome = self.run_external_editor(&path, terminal, rx);

        // Reload the config from disk so any edits the user made
        // are reflected in the running session.  Failures fall back
        // to the in-memory state with a warning — the user can
        // restart edamame to retry.  Run the reload regardless of
        // whether the editor actually launched: if we fell back to
        // the OS handler the user might still have edited the file.
        //
        // Any non-fatal warnings (parse error, unknown keys, invalid
        // keybinding entries) returned by `Config::load` are routed
        // into the same `ConfigWarningModal` we use at startup so the
        // user sees their typo as soon as they exit the editor.
        let truecolor = self.capabilities.color_depth == ColorDepth::TrueColor;
        // `persist_fallback = false`: the user just hand-edited
        // `config.toml` in their `$EDITOR`.  If they set `theme` to a
        // name whose file isn't on disk yet (typical "install the
        // theme later" workflow), we surface the warning but must
        // NOT silently rewrite the file they just saved seconds ago
        // — that would fight their stated intent.  At startup the
        // tradeoff goes the other way; see `Config::load` for the
        // rationale.
        // Captured before the reload replaces `self.config`: it tells
        // us whether a substitution that fires below is *new* (the user
        // just hand-edited `theme` to something this terminal can't
        // render) or the same one they already acknowledged at startup.
        // Only the former is worth a modal.
        let previous_downgrade = self.config.theme_downgraded_from.clone();
        match Config::load(truecolor, false) {
            Ok(loaded) => {
                self.config = loaded.config;
                self.keybindings = loaded.keybindings;
                // Rebuild the keymap so any keybinding edits take
                // effect for the next keystroke.
                match KeyMap::build(&self.keybindings) {
                    Ok(km) => self.keymap = Some(km),
                    Err(e) => {
                        tracing::warn!(error = %e, "rebuilt KeyMap failed after editor exit");
                    }
                }
                // Live-apply the theme so a `theme = "..."` edit in
                // the external editor takes effect without a
                // restart.  Uses the already-loaded `ThemeFile` so
                // we don't read the theme TOML twice.
                //
                // Re-apply the indexed-color substitution first: the
                // reload just replaced `config.theme` with whatever is
                // on disk, which on a terminal without 24-bit color is
                // exactly the palette we swapped away from at startup.
                // Without this, exiting `$EDITOR` would repaint the
                // session unreadable.
                let mut theme_file = loaded.theme;
                if let Some(d) = theme_fallback::apply(&mut self.config, &self.capabilities) {
                    theme_file = d.theme_file;
                    if previous_downgrade.as_deref() != Some(d.configured.as_str()) {
                        self.modal_stack
                            .push(Box::new(modal::ThemeDowngradeModal::new(
                                d.configured,
                                d.substituted,
                            )));
                        self.needs_draw = true;
                    }
                }
                let monochrome = self.capabilities.color_depth == ColorDepth::NoColor;
                let new_theme: &'static Theme =
                    Box::leak(Box::new(Theme::from_file(&theme_file, monochrome)));
                self.theme = new_theme;
                self.editor.set_theme(new_theme);
                // Everything else the editor reads out of `Config`, via
                // the single site `App::new` and the document-swap path
                // already share.  The reload replaced `self.config`
                // wholesale, so without this an edit to `big_h1`,
                // `syntax_highlighting`, `cursor_blink` or
                // `table.row_striping` in the very file the user just
                // saved would have no effect until the next launch —
                // silently, since the flash still says "Configuration
                // updated".  Theme and keybindings were live-applied
                // above precisely because someone noticed them; this
                // covers the rest of the family, and any future member
                // of it, without a fourth list to keep in sync.
                let (images_on, diagrams_on) =
                    (self.images_layout_enabled(), self.diagrams_layout_enabled());
                super::configure_new_editor(&mut self.editor, &self.config, images_on, diagrams_on);
                if let Some(modal) = modal::ConfigWarningModal::from_warnings(&loaded.warnings) {
                    self.modal_stack.push(Box::new(modal));
                    self.needs_draw = true;
                }
            }
            Err(e) => {
                tracing::warn!(error = %e, "failed to reload config after editor exit");
            }
        }

        match outcome {
            ExternalEditorOutcome::Exited(Ok(s)) if s.success() => {
                self.flash("Configuration updated", MessageKind::Success);
            }
            ExternalEditorOutcome::Exited(Ok(s)) => {
                // Non-zero exit is often deliberate (`:cq` in Vim, signal,
                // editor abort) — surface it as a passing hint rather than
                // a blocking modal.
                self.flash(format!("Editor exited {s}"), MessageKind::Info);
            }
            ExternalEditorOutcome::Exited(Err(e)) => {
                self.notify(format!("Editor failed: {e}"), ModalKind::Error);
            }
            // Suspend failure / OS-handler fallback already flashed
            // their own status — no extra message here.
            ExternalEditorOutcome::SuspendFailed | ExternalEditorOutcome::OsHandler => {}
        }
    }

    /// Save the current buffer (best-effort) and open it in the
    /// user's `$VISUAL` / `$EDITOR`.  After the editor exits the
    /// buffer is reloaded from disk so external edits are picked up
    /// — without this, subsequent saves from edamame would silently
    /// overwrite work done in the other editor.  Falls back to the
    /// OS handler when no shell editor is set; same flow the
    /// settings overlay uses for `config.toml`.
    pub(super) fn open_current_file_in_editor(
        &mut self,
        terminal: &mut Terminal<CrosstermBackend<Stdout>>,
        rx: &mpsc::Receiver<AppEvent>,
    ) {
        let Some(path) = self.editor.buffer.path().map(|p| p.to_path_buf()) else {
            self.notify("No file path for buffer", ModalKind::Error);
            return;
        };

        // Save first so the external editor sees the in-memory state.
        if self.editor.dirty {
            if let Err(e) = self.save_buffer() {
                tracing::warn!(error = %e, "failed to save buffer before editor launch");
                self.notify(format!("Save failed: {e}"), ModalKind::Error);
                return;
            }
        }

        let outcome = self.run_external_editor(&path, terminal, rx);

        // Reload the buffer from disk so any external edits are
        // reflected.  Skipped on suspend failure (terminal is in a
        // degraded state already) and on the OS-handler fallback
        // (the OS handler returns immediately and the user may not
        // have closed the file yet — reloading prematurely would
        // discard their in-edamame edits while they're still
        // working).
        if matches!(outcome, ExternalEditorOutcome::Exited(_)) {
            if let Err(e) = self.load_file_into_editor(path) {
                tracing::warn!(error = %e, "failed to reload buffer after editor exit");
                self.notify(format!("Reload failed: {e}"), ModalKind::Error);
                return;
            }
        }

        match outcome {
            ExternalEditorOutcome::Exited(Ok(s)) if s.success() => {
                self.flash("File reloaded", MessageKind::Success);
            }
            ExternalEditorOutcome::Exited(Ok(s)) => {
                self.flash(format!("Editor exited {s}"), MessageKind::Info);
            }
            ExternalEditorOutcome::Exited(Err(e)) => {
                self.notify(format!("Editor failed: {e}"), ModalKind::Error);
            }
            ExternalEditorOutcome::SuspendFailed | ExternalEditorOutcome::OsHandler => {}
        }
    }

    /// Open a theme `.toml` in the user's `$VISUAL` / `$EDITOR`, then
    /// reload the active theme so any edits take effect immediately.
    /// Mirrors [`Self::open_config_in_editor`] but scoped to a single
    /// theme file — the success modal pushed after
    /// `Action::CreateCustomTheme` routes here.
    pub(super) fn open_theme_in_editor(
        &mut self,
        path: &Path,
        terminal: &mut Terminal<CrosstermBackend<Stdout>>,
        rx: &mpsc::Receiver<AppEvent>,
    ) {
        if !path.exists() {
            self.notify(
                format!("Theme file no longer exists: {}", path.display()),
                ModalKind::Error,
            );
            return;
        }
        let outcome = self.run_external_editor(path, terminal, rx);

        match outcome {
            ExternalEditorOutcome::Exited(Ok(s)) if s.success() => {
                self.apply_active_theme();
                self.flash("Theme reloaded", MessageKind::Success);
            }
            ExternalEditorOutcome::Exited(Ok(s)) => {
                self.apply_active_theme();
                self.flash(format!("Editor exited {s}"), MessageKind::Info);
            }
            ExternalEditorOutcome::Exited(Err(e)) => {
                self.notify(format!("Editor failed: {e}"), ModalKind::Error);
            }
            ExternalEditorOutcome::SuspendFailed | ExternalEditorOutcome::OsHandler => {}
        }
    }

    /// Suspend the TUI, run an external editor on `path`, and resume.
    /// Shared between the settings-overlay "Open config.toml" flow
    /// and the palette "Open current file in system editor" flow:
    /// both need the same read-thread / terminal dance around
    /// `Command::status()`.  The caller is responsible for any
    /// pre-launch save / post-exit reload — this helper only owns
    /// the suspend / resume window.
    pub(super) fn run_external_editor(
        &mut self,
        path: &Path,
        terminal: &mut Terminal<CrosstermBackend<Stdout>>,
        rx: &mpsc::Receiver<AppEvent>,
    ) -> ExternalEditorOutcome {
        let editor = std::env::var("VISUAL")
            .ok()
            .filter(|s| !s.trim().is_empty())
            .or_else(|| {
                std::env::var("EDITOR")
                    .ok()
                    .filter(|s| !s.trim().is_empty())
            });

        let Some(editor) = editor else {
            // No shell editor — fall back to the OS handler.  This
            // is the same path link-following uses, so the
            // user sees consistent behaviour whether $EDITOR is set
            // or not.
            self.spawn_open_worker(path.display().to_string());
            self.flash("Opening with system default", MessageKind::Info);
            return ExternalEditorOutcome::OsHandler;
        };

        // Pause our crossterm read thread so the editor has
        // uncontested access to stdin.  Without this, our thread
        // and the editor both call `read()` on the same fd: bytes
        // get split between them, the editor sees corrupted input
        // (the `1;rgb:...` artifact users reported was the OSC 11
        // background-color response that neovim queried for, with
        // some bytes stolen by us), and keystrokes feel laggy
        // because half of them never reach the editor.
        if let Some(p) = self.read_paused.as_ref() {
            p.store(true, Ordering::Release);
        }
        // Drop the filesystem watch for the duration of the external
        // editor: `unwatch()` un-registers the parent directory from
        // notify *and* clears the worker's active path, so any
        // organic events generated by the editor's own writes never
        // reach the main mpsc.  The forced reconcile on the resume
        // side re-registers and replays the post-editor disk state
        // — that is how the editor's edits get picked up, not
        // through organic events at all.
        if let Some(w) = self.watcher.as_mut() {
            let _ = w.unwatch();
        }
        // The poll loop wakes every 100 ms; sleep slightly longer
        // so the read thread is guaranteed to have entered the
        // paused branch before we hand stdin to the editor.
        std::thread::sleep(Duration::from_millis(120));
        // Discard any events that were already parsed during the
        // overlap window so they don't reach the editor (or
        // re-emerge in our channel after resume).
        while rx.try_recv().is_ok() {}

        // Suspend the TUI.  Best-effort: a failure here means the
        // editor would launch into a confused terminal state, so
        // bail out and tell the user.
        if let Err(e) = crate::terminal::restore() {
            tracing::warn!(error = %e, "failed to suspend terminal for editor");
            self.notify(format!("Editor failed: {e}"), ModalKind::Error);
            if let Some(p) = self.read_paused.as_ref() {
                p.store(false, Ordering::Release);
            }
            return ExternalEditorOutcome::SuspendFailed;
        }

        let status = std::process::Command::new(&editor).arg(path).status();

        // Always try to restore the TUI, even if the editor failed —
        // otherwise we strand the user in a half-suspended state.
        let mouse = self.capabilities.mouse;
        let kbd = self.capabilities.keyboard_enhancement;
        let restore_result = crate::terminal::re_enter(mouse, kbd);
        if let Err(e) = restore_result {
            tracing::error!(error = %e, "failed to re-enter TUI after editor");
            // We can still draw something, but the terminal is in
            // a degraded state.  Surface it loudly.
            self.notify(format!("Terminal restore failed: {e}"), ModalKind::Error);
        }
        // Some terminals emit acknowledgements for the re-enter
        // sequences (kitty keyboard, mouse mode).  Pause stays on
        // here so any such bytes flow into the kernel buffer
        // rather than racing with the read thread that's about to
        // resume.  After this short wait, drain the channel and
        // resume — the read thread will pick up anything still
        // pending on its first post-resume poll.
        std::thread::sleep(Duration::from_millis(30));
        while rx.try_recv().is_ok() {}
        if let Some(p) = self.read_paused.as_ref() {
            p.store(false, Ordering::Release);
        }
        // Re-arm the filesystem watcher on the active file path and
        // request a forced reconcile — this is how the worker
        // notices any edits the external editor made.  Both calls
        // are best-effort: a watcher rebuild failure leaves us
        // without external-edit prompts until the next file open,
        // not in a corrupted state.
        if let Some(file_path) = self.file_path.clone() {
            if let Some(w) = self.watcher.as_mut() {
                if let Err(e) = w.watch(&file_path) {
                    tracing::warn!(target: "watcher", error = %e, "watch re-arm after editor failed");
                }
                if let Err(e) = w.force_reconcile() {
                    tracing::warn!(target: "watcher", error = %e, "force_reconcile after editor failed");
                }
            }
        }

        // Ratatui caches the previous frame; clearing forces it to
        // redraw every cell on the next `terminal.draw` call.
        let _ = terminal.clear();
        // The clear wiped the screen, so nothing we previously
        // transmitted to a graphics protocol is still displayed.
        self.editor.images.invalidate_native_paints();
        self.needs_draw = true;

        ExternalEditorOutcome::Exited(status)
    }

    /// Spawn a worker thread that calls `open::that` and reports the
    /// outcome via `AppEvent::LinkOpenResult`.  Keeps the UI thread
    /// responsive — `xdg-open` can take several hundred milliseconds
    /// on some desktops.
    pub(super) fn spawn_open_worker(&self, target: String) {
        let Some(tx) = self.app_tx.clone() else {
            return;
        };
        std::thread::spawn(move || {
            let result = open::that(&target).map_err(|e| e.to_string());
            let _ = tx.send(AppEvent::LinkOpenResult(result));
        });
    }
}