Ely Office: six offline-first desktop apps in Rust and GPUI

ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git),
ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-08 21:20:08 -04:00
commit b7fca0146d
817 files changed
+143101

No files matched your search

+163
View File
@@ -0,0 +1,163 @@
//! Images dropped or pasted, copied beside the file (MARK-07).
use std::{
io,
io::Write as _,
path::{Path, PathBuf},
};
use ely_platform::clipboard::{self, Clip};
use ely_ui::i18n::text;
use gpui::{App, Context, WeakEntity, Window};
use serde_json::json;
use crate::view::{Failed, MarkView};
/// The folder beside the file that takes its images.
pub const FOLDER: &str = "assets";
const IMAGES: [&str; 8] = ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "avif"];
fn image(path: &Path) -> bool {
let extension = path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or_default();
IMAGES.contains(&extension.to_lowercase().as_str())
}
/// A relative link to a file in the folder, escaped.
pub fn link(name: &str) -> String {
format!("{FOLDER}/{}", crate::paper::segment(name))
}
/// Writes `png` into `dir` under a new name.
fn save_png(dir: &Path, png: &[u8]) -> io::Result<PathBuf> {
std::fs::create_dir_all(dir)?;
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(io::Error::other)?
.as_millis();
let mut n = 0;
loop {
let name = match n {
0 => format!("pasted-{stamp}.png"),
n => format!("pasted-{stamp}-{n}.png"),
};
let path = dir.join(name);
match std::fs::File::create_new(&path) {
Ok(mut file) => {
file.write_all(png)?;
file.flush()?;
return Ok(path);
}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => n += 1,
Err(error) => return Err(error),
}
}
}
/// The paste hook: an image on the clipboard is attached.
pub fn paste(view: WeakEntity<MarkView>) -> impl Fn(&mut Window, &mut App) -> bool {
move |_, cx| match clipboard::read() {
Ok(Some(Clip::Png(png))) => {
view.update(cx, |view, cx| view.attach(Attach::Png(png), cx))
.ok();
true
}
Ok(_) => false,
Err(error) => {
log::error!("mark: the clipboard did not read: {error:#}");
let why = format!("{error:#}");
view.update(cx, |_, cx| cx.emit(Failed(why))).ok();
false
}
}
}
pub enum Attach {
Files(Vec<PathBuf>),
Png(Vec<u8>),
}
impl MarkView {
/// Copies into the file's folder, then links at the caret.
pub fn attach(&mut self, what: Attach, cx: &mut Context<Self>) {
let refused = match (&what, self.attachable, self.mode) {
(_, false, _) => Some("mark.attach.untitled"),
(_, _, crate::page::Mode::Preview) => Some("mark.attach.preview"),
(Attach::Files(files), ..) if !files.iter().all(|file| image(file)) => {
Some("mark.attach.images")
}
_ => None,
};
if let Some(key) = refused {
return cx.emit(Failed(text(cx, key, &[]).to_string()));
}
let folder = self.folder.clone();
let dir = folder.join(FOLDER);
cx.spawn(async move |view, cx| {
let copied = cx
.background_executor()
.spawn(async move {
match what {
Attach::Files(files) => files
.iter()
.map(|file| ely_files::import_attachment(file, &dir))
.collect(),
Attach::Png(png) => save_png(&dir, &png).map(|path| vec![path]),
}
})
.await;
view.update(cx, |view, cx| view.attached(&folder, copied, cx))
.ok();
})
.detach();
}
fn attached(
&mut self,
folder: &Path,
copied: io::Result<Vec<PathBuf>>,
cx: &mut Context<Self>,
) {
let copied = match copied {
Ok(copied) => copied,
Err(error) => {
log::error!("mark: an image did not copy: {error}");
return cx.emit(Failed(error.to_string()));
}
};
// Links resolve from the file's folder; it moved meanwhile.
if self.folder != folder {
log::warn!("mark: the file moved while images copied; no links");
let at = folder.join(FOLDER).display().to_string();
return cx.emit(Failed(
text(cx, "mark.attach.moved", &[("folder", &at)]).to_string(),
));
}
log::info!("mark: attached {} images", copied.len());
let images: Vec<_> = copied
.iter()
.filter_map(|path| {
let name = path.file_name()?.to_string_lossy().into_owned();
let alt = path.file_stem()?.to_string_lossy().into_owned();
Some(json!({ "src": link(&name), "alt": alt }))
})
.collect();
crate::page::call(self.web.read(cx), "insert", &json!(images));
}
}
#[cfg(test)]
mod tests {
#[test]
fn links_escape_and_names_never_repeat() {
assert_eq!(super::link("a b#1.png"), "assets/a%20b%231.png");
let dir = tempfile::tempdir().expect("a temp dir");
let first = super::save_png(dir.path(), b"x").expect("saves");
let second = super::save_png(dir.path(), b"y").expect("saves");
assert_ne!(first, second);
assert_eq!(std::fs::read(first).expect("reads"), b"x");
}
}
+61
View File
@@ -0,0 +1,61 @@
//! ElyMark saves a second after typing stops (§6.2).
use std::time::{Duration, Instant};
use ely_files::{Action, DEBOUNCE, SaveState};
use ely_office::{in_window, prelude::*};
use gpui::{Context, Window};
use crate::desk::Desk;
use crate::outside::Outside;
impl Desk {
/// A page change restarts the wait; finished saves report.
pub fn autosave(&mut self, id: u64, changed: u64, cx: &mut Context<Self>) {
let Some(tab) = self.tab_mut(id) else {
return;
};
let saving = tab.exporting || tab.session.state() == SaveState::Saving;
if let Some(revision) = tab.extra.writing.filter(|_| !saving) {
let ok = tab.session.state() != SaveState::SaveFailed;
tab.extra.policy.save_finished(revision, ok);
tab.extra.writing = None;
}
if tab.session.path().is_some() {
tab.extra.policy.set_has_path();
}
// A page reload restarts its count: any difference is news.
if changed == tab.extra.heard {
return;
}
tab.extra.heard = changed;
tab.extra.policy.edit(Instant::now());
cx.spawn(async move |desk, cx| {
let wait = DEBOUNCE + Duration::from_millis(50);
cx.background_executor().timer(wait).await;
in_window(&desk, cx, |desk, window, cx| {
desk.autosave_due(id, window, cx)
})
.ok();
})
.detach();
}
fn autosave_due(&mut self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
let Some(tab) = self.tab_mut(id) else {
return;
};
if !matches!(tab.extra.outside, Outside::Clear) || tab.extra.writing.is_some() {
return;
}
let Action::SaveDue(revision) = tab.extra.policy.tick(Instant::now()) else {
return;
};
if !tab.dirty(cx) {
return tab.extra.policy.save_finished(revision, true);
}
log::info!("mark: autosaves tab {id}");
tab.extra.writing = Some(revision);
self.save(id, None, window, cx);
}
}
+205
View File
@@ -0,0 +1,205 @@
//! The disk's version against unsaved text, over their base (§14.4).
use std::rc::Rc;
use ely_files::{Fingerprint, Policy, PolicyEngine};
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
git::{self as merging, Region, Take, ThreeWayMerge},
overlays::Dialog,
primitives::Severity,
};
use ely_office::prelude::*;
use ely_ui::i18n::text;
use gpui::{AnyElement, Context, IntoElement as _, ParentElement as _, Styled as _, Window, div};
use crate::desk::Desk;
use crate::outside::Outside;
use crate::text::Source;
/// A comparison open over tab `id`.
pub struct Comparing {
pub id: u64,
disk: Source,
/// The disk as read; a choice checks it first.
fingerprint: Fingerprint,
regions: Rc<Vec<Region>>,
takes: Vec<Option<Take>>,
}
impl Desk {
/// Mine, the base and the disk's, side by side.
pub fn compare(&mut self, id: u64, cx: &mut Context<Self>) {
// The page's newest text first.
if !self.settled(id, cx) {
return self.when_settled(id, move |desk, _, cx| desk.compare(id, cx));
}
let Some(tab) = self.tab(id) else {
return;
};
let Outside::Changed(disk, fingerprint) = &tab.extra.outside else {
return;
};
let mine = tab.doc.lock().expect("the text lock").current.clone();
let regions = merging::regions(&tab.extra.base, &mine, &disk.text);
let conflicts = regions
.iter()
.filter(|region| region.kind == merging::RegionKind::Conflict)
.count();
log::info!("mark: compares tab {id}: {conflicts} conflicts");
self.comparing = Some(Comparing {
id,
disk: disk.clone(),
fingerprint: *fingerprint,
regions: Rc::new(regions),
takes: vec![None; conflicts],
});
cx.notify();
}
fn take(&mut self, conflict: usize, take: Take, cx: &mut Context<Self>) {
if let Some(slot) = self
.comparing
.as_mut()
.and_then(|comparing| comparing.takes.get_mut(conflict))
{
*slot = Some(take);
cx.notify();
}
}
/// Settles tab `id`: the disk is the new base.
fn settle(&mut self, choice: Choice, window: &mut Window, cx: &mut Context<Self>) {
let Some(comparing) = self.comparing.as_ref() else {
return;
};
let id = comparing.id;
let now = self
.tab(id)
.and_then(|tab| tab.session.path())
.and_then(|path| Fingerprint::of(path).ok());
// The disk moved on: compare again, never write blind.
if now != Some(comparing.fingerprint) {
log::warn!("mark: tab {id} changed on disk during the comparison");
let said = text(cx, "mark.compare.again", &[]);
self.toast(said, Severity::Warning, cx);
return self.look(id, cx);
}
let Some(Comparing {
id,
disk,
fingerprint,
regions,
takes,
}) = self.comparing.take()
else {
return;
};
log::info!("mark: tab {id} settles with {choice:?}");
match choice {
Choice::Disk => self.take_disk(id, disk, fingerprint, cx),
// The checked save: a later disk change conflicts again.
Choice::Mine => {
if let Some(tab) = self.tab_mut(id) {
tab.session.rebase(fingerprint);
tab.extra.policy = PolicyEngine::new(Policy::MarkAutoSave, true);
}
self.set_outside(id, Outside::Clear, cx);
self.save(id, None, window, cx);
}
Choice::Merged => {
let (merged, open) = merging::result(&regions, &takes);
if open > 0 {
return log::error!("mark: a merge with {open} open conflicts");
}
let Some(tab) = self.tab_mut(id) else {
return;
};
if let Err(error) = tab.session.reload_at(fingerprint) {
let why = error.to_string();
return self.set_outside(id, Outside::Unread(why), cx);
}
tab.extra.base = disk.text.clone();
tab.extra.policy = PolicyEngine::new(Policy::MarkAutoSave, true);
tab.doc.lock().expect("the text lock").source = disk;
let view = tab.view.clone();
view.update(cx, |view, cx| view.adopt(merged, cx));
self.set_outside(id, Outside::Clear, cx);
}
}
}
pub fn compare_dialog(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let comparing = self.comparing.as_ref()?;
let name = self.tab(comparing.id)?.name.to_string();
let this = cx.entity();
let labels = [
"mark.compare.mine",
"mark.compare.base",
"mark.compare.disk",
]
.map(|key| text(cx, key, &[]).to_string());
let merge = ThreeWayMerge::new(
"mark-compare",
[&labels[0], &labels[1], &labels[2]],
comparing.regions.clone(),
)
.takes(comparing.takes.clone())
.on_take({
let this = this.clone();
move |conflict, take, _, cx| this.update(cx, |desk, cx| desk.take(conflict, take, cx))
});
let open = comparing.takes.iter().filter(|take| take.is_none()).count();
let title = text(cx, "mark.compare.title", &[("name", &name)]);
let close = {
let this = this.clone();
move |_: &mut Window, cx: &mut gpui::App| {
this.update(cx, |desk, cx| {
desk.comparing = None;
cx.notify();
})
}
};
let mut dialog = Dialog::new("mark-compare-dialog", title, close)
.fullscreen()
.child(div().flex_1().min_h_0().size_full().child(merge));
for (choice, key, variant) in [
(
Choice::Disk,
"mark.compare.use_disk",
ButtonVariant::Secondary,
),
(
Choice::Mine,
"mark.compare.keep_mine",
ButtonVariant::Secondary,
),
(
Choice::Merged,
"mark.compare.use_merged",
ButtonVariant::Primary,
),
] {
let label = text(cx, key, &[]);
let this = this.clone();
let disabled = choice == Choice::Merged && open > 0;
dialog = dialog.action(move |_| {
Button::new(key, label)
.variant(variant)
.disabled(disabled)
.on_click(move |_, window, cx| {
this.update(cx, |desk, cx| desk.settle(choice, window, cx))
})
});
}
Some(dialog.into_any_element())
}
}
/// How a comparison ends.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Choice {
Disk,
Mine,
Merged,
}
+446
View File
@@ -0,0 +1,446 @@
//! 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, Pages};
use crate::keys::{
CheckDisk, ColumnLeft, ColumnRight, DeleteColumn, DeleteRow, InsertTable, PreviewMode,
RowAbove, RowBelow, SourceMode, VisualMode,
};
use crate::page::Mode;
use crate::view::MarkView;
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 place.
pub going: Option<(PathBuf, serde_json::Value)>,
/// The path each tab last put in the recent list.
pub remembered: HashMap<u64, PathBuf>,
side: Entity<crate::side::Side>,
pub finder: Option<crate::finding::Finder>,
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,
/// The export dialog's choice, while it is open.
pub output: Option<crate::export::Output>,
pub printing: Option<crate::export::Printing>,
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 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 look = serde_json::to_value(&self.look).expect("a look serializes");
view.update(cx, |view, cx| view.set_look(look, 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);
}
if view.read(cx).shown {
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 {
view.update(cx, |view, _| view.attachable = true);
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((_, place)) = self.going.take()
{
view.update(cx, |view, cx| view.reveal(place, 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:#}")));
if let Err(error) = ely_web_content::start(&data, cx) {
fail(format!("{error:#}"));
}
let dir = crate::page::content().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 pages = Arc::new(Mutex::new(Pages { dir }));
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(pages, work, cx),
naming: None,
folder: None,
listings: 0,
recent,
recent_file,
going: None,
remembered: HashMap::new(),
side,
finder: None,
watcher: None,
watched: Vec::new(),
comparing: None,
keeping,
output: None,
printing: None,
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.core.window = Some(window.window_handle());
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 name in desk.keeping.untitled() {
desk.new_from_text(&name, "", cx);
}
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, _| {
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 table(&mut self, op: &str, cx: &mut Context<Self>) {
if let Some(view) = self.active_view() {
view.update(cx, |view, cx| view.table(op, cx));
}
}
fn switch(&mut self, mode: Mode, cx: &mut Context<Self>) {
if let Some(view) = self.active_view() {
view.update(cx, |view, cx| view.switch(mode, cx));
}
}
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, _, cx| {
if let Some(mode) = Mode::parse(key) {
this.update(cx, |desk, cx| desk.switch(mode, 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_find))
.on_action(cx.listener(Self::on_export))
.on_action(cx.listener(Self::on_print))
.on_action(cx.listener(Self::on_close_tab))
.on_action(cx.listener(|desk, quit, window, cx| {
desk.leave(cx);
desk.on_quit(quit, window, cx)
}))
.on_action(cx.listener(|desk, _: &InsertTable, _, cx| desk.table("insert", cx)))
.on_action(cx.listener(|desk, _: &RowAbove, _, cx| desk.table("row-above", cx)))
.on_action(cx.listener(|desk, _: &RowBelow, _, cx| desk.table("row-below", cx)))
.on_action(cx.listener(|desk, _: &ColumnLeft, _, cx| desk.table("column-left", cx)))
.on_action(cx.listener(|desk, _: &ColumnRight, _, cx| desk.table("column-right", cx)))
.on_action(cx.listener(|desk, _: &DeleteRow, _, cx| desk.table("delete-row", cx)))
.on_action(cx.listener(|desk, _: &DeleteColumn, _, cx| desk.table("delete-column", cx)))
.on_action(cx.listener(|desk, _: &CheckDisk, _, cx| desk.look_all(cx)))
.on_action(cx.listener(|desk, _: &VisualMode, _, cx| desk.switch(Mode::Visual, cx)))
.on_action(cx.listener(|desk, _: &SourceMode, _, cx| desk.switch(Mode::Source, cx)))
.on_action(cx.listener(|desk, _: &PreviewMode, _, cx| desk.switch(Mode::Preview, cx)))
.child(shell)
.children(self.core_dialog(cx))
.children(self.naming_element(cx))
.children(self.find_bar(cx))
.children(self.compare_dialog(cx))
.children(self.export_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,
))
}
+201
View File
@@ -0,0 +1,201 @@
//! Markdown files as the shell's engine (§14.3).
use std::{
convert::Infallible,
hash::{DefaultHasher, Hash as _, Hasher as _},
io,
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
use ely_office::{Engine, Host, Load};
use ely_web_content::WebView;
use gpui::{App, AppContext as _, Context, Entity, FocusHandle, Window};
use crate::text::Source;
use crate::view::{Failed, MarkView, Reported};
/// The bundled page every tab shows.
pub struct Pages {
pub dir: PathBuf,
}
/// A file as read, and its text now.
pub struct Text {
pub source: Source,
pub current: String,
/// The last write's mark, until history reads it.
written: Option<String>,
}
pub type Doc = Arc<Mutex<Text>>;
/// A load: the file as read, and where it is.
pub struct Opened {
pub source: Source,
pub path: PathBuf,
}
/// The history mark: this text's length and hash.
pub fn mark(text: &str) -> String {
let mut hasher = DefaultHasher::new();
text.hash(&mut hasher);
format!("{}:{:016x}", text.len(), hasher.finish())
}
pub struct Mark;
impl Engine for Mark {
type Worker = Pages;
type Doc = Doc;
type View = MarkView;
type Loaded = Opened;
type Import = Infallible;
fn load(_: &mut Pages, from: Load<Infallible>) -> io::Result<Opened> {
let path = match from {
Load::File(path, _) => path,
Load::Import(_, never) => match *never {},
};
// The shell reads PermissionDenied as a locked file.
let bytes = std::fs::read(path).map_err(|error| match error.kind() {
io::ErrorKind::PermissionDenied => io::Error::other(error),
_ => error,
})?;
Ok(Opened {
source: Source::decode(bytes)?,
path: path.to_path_buf(),
})
}
fn open(
worker: &Arc<Mutex<Pages>>,
loaded: Opened,
window: &mut Window,
cx: &mut App,
) -> io::Result<(Doc, Entity<MarkView>, String)> {
let Opened {
source: loaded,
path,
} = loaded;
let folder = path
.parent()
.expect("a file sits in a folder")
.to_path_buf();
let dir = worker.lock().expect("the pages lock").dir.clone();
let web =
WebView::open(&dir.join("index.html"), &dir, window, cx).map_err(io::Error::other)?;
let mark = mark(&loaded.text);
let current = loaded.text.clone();
let doc = Arc::new(Mutex::new(Text {
source: loaded,
current,
written: None,
}));
web.read(cx)
.allow_images(&folder)
.map_err(io::Error::other)?;
let view = cx.new(|cx| MarkView::new(web, Arc::clone(&doc), mark.clone(), folder, cx));
Ok((doc, view, mark))
}
/// After a write, what it wrote: later edits stay unsaved.
fn history(_: &mut Pages, doc: &Doc) -> io::Result<String> {
let mut text = doc.lock().expect("the text lock");
Ok(text.written.take().unwrap_or_else(|| mark(&text.current)))
}
fn write(_: &mut Pages, doc: &Doc, path: &Path, format: &str) -> io::Result<()> {
if !matches!(format, "md" | "markdown") {
return Err(io::Error::new(
io::ErrorKind::Unsupported,
format!("writing .{format}"),
));
}
let mut text = doc.lock().expect("the text lock");
std::fs::write(path, text.source.encode(&text.current))?;
text.written = Some(mark(&text.current));
Ok(())
}
fn close(_: &mut Pages, _: Doc) -> io::Result<()> {
Ok(())
}
fn retire(view: &Entity<MarkView>, cx: &mut App) -> ely_gpui_component::primitives::Retired {
let web = view.read(cx).web.clone();
web.update(cx, |web, _| web.retire_all())
}
fn settled(view: &MarkView) -> bool {
view.settled()
}
fn edited(view: &MarkView) -> u64 {
view.changed
}
fn edit_pending(view: &MarkView) -> bool {
!view.settled()
}
fn focus(view: &MarkView) -> FocusHandle {
view.focus.clone()
}
fn saved(view: &Entity<MarkView>, mark: &str, cx: &mut App) {
view.update(cx, |view, cx| view.saved(mark, cx));
}
fn watch<H: Host<Engine = Self>>(
view: &Entity<MarkView>,
cx: &mut Context<H>,
reported: impl Fn(&mut H, &Entity<MarkView>, bool, &mut Context<H>) + 'static,
failed: impl Fn(&mut H, &str, &mut Context<H>) + 'static,
) {
cx.subscribe(view, move |host, _, error: &Failed, cx| {
failed(host, &error.0, cx)
})
.detach();
cx.subscribe(view, move |host, view, _: &Reported, cx| {
let modified = view.read(cx).modified();
reported(host, &view, modified, cx)
})
.detach();
}
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use ely_office::Engine as _;
use super::{Mark, Pages, Text, mark};
use crate::text::Source;
#[test]
fn a_save_marks_the_text_it_wrote() {
let dir = tempfile::tempdir().expect("a temp dir");
let source = Source::decode(b"Alpha.\n".to_vec()).expect("reads");
let current = source.text.clone();
let doc = Arc::new(Mutex::new(Text {
source,
current,
written: None,
}));
let mut pages = Pages {
dir: dir.path().to_path_buf(),
};
let path = dir.path().join("save.md");
Mark::write(&mut pages, &doc, &path, "md").expect("writes");
// An edit lands between the write and the history read.
doc.lock().expect("the text lock").current = "xAlpha.\n".into();
let saved = Mark::history(&mut pages, &doc).expect("a mark");
assert_eq!(saved, mark("Alpha.\n"));
assert_eq!(
Mark::history(&mut pages, &doc).expect("a mark"),
mark("xAlpha.\n")
);
}
}
+474
View File
@@ -0,0 +1,474 @@
//! Export and print: HTML, linked HTML, PDF, text (MARK-11).
use std::{
io,
path::{Path, PathBuf},
};
use ely_gpui_component::{
buttons::{Button, ButtonVariant, SegmentedControl},
overlays::Dialog,
primitives::Severity,
};
use ely_office::{cancel, files::file_name, in_window, prelude::*};
use ely_ui::{commands, i18n::text};
use ely_web_content::{Event, WebView};
use gpui::{
AnyElement, App, AppContext as _, Context, Entity, IntoElement as _, ParentElement as _,
Subscription, Window,
};
use serde_json::Value;
use crate::desk::Desk;
use crate::paper;
/// What an export writes.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Output {
Page,
Linked,
Pdf,
Text,
}
impl Output {
const ALL: [Output; 4] = [Output::Page, Output::Linked, Output::Pdf, Output::Text];
fn key(self) -> &'static str {
match self {
Output::Page => "page",
Output::Linked => "linked",
Output::Pdf => "pdf",
Output::Text => "text",
}
}
fn extension(self) -> &'static str {
match self {
Output::Page | Output::Linked => "html",
Output::Pdf => "pdf",
Output::Text => "txt",
}
}
}
/// What an export lacks: images, diagrams, formulas.
#[derive(Clone, Debug, Default)]
pub struct Lost {
images: Vec<String>,
diagrams: Vec<u64>,
/// Each broken formula's line and KaTeX's message.
formulas: Vec<(u64, String)>,
}
impl Lost {
fn of(images: Vec<String>, rendered: &Value) -> Lost {
let diagrams = rendered["failed"].as_array().into_iter().flatten();
let formulas = rendered["formulas"].as_array().into_iter().flatten();
Lost {
images,
diagrams: diagrams.filter_map(Value::as_u64).collect(),
formulas: formulas
.filter_map(|f| Some((f["line"].as_u64()?, f["error"].as_str()?.to_owned())))
.collect(),
}
}
fn whole(&self) -> bool {
self.images.is_empty() && self.diagrams.is_empty() && self.formulas.is_empty()
}
}
/// No export lands on a file a tab holds (FILE-06).
fn not_open(desk: &Desk, path: &Path) -> io::Result<()> {
match desk.holder(path)? {
Some(_) => Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("{} is open in ElyMark", file_name(path)),
)),
None => Ok(()),
}
}
/// A page printing to PDF in a view nobody sees.
pub struct Printing {
_view: Entity<WebView>,
_heard: Subscription,
}
impl Desk {
pub fn on_export(&mut self, _: &commands::Export, _: &mut Window, cx: &mut Context<Self>) {
if self.active_tab().is_some() {
self.output = Some(Output::Page);
cx.notify();
}
}
pub fn on_print(&mut self, _: &commands::Print, _: &mut Window, cx: &mut Context<Self>) {
let Some(id) = self.active_tab().map(|tab| tab.id) else {
return;
};
let pdf = self.core.work.join(format!("print-{id}.pdf"));
self.render_pdf(id, pdf, None, cx);
}
/// Asks where, renders the whole text, then writes it.
fn export_to(&mut self, output: Output, cx: &mut Context<Self>) {
let Some(tab) = self.active_tab() else {
return;
};
let id = tab.id;
let stem = tab
.name
.rsplit_once('.')
.map_or(tab.name.as_ref(), |(stem, _)| stem)
.to_owned();
let dir = ely_office::folder(tab).unwrap_or_else(std::env::temp_dir);
let chosen = cx.prompt_for_new_path(&dir, Some(&format!("{stem}.{}", output.extension())));
cx.spawn(async move |desk, cx| {
let Ok(Ok(Some(path))) = chosen.await else {
return log::info!("mark: the export panel closed without a path");
};
desk.update(cx, |desk, cx| {
if let Err(error) = not_open(desk, &path) {
return desk.exported(Err(error), cx);
}
match output {
Output::Pdf => desk.render_pdf(
id,
desk.core.work.join(format!("export-{id}.pdf")),
Some(path),
cx,
),
_ => desk.write_output(id, output, path, cx),
}
})
.ok();
})
.detach();
}
/// HTML or text, assembled and written off the main thread.
fn write_output(&mut self, id: u64, output: Output, path: PathBuf, cx: &mut Context<Self>) {
let Some(tab) = self.tab(id) else {
return;
};
let (view, folder) = (tab.view.clone(), tab.view.read(cx).folder.clone());
let open = tab.session.path().map(Path::to_path_buf);
let style = self.pages_dir().join("export.css");
let kind = if output == Output::Text {
"text"
} else {
"html"
};
let desk = cx.weak_entity();
view.update(cx, |view, cx| {
view.render(
kind,
move |rendered, cx: &mut App| {
// Checked again: tabs may have opened meanwhile.
let free = desk.update(cx, |desk, _| not_open(desk, &path));
let free = free.map_err(io::Error::other).and_then(|free| free);
let job = cx.background_spawn(async move {
free?;
let rendered = rendered.map_err(io::Error::other)?;
write(output, &rendered, &folder, &style, &path, open.as_deref())
});
cx.spawn(async move |cx| {
let written = job.await;
desk.update(cx, |desk, cx| desk.exported(written, cx)).ok();
})
.detach();
},
cx,
)
});
}
fn exported(&mut self, written: io::Result<(PathBuf, Lost)>, cx: &mut Context<Self>) {
match written {
Ok((path, lost)) if lost.whole() => {
log::info!("mark: exported {}", path.display());
let said = text(cx, "mark.export.done", &[("name", &file_name(&path))]);
self.toast_output(said, path, cx);
}
Ok((path, lost)) => {
// Counts only: image names come from the text.
log::warn!(
"mark: exported {} without {} images and {} diagrams; formulas failed at lines {:?}",
path.display(),
lost.images.len(),
lost.diagrams.len(),
lost.formulas.iter().map(|(line, _)| line).collect::<Vec<_>>()
);
self.warn_lost(&lost, Some(path), cx);
}
Err(error) => {
log::error!("mark: an export failed: {error}");
let said = text(cx, "mark.export.failed", &[("error", &error.to_string())]);
self.toast(said, Severity::Danger, cx);
}
}
}
/// Says which images, diagrams and formulas an output lacks.
fn warn_lost(&mut self, lost: &Lost, output: Option<PathBuf>, cx: &mut Context<Self>) {
let mut said = Vec::new();
if !lost.images.is_empty() {
said.push(text(
cx,
"mark.export.missing",
&[("list", &lost.images.join(", "))],
));
}
if !lost.diagrams.is_empty() {
said.push(text(
cx,
"mark.export.diagrams",
&[(
"list",
&lost
.diagrams
.iter()
.map(u64::to_string)
.collect::<Vec<_>>()
.join(", "),
)],
));
}
for (line, error) in &lost.formulas {
let line = line.to_string();
said.push(text(
cx,
"mark.export.formula",
&[("line", &line), ("error", error)],
));
}
// Names and messages quote the text: no log.
let said: gpui::SharedString = said.join(" ").into();
match (output, self.core.root.upgrade()) {
(Some(path), Some(root)) => root.update(cx, |root, cx| root.toast_output(said, Severity::Warning, path, cx)),
_ => self.toast_unlogged(said, Severity::Warning, cx),
}
}
fn pages_dir(&self) -> PathBuf {
self.core.worker.lock().expect("the pages lock").dir.clone()
}
/// The whole text as PDF; then to `to`, else printed.
fn render_pdf(&mut self, id: u64, pdf: PathBuf, to: Option<PathBuf>, cx: &mut Context<Self>) {
let Some(tab) = self.tab(id) else {
return;
};
let (view, folder, name) = (
tab.view.clone(),
tab.view.read(cx).folder.clone(),
tab.name.to_string(),
);
let open = tab.session.path().map(Path::to_path_buf);
let (style, work) = (self.pages_dir().join("export.css"), self.core.work.clone());
let desk = cx.weak_entity();
view.update(cx, |view, cx| {
view.render(
"html",
move |rendered, cx: &mut App| {
let staged = work.join(format!("print-{id}.html"));
let page = rendered.map_err(io::Error::other).and_then(|rendered| {
let (html, images) = paper::Marked::read(&rendered)?.inline(&folder);
let style = format!("<style>{}</style>", std::fs::read_to_string(&style)?);
std::fs::write(&staged, paper::page(&name, &style, &html))?;
Ok(Lost::of(images, &rendered))
});
cx.spawn(async move |cx| {
in_window(&desk, cx, |desk, window, cx| match page {
Ok(lost) => {
let job = Job {
pdf,
to,
open,
lost,
};
desk.print_page(&staged, job, window, cx)
}
Err(error) => desk.exported(Err(error), cx),
})
.ok();
})
.detach();
},
cx,
)
});
}
/// Chromium prints the staged page; the PDF goes where asked.
fn print_page(&mut self, page: &Path, job: Job, window: &mut Window, cx: &mut Context<Self>) {
let root = page
.parent()
.expect("a staged page sits in a folder")
.to_path_buf();
let view = match WebView::open(page, &root, window, cx) {
Ok(view) => view,
Err(error) => return self.exported(Err(io::Error::other(error)), cx),
};
let heard = cx.subscribe(&view, move |desk, view, event: &Event, cx| match event {
Event::Loaded => view.read(cx).print_pdf(&job.pdf),
Event::Printed(path, ok) => {
desk.printing = None;
let printed = ok
.then(|| path.clone())
.ok_or_else(|| io::Error::other("Chromium did not print the page"));
desk.printed(printed, job.clone(), cx);
}
Event::Failed(why) => {
desk.printing = None;
desk.exported(Err(io::Error::other(why.clone())), cx);
}
Event::Query(_, reply) => reply.ok(""),
Event::Leave(_) => {}
});
self.printing = Some(Printing {
_view: view,
_heard: heard,
});
}
fn printed(&mut self, pdf: io::Result<PathBuf>, job: Job, cx: &mut Context<Self>) {
let pdf = match pdf {
Ok(pdf) => pdf,
Err(error) => return self.exported(Err(error), cx),
};
let Some(to) = job.to else {
if !job.lost.whole() {
self.warn_lost(&job.lost, None, cx);
}
return ely_office::print_later(pdf, cx, |desk, why, cx| {
desk.exported(Err(io::Error::other(why)), cx)
});
};
// Never over the open file, not even through a link.
let moved = not_open(self, &to)
.and_then(|()| std::fs::read(&pdf))
.and_then(|bytes| ely_files::export(&to, job.open.as_deref(), &bytes, 0).map(|_| ()));
let _ = std::fs::remove_file(&pdf);
self.exported(moved.map(|()| (to, job.lost)), cx);
}
pub fn export_dialog(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let chosen = self.output?;
let this = cx.entity();
let kinds = Output::ALL.into_iter().fold(
SegmentedControl::new("mark-export-kind", chosen.key()),
|control, output| {
control.segment(
output.key(),
text(cx, &format!("mark.export.{}", output.key()), &[]),
None,
)
},
);
let kinds = kinds.on_change({
let this = this.clone();
move |key, _, cx| {
let picked = Output::ALL
.into_iter()
.find(|output| key.as_ref() == output.key());
this.update(cx, |desk, cx| {
desk.output = picked;
cx.notify();
})
}
});
let close = {
let this = this.clone();
move |_: &mut Window, cx: &mut App| {
this.update(cx, |desk, cx| {
desk.output = None;
cx.notify();
})
}
};
let label = text(cx, "mark.export.go", &[]);
let dialog = Dialog::new("mark-export", text(cx, "mark.export", &[]), close)
.child(kinds)
.action(cancel(cx))
.action(move |close| {
Button::new("mark-export-go", label)
.variant(ButtonVariant::Primary)
.on_click(move |_, window, cx| {
close(window, cx);
this.update(cx, |desk, cx| desk.export_to(chosen, cx));
})
});
Some(dialog.into_any_element())
}
}
/// A PDF in flight: where printed, where bound, what lacks.
#[derive(Clone)]
struct Job {
pdf: PathBuf,
to: Option<PathBuf>,
/// The tab's own file, never overwritten.
open: Option<PathBuf>,
lost: Lost,
}
/// Writes `output` at `path`; and what it left out.
fn write(
output: Output,
rendered: &Value,
folder: &Path,
style: &Path,
path: &Path,
open: Option<&Path>,
) -> io::Result<(PathBuf, Lost)> {
let title = path
.file_stem()
.map(|stem| stem.to_string_lossy().into_owned())
.unwrap_or_default();
let (bytes, missing) = match output {
Output::Text => (
rendered["text"]
.as_str()
.unwrap_or_default()
.as_bytes()
.to_vec(),
Vec::new(),
),
Output::Page => {
let (html, missing) = paper::Marked::read(rendered)?.inline(folder);
let style = format!("<style>{}</style>", std::fs::read_to_string(style)?);
(paper::page(&title, &style, &html).into_bytes(), missing)
}
Output::Linked => {
let assets = format!("{title}_files");
let place = path.with_file_name(&assets);
let (html, copies, missing) = paper::Marked::read(rendered)?.linked(folder, &assets);
// A folder already there is never written into.
std::fs::create_dir(&place)?;
let copied = copies
.iter()
.try_for_each(|(from, name)| std::fs::copy(from, place.join(name)).map(|_| ()))
.and_then(|()| std::fs::copy(style, place.join("export.css")).map(|_| ()));
if let Err(error) = copied {
let _ = std::fs::remove_dir_all(&place);
return Err(error);
}
let style = format!(
"<link rel=\"stylesheet\" href=\"{}/export.css\">",
paper::segment(&assets)
);
(paper::page(&title, &style, &html).into_bytes(), missing)
}
Output::Pdf => unreachable!("PDF prints in a page"),
};
if let Err(error) = ely_files::export(path, open, &bytes, 0) {
if output == Output::Linked {
let _ = std::fs::remove_dir_all(path.with_file_name(format!("{title}_files")));
}
return Err(error);
}
Ok((path.to_path_buf(), Lost::of(missing, rendered)))
}
+185
View File
@@ -0,0 +1,185 @@
//! Find and replace over the shown editor (MARK-06).
use ely_gpui_component::primitives::raise;
use ely_gpui_component::{
editor::{FindOptions, FindWidget},
forms::{InputEvent, TextInput},
};
use ely_office::prelude::*;
use gpui::{
AnyElement, AppContext as _, Context, Entity, Focusable as _, InteractiveElement as _,
IntoElement as _, ParentElement as _, Styled as _, Subscription, Window, actions, div,
};
use serde_json::json;
use crate::desk::Desk;
actions!(mark, [Find]);
/// Where the shown editor's search stands.
#[derive(Clone, Debug, PartialEq)]
pub struct Found {
pub current: Option<usize>,
pub total: usize,
/// A pattern that does not compile.
pub error: Option<String>,
}
pub struct Finder {
query: Entity<TextInput>,
with: Entity<TextInput>,
options: FindOptions,
replacing: bool,
/// The tab last searched; another one is searched anew.
tab: Option<u64>,
_follow: Subscription,
}
impl Desk {
/// Opens the find box, or takes focus to it.
pub fn on_find(&mut self, _: &Find, window: &mut Window, cx: &mut Context<Self>) {
if let Some(finder) = &self.finder {
let focus = finder.query.focus_handle(cx);
return window.focus(&focus, cx);
}
let query = cx.new(|cx| TextInput::new(window, cx));
let with = cx.new(|cx| TextInput::new(window, cx));
cx.subscribe(&query, |desk, _, event: &InputEvent, cx| match event {
InputEvent::Changed => desk.seek(cx),
InputEvent::Submit => desk.step(true, cx),
_ => {}
})
.detach();
cx.subscribe(&with, |desk, _, event: &InputEvent, cx| match event {
InputEvent::Changed => desk.seek(cx),
InputEvent::Submit => desk.replace(false, cx),
_ => {}
})
.detach();
let focus = query.focus_handle(cx);
let follow = cx.observe_self(|desk, cx| {
let shown = desk.active_tab().map(|tab| tab.id);
if desk
.finder
.as_ref()
.is_some_and(|finder| finder.tab != shown)
{
desk.seek(cx);
}
});
self.finder = Some(Finder {
query,
with,
options: FindOptions::default(),
replacing: false,
tab: None,
_follow: follow,
});
window.focus(&focus, cx);
cx.notify();
}
/// The query and options as the page's search spec.
fn spec(&self, cx: &Context<Self>) -> Option<serde_json::Value> {
let finder = self.finder.as_ref()?;
Some(json!({
"search": finder.query.read(cx).text(),
"replace": finder.with.read(cx).text(),
"caseSensitive": finder.options.case,
"regexp": finder.options.regex,
"wholeWord": finder.options.word,
}))
}
fn seek(&mut self, cx: &mut Context<Self>) {
let shown = self.active_tab().map(|tab| tab.id);
if let Some(finder) = self.finder.as_mut() {
finder.tab = shown;
}
if let (Some(spec), Some(view)) = (self.spec(cx), self.active_view()) {
view.update(cx, |view, cx| view.find(spec, cx));
}
}
fn step(&mut self, forward: bool, cx: &mut Context<Self>) {
if let (Some(spec), Some(view)) = (self.spec(cx), self.active_view()) {
view.update(cx, |view, cx| view.step(spec, forward, cx));
}
}
fn replace(&mut self, all: bool, cx: &mut Context<Self>) {
if let (Some(spec), Some(view)) = (self.spec(cx), self.active_view()) {
view.update(cx, |view, cx| view.replace(spec, all, cx));
}
}
fn close_find(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.finder = None;
if let Some(view) = self.active_view() {
view.update(cx, |view, cx| view.unfind(cx));
let focus = view.read(cx).focus.clone();
window.focus(&focus, cx);
}
cx.notify();
}
pub fn find_bar(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let finder = self.finder.as_ref()?;
let found = self
.active_view()
.and_then(|view| view.read(cx).found.clone());
let (current, total) = found
.as_ref()
.map_or((None, 0), |found| (found.current, found.total));
let this = cx.entity();
let widget = FindWidget::new("mark-find", &finder.query, current, total)
.options(finder.options)
.on_options({
let this = this.clone();
move |options, _, cx| {
this.update(cx, |desk, cx| {
if let Some(finder) = desk.finder.as_mut() {
finder.options = options;
}
desk.seek(cx);
})
}
})
.on_step({
let this = this.clone();
move |forward, _, cx| this.update(cx, |desk, cx| desk.step(forward, cx))
})
.on_replace({
let this = this.clone();
move |all, _, cx| this.update(cx, |desk, cx| desk.replace(all, cx))
})
.on_toggle_replace({
let this = this.clone();
move |open, _, cx| {
this.update(cx, |desk, cx| {
if let Some(finder) = desk.finder.as_mut() {
finder.replacing = open;
}
cx.notify();
})
}
})
.on_close(move |window, cx| this.update(cx, |desk, cx| desk.close_find(window, cx)));
let widget = match finder.replacing {
true => widget.replace(&finder.with),
false => widget,
};
let widget = match found.and_then(|found| found.error) {
Some(error) => widget.error(error),
None => widget,
};
// Over the page's top right, as elsewhere.
let place = div()
.absolute()
.top_2()
.right_4()
.block_mouse_except_scroll()
.child(widget);
Some(raise("mark-find-layer", place).into_any_element())
}
}
+115
View File
@@ -0,0 +1,115 @@
//! An opened folder's Markdown files, and searching them (MARK-05, MARK-10).
use std::{
io,
path::{Path, PathBuf},
};
/// An opened folder and its Markdown files.
pub struct Folder {
pub root: PathBuf,
pub files: Vec<String>,
}
/// Hits a search keeps; it looks no further.
pub const MOST_HITS: usize = 500;
fn markdown(name: &str) -> bool {
let name = name.to_lowercase();
name.ends_with(".md") || name.ends_with(".markdown")
}
/// Markdown files under `root`; no links, no dot names.
pub fn markdown_under(root: &Path) -> io::Result<Vec<String>> {
let mut found = Vec::new();
let mut folders = vec![(root.to_path_buf(), String::new())];
while let Some((dir, prefix)) = folders.pop() {
for entry in std::fs::read_dir(&dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().into_owned();
if name.starts_with('.') {
continue;
}
let kind = entry.file_type()?;
let path = format!("{prefix}{name}");
if kind.is_dir() {
folders.push((entry.path(), format!("{path}/")));
} else if kind.is_file() && markdown(&name) {
found.push(path);
}
}
}
found.sort_by_key(|path| path.to_lowercase());
Ok(found)
}
/// One line that holds the words.
#[derive(Clone, Debug, PartialEq)]
pub struct Hit {
pub file: PathBuf,
/// Counted from one.
pub line: usize,
pub text: String,
}
/// Lines of `text` holding `query`, any case.
pub fn lines_with(file: &Path, text: &str, query: &str, hits: &mut Vec<Hit>) {
let query = query.to_lowercase();
for (at, line) in text.lines().enumerate() {
if hits.len() == MOST_HITS {
return;
}
if line.to_lowercase().contains(&query) {
let shown: String = line.trim().chars().take(160).collect();
hits.push(Hit {
file: file.to_path_buf(),
line: at + 1,
text: shown,
});
}
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
#[cfg(unix)]
#[test]
fn markdown_files_list_without_hidden_or_other_files() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = dir.path();
std::fs::create_dir_all(root.join("notes/deep")).expect("makes");
std::fs::create_dir_all(root.join(".git")).expect("makes");
for file in [
"b.md",
"A.markdown",
"notes/deep/c.MD",
"notes/skip.txt",
".git/x.md",
".hidden.md",
] {
std::fs::write(root.join(file), "x").expect("writes");
}
#[cfg(unix)]
std::os::unix::fs::symlink(root.join("notes"), root.join("loop")).expect("links");
let found = super::markdown_under(root).expect("lists");
assert_eq!(found, ["A.markdown", "b.md", "notes/deep/c.MD"]);
}
#[test]
fn lines_with_the_words_any_case() {
let mut hits = Vec::new();
super::lines_with(
Path::new("a.md"),
"# Title\nsome Words here\nnone\nWORDS",
"words",
&mut hits,
);
let lines: Vec<_> = hits
.iter()
.map(|hit| (hit.line, hit.text.as_str()))
.collect();
assert_eq!(lines, [(2, "some Words here"), (4, "WORDS")]);
}
}
+118
View File
@@ -0,0 +1,118 @@
//! Modes, menus and keys (MARK-01, MARK-02).
use ely_ui::{commands, i18n::text};
use gpui::{App, KeyBinding, Menu, MenuItem, actions};
use crate::page::Mode;
actions!(
mark,
[
VisualMode,
SourceMode,
PreviewMode,
InsertTable,
RowAbove,
RowBelow,
ColumnLeft,
ColumnRight,
DeleteRow,
DeleteColumn,
CheckDisk
]
);
pub fn bind(cx: &mut App) {
let at = Some(commands::CONTEXT);
cx.bind_keys([
KeyBinding::new("secondary-1", VisualMode, at),
KeyBinding::new("secondary-2", SourceMode, at),
KeyBinding::new("secondary-3", PreviewMode, at),
KeyBinding::new("secondary-f", crate::finding::Find, at),
KeyBinding::new("secondary-shift-f", crate::look::FocusMode, at),
KeyBinding::new("secondary-=", crate::look::Larger, at),
KeyBinding::new("secondary--", crate::look::Smaller, at),
KeyBinding::new("secondary-0", crate::look::ActualSize, at),
]);
}
/// The label of `mode`.
pub fn label(mode: Mode, cx: &App) -> gpui::SharedString {
text(cx, &format!("mark.mode.{}", mode.name()), &[])
}
pub fn menus(look: &crate::look::Look, cx: &App) -> Vec<Menu> {
use crate::look::{self, Font, Width};
let checked = |key: &str, action: Box<dyn gpui::Action>, checked: bool| MenuItem::Action {
name: text(cx, key, &[]),
action,
os_action: None,
checked,
disabled: false,
};
let item = |key: &str, action: Box<dyn gpui::Action>| checked(key, action, false);
let table = Menu::new(text(cx, "mark.menu.table", &[])).items([
item("mark.table.insert", Box::new(InsertTable)),
MenuItem::separator(),
item("mark.table.row_above", Box::new(RowAbove)),
item("mark.table.row_below", Box::new(RowBelow)),
item("mark.table.column_left", Box::new(ColumnLeft)),
item("mark.table.column_right", Box::new(ColumnRight)),
MenuItem::separator(),
item("mark.table.delete_row", Box::new(DeleteRow)),
item("mark.table.delete_column", Box::new(DeleteColumn)),
]);
vec![
table,
Menu::new(text(cx, "menu.view", &[])).items([
MenuItem::action(label(Mode::Visual, cx), VisualMode),
MenuItem::action(label(Mode::Source, cx), SourceMode),
MenuItem::action(label(Mode::Preview, cx), PreviewMode),
MenuItem::separator(),
checked("mark.look.focus", Box::new(look::FocusMode), look.focus),
checked(
"mark.look.typewriter",
Box::new(look::Typewriter),
look.typewriter,
),
MenuItem::separator(),
checked(
"mark.look.narrow",
Box::new(look::Narrow),
look.width == Width::Narrow,
),
checked(
"mark.look.medium",
Box::new(look::Medium),
look.width == Width::Medium,
),
checked(
"mark.look.wide",
Box::new(look::Wide),
look.width == Width::Wide,
),
MenuItem::separator(),
checked(
"mark.look.sans",
Box::new(look::Sans),
look.font == Font::Sans,
),
checked(
"mark.look.serif",
Box::new(look::Serif),
look.font == Font::Serif,
),
checked(
"mark.look.mono",
Box::new(look::Mono),
look.font == Font::Mono,
),
MenuItem::separator(),
item("mark.look.larger", Box::new(look::Larger)),
item("mark.look.smaller", Box::new(look::Smaller)),
item("mark.look.actual", Box::new(look::ActualSize)),
MenuItem::separator(),
item("mark.outside.check", Box::new(CheckDisk)),
]),
]
}
+166
View File
@@ -0,0 +1,166 @@
//! The writing view, kept across launches (MARK-09, MARK-12).
use std::{io, path::Path};
use gpui::{Context, actions};
use serde::{Deserialize, Serialize};
use crate::desk::Desk;
actions!(
mark,
[
FocusMode, Typewriter, Narrow, Medium, Wide, Sans, Serif, Mono, Larger, Smaller, ActualSize
]
);
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Width {
Narrow,
Medium,
Wide,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Font {
Sans,
Serif,
Mono,
}
/// How text shows; the Markdown never changes.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Look {
pub focus: bool,
pub typewriter: bool,
pub width: Width,
pub font: Font,
/// Percent of the app's text size.
pub size: u32,
}
impl Default for Look {
fn default() -> Self {
Look {
focus: false,
typewriter: false,
width: Width::Medium,
font: Font::Sans,
size: 100,
}
}
}
/// The kept look; none kept is the default.
pub fn load(file: &Path) -> io::Result<Look> {
match std::fs::read(file) {
Ok(bytes) => serde_json::from_slice(&bytes).map_err(io::Error::other),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Look::default()),
Err(error) => Err(error),
}
}
/// Sizes step by a tenth, from 70 to 200 percent.
const SIZES: (u32, u32, u32) = (70, 200, 10);
impl Desk {
/// Changes the look; pages, file and menus follow.
pub fn relook(&mut self, change: impl FnOnce(&mut Look), cx: &mut Context<Self>) {
change(&mut self.look);
self.look.size = self.look.size.clamp(SIZES.0, SIZES.1);
log::info!("mark: the look is now {:?}", self.look);
self.show_look(cx);
let json = serde_json::to_vec_pretty(&self.look).expect("a look serializes");
if let Err(error) = ely_files::save_atomic(&self.look_file, &json, 0) {
log::error!("mark: the look did not save: {error}");
}
let menus = crate::keys::menus(&self.look, cx);
ely_ui::set_document_menus(
ely_platform::AppId::Mark,
&ely_ui::commands::RENAMING,
menus,
cx,
);
cx.notify();
}
/// Every tab's page takes the look.
pub fn show_look(&mut self, cx: &mut Context<Self>) {
let look = serde_json::to_value(&self.look).expect("a look serializes");
for tab in &self.core.tabs {
let look = look.clone();
tab.view.update(cx, |view, cx| view.set_look(look, cx));
}
}
pub fn look_actions(element: gpui::Div, cx: &mut Context<Self>) -> gpui::Div {
use gpui::InteractiveElement as _;
let step = SIZES.2;
element
.on_action(cx.listener(|desk, _: &FocusMode, _, cx| {
desk.relook(|look| look.focus = !look.focus, cx)
}))
.on_action(cx.listener(|desk, _: &Typewriter, _, cx| {
desk.relook(|look| look.typewriter = !look.typewriter, cx)
}))
.on_action(cx.listener(|desk, _: &Narrow, _, cx| {
desk.relook(|look| look.width = Width::Narrow, cx)
}))
.on_action(cx.listener(|desk, _: &Medium, _, cx| {
desk.relook(|look| look.width = Width::Medium, cx)
}))
.on_action(
cx.listener(|desk, _: &Wide, _, cx| {
desk.relook(|look| look.width = Width::Wide, cx)
}),
)
.on_action(
cx.listener(|desk, _: &Sans, _, cx| desk.relook(|look| look.font = Font::Sans, cx)),
)
.on_action(
cx.listener(|desk, _: &Serif, _, cx| {
desk.relook(|look| look.font = Font::Serif, cx)
}),
)
.on_action(
cx.listener(|desk, _: &Mono, _, cx| desk.relook(|look| look.font = Font::Mono, cx)),
)
.on_action(
cx.listener(move |desk, _: &Larger, _, cx| {
desk.relook(|look| look.size += step, cx)
}),
)
.on_action(cx.listener(move |desk, _: &Smaller, _, cx| {
desk.relook(|look| look.size = look.size.saturating_sub(step), cx)
}))
.on_action(
cx.listener(|desk, _: &ActualSize, _, cx| desk.relook(|look| look.size = 100, cx)),
)
}
}
#[cfg(test)]
mod tests {
#[test]
fn a_kept_look_reads_back_and_none_is_default() {
let dir = tempfile::tempdir().expect("a temp dir");
let file = dir.path().join("look.json");
assert_eq!(
super::load(&file).expect("none kept"),
super::Look::default()
);
std::fs::write(
&file,
r#"{"focus":true,"typewriter":false,"width":"wide","font":"serif","size":120}"#,
)
.expect("a look");
let look = super::load(&file).expect("it reads");
assert!(look.focus);
assert_eq!(
(look.width, look.font, look.size),
(super::Width::Wide, super::Font::Serif, 120)
);
}
}
+30
View File
@@ -0,0 +1,30 @@
mod attach;
mod autosave;
mod compare;
mod desk;
mod engine;
mod export;
mod finding;
mod folder;
mod keys;
mod look;
mod outside;
mod page;
mod paper;
mod places;
mod rename;
mod restore;
mod side;
mod text;
mod vcs;
mod view;
use ely_platform::AppId;
fn main() {
// CEF runs its helper processes from this same binary.
if let Some(code) = ely_web_content::subprocess() {
std::process::exit(code);
}
ely_ui::run(AppId::Mark, desk::open);
}
+347
View File
@@ -0,0 +1,347 @@
//! Changes made to a file outside ElyMark (MARK-08, §14.4).
use std::{
io,
path::{Path, PathBuf},
time::Duration,
};
use ely_files::{Fingerprint, Policy, PolicyEngine, SaveState};
use ely_gpui_component::{feedback::Banner, primitives::Severity};
use ely_office::prelude::*;
use futures::{StreamExt as _, channel::mpsc};
use gpui::Context;
use notify::{RecommendedWatcher, RecursiveMode, Watcher as _};
use crate::desk::Desk;
use crate::text::Source;
use ely_ui::i18n::text;
/// Bursts, such as a save's rename, settle into one look.
const SETTLE: Duration = Duration::from_millis(200);
/// An atomic replace empties the path this long at most.
const GAP: Duration = Duration::from_millis(300);
/// What happened to a tab's file on disk.
#[derive(Default)]
pub enum Outside {
#[default]
Clear,
/// Changed under unsaved text: the disk's version, as read.
Changed(Source, Fingerprint),
Deleted,
/// Changes cannot be followed; why.
Unread(String),
}
/// What ElyMark keeps per tab.
pub struct Extra {
pub outside: Outside,
/// The text as last loaded or saved: the merge base.
pub base: String,
pub policy: PolicyEngine,
/// The page change autosave last heard.
pub heard: u64,
/// The policy revision an autosave writes.
pub writing: Option<u64>,
/// A look put off while a save runs.
pub recheck: bool,
/// Its recovery folder's name, once given.
pub slot: String,
/// The last recovery point's mark, and one in flight.
pub pointed: Option<String>,
pub pointing: bool,
/// A kept tab's mode, place and text came back once.
pub restored: bool,
}
impl Default for Extra {
fn default() -> Self {
Extra {
outside: Outside::Clear,
base: String::new(),
policy: PolicyEngine::new(Policy::MarkAutoSave, false),
heard: 0,
writing: None,
recheck: false,
slot: String::new(),
pointed: None,
pointing: false,
restored: false,
}
}
}
/// The file's bytes and the fingerprint they match.
fn read_still(path: &Path) -> io::Result<(Fingerprint, Source)> {
// A writer mid-way changes the fingerprint; read again.
for _ in 0..5 {
let before = Fingerprint::of(path)?;
let bytes = std::fs::read(path)?;
if Fingerprint::of(path)? == before {
return Ok((before, Source::decode(bytes)?));
}
}
Err(io::Error::other("the file kept changing while read"))
}
impl Desk {
/// Watches every tab's folder; a change looks at the tabs.
pub fn watch_folders(&mut self, cx: &mut Context<Self>) {
let mut dirs: Vec<PathBuf> = self
.core
.tabs
.iter()
.filter_map(|tab| Some(tab.session.path()?.parent()?.to_path_buf()))
.collect();
dirs.sort();
dirs.dedup();
if dirs == self.watched {
return;
}
self.watcher = None;
self.watched = dirs.clone();
if dirs.is_empty() {
return;
}
match watch(&dirs, cx) {
Ok(watcher) => self.watcher = Some(watcher),
Err(error) => self.unwatched(error.to_string(), cx),
}
}
/// Watching failed: every file says so and offers a look.
fn unwatched(&mut self, why: String, cx: &mut Context<Self>) {
log::error!("mark: watching files failed: {why}");
let ids: Vec<u64> = self.core.tabs.iter().map(|tab| tab.id).collect();
for id in ids {
self.set_outside(id, Outside::Unread(why.clone()), cx);
}
}
pub fn look_all(&mut self, cx: &mut Context<Self>) {
// A write, ours or not, may change what Git sees.
self.ask_git(cx);
let ids: Vec<u64> = self.core.tabs.iter().map(|tab| tab.id).collect();
for id in ids {
self.look(id, cx);
}
}
/// Compares tab `id`'s file with its last read or write.
pub fn look(&mut self, id: u64, cx: &mut Context<Self>) {
let Some(tab) = self.tab_mut(id) else {
return;
};
let Some(path) = tab.session.path().map(Path::to_path_buf) else {
return;
};
if tab.exporting || tab.session.state() == SaveState::Saving {
tab.extra.recheck = true;
return;
}
tab.extra.recheck = false;
let ours = tab.session.fingerprint();
match Fingerprint::of(&path) {
Ok(now) if Some(now) == ours => {
if !matches!(tab.extra.outside, Outside::Changed(..)) {
self.set_outside(id, Outside::Clear, cx);
}
}
Ok(_) => self.read_disk(id, path, cx),
Err(error) if error.kind() == io::ErrorKind::NotFound => self.missing(id, path, cx),
Err(error) => self.set_outside(id, Outside::Unread(error.to_string()), cx),
}
}
/// Gone: an atomic replace, a rename, or a delete.
fn missing(&mut self, id: u64, path: PathBuf, cx: &mut Context<Self>) {
cx.spawn(async move |desk, cx| {
cx.background_executor().timer(GAP).await;
desk.update(cx, |desk, cx| desk.still_missing(id, path, cx))
.ok();
})
.detach();
}
fn still_missing(&mut self, id: u64, path: PathBuf, cx: &mut Context<Self>) {
let Some(tab) = self.tab(id).filter(|tab| tab.session.path() == Some(&path)) else {
return;
};
if path.exists() {
return self.look(id, cx);
}
let found = match (tab.registry, path.parent()) {
(Some(registry), Some(dir)) => self.core.sessions.renamed_in(registry, dir),
_ => Ok(None),
};
match found {
Ok(Some(to)) => {
log::info!("mark: {} was renamed outside", path.display());
self.follow(id, to, cx);
self.look(id, cx);
}
Ok(None) => {
log::warn!("mark: {} was deleted outside", path.display());
self.set_outside(id, Outside::Deleted, cx);
}
Err(error) => self.set_outside(id, Outside::Unread(error.to_string()), cx),
}
}
/// Changed: a clean tab follows, a dirty one asks.
fn read_disk(&mut self, id: u64, path: PathBuf, cx: &mut Context<Self>) {
let reading = path.clone();
cx.spawn(async move |desk, cx| {
let read = cx
.background_executor()
.spawn(async move { read_still(&reading) })
.await;
desk.update(cx, |desk, cx| desk.disk_read(id, path, read, cx))
.ok();
})
.detach();
}
fn disk_read(
&mut self,
id: u64,
path: PathBuf,
read: io::Result<(Fingerprint, Source)>,
cx: &mut Context<Self>,
) {
let Some(tab) = self.tab(id).filter(|tab| tab.session.path() == Some(&path)) else {
return log::info!("mark: a read for a moved or closed tab was dropped");
};
let (fingerprint, disk) = match read {
Ok(read) => read,
Err(error) => return self.set_outside(id, Outside::Unread(error.to_string()), cx),
};
let current = tab.doc.lock().expect("the text lock").current.clone();
let dirty = tab.dirty(cx);
log::info!("mark: {} changed outside; unsaved {dirty}", path.display());
match (disk.text == current, dirty) {
(true, _) | (false, false) => self.take_disk(id, disk, fingerprint, cx),
(false, true) => {
let tab = self.tab_mut(id).expect("open");
tab.session.external_modified();
tab.extra.policy.external_modified();
self.set_outside(id, Outside::Changed(disk, fingerprint), cx);
// An open comparison shows the newest disk text.
if self
.comparing
.as_ref()
.is_some_and(|comparing| comparing.id == id)
{
self.compare(id, cx);
}
}
}
}
/// The disk's text replaces the tab's; autosave starts afresh.
pub fn take_disk(
&mut self,
id: u64,
disk: Source,
fingerprint: Fingerprint,
cx: &mut Context<Self>,
) {
let Some(tab) = self.tab_mut(id) else {
return;
};
if let Err(error) = tab.session.reload_at(fingerprint) {
return self.set_outside(id, Outside::Unread(error.to_string()), cx);
}
tab.extra.base = disk.text.clone();
tab.extra.policy = PolicyEngine::new(Policy::MarkAutoSave, true);
let (view, registry) = (tab.view.clone(), tab.registry);
let path = tab.session.path().map(Path::to_path_buf);
// An atomic replace is a new file underneath.
if let (Some(registry), Some(path)) = (registry, path)
&& let Err(error) = self.core.sessions.track(registry, &path)
{
log::warn!("mark: the replaced file did not register: {error}");
}
view.update(cx, |view, cx| view.refresh(disk, cx));
self.set_outside(id, Outside::Clear, cx);
}
pub fn set_outside(&mut self, id: u64, outside: Outside, cx: &mut Context<Self>) {
if let Some(tab) = self.tab_mut(id) {
tab.extra.outside = outside;
cx.notify();
}
}
/// The active file's trouble on disk, with its way out.
pub fn outside_banner(&self, cx: &mut Context<Self>) -> Option<Banner> {
let tab = self.active_tab()?;
let (id, name) = (tab.id, tab.name.to_string());
let this = cx.entity();
let (key, why, action, severity) = match &tab.extra.outside {
Outside::Clear => return None,
Outside::Changed(..) => (
"mark.outside.changed",
None,
"mark.outside.compare",
Severity::Warning,
),
Outside::Deleted => (
"mark.outside.deleted",
None,
"mark.outside.save_as",
Severity::Warning,
),
Outside::Unread(why) => (
"mark.outside.unread",
Some(why.clone()),
"mark.outside.refresh",
Severity::Danger,
),
};
let why = why.unwrap_or_default();
let said = text(cx, key, &[("name", &name), ("why", &why)]);
let label = text(cx, action, &[]);
let banner = Banner::new("mark-outside", severity, said);
Some(banner.action(label, move |_, cx| {
this.update(cx, |desk, cx| {
match desk.tab(id).map(|tab| &tab.extra.outside) {
Some(Outside::Changed(..)) => desk.compare(id, cx),
Some(Outside::Deleted) => desk.save_as(id, None, cx),
_ => desk.look(id, cx),
}
})
}))
}
}
/// Watches `dirs`; each settled burst looks at the tabs.
fn watch(dirs: &[PathBuf], cx: &mut Context<Desk>) -> notify::Result<RecommendedWatcher> {
let (sender, mut changes) = mpsc::unbounded();
let mut watcher = notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
let _ = sender.unbounded_send(event.map(|_| ()).map_err(|error| error.to_string()));
})?;
for dir in dirs {
watcher.watch(dir, RecursiveMode::NonRecursive)?;
}
cx.spawn(async move |desk, cx| {
while let Some(first) = changes.next().await {
cx.background_executor().timer(SETTLE).await;
let mut failed = first.err();
while let Ok(more) = changes.try_recv() {
failed = more.err().or(failed);
}
let looked = desk.update(cx, |desk, cx| match failed {
Some(why) => desk.unwatched(why, cx),
None => desk.look_all(cx),
});
if looked.is_err() {
break;
}
}
})
.detach();
log::info!("mark: watching {} folders", dirs.len());
Ok(watcher)
}
+92
View File
@@ -0,0 +1,92 @@
//! The bundled page: its folder and its input.
use std::path::{Path, PathBuf};
use anyhow::{Result, bail};
use ely_web_content::WebView;
use gpui::App;
use serde_json::json;
/// The app's Resources, else this build's `engines/content`.
pub fn content() -> Result<PathBuf> {
let bundled = std::env::current_exe()?
.parent()
.and_then(|dir| Some(dir.parent()?.join("Resources/content")));
let built = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../engines/content/ely-mark");
for dir in bundled.into_iter().chain([built]) {
if dir.join("index.html").is_file() {
log::info!("mark: page from {}", dir.display());
return Ok(dir);
}
}
bail!("ElyMark's page is not built; run scripts/build-mark-content.sh")
}
/// How the page shows a document (MARK-02).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Mode {
Visual,
Source,
Preview,
}
impl Mode {
pub const ALL: [Mode; 3] = [Mode::Visual, Mode::Source, Mode::Preview];
pub fn name(self) -> &'static str {
match self {
Mode::Visual => "visual",
Mode::Source => "source",
Mode::Preview => "preview",
}
}
pub fn parse(name: &str) -> Option<Mode> {
Mode::ALL.into_iter().find(|mode| mode.name() == name)
}
}
/// Shows `markdown` as `mode` after load `load`, maybe placed.
pub fn open(
view: &WebView,
markdown: &str,
(mode, load, place): (Mode, u64, Option<crate::view::Place>),
folder: &Path,
cx: &App,
) {
let text = |key| ely_ui::i18n::text(cx, key, &[]).to_string();
let texts = json!({
"searchLanguage": text("mark.search_language"),
"noResult": text("mark.no_result"),
"copy": text("mark.copy"),
"preview": text("mark.preview"),
"loading": text("mark.loading"),
"edit": text("mark.edit"),
"hide": text("mark.hide"),
});
// Relative links and images resolve in the document's folder.
let base = format!("{}/", ely_web_content::file_url(folder));
view.run_js(&format!(
"ely.open({}, '{}', {texts}, {load}, {}, {})",
json!(markdown),
mode.name(),
json!(base),
json!(place)
));
}
/// The same text in another mode; pending edits go first.
pub fn switch(view: &WebView, mode: Mode) {
view.run_js(&format!("ely.mode('{}')", mode.name()));
}
/// The folder for relative links and images.
pub fn folder(view: &WebView, folder: &Path) {
let base = format!("{}/", ely_web_content::file_url(folder));
view.run_js(&format!("ely.base({})", json!(base)));
}
/// Calls `ely.<name>(argument)` in the page.
pub fn call(view: &WebView, name: &str, argument: &serde_json::Value) {
view.run_js(&format!("ely.{name}({argument})"));
}
+229
View File
@@ -0,0 +1,229 @@
//! Rendered Markdown as files: HTML pages and their images (MARK-11).
use std::path::{Path, PathBuf};
use base64::Engine as _;
/// A whole HTML page: the body, its title, its stylesheet.
pub fn page(title: &str, style: &str, body: &str) -> String {
format!(
"<!doctype html>\n<html>\n<head>\n<meta charset=\"utf-8\">\n<title>{}</title>\n{style}\n</head>\n<body>\n{body}\n</body>\n</html>\n",
escape(title)
)
}
fn escape(text: &str) -> String {
text.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
/// `%XX` decoded, as links to files are written.
fn decoded(text: &str) -> String {
let bytes = text.as_bytes();
let (mut out, mut at) = (Vec::with_capacity(bytes.len()), 0);
while at < bytes.len() {
let hex = bytes
.get(at + 1..at + 3)
.and_then(|pair| u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok());
match (bytes[at], hex) {
(b'%', Some(byte)) => (out.push(byte), at += 3),
(byte, _) => (out.push(byte), at += 1),
};
}
String::from_utf8_lossy(&out).into_owned()
}
/// One path segment for a URL: reserved bytes percent-escaped.
pub fn segment(name: &str) -> String {
let mut out = String::new();
for byte in name.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(byte as char)
}
_ => out.push_str(&format!("%{byte:02X}")),
}
}
out
}
/// A file image's path; web links stay as they are.
fn local(src: &str, folder: &Path) -> Option<PathBuf> {
if src.starts_with("data:") || src.starts_with('#') {
return None;
}
if let Some(path) = src.strip_prefix("file://") {
return Some(PathBuf::from(decoded(path)));
}
if src.contains("://") {
return None;
}
let path = PathBuf::from(decoded(src.split(['?', '#']).next().unwrap_or(src)));
Some(if path.is_absolute() {
path
} else {
folder.join(path)
})
}
fn kind(path: &Path) -> &'static str {
let extension = path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or_default();
match extension.to_lowercase().as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"svg" => "image/svg+xml",
"bmp" => "image/bmp",
"avif" => "image/avif",
_ => "application/octet-stream",
}
}
/// The page's HTML; image `src` values replaced by numbered tokens.
#[derive(Clone, Debug, Default)]
pub struct Marked {
pub html: String,
/// The original `src` values, decoded, in order.
pub images: Vec<String>,
pub token: String,
}
impl Marked {
/// From the page's answer; anything missing is an error.
pub fn read(rendered: &serde_json::Value) -> std::io::Result<Marked> {
let bad = || std::io::Error::other("the page's render lacks its parts");
let html = rendered["html"].as_str().ok_or_else(bad)?.to_owned();
let token = rendered["token"].as_str().ok_or_else(bad)?.to_owned();
let images = rendered["images"].as_array().ok_or_else(bad)?;
let images = images
.iter()
.map(|src| src.as_str().map(str::to_owned).ok_or_else(bad));
let images = images.collect::<std::io::Result<_>>()?;
Ok(Marked {
html,
images,
token,
})
}
/// Each mark swapped for `each` of its file; unread listed.
fn rewrite(
&self,
folder: &Path,
mut each: impl FnMut(&Path) -> Option<String>,
) -> (String, Vec<String>) {
let (mut html, mut missing) = (self.html.clone(), Vec::new());
for (n, src) in self.images.iter().enumerate() {
let new = match local(src, folder) {
None => src.clone(),
Some(path) => each(&path).unwrap_or_else(|| {
missing.push(src.clone());
src.clone()
}),
};
// Quoted, so mark 1 never matches inside mark 10.
let mark = format!("\"{}{n}\"", self.token);
html = html.replacen(&mark, &format!("\"{}\"", escape(&new)), 1);
}
(html, missing)
}
/// Images as data URLs: one file carries all (self-contained).
pub fn inline(&self, folder: &Path) -> (String, Vec<String>) {
self.rewrite(folder, |path| {
let bytes = std::fs::read(path).ok()?;
let data = base64::engine::general_purpose::STANDARD.encode(bytes);
Some(format!("data:{};base64,{data}", kind(path)))
})
}
/// Images linked into the `assets` folder; and the copies.
pub fn linked(
&self,
folder: &Path,
assets: &str,
) -> (String, Vec<(PathBuf, String)>, Vec<String>) {
let mut copies: Vec<(PathBuf, String)> = Vec::new();
let (html, missing) = self.rewrite(folder, |path| {
if !path.is_file() {
return None;
}
if let Some((_, name)) = copies.iter().find(|(from, _)| from == path) {
return Some(format!("{}/{}", segment(assets), segment(name)));
}
let file = path.file_name()?.to_string_lossy().into_owned();
// Same names from different folders stay apart.
let name = format!("{}-{file}", copies.len() + 1);
let url = format!("{}/{}", segment(assets), segment(&name));
copies.push((path.to_path_buf(), name));
Some(url)
});
(html, copies, missing)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// HTML as the page marks it.
fn marked(images: &[&str]) -> Marked {
let token = "ely-image-t-".to_owned();
let html = (0..images.len())
.map(|n| format!("<img alt=\"a > b src='x'\" src=\"{token}{n}\">"))
.collect();
Marked {
html,
images: images.iter().map(|src| src.to_string()).collect(),
token,
}
}
#[test]
fn images_inline_and_links_stay() {
let dir = tempfile::tempdir().expect("a temp dir");
std::fs::create_dir(dir.path().join("assets")).expect("assets");
std::fs::write(dir.path().join("assets/a b.png"), b"png").expect("an image");
let page = marked(&["assets/a%20b.png", "https://e.x/y.png", "gone.png"]);
let (out, missing) = page.inline(dir.path());
assert!(out.contains(r#"src="data:image/png;base64,cG5n""#), "{out}");
assert!(out.contains(r#"src="https://e.x/y.png""#));
assert!(
out.contains(r#"alt="a > b src='x'""#),
"alt text stays: {out}"
);
assert_eq!(missing, vec!["gone.png".to_string()]);
}
#[test]
fn linked_images_copy_once_under_encoded_names() {
let dir = tempfile::tempdir().expect("a temp dir");
std::fs::write(dir.path().join("a#b.png"), b"1").expect("an image");
let mut page = marked(&["a%23b.png"; 11]);
page.images[10] = "a%23b.png".into();
let (out, copies, missing) = page.linked(dir.path(), "report#1_files");
assert_eq!(
out.matches("src=\"report%231_files/1-a%23b.png\"").count(),
11,
"{out}"
);
assert_eq!(copies.len(), 1);
assert!(missing.is_empty());
}
#[test]
fn a_page_lacking_its_parts_is_an_error() {
assert!(Marked::read(&serde_json::json!({ "html": "<p>" })).is_err());
}
#[test]
fn titles_escape() {
assert!(page("a<b>&", "", "").contains("<title>a&lt;b&gt;&amp;</title>"));
}
}
+162
View File
@@ -0,0 +1,162 @@
//! Folders, files, the recent list, going to a line.
use std::path::PathBuf;
use ely_gpui_component::primitives::Severity;
use ely_office::prelude::*;
use ely_ui::i18n::text;
use gpui::{Context, Window};
use crate::desk::Desk;
use crate::folder::{Folder, markdown_under};
impl Desk {
/// A folder lists beside; a file opens in a tab.
pub fn open_path(&mut self, path: PathBuf, cx: &mut Context<Self>) {
match path.is_dir() {
true => self.open_folder(path, cx),
false => self.open_file(path, cx),
}
}
/// Lists `root`'s Markdown files off the window (MARK-01).
pub fn open_folder(&mut self, root: PathBuf, cx: &mut Context<Self>) {
log::info!("mark: opens folder {}", root.display());
cx.spawn(async move |desk, cx| {
let listed = root.clone();
// The real root: listed files compare with tabs' paths.
let files = cx
.background_executor()
.spawn(async move {
let real = std::fs::canonicalize(&listed)?;
Ok::<_, std::io::Error>((markdown_under(&real)?, real))
})
.await;
desk.update(cx, |desk, cx| match files {
Ok((files, real)) => {
desk.remember(&real, cx);
desk.folder = Some(Folder { root: real, files });
desk.listings += 1;
cx.notify();
}
Err(error) => {
log::error!("mark: {} did not list: {error}", root.display());
let (name, error) = (root.display().to_string(), error.to_string());
let said = text(
cx,
"office.error.open",
&[("name", &name), ("error", &error)],
);
desk.toast(said, Severity::Danger, cx);
}
})
.ok();
})
.detach();
}
/// The open folder again, as the disk has it now.
pub fn relist(&mut self, cx: &mut Context<Self>) {
if let Some(root) = self.folder.as_ref().map(|folder| folder.root.clone()) {
self.open_folder(root, cx);
}
}
/// Puts `path` first in the recent list.
pub fn remember(&mut self, path: &std::path::Path, cx: &mut Context<Self>) {
// One spelling per file: its real path.
let remembered = std::fs::canonicalize(path)
.and_then(|path| ely_files::recent::remember(&self.recent_file, &path));
match remembered {
Ok(list) => self.recent = list,
Err(error) => {
log::error!("mark: the recent list did not save: {error}");
self.toast(
text(cx, "office.error.engine", &[("error", &error.to_string())]),
Severity::Danger,
cx,
);
}
}
}
/// Tab `id` shows `path`; the recent list follows.
pub fn track_recent(&mut self, id: u64, path: &std::path::Path, cx: &mut Context<Self>) {
let Ok(real) = std::fs::canonicalize(path) else {
return log::warn!("mark: {} is gone; the recent list keeps it", path.display());
};
if self.remembered.get(&id) == Some(&real) {
return;
}
if let Some(old) = self.remembered.insert(id, real)
&& !old.exists()
{
self.forget(&old, cx);
}
self.remember(path, cx);
}
/// Takes a path that is gone out of the list.
pub fn forget(&mut self, path: &std::path::Path, cx: &mut Context<Self>) {
match ely_files::recent::forget(&self.recent_file, path) {
Ok(()) => self.recent.retain(|known| known != path),
Err(error) => {
log::error!("mark: the recent list did not save: {error}");
let said = text(cx, "office.error.engine", &[("error", &error.to_string())]);
self.toast(said, Severity::Danger, cx);
}
}
}
/// `place` in `tab`, else in `file` once open.
pub fn open_at(
&mut self,
tab: Option<u64>,
file: PathBuf,
place: serde_json::Value,
window: &mut Window,
cx: &mut Context<Self>,
) {
let held = tab.or_else(|| self.tab_of(&file));
if let Some(id) = held.filter(|id| self.tab(*id).is_some()) {
self.core.active = Some(id);
let view = self.tab(id).map(|open| open.view.clone());
if let Some(view) = view {
view.update(cx, |view, cx| view.reveal(place, cx));
// After the list's own key handling, which keeps focus.
let focus = view.read(cx).focus.clone();
window.defer(cx, move |window, cx| window.focus(&focus, cx));
}
return cx.notify();
}
// A file gone is reported by the open itself.
if let Ok(real) = std::fs::canonicalize(&file) {
self.going = Some((real, place));
}
self.open_file(file, cx);
}
/// The tab showing `path`, by real path.
pub fn tab_of(&self, path: &std::path::Path) -> Option<u64> {
let real = std::fs::canonicalize(path).ok()?;
let held = |tab: &&ely_office::Tab<crate::outside::Extra, crate::engine::Mark>| {
tab.session
.path()
.and_then(|open| std::fs::canonicalize(open).ok())
== Some(real.clone())
};
self.core.tabs.iter().find(held).map(|tab| tab.id)
}
/// Tab `id`'s text as the page last sent it.
pub fn text_of(&self, id: u64) -> Option<String> {
Some(
self.tab(id)?
.doc
.lock()
.expect("the text lock")
.current
.clone(),
)
}
}
+165
View File
@@ -0,0 +1,165 @@
//! Renaming the open file in its folder (MARK-01).
use std::{io, path::PathBuf};
use ely_files::SaveState;
use ely_gpui_component::{forms::TextInput, overlays::PromptDialog, primitives::Severity};
use ely_office::{Host as _, files::file_name, prelude::*};
use ely_ui::{commands, i18n::text};
use gpui::{AnyElement, AppContext as _, Context, IntoElement as _, Window};
use crate::desk::Desk;
/// The text key saying why `name` is refused.
fn refused(name: &str) -> Option<&'static str> {
let name = name.trim();
if name.is_empty() || name == "." || name == ".." || name.contains(['/', '\\', '\0']) {
return Some("mark.rename.bad");
}
let extension = std::path::Path::new(name)
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or_default()
.to_lowercase();
(!Desk::KIND.kept.contains(&extension.as_str())).then_some("mark.rename.format")
}
impl Desk {
/// Asks a new name for the active file.
pub fn on_rename(&mut self, _: &commands::Rename, window: &mut Window, cx: &mut Context<Self>) {
let Some(tab) = self.active_tab() else {
return;
};
let (id, name) = (tab.id, tab.name.clone());
let field = cx.new(|cx| {
let mut field = TextInput::new(window, cx);
field.set_text(name, cx);
field
});
self.naming = Some((id, field));
cx.notify();
}
pub fn naming_element(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let (id, field) = self.naming.as_ref()?;
let (closed, sent, id) = (cx.entity(), cx.entity(), *id);
let formats = Desk::KIND.kept.join(", .");
let bad = text(cx, "mark.rename.bad", &[]);
let format = text(cx, "mark.rename.format", &[("formats", &formats)]);
let prompt = PromptDialog::new(
"mark-rename",
text(cx, "mark.rename", &[]),
field,
move |_, cx| {
closed.update(cx, |desk, cx| {
desk.naming = None;
cx.notify();
})
},
)
.label(text(cx, "mark.rename.name", &[]))
.submit(text(cx, "mark.rename.apply", &[]))
.check(move |name| match refused(name) {
None => Ok(()),
Some("mark.rename.bad") => Err(bad.clone()),
Some(_) => Err(format.clone()),
})
.on_submit(move |name, _, cx| {
sent.update(cx, |desk, cx| {
desk.naming = None;
desk.rename(id, name, cx);
})
});
Some(prompt.into_any_element())
}
/// Renames tab `id`'s file; an existing name is refused.
fn rename(&mut self, id: u64, name: &str, cx: &mut Context<Self>) {
let Some(tab) = self.tab(id) else {
return;
};
let Some(from) = tab.session.path().map(|path| path.to_path_buf()) else {
return self.toast(text(cx, "mark.rename.untitled", &[]), Severity::Warning, cx);
};
if tab.exporting || tab.session.state() == SaveState::Saving {
return self.toast(text(cx, "mark.rename.saving", &[]), Severity::Warning, cx);
}
let to = from.with_file_name(name.trim());
let (source, target) = (from.clone(), to.clone());
cx.spawn(async move |this, cx| {
let done = cx
.background_executor()
.spawn(async move { ely_files::rename_new(&source, &target) })
.await;
this.update(cx, |desk, cx| desk.renamed(id, from, to, done, cx))
.ok();
})
.detach();
}
/// The tab follows only if it holds the old path.
fn renamed(
&mut self,
id: u64,
from: PathBuf,
to: PathBuf,
done: io::Result<()>,
cx: &mut Context<Self>,
) {
if let Err(error) = done {
log::error!("mark: {} did not rename: {error}", from.display());
let (name, error) = (file_name(&from), error.to_string());
let said = text(
cx,
"mark.rename.failed",
&[("name", &name), ("error", &error)],
);
return self.toast(said, Severity::Danger, cx);
}
log::info!("mark: renamed {} to {}", from.display(), to.display());
match self.tab(id).and_then(|tab| tab.session.path()) {
Some(path) if path == from => self.follow(id, to, cx),
// The watcher saw the rename first.
Some(path) if path == to => log::info!("mark: the tab already follows"),
_ => log::info!("mark: the renamed file's tab is gone"),
}
}
/// Tab `id`'s file now lives at `to`.
pub fn follow(&mut self, id: u64, to: PathBuf, cx: &mut Context<Self>) {
let Some(registry) = self.tab(id).map(|tab| tab.registry) else {
return;
};
// One session moves: an open in the gap focused it.
if let Some(registry) = registry
&& let Err(error) = self.core_mut().sessions.track(registry, &to)
{
log::error!("mark: the renamed file did not register: {error}");
let said = text(cx, "office.error.engine", &[("error", &error.to_string())]);
self.toast(said, Severity::Danger, cx);
}
if let Some(tab) = self.tab_mut(id) {
tab.session.renamed(to.clone());
tab.name = file_name(&to);
}
if let Some(view) = self.tab(id).map(|tab| tab.view.clone()) {
self.observed(id, &view, cx);
}
// The tree and folder search see the new name.
self.relist(cx);
cx.notify();
}
}
#[cfg(test)]
mod tests {
#[test]
fn names_need_no_slashes_and_a_markdown_end() {
for bad in ["", " ", "..", "a/b.md", "a\\b.md"] {
assert_eq!(super::refused(bad), Some("mark.rename.bad"), "{bad:?}");
}
assert_eq!(super::refused("notes.txt"), Some("mark.rename.format"));
assert_eq!(super::refused("Notes.MD"), None);
assert_eq!(super::refused("a.markdown"), None);
}
}
+395
View File
@@ -0,0 +1,395 @@
//! The session kept across launches (MARK-12, §6.2, §6.3).
use std::{
io,
path::{Path, PathBuf},
time::SystemTime,
};
use ely_files::{Crash, Marker, Recovery};
use ely_office::prelude::*;
use ely_ui::i18n::text;
use gpui::Context;
use serde::{Deserialize, Serialize};
use crate::desk::Desk;
use crate::engine::mark;
use crate::page::Mode;
use crate::view::Place;
/// A kept tab: file, mode, place, recovery folder.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Kept {
path: Option<PathBuf>,
name: String,
mode: String,
place: Option<Place>,
slot: String,
}
#[derive(Debug, Default, PartialEq, Serialize, Deserialize)]
struct Saved {
tabs: Vec<Kept>,
}
/// A kept tab yet to show; recovered text copied.
struct Waiting {
kept: Kept,
real: Option<PathBuf>,
text: Option<(String, SystemTime)>,
}
/// The session's files and what still waits to restore.
pub struct Keeping {
dir: PathBuf,
crash: Option<Crash>,
waiting: Vec<Waiting>,
written: Option<String>,
/// A quit began: the kept list stays as then.
pub leaving: bool,
/// The list as kept when the quit began.
frozen: Vec<Kept>,
}
fn recovery(dir: &Path, slot: &str) -> PathBuf {
dir.join("recovery").join(slot)
}
/// The newest point's text in `slot`, after a crash.
fn recovered(dir: &Path, slot: &str) -> io::Result<Option<(String, SystemTime)>> {
let Some(point) = Recovery::new(recovery(dir, slot))
.points()?
.into_iter()
.next()
else {
return Ok(None);
};
let bytes = std::fs::read(&point.content)?;
let text = String::from_utf8(bytes)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
Ok(Some((text, point.at)))
}
impl Keeping {
/// Reads the last session; a crash brings its unsaved text.
pub fn start(dir: &Path) -> io::Result<(Keeping, Vec<PathBuf>, Vec<String>)> {
let marker = Crash::check(dir, ely_files::alive)?;
log::info!("mark: the last session ended {marker:?}");
let crash = Crash::open(dir, std::process::id())?;
let saved: Saved = match std::fs::read(dir.join("session.json")) {
Ok(bytes) => serde_json::from_slice(&bytes).map_err(io::Error::other)?,
Err(error) if error.kind() == io::ErrorKind::NotFound => Saved::default(),
Err(error) => return Err(error),
};
let (mut waiting, mut missing) = (Vec::new(), Vec::new());
for kept in saved.tabs {
let text = match marker {
Marker::Crashed => recovered(dir, &kept.slot)?,
Marker::Clean | Marker::Discarded => None,
};
if let Some(path) = &kept.path {
match std::fs::canonicalize(path) {
// The file reopens as it is on disk.
Ok(real) => waiting.push(Waiting {
kept: Kept {
slot: String::new(),
..kept.clone()
},
real: Some(real),
text: None,
}),
Err(error) => {
log::warn!("mark: a kept file does not open: {error}");
missing.push(format!("{}: {error}", path.display()));
}
}
}
// Recovered text never overwrites a file: it is a copy.
if text.is_some() {
let kept = Kept { path: None, ..kept };
waiting.push(Waiting {
kept,
real: None,
text,
});
}
}
if marker != Marker::Crashed {
clear(&dir.join("recovery"))?;
}
let paths = waiting
.iter()
.filter_map(|waiting| waiting.real.clone())
.collect();
let keeping = Keeping {
dir: dir.to_path_buf(),
crash: Some(crash),
waiting,
written: None,
leaving: false,
frozen: Vec::new(),
};
Ok((keeping, paths, missing))
}
/// The untitled tabs to make, with their names.
pub fn untitled(&self) -> Vec<String> {
let untitled = self
.waiting
.iter()
.filter(|waiting| waiting.kept.path.is_none());
untitled.map(|waiting| waiting.kept.name.clone()).collect()
}
/// A clean end: no crash next time, no stale points.
pub fn close(&mut self) {
let Some(crash) = self.crash.take() else {
return;
};
if let Err(error) = crash
.close()
.and_then(|()| clear(&self.dir.join("recovery")))
{
log::error!("mark: the session did not close cleanly: {error}");
}
}
}
/// Removes every slot folder in `dir` not in `live`.
fn sweep(dir: &Path, live: &[&str]) -> io::Result<()> {
let entries = match std::fs::read_dir(dir) {
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
entries => entries?,
};
for entry in entries {
let entry = entry?;
if !live.contains(&entry.file_name().to_string_lossy().as_ref()) {
clear(&entry.path())?;
}
}
Ok(())
}
fn clear(dir: &Path) -> io::Result<()> {
match std::fs::remove_dir_all(dir) {
Err(error) if error.kind() != io::ErrorKind::NotFound => Err(error),
_ => Ok(()),
}
}
impl Desk {
/// A tab just shown takes its kept mode, place, text.
pub fn restore_shown(&mut self, id: u64, cx: &mut Context<Self>) {
let Some(tab) = self.tab_mut(id).filter(|tab| !tab.extra.restored) else {
return;
};
// Each tab takes one kept entry, on its first show.
tab.extra.restored = true;
let tab = self.tab(id).expect("open");
let real = tab
.session
.path()
.and_then(|path| std::fs::canonicalize(path).ok());
let untitled = tab.session.path().is_none();
let waiting = &mut self.keeping.waiting;
let Some(at) = waiting
.iter()
.position(|waiting| match (&waiting.real, &real) {
(Some(kept), Some(open)) => kept == open,
(None, None) => untitled,
_ => false,
})
else {
return;
};
let Waiting {
kept, text: back, ..
} = waiting.remove(at);
let mode = Mode::parse(&kept.mode).unwrap_or(Mode::Visual);
log::info!("mark: restores tab {id} as {}", mode.name());
let tab = self.tab_mut(id).expect("open");
// A recovered copy keeps writing to the same points.
tab.extra.slot = kept.slot.clone();
let view = tab.view.clone();
if let Some((_, at)) = &back {
let at = humantime(*at);
let said = text(
cx,
"mark.restore.recovered",
&[("name", &kept.name), ("at", &at)],
);
self.toast(said, ely_gpui_component::primitives::Severity::Info, cx);
}
let back = back.map(|(back, _)| back);
view.update(cx, |view, cx| view.reopen(mode, kept.place, back, cx));
}
/// Every ten seconds: unsaved text to points, tabs to disk.
pub fn keep(&mut self, cx: &mut Context<Self>) {
let dir = self.keeping.dir.clone();
let started = std::time::UNIX_EPOCH
.elapsed()
.map_or(0, |since| since.as_millis());
let mut kept = Vec::new();
for tab in &mut self.core.tabs {
if tab.extra.slot.is_empty() {
tab.extra.slot = format!("{started}-{}", tab.id);
}
let view = tab.view.read(cx);
kept.push(Kept {
path: tab.session.path().map(Path::to_path_buf),
name: tab.name.to_string(),
mode: view.mode.name().to_owned(),
place: view.place,
slot: tab.extra.slot.clone(),
});
let current = tab.doc.lock().expect("the text lock").current.clone();
let point = mark(&current);
if !tab.dirty(cx) || tab.extra.pointed.as_ref() == Some(&point) || tab.extra.pointing {
continue;
}
tab.extra.pointing = true;
let (id, slot) = (tab.id, recovery(&dir, &tab.extra.slot));
cx.spawn(async move |desk, cx| {
let folder = slot.clone();
let wrote = cx
.background_executor()
.spawn(async move {
let now = SystemTime::now();
let limits = ely_platform::storage::limits(ely_platform::AppId::Mark)?;
let mut recovery = Recovery::new(folder).with_limits(limits.keep, limits.megabytes * 1024 * 1024);
recovery.write_once(current.as_bytes(), None, now)
})
.await;
desk.update(cx, |desk, _| desk.pointed(id, point, wrote, &slot))
.ok();
})
.detach();
}
// Closed or discarded tabs' points go (§6.2).
let live = kept.iter().map(|kept| kept.slot.as_str());
let live: Vec<&str> = live
.chain(
self.keeping
.waiting
.iter()
.map(|waiting| waiting.kept.slot.as_str()),
)
.collect();
if let Err(error) = sweep(&dir.join("recovery"), &live) {
log::error!("mark: old recovery points stay: {error}");
}
if !self.keeping.leaving {
self.keeping.frozen = kept.clone();
self.write_session(Saved { tabs: kept });
}
}
fn pointed(&mut self, id: u64, point: String, wrote: io::Result<Option<PathBuf>>, slot: &Path) {
// The tab closed while it wrote: the point goes too.
let Some(tab) = self.tab_mut(id) else {
if let Err(error) = clear(slot) {
log::error!("mark: a closed tab's point stays: {error}");
}
return;
};
tab.extra.pointing = false;
match wrote {
Ok(_) => tab.extra.pointed = Some(point),
Err(error) => log::error!("mark: a recovery point for tab {id} failed: {error}"),
}
}
fn write_session(&mut self, saved: Saved) {
let json = serde_json::to_string_pretty(&saved).expect("the session serializes");
if self.keeping.written.as_ref() == Some(&json) {
return;
}
let file = self.keeping.dir.join("session.json");
match ely_files::save_atomic(&file, json.as_bytes(), 0) {
Ok(_) => self.keeping.written = Some(json),
Err(error) => log::error!("mark: the session did not save: {error}"),
}
}
/// Keeps every ten seconds while the window lives (§6.2).
pub fn keep_every(&mut self, cx: &mut Context<Self>) {
cx.spawn(async move |desk, cx| {
loop {
cx.background_executor().timer(Recovery::INTERVAL).await;
if desk.update(cx, |desk, cx| desk.keep(cx)).is_err() {
break;
}
}
})
.detach();
}
/// Slot `slot` closed: its points go, untitled entries too.
pub fn drop_slot(
&mut self,
slot: &str,
path: Option<PathBuf>,
name: String,
cx: &mut Context<Self>,
) {
if !slot.is_empty()
&& let Err(error) = clear(&recovery(&self.keeping.dir, slot))
{
log::error!("mark: a closed tab's points stay: {error}");
}
if !self.keeping.leaving {
return self.keep(cx);
}
// Discarded untitled text goes; a first save keeps its file.
match path {
None => self.keeping.frozen.retain(|kept| kept.slot != slot),
Some(path) => {
let saved = self
.keeping
.frozen
.iter_mut()
.filter(|kept| kept.slot == slot);
for kept in saved {
(kept.path, kept.name) = (Some(path.clone()), name.clone());
}
}
}
let tabs = self.keeping.frozen.clone();
self.write_session(Saved { tabs });
}
/// A quit or close begins: the tabs now stay kept.
pub fn leave(&mut self, cx: &mut Context<Self>) {
self.keeping.leaving = false;
self.keep(cx);
self.keeping.leaving = true;
}
}
/// A recovery time as this machine's clock reads it.
fn humantime(at: SystemTime) -> String {
match jiff::Timestamp::try_from(at) {
Ok(at) => at
.to_zoned(jiff::tz::TimeZone::system())
.strftime("%Y-%m-%d %H:%M")
.to_string(),
Err(error) => {
log::warn!("mark: a recovery time is out of range: {error}");
error.to_string()
}
}
}
#[cfg(test)]
mod tests {
#[test]
fn a_place_without_a_column_reads_zero() {
let kept: super::Kept = serde_json::from_str(
r#"{"path":null,"name":"a.md","mode":"source","place":{"line":3,"scroll":0},"slot":"s"}"#,
)
.expect("an older place reads");
let place = kept.place.expect("a place");
assert_eq!((place.line, place.column), (3, 0));
}
}
+377
View File
@@ -0,0 +1,377 @@
//! Files, outline and search beside the document (MARK-05, MARK-10).
use std::path::PathBuf;
use ely_gpui_component::{
buttons::{Button, SegmentedControl},
feedback::EmptyState,
forms::{InputEvent, SearchInput, TextInput},
lists::{FileTree, ListItem, Outline, SelectableList},
primitives::IconName,
theme::ControlSize,
};
use ely_office::prelude::*;
use ely_ui::{commands, i18n::text};
use gpui::{
AnyElement, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
SharedString, Styled as _, WeakEntity, Window, div,
};
use serde_json::json;
use crate::desk::Desk;
use crate::folder::{Hit, MOST_HITS, lines_with};
#[derive(Clone, Copy, PartialEq)]
enum Pane {
Files,
Outline,
Search,
}
pub struct Side {
desk: WeakEntity<Desk>,
pane: Pane,
query: Entity<TextInput>,
/// Search the whole folder, else the shown file.
wide: bool,
/// Each hit, with its tab when the file is open.
hits: Vec<(Option<u64>, Hit)>,
/// Searches started; a late answer is dropped.
runs: u64,
/// The words searched, and the tab when one file was.
searched: String,
tab: Option<u64>,
/// The folder listing searched.
listing: u64,
/// Files that could not be read, and why.
missed: Vec<(PathBuf, String)>,
}
impl Side {
pub fn new(desk: &Entity<Desk>, window: &mut Window, cx: &mut Context<Self>) -> Self {
// A new shown file is searched again.
cx.observe(desk, |side, desk, cx| {
let (active, listing) = (desk.read(cx).core.active, desk.read(cx).listings);
let shown = !side.wide && side.tab != active;
let listed = side.wide && side.listing != listing;
if !side.searched.is_empty() && (shown || listed) {
side.search(cx);
}
cx.notify();
})
.detach();
let query = cx.new(|cx| TextInput::new(window, cx));
cx.subscribe(&query, |side, _, event: &InputEvent, cx| {
if *event == InputEvent::Submit {
side.search(cx);
}
})
.detach();
Side {
desk: desk.downgrade(),
pane: Pane::Files,
query,
wide: false,
hits: Vec::new(),
runs: 0,
searched: String::new(),
tab: None,
listing: 0,
missed: Vec::new(),
}
}
/// Lines holding the query, off the window.
fn search(&mut self, cx: &mut Context<Self>) {
let query = self.query.read(cx).text().trim().to_owned();
let Some(desk) = self.desk.upgrade() else {
return;
};
self.runs += 1;
let run = self.runs;
let desk = desk.read(cx);
(self.tab, self.listing) = (desk.core.active, desk.listings);
self.searched = query.clone();
if query.is_empty() {
(self.hits, self.missed) = (Vec::new(), Vec::new());
return cx.notify();
}
// Open files search as they are now, saved or not.
let open: Vec<(u64, PathBuf, String)> = desk
.core
.tabs
.iter()
.filter_map(|tab| {
let name = PathBuf::from(tab.name.as_ref());
let path = tab.session.path().map_or(name, PathBuf::from);
Some((tab.id, path, desk.text_of(tab.id)?))
})
.collect();
// Open files by real path, as the folder lists them.
let reals: Vec<(u64, PathBuf)> = open
.iter()
.filter_map(|(id, path, _)| Some((*id, std::fs::canonicalize(path).ok()?)))
.collect();
let sources: Vec<(Option<u64>, PathBuf)> = match (self.wide, &desk.folder) {
(true, Some(folder)) => folder
.files
.iter()
.map(|file| folder.root.join(file))
.map(|path| {
let held = reals
.iter()
.find(|(_, real)| *real == path)
.map(|(id, _)| *id);
(held, path)
})
.collect(),
_ => desk
.active_tab()
.map(|tab| (Some(tab.id), PathBuf::new()))
.into_iter()
.collect(),
};
log::info!("mark: searches {} files", sources.len());
cx.spawn(async move |side, cx| {
let hits = cx
.background_executor()
.spawn(async move {
let (mut hits, mut missed) = (Vec::new(), Vec::new());
for (tab, path) in sources {
let held = open.iter().find(|(id, ..)| Some(*id) == tab);
let (path, text) = match held {
Some((_, path, text)) => (path.clone(), text.clone()),
None => {
match std::fs::read(&path).and_then(crate::text::Source::decode) {
Ok(source) => (path, source.text),
Err(error) => {
log::warn!(
"mark: {} was not searched: {error}",
path.display()
);
missed.push((path, error.to_string()));
continue;
}
}
}
};
let mut found = Vec::new();
lines_with(&path, &text, &query, &mut found);
hits.extend(found.into_iter().map(|hit| (tab, hit)));
if hits.len() >= MOST_HITS {
hits.truncate(MOST_HITS);
break;
}
}
(hits, missed)
})
.await;
side.update(cx, |side, cx| {
if side.runs == run {
(side.hits, side.missed) = hits;
cx.notify();
}
})
.ok();
})
.detach();
}
fn files(&self, desk: &Entity<Desk>, cx: &mut Context<Self>) -> AnyElement {
let read = desk.read(cx);
if let Some(folder) = &read.folder {
let (root, opener) = (folder.root.clone(), desk.downgrade());
return FileTree::new("mark-files", folder.files.clone())
.on_open(move |path, _, cx| {
let path = root.join(path.as_ref());
opener.update(cx, |desk, cx| desk.open_path(path, cx)).ok();
})
.size_full()
.into_any_element();
}
if read.recent.is_empty() {
let open = Button::new("mark-open", text(cx, "command.file.open", &[]))
.on_click(|_, window, cx| window.dispatch_action(Box::new(commands::Open), cx));
return EmptyState::new(
"mark-no-recent",
IconName::Folder,
text(cx, "mark.recent.none", &[]),
)
.action(open)
.into_any_element();
}
let opener = desk.downgrade();
let rows = read.recent.iter().map(|path| {
let name = path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default();
let key: SharedString = path.to_string_lossy().into_owned().into();
let item = ListItem::new(key.clone(), name).description(
path.parent()
.map(|dir| dir.display().to_string())
.unwrap_or_default(),
);
(key, item)
});
rows.fold(
SelectableList::new("mark-recent").label(text(cx, "mark.recent", &[])),
|list, (key, item)| list.row(key, item),
)
.on_activate(move |key, _, cx| {
let path = PathBuf::from(key.as_ref());
opener.update(cx, |desk, cx| desk.open_path(path, cx)).ok();
})
.into_any_element()
}
fn outline(&self, desk: &Entity<Desk>, cx: &mut Context<Self>) -> AnyElement {
let Some(view) = desk.read(cx).active_view() else {
return EmptyState::new(
"mark-no-outline",
IconName::File,
text(cx, "command.blocked.no_document", &[]),
)
.into_any_element();
};
let headings = view.read(cx).outline.clone();
if headings.is_empty() {
return EmptyState::new(
"mark-no-headings",
IconName::File,
text(cx, "mark.outline.none", &[]),
)
.into_any_element();
}
let outline = headings.iter().enumerate().fold(
Outline::new("mark-outline"),
|outline, (at, heading)| {
outline.item(at.to_string(), heading.title.clone(), heading.level)
},
);
outline
.on_select(move |key, window, cx| {
if let Ok(at) = key.parse::<usize>() {
view.update(cx, |view, cx| view.reveal(json!({ "heading": at }), cx));
// Keys go to the document, at the heading.
let focus = view.read(cx).focus.clone();
window.focus(&focus, cx);
}
})
.into_any_element()
}
fn search_pane(&self, desk: &Entity<Desk>, cx: &mut Context<Self>) -> AnyElement {
let this = cx.entity();
let scope = SegmentedControl::new("mark-scope", if self.wide { "folder" } else { "file" })
.size(ControlSize::Sm)
.segment("file", text(cx, "mark.search.file", &[]), Some(IconName::File));
let scope = match desk.read(cx).folder {
Some(_) => scope.segment("folder", text(cx, "mark.search.folder", &[]), Some(IconName::Folder)),
None => scope,
};
let scope = scope.on_change(move |key, _, cx| {
this.update(cx, |side, cx| {
side.wide = key == "folder";
side.search(cx);
})
});
let opener = desk.downgrade();
let (hits, query) = (self.hits.clone(), self.searched.clone());
// Files not read come first: the results lack them.
let missed = self.missed.iter().enumerate().map(|(at, (path, why))| {
let name = path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default();
let title = text(cx, "mark.search.missed", &[("name", &name)]);
(
SharedString::from(format!("missed-{at}")),
ListItem::new(format!("missed-{at}"), title).description(why.clone()),
)
});
let rows = self.hits.iter().enumerate().map(|(at, (_, hit))| {
let name = hit
.file
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default();
let item = ListItem::new(at.to_string(), hit.text.clone())
.description(format!("{name}:{}", hit.line));
(SharedString::from(at.to_string()), item)
});
let list = missed
.collect::<Vec<_>>()
.into_iter()
.chain(rows)
.fold(
SelectableList::new("mark-hits").label(text(cx, "mark.search", &[])),
|list, (key, item)| list.row(key, item),
)
.on_activate(move |key, window, cx| {
let Some((tab, hit)) = key.parse::<usize>().ok().and_then(|at| hits.get(at)) else {
return;
};
let (tab, file, line) = (*tab, hit.file.clone(), hit.line);
let place = json!({ "line": line, "query": query });
opener
.update(cx, |desk, cx| desk.open_at(tab, file, place, window, cx))
.ok();
});
let more = (self.hits.len() == MOST_HITS)
.then(|| text(cx, "mark.search.more", &[("count", &MOST_HITS.to_string())]));
div()
.flex()
.flex_col()
.gap_2()
.size_full()
.child(SearchInput::new("mark-search", &self.query))
.child(scope)
.child(list)
.children(more)
.into_any_element()
}
}
impl Render for Side {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let Some(desk) = self.desk.upgrade() else {
return div().into_any_element();
};
let this = cx.entity();
let key = match self.pane {
Pane::Files => "files",
Pane::Outline => "outline",
Pane::Search => "search",
};
let panes = SegmentedControl::new("mark-pane", key)
.size(ControlSize::Sm)
.segment("files", text(cx, "mark.pane.files", &[]), Some(IconName::Folder))
.segment("outline", text(cx, "mark.pane.outline", &[]), Some(IconName::List))
.segment("search", text(cx, "mark.pane.search", &[]), Some(IconName::Search))
.on_change(move |key, _, cx| {
this.update(cx, |side, cx| {
side.pane = match key.as_ref() {
"outline" => Pane::Outline,
"search" => Pane::Search,
_ => Pane::Files,
};
cx.notify();
})
});
let body = match self.pane {
Pane::Files => self.files(&desk, cx),
Pane::Outline => self.outline(&desk, cx),
Pane::Search => self.search_pane(&desk, cx),
};
div()
.flex()
.flex_col()
.gap_2()
.p_2()
.size_full()
.child(panes)
.child(div().flex_1().min_h_0().child(body))
.into_any_element()
}
}
+104
View File
@@ -0,0 +1,104 @@
//! File bytes to editor text and back (§14.3, QA-21).
use std::io;
const BOM: &[u8] = b"\xEF\xBB\xBF";
/// A file as read: its bytes, BOM and line ending.
#[derive(Clone, Debug)]
pub struct Source {
bytes: Vec<u8>,
bom: bool,
/// More CRLF than lone LF line ends.
crlf: bool,
/// The editor's text: no BOM, CRLF as LF.
pub text: String,
}
impl Source {
/// Reads UTF-8, with or without BOM; other bytes fail.
pub fn decode(bytes: Vec<u8>) -> io::Result<Source> {
if bytes.starts_with(b"\xFF\xFE") || bytes.starts_with(b"\xFE\xFF") {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"UTF-16 text is not read",
));
}
let bom = bytes.starts_with(BOM);
let body = &bytes[if bom { BOM.len() } else { 0 }..];
let text = std::str::from_utf8(body).map_err(|error| {
let at = error.valid_up_to();
io::Error::new(
io::ErrorKind::InvalidData,
format!("not UTF-8 at byte {at}"),
)
})?;
let crlfs = text.matches("\r\n").count();
let crlf = crlfs > text.matches('\n').count() - crlfs;
let text = text.replace("\r\n", "\n");
Ok(Source {
bytes,
bom,
crlf,
text,
})
}
/// The bytes for `text`; unedited text is the file itself.
pub fn encode(&self, text: &str) -> Vec<u8> {
if text == self.text {
return self.bytes.clone();
}
let text = match self.crlf {
true => text.replace('\n', "\r\n"),
false => text.to_owned(),
};
let mut bytes = Vec::with_capacity(text.len() + BOM.len());
if self.bom {
bytes.extend_from_slice(BOM);
}
bytes.extend_from_slice(text.as_bytes());
bytes
}
}
#[cfg(test)]
mod tests {
use super::Source;
#[test]
fn unedited_text_writes_the_same_bytes() {
for bytes in [
&b"# a\r\n\r\nb\n"[..],
b"\xEF\xBB\xBF# a\r\n",
b"a\rb\n",
b"",
b"no end",
] {
let source = Source::decode(bytes.to_vec()).expect("reads");
assert_eq!(source.encode(&source.text.clone()), bytes);
}
}
#[test]
fn edits_keep_the_bom_and_line_ending() {
let source = Source::decode(b"\xEF\xBB\xBF# a\r\n\r\nb\r\n".to_vec()).expect("reads");
assert_eq!(source.text, "# a\n\nb\n");
assert_eq!(source.encode("# a\n\nc\n"), b"\xEF\xBB\xBF# a\r\n\r\nc\r\n");
let plain = Source::decode(b"a\nb\n".to_vec()).expect("reads");
assert_eq!(plain.encode("a\nc\n"), b"a\nc\n");
}
#[test]
fn mixed_line_ends_follow_the_most() {
let source = Source::decode(b"a\r\nb\r\nc\n".to_vec()).expect("reads");
assert_eq!(source.encode("a\nb\nd\n"), b"a\r\nb\r\nd\r\n");
}
#[test]
fn other_encodings_fail_with_the_place() {
let error = Source::decode(b"ok \xFF".to_vec()).expect_err("not UTF-8");
assert!(error.to_string().contains("byte 3"), "{error}");
assert!(Source::decode(b"\xFF\xFEa\0".to_vec()).is_err());
}
}
+189
View File
@@ -0,0 +1,189 @@
//! Whether Git sees the file changed; ElyTree does the rest.
use std::{
io,
path::Path,
};
use ely_gpui_component::{primitives::Severity, shell::StatusBarItem};
use ely_office::prelude::*;
use ely_platform::AppId;
use ely_ui::i18n::text;
use gpui::Context;
use crate::desk::Desk;
/// What Git says of one file.
#[derive(Clone, Debug, PartialEq)]
pub enum Tracked {
/// Not in a repository: nothing to show.
Outside,
Unchanged,
Changed,
Untracked,
Ignored,
Conflicted,
/// Git could not answer; why.
Unknown(String),
}
/// Git's word on `file`: in a repository, then its status.
pub fn ask(file: &Path) -> Tracked {
match status(file) {
Ok(tracked) => tracked,
Err(error) => Tracked::Unknown(error.to_string()),
}
}
fn run(git: &Path, dir: &Path, args: &[&str]) -> io::Result<std::process::Output> {
ely_platform::git::command(git, dir, args).output()
}
fn status(file: &Path) -> io::Result<Tracked> {
let (git, _) = ely_platform::git::locate()?;
let dir = file
.parent()
.ok_or_else(|| io::Error::other("a file without a folder"))?;
let top = run(&git, dir, &["rev-parse", "--show-toplevel"])?;
let said = String::from_utf8_lossy(&top.stderr);
// Not a repository is an answer; other failures are errors.
if !top.status.success() {
return match said.contains("not a git repository") {
true => Ok(Tracked::Outside),
false => Err(io::Error::other(said.trim().to_owned())),
};
}
let name = file
.file_name()
.ok_or_else(|| io::Error::other("a file without a name"))?;
let name = name.to_string_lossy();
let said = run(
&git,
dir,
&["status", "--porcelain=v2", "-z", "--ignored", "--", &name],
)?;
if !said.status.success() {
return Err(io::Error::other(
String::from_utf8_lossy(&said.stderr).trim().to_owned(),
));
}
Ok(match said.stdout.first() {
None => Tracked::Unchanged,
Some(b'1' | b'2') => Tracked::Changed,
Some(b'u') => Tracked::Conflicted,
Some(b'?') => Tracked::Untracked,
Some(b'!') => Tracked::Ignored,
Some(other) => {
return Err(io::Error::other(format!(
"git status said {:?}",
*other as char
)));
}
})
}
impl Desk {
/// Asks Git about the shown file, off the main thread.
pub fn ask_git(&mut self, cx: &mut Context<Self>) {
let Some(tab) = self.active_tab() else {
return;
};
let Some(path) = tab.session.path().map(Path::to_path_buf) else {
self.tracked = None;
return;
};
let id = tab.id;
cx.spawn(async move |desk, cx| {
let asked = path.clone();
let tracked = cx
.background_executor()
.spawn(async move { ask(&asked) })
.await;
desk.update(cx, |desk, cx| {
// Only the shown file's answer counts.
let shown = desk.active_tab().filter(|tab| tab.id == id);
if shown.and_then(|tab| tab.session.path()) == Some(&path) {
log::info!("mark: git says {tracked:?}");
desk.tracked = Some(tracked);
cx.notify();
}
})
.ok();
})
.detach();
}
/// The Git status item; pressing shows the file in ElyTree.
pub fn git_item(&self, cx: &mut Context<Self>) -> Option<StatusBarItem> {
let key = match self.tracked.as_ref()? {
Tracked::Outside => return None,
Tracked::Unchanged => "mark.git.unchanged",
Tracked::Changed => "mark.git.changed",
Tracked::Untracked => "mark.git.untracked",
Tracked::Ignored => "mark.git.ignored",
Tracked::Conflicted => "mark.git.conflicted",
Tracked::Unknown(why) => {
let label = text(cx, "mark.git.unknown", &[]);
return Some(
StatusBarItem::new("mark-git")
.label(label)
.tooltip(why.clone()),
);
}
};
// ElyTree takes the file: its repository and its history (§16).
let file = self.active_tab()?.session.path()?.to_path_buf();
let item = StatusBarItem::new("mark-git")
.label(text(cx, key, &[]))
.tooltip(text(cx, "mark.git.open", &[]));
let this = cx.entity();
Some(item.on_click(move |_, _, cx| {
if !ely_platform::apps::installed(AppId::Tree) {
let said = text(cx, "mark.git.missing", &[]);
this.update(cx, |desk, cx| desk.toast(said, Severity::Warning, cx));
return;
}
let (this, file) = (this.clone(), file.clone());
cx.spawn(async move |cx| {
let opened = cx.background_executor().spawn(async move { ely_platform::apps::open(AppId::Tree, &[file]) }).await;
if let Err(error) = opened {
log::error!("mark: ElyTree did not open: {error}");
cx.update(|cx| {
let said = text(cx, "mark.git.failed", &[("error", &error.to_string())]);
this.update(cx, |desk, cx| desk.toast(said, Severity::Danger, cx));
});
}
})
.detach();
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn git(dir: &Path, args: &[&str]) {
let (git, _) = ely_platform::git::locate().expect("a Git");
let done = std::process::Command::new(git)
.args(args)
.current_dir(dir)
.output();
assert!(done.expect("git runs").status.success(), "git {args:?}");
}
#[test]
fn spaces_stay_and_broken_repositories_say_why() {
let dir = tempfile::tempdir().expect("a temp dir");
let repo = dir.path().join("repo ");
std::fs::create_dir(&repo).expect("the folder");
git(&repo, &["init", "-q"]);
std::fs::write(repo.join("t.md"), "a").expect("a file");
assert_eq!(ask(&repo.join("t.md")), Tracked::Untracked);
std::fs::write(repo.join(".git/config"), "[bad\n").expect("a broken config");
assert!(matches!(ask(&repo.join("t.md")), Tracked::Unknown(_)));
let outside = tempfile::tempdir().expect("a temp dir");
std::fs::write(outside.path().join("t.md"), "a").expect("a file");
assert_eq!(ask(&outside.path().join("t.md")), Tracked::Outside);
}
}
+446
View File
@@ -0,0 +1,446 @@
//! One Markdown file in a page, its text returned.
use ely_web_content::{Event, Reply, WebView};
use gpui::{
Context, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable as _,
InteractiveElement as _, IntoElement, ParentElement as _, Render, Styled as _, Window, div,
};
use serde_json::Value;
use std::path::PathBuf;
use crate::engine::{Doc, mark};
use crate::page::Mode;
/// The page reported a change or new text.
pub struct Reported;
/// One heading of the document's outline.
#[derive(Clone, Debug, PartialEq)]
pub struct Heading {
pub level: usize,
pub title: String,
}
/// Where the reader was: a file line and the scroll.
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Place {
pub line: u64,
/// Sessions from before the column was kept read zero.
#[serde(default)]
pub column: u64,
pub scroll: f64,
}
/// Text past this many bytes opens as source (PERF-07).
pub const LARGE: usize = 2 * 1024 * 1024;
/// A render's answer: the page's reply, or why not.
type Rendered = Box<dyn FnOnce(Result<Value, String>, &mut gpui::App)>;
/// The page failed; the shell shows why.
pub struct Failed(pub String);
pub struct MarkView {
pub(crate) web: Entity<WebView>,
doc: Doc,
/// The newest change the page announced.
pub changed: u64,
/// The change whose text `doc` holds.
reported: u64,
/// The mark at the last load or save.
saved: String,
/// Page loads so far; a reload restarts its versions.
load: u64,
pub mode: Mode,
/// Headings as the shown editor reads them.
pub outline: Vec<Heading>,
/// Shown since the last page load.
pub shown: bool,
/// A place to bring into view once shown.
waiting: Option<Value>,
/// Where the page's search stands, while one runs.
pub found: Option<crate::finding::Found>,
/// The file's folder: links and images resolve here.
pub(crate) folder: PathBuf,
/// A saved file: images can go beside it.
pub attachable: bool,
/// Past `LARGE`: source alone, every byte still saved (MARK-14).
pub large: bool,
/// The caret's line and the scroll, as last reported.
pub place: Option<Place>,
/// The writing view, as the page applies it (MARK-09).
look: Value,
/// Renders asked of the page, by number.
renders: std::collections::HashMap<u64, Rendered>,
asked: u64,
pub focus: FocusHandle,
}
impl EventEmitter<Reported> for MarkView {}
impl EventEmitter<Failed> for MarkView {}
impl MarkView {
pub fn new(
web: Entity<WebView>,
doc: Doc,
saved: String,
folder: PathBuf,
cx: &mut Context<Self>,
) -> Self {
let focus = web.read(cx).focus_handle(cx);
let large = doc.lock().expect("the text lock").current.len() > LARGE;
cx.subscribe(&web, Self::heard).detach();
let paste = crate::attach::paste(cx.weak_entity());
web.update(cx, |web, _| web.intercept_paste(paste));
MarkView {
web,
doc,
changed: 0,
reported: 0,
saved,
load: 0,
mode: if large { Mode::Source } else { Mode::Visual },
large,
outline: Vec::new(),
shown: false,
waiting: None,
found: None,
attachable: false,
place: None,
look: Value::Null,
renders: std::collections::HashMap::new(),
asked: 0,
folder,
focus,
}
}
/// Every change's text has arrived.
pub fn settled(&self) -> bool {
self.reported == self.changed
}
/// The text's size now, in bytes.
pub fn bytes(&self) -> usize {
self.doc.lock().expect("the text lock").current.len()
}
pub fn modified(&self) -> bool {
mark(&self.doc.lock().expect("the text lock").current) != self.saved
}
pub fn saved(&mut self, mark: &str, cx: &mut Context<Self>) {
self.saved = mark.to_owned();
cx.emit(Reported);
}
/// The same text in `mode` (MARK-02).
pub fn switch(&mut self, mode: Mode, cx: &mut Context<Self>) {
if self.large && mode != Mode::Source {
log::info!("mark: a large file stays in source");
let said = ely_ui::i18n::text(cx, "mark.large.modes", &[]).to_string();
return cx.emit(Failed(said));
}
log::info!("mark: switches to {}", mode.name());
self.mode = mode;
crate::page::switch(self.web.read(cx), mode);
cx.notify();
}
/// Searches the page for `spec` (MARK-06).
pub fn find(&mut self, spec: Value, cx: &mut Context<Self>) {
crate::page::call(self.web.read(cx), "find", &spec);
}
/// Steps under `spec`, the box's own; tabs keep older ones.
pub fn step(&mut self, spec: Value, forward: bool, cx: &mut Context<Self>) {
let ask = serde_json::json!({ "spec": spec, "forward": forward });
crate::page::call(self.web.read(cx), "step", &ask);
}
pub fn replace(&mut self, spec: Value, all: bool, cx: &mut Context<Self>) {
let ask = serde_json::json!({ "spec": spec, "all": all });
crate::page::call(self.web.read(cx), "replace", &ask);
}
/// A table step: insert, rows and columns (MARK-06).
pub fn table(&mut self, op: &str, cx: &mut Context<Self>) {
crate::page::call(self.web.read(cx), "table", &Value::from(op));
}
pub fn unfind(&mut self, cx: &mut Context<Self>) {
self.found = None;
crate::page::call(self.web.read(cx), "unfind", &Value::Null);
}
/// Brings `{"heading": n}` or `{"line": n}` into view.
pub fn reveal(&mut self, place: Value, cx: &mut Context<Self>) {
match self.shown {
true => crate::page::call(self.web.read(cx), "reveal", &place),
false => self.waiting = Some(place),
}
}
/// The disk's text replaces a clean one; the place stays.
pub fn refresh(&mut self, source: crate::text::Source, cx: &mut Context<Self>) {
let text = source.text.clone();
{
let mut doc = self.doc.lock().expect("the text lock");
doc.current = text.clone();
doc.source = source;
}
self.saved = mark(&text);
(self.changed, self.reported) = (0, 0);
crate::page::call(self.web.read(cx), "refresh", &Value::from(text));
cx.emit(Reported);
}
/// The writing view's settings, applied now and at each show.
pub fn set_look(&mut self, look: Value, cx: &mut Context<Self>) {
if look == self.look {
return;
}
if self.shown {
crate::page::call(self.web.read(cx), "look", &look);
}
self.look = look;
}
/// The whole text as `kind`; `done` takes it.
pub fn render(
&mut self,
kind: &str,
done: impl FnOnce(Result<Value, String>, &mut gpui::App) + 'static,
cx: &mut Context<Self>,
) {
self.asked += 1;
self.renders.insert(self.asked, Box::new(done));
let ask = serde_json::json!({ "kind": kind, "ask": self.asked });
crate::page::call(self.web.read(cx), "render", &ask);
}
/// `text` replaces the editor's, as one undoable edit.
pub fn adopt(&mut self, text: String, cx: &mut Context<Self>) {
crate::page::call(self.web.read(cx), "adopt", &Value::from(text));
}
/// Shows again as `mode` at `place`; recovered text unsaved.
pub fn reopen(
&mut self,
mode: Mode,
place: Option<Place>,
text: Option<String>,
cx: &mut Context<Self>,
) {
let mut doc = self.doc.lock().expect("the text lock");
if let Some(text) = text {
doc.current = text;
}
let current = doc.current.clone();
drop(doc);
let mode = if self.large { Mode::Source } else { mode };
self.mode = mode;
let at = (mode, self.load, place);
crate::page::open(self.web.read(cx), &current, at, &self.folder, cx);
cx.emit(Reported);
}
/// The file moved; images and links follow it.
pub fn moved(&mut self, folder: PathBuf, cx: &mut Context<Self>) {
if folder == self.folder {
return;
}
if let Err(error) = self.web.read(cx).allow_images(&folder) {
log::error!("mark: images cannot follow the file: {error:#}");
return cx.emit(Failed(format!("{error:#}")));
}
crate::page::folder(self.web.read(cx), &folder);
self.folder = folder;
}
fn heard(&mut self, web: Entity<WebView>, event: &Event, cx: &mut Context<Self>) {
match event {
// A reload shows the last text that arrived.
Event::Loaded => {
self.load += 1;
self.shown = false;
(self.changed, self.reported) = (0, 0);
let text = self.doc.lock().expect("the text lock").current.clone();
let at = (self.mode, self.load, self.place);
crate::page::open(web.read(cx), &text, at, &self.folder, cx);
cx.emit(Reported);
}
Event::Query(said, reply) => self.query(said, reply, cx),
Event::Leave(url) => log::info!("mark: a link out was not followed: {url}"),
Event::Failed(why) => cx.emit(Failed(why.clone())),
Event::Printed(path, _) => {
log::error!("mark: an editor page printed {}", path.display())
}
}
}
fn query(&mut self, said: &str, reply: &Reply, cx: &mut Context<Self>) {
let said: Value = match serde_json::from_str(said) {
Ok(said) => said,
Err(error) => {
log::error!("mark: the page sent bad JSON: {error}");
return reply.fail("bad JSON");
}
};
if said["generation"]
.as_u64()
.is_some_and(|load| load != self.load)
{
log::info!("mark: a report from an earlier page load was dropped");
return reply.ok("");
}
let version = said["version"].as_u64();
match (said["kind"].as_str(), version, said["text"].as_str()) {
// A fallback shows source; the tab follows.
(Some("shown"), ..) => {
log::info!(
"mark: shown as {}, fallback {}",
said["mode"],
said["fallback"]
);
if let Some(mode) = said["mode"].as_str().and_then(Mode::parse) {
self.mode = mode;
cx.notify();
}
self.shown = true;
if let Some(place) = self.waiting.take() {
crate::page::call(self.web.read(cx), "reveal", &place);
}
if !self.look.is_null() {
crate::page::call(self.web.read(cx), "look", &self.look);
}
cx.emit(Reported);
}
(Some("outline"), ..) => match headings(&said["items"]) {
Some(outline) => {
self.outline = outline;
cx.notify();
}
None => {
log::error!("mark: the page sent a bad outline");
return reply.fail("bad outline");
}
},
(Some("found"), ..) => match found(&said) {
Some(found) => {
self.found = Some(found);
cx.notify();
}
None => {
log::error!("mark: the page sent a bad search state");
return reply.fail("bad search state");
}
},
(Some("table"), ..) if said["done"] == false => {
let key = match self.mode {
Mode::Visual => "mark.table.none",
_ => "mark.table.visual",
};
cx.emit(Failed(ely_ui::i18n::text(cx, key, &[]).to_string()));
}
(Some("inserted"), ..) if said["done"] == false => {
cx.emit(Failed(
ely_ui::i18n::text(cx, "mark.attach.preview", &[]).to_string(),
));
}
(Some("table" | "inserted"), ..) => {}
(Some("rendered"), ..) => {
let ask = said["ask"].as_u64().unwrap_or_default();
let Some(done) = self.renders.remove(&ask) else {
log::error!("mark: a render nobody asked for came back");
return reply.fail("unasked render");
};
let result = match said["error"].as_str() {
Some(error) => Err(error.to_owned()),
None => Ok(said.clone()),
};
done(result, cx);
}
(Some("place"), ..) => match (
said["line"].as_u64(),
said["column"].as_u64(),
said["scroll"].as_f64(),
) {
(Some(line), Some(column), Some(scroll)) => {
self.place = Some(Place {
line,
column,
scroll,
})
}
_ => {
log::error!("mark: the page sent a bad place");
return reply.fail("bad place");
}
},
(Some("changed"), Some(version), _) => {
self.changed = self.changed.max(version);
cx.emit(Reported);
}
// Only the newest text counts.
(Some("text"), Some(version), Some(text)) if version >= self.reported => {
self.doc.lock().expect("the text lock").current = text.to_owned();
self.reported = version;
cx.emit(Reported);
}
(Some("text"), Some(_), Some(_)) => log::info!("mark: an older text was dropped"),
_ => {
log::error!("mark: the page asked something unknown");
return reply.fail("unknown request");
}
}
reply.ok("");
}
}
/// A search state from the page; anything malformed is None.
fn found(said: &Value) -> Option<crate::finding::Found> {
if let Some(error) = said["error"].as_str() {
let error = Some(error.to_owned());
return Some(crate::finding::Found {
current: None,
total: 0,
error,
});
}
let total = said["total"].as_u64()? as usize;
let current = match &said["current"] {
Value::Null => None,
at => Some(at.as_u64()? as usize),
};
Some(crate::finding::Found {
current,
total,
error: None,
})
}
/// Headings from the page; anything malformed is None.
fn headings(items: &Value) -> Option<Vec<Heading>> {
let heading = |item: &Value| {
let level = item["level"]
.as_u64()
.filter(|level| (1..=6).contains(level))?;
let title = item["title"].as_str()?.to_owned();
Some(Heading {
level: level as usize,
title,
})
};
items.as_array()?.iter().map(heading).collect()
}
impl Render for MarkView {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
// Dropped files: images go beside the document.
let drop = cx.listener(|view, dropped: &ExternalPaths, _, cx| {
view.attach(crate::attach::Attach::Files(dropped.paths().to_vec()), cx)
});
div().size_full().on_drop(drop).child(self.web.clone())
}
}