Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
438 lines
16 KiB
Rust
438 lines
16 KiB
Rust
//! 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<Desk>,
|
|
/// The tab being renamed and its name field.
|
|
pub naming: Option<(u64, Entity<TextInput>)>,
|
|
pub folder: Option<crate::folder::Folder>,
|
|
/// Folder listings so far; searches redo on a new one.
|
|
pub listings: u64,
|
|
/// Files and folders opened lately, newest first.
|
|
pub recent: Vec<PathBuf>,
|
|
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<u64, PathBuf>,
|
|
side: Entity<crate::side::Side>,
|
|
pub watcher: Option<notify::RecommendedWatcher>,
|
|
/// The folders the watcher covers.
|
|
pub watched: Vec<PathBuf>,
|
|
pub comparing: Option<crate::compare::Comparing>,
|
|
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<crate::vcs::Tracked>,
|
|
asked: Option<u64>,
|
|
/// The shown file's fingerprint when Git was last asked.
|
|
asked_print: Option<ely_files::Fingerprint>,
|
|
}
|
|
|
|
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> {
|
|
&self.core
|
|
}
|
|
|
|
fn core_mut(&mut self) -> &mut Core<Self> {
|
|
&mut self.core
|
|
}
|
|
|
|
/// A quit records the open tabs first.
|
|
fn quitting(&mut self, cx: &mut Context<Self>) {
|
|
self.leave(cx);
|
|
}
|
|
|
|
/// A quit or close stopped: the kept list follows again.
|
|
fn close_cancelled(&mut self, cx: &mut Context<Self>) {
|
|
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<Self>) -> 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<Self::Extra, Mark>, cx: &mut Context<Self>) {
|
|
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<MarkView>, cx: &mut Context<Self>) {
|
|
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<Root> {
|
|
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<Entity<MarkView>> {
|
|
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<Self>) {
|
|
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>) {
|
|
self.new_from_text("untitled.md", "", cx);
|
|
}
|
|
|
|
fn switch(&mut self, mode: Mode, window: &mut Window, cx: &mut Context<Self>) {
|
|
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<MarkView>, line: usize, cx: &mut Context<Self>) {
|
|
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<Self>) -> 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<Self>) -> 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<MarkView>,
|
|
cx: &mut Context<Desk>,
|
|
) -> Option<ely_gpui_component::feedback::Banner> {
|
|
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,
|
|
))
|
|
}
|