//! Markdown files in the shared shell (§14). use std::{ collections::HashMap, path::PathBuf, sync::{Arc, Mutex}, }; use ely_gpui_component::{ buttons::SegmentedControl, feedback::EmptyState, forms::TextInput, primitives::IconName, shell::{StatusBar, StatusBarItem}, theme::ControlSize, }; use ely_office::{Core, Host, Kind, prelude::*}; use ely_platform::{AppId, startup}; use ely_ui::{Root, Shell, commands, i18n::text}; use gpui::{ App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, PathPromptOptions, Render, Styled as _, Window, div, }; use crate::engine::Mark; use crate::keys::{CheckDisk, PreviewMode, SourceMode, VisualMode}; use crate::view::{MarkView, Mode}; pub struct Desk { pub core: Core, /// The tab being renamed and its name field. pub naming: Option<(u64, Entity)>, pub folder: Option, /// Folder listings so far; searches redo on a new one. pub listings: u64, /// Files and folders opened lately, newest first. pub recent: Vec, pub recent_file: PathBuf, /// A file asked to open at a line. pub going: Option<(PathBuf, usize)>, /// The path each tab last put in the recent list. pub remembered: HashMap, side: Entity, pub watcher: Option, /// The folders the watcher covers. pub watched: Vec, pub comparing: Option, pub keeping: crate::restore::Keeping, pub look: crate::look::Look, pub look_file: PathBuf, /// Git's word on the shown file, and which tab asked. pub tracked: Option, asked: Option, /// The shown file's fingerprint when Git was last asked. asked_print: Option, } impl Host for Desk { type Extra = crate::outside::Extra; type Engine = Mark; const KIND: Kind = Kind { app: AppId::Mark, kept: &["md", "markdown"], save_as: &["md"], macros: None, }; fn core(&self) -> &Core { &self.core } fn core_mut(&mut self) -> &mut Core { &mut self.core } /// A quit records the open tabs first. fn quitting(&mut self, cx: &mut Context) { self.leave(cx); } /// A quit or close stopped: the kept list follows again. fn close_cancelled(&mut self, cx: &mut Context) { self.keeping.leaving = false; self.keep(cx); } /// An autosave met an outside change: compare, never guess. fn conflicted(&mut self, id: u64, cx: &mut Context) -> bool { log::info!("mark: a save of tab {id} met an outside change"); if let Some(tab) = self.tab_mut(id) { tab.extra.policy.external_modified(); } self.look(id, cx); true } /// A closed tab's unsaved text is gone for good (§6.2). fn tab_closed(&mut self, tab: &ely_office::Tab, cx: &mut Context) { let path = tab.session.path().map(std::path::Path::to_path_buf); self.drop_slot(&tab.extra.slot, path, tab.name.to_string(), cx); } /// Save As or rename moves where images come from. fn observed(&mut self, id: u64, view: &Entity, cx: &mut Context) { self.watch_folders(cx); let changed = view.read(cx).changed(); self.autosave(id, changed, cx); let recheck = self.tab_mut(id).is_some_and(|tab| { if !tab.dirty(cx) { tab.extra.base = tab.doc.lock().expect("the text lock").current.clone(); } tab.extra.recheck }); if recheck { self.look(id, cx); } self.restore_shown(id, cx); // A save changes what Git sees. let print = self.active_tab().and_then(|tab| tab.session.fingerprint()); if print != self.asked_print { self.asked_print = print; self.ask_git(cx); } let path = self .tab(id) .and_then(|tab| tab.session.path().map(PathBuf::from)); if let Some(path) = path { if let Some(folder) = path.parent() { view.update(cx, |view, cx| view.moved(folder.to_path_buf(), cx)); } self.track_recent(id, &path, cx); let real = std::fs::canonicalize(&path).ok(); if self .going .as_ref() .is_some_and(|(file, _)| Some(file) == real.as_ref()) && let Some((_, line)) = self.going.take() { self.reveal_later(view.clone(), line, cx); } } cx.notify(); } } /// The window root; files given at launch open. pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity { let fail = |error: String| -> ! { startup::fail(app, &error) }; let data = ely_platform::data_dir(app).unwrap_or_else(|error| fail(format!("{error:#}"))); let work = ely_office::fresh_work(&data).unwrap_or_else(|error| fail(format!("{error}"))); crate::keys::bind(cx); let recent_file = data.join("recent.json"); let look_file = data.join("look.json"); // An unreadable look is reported; the default shows. let (look, look_unread) = match crate::look::load(&look_file) { Ok(look) => (look, None), Err(error) => (crate::look::Look::default(), Some(error)), }; let (keeping, kept, missing) = crate::restore::Keeping::start(&data).unwrap_or_else(|error| fail(format!("{error}"))); // An unreadable list is reported, never overwritten. let (recent, unread) = match ely_files::recent::recent(&recent_file) { Ok(list) => (list, None), Err(error) => (Vec::new(), Some(error)), }; let desk = cx.new(|cx| { let this = cx.entity(); let side = cx.new(|cx| crate::side::Side::new(&this, window, cx)); Desk { core: Core::new(Arc::new(Mutex::new(())), work, cx), naming: None, folder: None, listings: 0, recent, recent_file, going: None, remembered: HashMap::new(), side, watcher: None, watched: Vec::new(), comparing: None, keeping, look: look.clone(), look_file, tracked: None, asked: None, asked_print: None, } }); let menus = crate::keys::menus(&look, cx); let root = ely_ui::document_root( app, desk.clone().into(), &commands::RENAMING, menus, window, cx, ); let asked = ely_ui::launch_files(cx); desk.update(cx, |desk, cx| { desk.core.root = root.downgrade(); desk.own_window(window, cx); window.focus(&desk.core.focus, cx); // Launch files stand alone; unsaved text still returns. if asked.is_empty() { for path in kept { desk.open_path(path, cx); } } for (at, name) in desk.keeping.untitled() { // Tab numbers come from `next` as the load starts. let id = desk.core.next; desk.new_from_text(&name, "", cx); if desk.core.next > id { desk.keeping.made(at, id); } } for path in asked { desk.open_path(path, cx); } desk.keep_every(cx); cx.on_release(|desk, _| desk.keeping.close()).detach(); // Another shown tab asks Git again. cx.observe_self(|desk, cx| { let shown = desk.active_tab().map(|tab| tab.id); if shown != desk.asked { desk.asked = shown; desk.tracked = None; desk.ask_git(cx); } }) .detach(); cx.on_app_quit(|desk, cx| { // A system end skips asking: unsaved tabs keep points. if desk.core.tabs.iter().any(|tab| tab.dirty(cx)) { desk.keeping.hold(); } else { desk.keeping.close(); } async {} }) .detach(); for gone in missing { let said = text(cx, "mark.restore.missing", &[("file", &gone)]); desk.toast(said, ely_gpui_component::primitives::Severity::Warning, cx); } if let Some(error) = look_unread { log::error!("mark: the look did not read: {error}"); let said = text(cx, "office.error.engine", &[("error", &error.to_string())]); desk.toast(said, ely_gpui_component::primitives::Severity::Danger, cx); } if let Some(error) = unread { log::error!("mark: the recent list did not read: {error}"); let said = text(cx, "office.error.engine", &[("error", &error.to_string())]); desk.toast(said, ely_gpui_component::primitives::Severity::Danger, cx); } // Back in front: the folder may have changed. cx.observe_window_activation(window, |desk, window, cx| { if window.is_window_active() { desk.relist(cx); desk.ask_git(cx); } }) .detach(); }); let opening = desk.clone(); ely_ui::on_open(cx, move |path, cx| { opening.update(cx, |desk, cx| desk.open_path(path, cx)) }); let closing = desk.clone(); window.on_window_should_close(cx, move |window, cx| { closing.update(cx, |desk, cx| { desk.leave(cx); desk.should_close(window, cx) }) }); root } impl Desk { pub fn active_view(&self) -> Option> { self.active_tab().map(|tab| tab.view.clone()) } /// Asks for files; each opens in its own tab. fn open_picked(&mut self, _: &commands::Open, _: &mut Window, cx: &mut Context) { let picked = cx.prompt_for_paths(PathPromptOptions { files: true, directories: true, multiple: true, prompt: None, }); cx.spawn(async move |this, cx| { let picked = match picked.await { Ok(Ok(paths)) => Ok(paths), Ok(Err(error)) => Err(error.to_string()), Err(error) => Err(error.to_string()), }; let done = this.update(cx, |desk, cx| match picked { Ok(Some(paths)) => paths.into_iter().for_each(|path| desk.open_path(path, cx)), Ok(None) => log::info!("mark: the open panel was cancelled"), Err(error) => desk.panel_failed(&error, cx), }); if done.is_err() { log::info!("mark: the window closed before the panel"); } }) .detach(); } /// An empty untitled file (MARK-01). fn on_new(&mut self, _: &commands::New, _: &mut Window, cx: &mut Context) { self.new_from_text("untitled.md", "", cx); } fn switch(&mut self, mode: Mode, window: &mut Window, cx: &mut Context) { if let Some(view) = self.active_view() { view.update(cx, |view, cx| view.switch(mode, window, cx)); } } /// Brings `line` into view from outside a window update. pub fn reveal_later(&mut self, view: Entity, line: usize, cx: &mut Context) { cx.spawn(async move |desk, cx| { let shown = ely_office::in_window(&desk, cx, |_, window, cx| { view.update(cx, |view, cx| view.reveal(line, window, cx)) }); if let Err(error) = shown { log::warn!("mark: line {line} was not shown: {error:#}"); } }) .detach(); } fn status(&self, cx: &mut Context) -> StatusBar { let state = StatusBarItem::new("mark-state").label(self.save_state(cx)); let Some(view) = self.active_view() else { return StatusBar::new().left(state); }; let git = self.git_item(cx); let this = cx.entity(); let modes = Mode::ALL.into_iter().fold( SegmentedControl::new("mark-mode", view.read(cx).mode.name()).size(ControlSize::Sm), |control, mode| { let icon = match mode { Mode::Visual => IconName::PenLine, Mode::Source => IconName::Code, Mode::Preview => IconName::Eye, }; control.segment(mode.name(), crate::keys::label(mode, cx), Some(icon)) }, ); let modes = modes.on_change(move |key, window, cx| { if let Some(mode) = Mode::parse(key) { this.update(cx, |desk, cx| desk.switch(mode, window, cx)); } }); let bar = StatusBar::new().left(state); let bar = match git { Some(git) => bar.left(git), None => bar, }; bar.right(modes) } } impl Render for Desk { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let tab = self.active_tab(); let title = tab.map_or_else(|| AppId::Mark.name().into(), |tab| tab.name.clone()); let body = match tab { Some(tab) => div() .size_full() .flex() .flex_col() .children(self.outside_banner(cx)) .children(large_banner(&tab.view, cx)) .child(div().flex_1().min_h_0().child(tab.view.clone())) .into_any_element(), None => { let none = text(cx, "command.blocked.no_document", &[]); EmptyState::new("mark-empty", IconName::File, none).into_any_element() } }; // Focus mode folds tabs, side and status away. let shell = match self.look.focus { true => Shell::new(title, &self.core.focus), false => Shell::new(title, &self.core.focus) .tabs(self.tab_strip(cx)) .left(Some(self.side.clone().into())) .status(self.status(cx)), } .child(body); let root = div() .size_full() .key_context(commands::CONTEXT) .track_focus(&self.core.focus) .on_action(cx.listener(Self::open_picked)) .on_action(cx.listener(Self::on_new)) .on_action(cx.listener(Self::on_save)) .on_action(cx.listener(Self::on_save_as)) .on_action(cx.listener(Self::on_rename)) .on_action(cx.listener(Self::on_close_tab)) .on_action(cx.listener(|desk, _: &CheckDisk, _, cx| desk.look_all(cx))) .on_action(cx.listener(|desk, _: &VisualMode, window, cx| { desk.switch(Mode::Visual, window, cx) })) .on_action(cx.listener(|desk, _: &SourceMode, window, cx| { desk.switch(Mode::Source, window, cx) })) .on_action(cx.listener(|desk, _: &PreviewMode, window, cx| { desk.switch(Mode::Preview, window, cx) })) .child(shell) .children(self.core_dialog(cx)) .children(self.naming_element(cx)) .children(self.compare_dialog(cx)); Self::look_actions(root, cx) } } /// A large file says why it shows as source. fn large_banner( view: &Entity, cx: &mut Context, ) -> Option { if !view.read(cx).large() { return None; } let size = view.read(cx).bytes() as f64 / (1024.0 * 1024.0); let said = text(cx, "mark.large", &[("size", &format!("{size:.1}"))]); Some(ely_gpui_component::feedback::Banner::new( "mark-large", ely_gpui_component::primitives::Severity::Info, said, )) }