use std::any::Any;
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use crossterm::event::KeyEvent;
use ratatui::layout::Rect;
use ratatui::Frame;
use super::types::{Modal, ModalOutcome, ModalRenderCtx};
use crate::app::{App, AppEvent};
use crate::config::{Config, CustomExportEntry};
use crate::export::{self, HtmlExportOptions, PreflightError, Stylesheet};
use crate::ui::{ExportChoices, ExportFormat, ExportResponse, ExportState, ExportView, ModalKind};
static EXPORT_SEQ: AtomicU64 = AtomicU64::new(1);
#[derive(Debug, Clone)]
pub enum ExportJob {
Html,
Custom(CustomExportEntry),
}
impl ExportJob {
fn extension(&self) -> &str {
match self {
ExportJob::Html => "html",
ExportJob::Custom(entry) => entry.output_extension(),
}
}
fn format(&self) -> ExportFormat {
match self {
ExportJob::Html => ExportFormat::html(),
ExportJob::Custom(entry) => ExportFormat::custom(&entry.name),
}
}
}
pub struct ExportModal {
state: ExportState,
jobs: Vec<ExportJob>,
export_id: u64,
}
impl ExportModal {
pub fn new(state: ExportState, jobs: Vec<ExportJob>) -> Self {
Self {
state,
jobs,
export_id: 0,
}
}
fn selected_job(&self) -> ExportJob {
self.jobs
.get(self.state.format_idx)
.cloned()
.unwrap_or(ExportJob::Html)
}
pub fn on_export_done(&mut self, outcome: export::ExportOutcome) {
match outcome {
Ok(path) => self.state.set_success(path),
Err(message) => self.state.set_error(message),
}
}
fn resolve(&mut self, app: &mut App, response: ExportResponse) -> ModalOutcome {
match response {
ExportResponse::Continue => ModalOutcome::Continue,
ExportResponse::Cancelled => ModalOutcome::Close,
ExportResponse::Submit(choices) => {
self.submit(app, choices);
ModalOutcome::Continue
}
ExportResponse::ProceedOverwrite => {
self.proceed_overwrite(app);
ModalOutcome::Continue
}
ExportResponse::OpenResult => {
if let Some(path) = &self.state.result_path {
app.spawn_open_worker(path.display().to_string());
}
ModalOutcome::Continue
}
ExportResponse::OpenFolder => {
if let Some(dir) = self.state.result_path.as_deref().and_then(Path::parent) {
app.spawn_open_worker(dir.display().to_string());
}
ModalOutcome::Continue
}
}
}
fn submit(&mut self, app: &mut App, choices: ExportChoices) {
app.config.export.html.inline_images = choices.inline_images;
app.config.export.html.diagrams = choices.render_diagrams;
app.config.export.html.stylesheet = choices.stylesheet;
app.save_config_with_flash("failed to persist export options");
let Some(source) = app.file_path.clone() else {
self.state
.set_error("Save the document to a file before exporting.".to_owned());
return;
};
let job = self.selected_job();
let target = export::target_for_source(&source, job.extension());
if is_same_file(&target, &source) {
self.state.set_error(format!(
"\"{}\" would overwrite the document itself. \
Give the {} export a different `extension` in config.toml.",
target
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| target.display().to_string()),
job.format().label,
));
return;
}
match export::preflight(&target, false) {
Ok(()) => self.begin_export(app, target),
Err(PreflightError::TargetExists(_)) => self.state.enter_confirm_overwrite(target),
Err(e) => self.state.set_error(e.to_string()),
}
}
fn begin_export(&mut self, app: &mut App, target: std::path::PathBuf) {
let Some(tx) = app.app_tx.clone() else {
self.state
.set_error("Internal error: no event channel.".to_owned());
return;
};
let html = &app.config.export.html;
let opts = HtmlExportOptions {
stylesheet: Stylesheet::from_config_value(&html.stylesheet),
inline_images: html.inline_images,
source_dir: std::path::absolute(&target)
.ok()
.and_then(|t| t.parent().map(Path::to_path_buf)),
title: self.state.submitted_title.clone(),
render_diagrams: html.diagrams,
};
let markdown = app.editor.buffer.contents();
let id = EXPORT_SEQ.fetch_add(1, Ordering::Relaxed);
self.export_id = id;
self.state.enter_exporting(target.clone());
let done = move |outcome| {
let _ = tx.send(AppEvent::ExportDone(id, outcome));
};
match self.selected_job() {
ExportJob::Html => export::spawn_html_export(markdown, target, opts, done),
ExportJob::Custom(entry) => {
export::spawn_custom_export(entry, markdown, target, opts, done)
}
}
}
fn proceed_overwrite(&mut self, app: &mut App) {
let Some(target) = self.state.target.clone() else {
self.state
.set_error("Internal error: no export target.".to_owned());
return;
};
self.begin_export(app, target);
}
}
impl Modal for ExportModal {
fn render(&mut self, frame: &mut Frame<'_>, area: Rect, ctx: &ModalRenderCtx<'_>) {
let view = ExportView {
theme: ctx.theme,
cursor_visible: ctx.cursor_visible,
};
frame.render_stateful_widget(view, area, &mut self.state);
}
fn handle_key(
&mut self,
key: KeyEvent,
app: &mut App,
_doc_height: usize,
_doc_width: usize,
) -> ModalOutcome {
let response = self.state.handle_key(&key);
self.resolve(app, response)
}
fn handle_wheel(&mut self, delta: i32) {
self.state.handle_wheel(delta);
}
fn handle_paste(&mut self, text: &str) -> ModalOutcome {
self.state.paste(text);
ModalOutcome::Continue
}
fn handle_click(&mut self, col: u16, row: u16, app: &mut App) -> ModalOutcome {
let response = self.state.handle_click(col, row);
self.resolve(app, response)
}
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
}
impl App {
pub fn open_export_modal(&mut self) {
let mut jobs = vec![ExportJob::Html];
jobs.extend(
self.config
.export
.custom
.iter()
.filter(|e| e.config_problem().is_none())
.cloned()
.map(ExportJob::Custom),
);
let formats: Vec<ExportFormat> = jobs.iter().map(ExportJob::format).collect();
let markdown = self.editor.buffer.contents();
let title = first_h1(&markdown)
.or_else(|| {
self.file_path
.as_ref()
.and_then(|p| p.file_stem())
.map(|s| s.to_string_lossy().into_owned())
})
.unwrap_or_default();
let html = &self.config.export.html;
let mut stylesheets: Vec<(String, String)> =
vec![("Default".to_owned(), "builtin".to_owned())];
if let Some(dir) = Config::config_dir() {
for path in crate::config::list_export_stylesheets(&dir) {
let label = path
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string());
stylesheets.push((label, path.display().to_string()));
}
}
let current = if html.stylesheet.eq_ignore_ascii_case("builtin") {
"builtin".to_owned()
} else {
html.stylesheet.clone()
};
let mut idx = stylesheets.iter().position(|(_, v)| *v == current);
if idx.is_none() && current != "builtin" {
let label = Path::new(¤t)
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| current.clone());
stylesheets.push((label, current));
idx = Some(stylesheets.len() - 1);
}
let state = ExportState::new(
formats,
title,
html.inline_images,
html.diagrams,
stylesheets,
idx.unwrap_or(0),
);
self.modal_stack
.push(Box::new(ExportModal::new(state, jobs)));
self.needs_draw = true;
}
pub(in crate::app) fn handle_export_done(&mut self, id: u64, outcome: export::ExportOutcome) {
match self.modal_stack.find_first_mut::<ExportModal>() {
Some(modal) if modal.export_id == id => modal.on_export_done(outcome),
_ => match outcome {
Ok(path) => self.flash(
format!("Exported to {}", path.display()),
crate::app::MessageKind::Success,
),
Err(message) => self.notify(format!("Export failed: {message}"), ModalKind::Error),
},
}
self.needs_draw = true;
}
}
fn is_same_file(a: &Path, b: &Path) -> bool {
if a == b {
return true;
}
match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
(Ok(a), Ok(b)) => a == b,
_ => false,
}
}
fn first_h1(markdown: &str) -> Option<String> {
markdown.lines().find_map(|line| {
line.trim_start()
.strip_prefix("# ")
.map(str::trim)
.filter(|h| !h.is_empty())
.map(str::to_owned)
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::test_utils::make_app;
#[test]
fn first_h1_picks_the_first_level_one_heading() {
assert_eq!(first_h1("# Title\n\nbody"), Some("Title".to_owned()));
assert_eq!(
first_h1("intro\n\n# Spaced \nmore"),
Some("Spaced".to_owned())
);
}
#[test]
fn first_h1_ignores_deeper_headings_and_blanks() {
assert_eq!(first_h1("## Sub\n### Deep"), None);
assert_eq!(first_h1("#\n# \nnope"), None);
assert_eq!(first_h1(""), None);
}
#[test]
fn fresh_modal_has_no_export_generation() {
let state = ExportState::new(
vec![ExportFormat::html()],
String::new(),
false,
true,
vec![],
0,
);
let modal = ExportModal::new(state, vec![ExportJob::Html]);
assert_eq!(modal.export_id, 0);
assert!(EXPORT_SEQ.load(Ordering::Relaxed) >= 1);
}
#[test]
fn the_output_extension_comes_from_the_job() {
assert_eq!(ExportJob::Html.extension(), "html");
assert_eq!(
ExportJob::Custom(entry("PDF (weasyprint)", "pdf")).extension(),
"pdf"
);
}
#[test]
fn the_format_row_names_the_entry() {
let format = ExportJob::Custom(entry("PDF (weasyprint)", "pdf")).format();
assert_eq!(format.label, "PDF (weasyprint)");
assert_eq!(format.open_result, "Open file");
assert_eq!(ExportJob::Html.format().label, "HTML");
assert_eq!(ExportJob::Html.format().open_result, "Open in browser");
}
fn entry(name: &str, extension: &str) -> CustomExportEntry {
CustomExportEntry {
name: name.to_owned(),
command: vec!["true".to_owned()],
extension: extension.to_owned(),
}
}
#[test]
fn open_builds_a_job_per_format_by_value() {
let mut app = make_app();
app.config.export.custom = vec![entry("PDF", "pdf"), entry("DOCX", "docx")];
app.open_export_modal();
let modal = app
.modal_stack
.find_first_mut::<ExportModal>()
.expect("the export modal should be open");
assert!(matches!(modal.jobs[0], ExportJob::Html));
let labels: Vec<String> = modal
.state
.formats
.iter()
.map(|f| f.label.clone())
.collect();
assert_eq!(labels, vec!["HTML", "PDF", "DOCX"]);
assert_eq!(modal.state.format_idx, 0, "HTML selected by default");
app.config.export.custom.clear();
let modal = app.modal_stack.find_first_mut::<ExportModal>().unwrap();
match &modal.jobs[2] {
ExportJob::Custom(e) => assert_eq!(e.extension, "docx"),
other => panic!("expected the custom job, got {other:?}"),
}
}
#[test]
fn a_flagged_custom_entry_is_not_offered() {
let mut app = make_app();
app.config.export.custom = vec![
entry("PDF", "pdf"),
CustomExportEntry {
name: "broken".to_owned(),
command: vec![], extension: "docx".to_owned(),
},
];
app.open_export_modal();
let modal = app.modal_stack.find_first_mut::<ExportModal>().unwrap();
let labels: Vec<String> = modal
.state
.formats
.iter()
.map(|f| f.label.clone())
.collect();
assert_eq!(labels, vec!["HTML", "PDF"], "the broken entry is excluded");
assert_eq!(modal.jobs.len(), 2);
}
#[test]
fn a_converter_whose_extension_matches_the_document_is_refused() {
let _guard = crate::test_env::config_isolation();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("guide.md");
std::fs::write(&source, "# Guide\n").unwrap();
let mut app = make_app();
app.file_path = Some(source.clone());
app.config.export.custom = vec![entry("Markdown", "md")];
app.open_export_modal();
let mut modal = app.modal_stack.pop().expect("modal was pushed");
let export = modal
.as_any_mut()
.downcast_mut::<ExportModal>()
.expect("an ExportModal");
export.state.format_idx = 1; export.resolve(
&mut app,
ExportResponse::Submit(ExportChoices {
title: None,
inline_images: false,
render_diagrams: true,
stylesheet: "builtin".to_owned(),
}),
);
assert_eq!(
export.state.phase,
crate::ui::ExportPhase::Error,
"the collision must stop the flow, not reach ConfirmOverwrite"
);
assert!(
export
.state
.error_message
.as_deref()
.unwrap_or_default()
.contains("overwrite the document itself"),
"got {:?}",
export.state.error_message
);
assert_eq!(std::fs::read_to_string(&source).unwrap(), "# Guide\n");
assert!(export.state.target.is_none());
}
#[test]
fn the_collision_guard_sees_through_extension_normalization() {
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("guide.md");
let job = ExportJob::Custom(entry("Markdown", " .md "));
let target = crate::export::target_for_source(&source, job.extension());
assert!(is_same_file(&target, &source));
}
#[test]
fn a_selected_custom_format_resolves_a_target_with_its_own_extension() {
let _guard = crate::test_env::config_isolation();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("guide.md");
std::fs::write(&source, "# Guide\n").unwrap();
std::fs::write(dir.path().join("guide.pdf"), b"old").unwrap();
let mut app = make_app();
app.file_path = Some(source);
app.config.export.custom = vec![entry("PDF", "pdf")];
app.open_export_modal();
let mut modal = app.modal_stack.pop().expect("modal was pushed");
let export = modal
.as_any_mut()
.downcast_mut::<ExportModal>()
.expect("an ExportModal");
export.state.format_idx = 1;
export.resolve(
&mut app,
ExportResponse::Submit(ExportChoices {
title: None,
inline_images: false,
render_diagrams: true,
stylesheet: "builtin".to_owned(),
}),
);
assert_eq!(
export.state.target.as_deref(),
Some(dir.path().join("guide.pdf").as_path()),
"the target should sit beside the document under the format's extension"
);
}
}