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:
817 files changed
+143101
No files matched your search
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(®ions, &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,
|
||||
}
|
||||
@@ -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,
|
||||
))
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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")]);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
]),
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})"));
|
||||
}
|
||||
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
/// `%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<b>&</title>"));
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(¤t);
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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), ¤t, 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())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user