diff --git a/agents.md b/agents.md index acc1e10..246e32e 100644 --- a/agents.md +++ b/agents.md @@ -16,7 +16,7 @@ | PDF | MuPDF(C 接口),Tesseract OCR,独立工作进程 | | Mail | MailCore 2 + SQLite;正文用 CEF 离屏内容区 | | Tree | Git CLI,参数数组启动 | -| Mark | Milkdown + CodeMirror 6,CEF 离屏内容区 | +| Mark | 组件库 `documents::MarkdownDocument`(GPUI,原文即文本,无序列化);markdown-rs 解析,RaTeX 公式,mermaid-rs-renderer 图 | | Agent | Kigi CLI 的 Rust 代码直接嵌入(不用 ACP),沿用其提供商登录与模型配置 | ## 目录 @@ -165,15 +165,6 @@ tests/scripts/theme.sh 真窗口 ElyTable 以深、浅两种外观启动 tests/scripts/lag.sh ely-doc 真窗口键入,从调试日志算输入落地、图块、按键到新图块的分位数(需辅助功能权限);ELY_LAG_LOG=路径 留下日志 tests/scripts/pdf-idle.sh 真窗口 ElyPDF 无文件启动时无 worker 子进程 scripts/build-mark-content.sh 按锁文件构建 ElyMark 页面到 engines/content/ely-mark(不跑安装脚本) -tests/scripts/mark-content.sh 真窗口:构建的页面离线渲染各包(可视、预览、源码) -tests/scripts/mark-session.sh 真窗口 ElyMark:不改保存逐字节相同,键入一字只改该块(需辅助功能权限) -tests/scripts/mark-modes.sh 真窗口 ElyMark:文档旁图片、三种模式往返、预览只读、跨模式撤销、改名、新建(需辅助功能与屏幕录制权限) -tests/scripts/mark-places.sh 真窗口 ElyMark:文件夹树、大纲跳转、文件夹搜索、最近列表(需辅助功能权限) -tests/scripts/mark-output.sh 真窗口 ElyMark:专注模式与外观持久、四种导出、打印、Git 状态与打开 ElyTree、大文件(需辅助功能权限与 pdftotext;ELY_CLIPBOARD_TEST=1 再测富文本复制) -tests/scripts/mark-outside.sh 真窗口 ElyMark:外部替换、改名、删除、冲突比较、自动保存、退出与崩溃后的恢复(需辅助功能权限) -tests/scripts/mark-links.mjs ElyMark 页面(Playwright,三端):可视模式单击编辑,Cmd(他处 Ctrl)单击交应用,预览单击交应用,地址取文档原文(需 site 的依赖) -tests/scripts/mark-broken.mjs ElyMark 页面(Playwright,三端):可视编辑器建不成即退回源码并告知,两者都建不成即报失败(需 site 的依赖) -tests/scripts/mark-tools.sh 真窗口 ElyMark:查找替换、正则、Tab、列表、表格、拖放与粘贴图片(需辅助功能权限;ELY_CLIPBOARD_TEST=1 再测粘贴) tests/scripts/comments-rules.sh comments.py 对长注释与跨行注释报错 tests/scripts/bad-settings.sh 通用设置读不出时 ElyDoc 照样启动,文件不被覆盖 tests/scripts/one-instance.sh 第二份 ElyDoc 被拒,第一份的工作目录不动 diff --git a/apps/ely-mark/Cargo.toml b/apps/ely-mark/Cargo.toml index d8e2c37..0454b5a 100644 --- a/apps/ely-mark/Cargo.toml +++ b/apps/ely-mark/Cargo.toml @@ -17,10 +17,8 @@ ely-gpui-component.workspace = true ely-office.workspace = true ely-platform.workspace = true ely-ui.workspace = true -ely-web-content.workspace = true gpui.workspace = true log.workspace = true -base64.workspace = true futures.workspace = true jiff.workspace = true notify.workspace = true @@ -31,5 +29,7 @@ serde_json.workspace = true workspace = true [dev-dependencies] +ely-gpui-component = { workspace = true, features = ["test-support"] } +ely-ui = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } tempfile.workspace = true diff --git a/apps/ely-mark/examples/markprobe.rs b/apps/ely-mark/examples/markprobe.rs deleted file mode 100644 index 6da9bde..0000000 --- a/apps/ely-mark/examples/markprobe.rs +++ /dev/null @@ -1,446 +0,0 @@ -//! The built page, live: each package renders offline. - -use std::{path::Path, time::Duration}; - -use ely_gpui_component::theme::{ActiveTheme as _, Mode, Theme}; -use ely_platform::AppId; -use ely_web_content::{Event, WebView}; -use gpui::{ - App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, Styled as _, - Window, div, -}; - -/// What the page holds, each block scrolled in and mounted. -const CHECK: &str = r#" -(async () => { - const view = document.querySelector('#document > div:not([hidden])') ?? document; - const settle = () => new Promise((done) => setTimeout(done, 700)); - const seen = (node) => { - if (!node || !node.checkVisibility({ opacityProperty: true, visibilityProperty: true })) return false; - node.scrollIntoView({ block: 'nearest' }); - const box = node.getBoundingClientRect(); - if (box.width < 1 || box.height < 1) return false; - const top = document.elementFromPoint(box.x + box.width / 2, box.y + Math.min(box.height / 2, 8)); - return Boolean(top && (node.contains(top) || top.contains(node))); - }; - const blocks = []; - for (const block of view.querySelectorAll('.milkdown-code-block')) { - block.scrollIntoView({ block: 'center' }); - await settle(); - const diagram = block.querySelector('.ely-diagram svg'); - const shape = diagram?.querySelector('.node rect, .node polygon'); - const box = diagram?.getBoundingClientRect(); - blocks.push({ - language: block.querySelector('.language-button')?.textContent.trim() ?? '', - editors: block.querySelectorAll('.cm-editor').length, - editable: block.querySelector('.cm-content')?.contentEditable ?? 'none', - code: seen(block.querySelector('.cm-line')), - math: seen(block.querySelector('.preview .katex')), - diagram: box ? [Math.round(box.width), Math.round(box.height)] : [0, 0], - drawn: seen(diagram), - fill: shape ? getComputedStyle(shape).fill : '', - first: window.firstFill ?? '', - failure: seen(block.querySelector('.ely-failure')) ? block.querySelector('.ely-failure').textContent : '', - }); - } - document.scrollingElement.scrollTop = 0; - await settle(); - const root = document.getElementById('document'); - window.elyQuery({ - request: JSON.stringify({ - step: 'STEP', - mode: root.dataset.mode, - editable: view.querySelector('.ProseMirror')?.contentEditable ?? 'none', - title: view.querySelector('.milkdown h1')?.textContent ?? '', - heading: seen(view.querySelector('.milkdown h1')), - inline: seen(view.querySelector('.milkdown p .katex')), - display: seen(view.querySelector('.milkdown p .katex-display')), - tasks: view.querySelectorAll('li[data-item-type="task"]').length, - boxes: [...view.querySelectorAll('li[data-item-type="task"]')].map((item) => { - const mark = getComputedStyle(item, '::before'); - const range = document.createRange(); - range.selectNodeContents(item.querySelector('p')); - const line = range.getClientRects()[0]; - const top = item.getBoundingClientRect().top + parseFloat(mark.marginTop); - const size = parseFloat(getComputedStyle(item).fontSize); - const shown = mark.display !== 'none' && mark.visibility === 'visible' && parseFloat(mark.opacity) > 0; - // A laid-out box pushes the text 1.5em in. - const inset = (item.querySelector('p').getBoundingClientRect().left - item.getBoundingClientRect().left) / size; - const drawn = shown && mark.content !== 'none' && parseFloat(mark.borderTopWidth) > 0 && Math.abs(inset - 1.5) < 0.05; - return [parseFloat(mark.width) / size, parseFloat(mark.height) / size, top + parseFloat(mark.height) / 2 - (line.top + line.height / 2), drawn]; - }), - cells: view.querySelectorAll('.milkdown td').length, - notes: view.querySelectorAll('[data-type="footnote_definition"]').length, - blocks, - font: [...document.fonts].some((f) => f.family === 'KaTeX_Main' && f.status === 'loaded'), - source: seen(view.querySelector(':scope > .cm-editor .cm-line')), - first: view.querySelector('.cm-line')?.textContent ?? '', - editors: document.querySelectorAll('#document > div:not([hidden])').length, - text: view.textContent.slice(0, 40), - bg: getComputedStyle(document.documentElement).getPropertyValue('--ely-bg').trim(), - }), - onSuccess: () => {}, - onFailure: () => {}, - }); -})(); -"#; - -const TEXTS: &str = r#"{"searchLanguage":"s","noResult":"n","copy":"c","preview":"p","loading":"l","edit":"e","hide":"h"}"#; - -/// Steps in order; each reports once. -const STEPS: [&str; 13] = [ - "visual", "themed", "preview", "source", "race", "language", "midtheme", "release", "asked", - "stale", "owner", "find", "look", -]; - -/// Empty and broken queries: errors, never CodeMirror's own panel. -const FIND: &str = r#" -(async () => { - const report = {}; - for (const mode of ['source', 'visual']) { - await ely.open('a{b alpha\n', mode, TEXTS, 1, location.href); - const empty = { search: '' }; - ely.find(empty); - ely.step({ spec: empty, forward: true }); - ely.replace({ spec: empty, all: false }); - for (const search of ['a{', '\\a']) ely.find({ search, regexp: true }); - ely.unfind(); - report[mode] = document.querySelectorAll('.cm-panels .cm-search').length; - await ely.open('\u{1F600} alpha\n', mode, TEXTS, 1, location.href); - ely.find({ search: '(?=.)', regexp: true }); - ely.unfind(); - } - await new Promise((done) => setTimeout(done, 300)); - window.elyQuery({ request: JSON.stringify({ step: 'find', panels: report }), onSuccess: () => {}, onFailure: () => {} }); -})(); -"#; - -/// The writing view: dim blocks, narrow serif column, centered caret. -const LOOK: &str = r#" -(async () => { - const paragraphs = Array.from({ length: 80 }, (_, n) => `Paragraph ${n + 1} of the look.`) - await ely.open(paragraphs.join('\n\n') + '\n', 'visual', TEXTS, 1, location.href) - ely.look({ focus: true, typewriter: true, width: 'narrow', font: 'serif', size: 120 }) - ely.reveal({ line: 159, query: '' }) - await new Promise((done) => setTimeout(done, 600)) - const blocks = [...document.querySelectorAll('.milkdown .ProseMirror > *')] - const dim = blocks.filter((block) => getComputedStyle(block).opacity === '0.35').length - const bright = document.querySelector('.ely-current') - const caret = window.getSelection().getRangeAt(0).getBoundingClientRect() - const page = document.getElementById('document') - const report = { - step: 'look', - blocks: blocks.length, - dim, - bright: bright ? getComputedStyle(bright).opacity : 'none', - centered: Math.abs(caret.top - window.innerHeight / 2) < window.innerHeight / 4, - width: getComputedStyle(page).maxWidth, - serif: getComputedStyle(page).fontFamily.includes('serif'), - } - ely.look({ focus: false, typewriter: false, width: 'medium', font: 'sans', size: 100 }) - window.elyQuery({ request: JSON.stringify(report), onSuccess: () => {}, onFailure: () => {} }) -})(); -"#; - -/// Picks Rust in the first block's list, once mounted. -const TO_RUST: &str = r#" -(function pick() { - const block = document.querySelector('#document > div:not([hidden]) .milkdown-code-block'); - const button = block?.querySelector('.language-button'); - if (!button) return requestAnimationFrame(pick); - button.click(); - setTimeout(() => [...block.querySelectorAll('.language-list-item')] - .find((item) => item.textContent.trim() === 'Rust').click(), 30); -})(); -"#; - -/// Says when a diagram waits, drawn not yet. -const PENDING: &str = r#" -(function wait() { - if (!document.querySelector('.ely-diagram:empty')) return requestAnimationFrame(wait); - new MutationObserver((_, watch) => { - const shape = document.querySelector('.ely-diagram svg .node rect'); - if (!shape) return; - window.firstFill = getComputedStyle(shape).fill; - watch.disconnect(); - }).observe(document.body, { childList: true, subtree: true }); - window.elyQuery({ request: JSON.stringify({ kind: 'pending' }), onSuccess: () => {}, onFailure: () => {} }); -})(); -"#; - -/// A diagram slow enough to change things mid-draw. -fn big() -> String { - let mut source = String::from("```mermaid\ngraph TD\n"); - for at in 0..150 { - source += &format!(" N{at} --> N{}\n N{at} --> N{}\n", at + 1, (at * 7) % 151); - } - serde_json::to_string(&(source + "```\n")).expect("json") -} - -/// Short sources, slow draws: blocks mount at once. -fn chains(blocks: usize) -> String { - let mut source = String::new(); - for block in 0..blocks { - let side = |name: &str| { - let nodes: Vec = (0..20).map(|at| format!("{name}{block}x{at}")).collect(); - nodes.join(" & ") - }; - source += &format!( - "```mermaid\ngraph LR\n{} --> {}\n```\n\n", - side("A"), - side("B") - ); - } - serde_json::to_string(&source).expect("json") -} - -/// Leaves for source mid-draw; counts long stalls after. -const RELEASE: &str = r#" -(async () => { - const sleep = (ms) => new Promise((done) => setTimeout(done, ms)); - await ely.open(BIG, 'preview', TEXTS, 1, location.href); - await sleep(50); - await ely.open('x', 'source', TEXTS, 1, location.href); - let last = performance.now(); - let long = 0; - const tick = setInterval(() => { - const now = performance.now(); - if (now - last > 500) long += 1; - last = now; - }, 20); - await sleep(7000); - clearInterval(tick); - window.elyQuery({ request: JSON.stringify({ step: 'release', long }), onSuccess: () => {}, onFailure: () => {} }); -})(); -"#; - -fn flip(cx: &mut App) { - let mode = match cx.theme().is_dark() { - true => Mode::Light, - false => Mode::Dark, - }; - Theme::set_mode_now(mode, cx); -} - -struct Probe { - view: Entity, - step: usize, - /// A fresh page: Mermaid's bundle not yet loaded. - fresh: bool, - /// Find reports that carried an error, and every total. - errors: usize, - totals: Vec, -} - -impl Probe { - /// Starts the current step; its check follows. - fn begin(&mut self, cx: &mut Context) { - let markdown = - serde_json::to_string(include_str!("../../../tests/fixtures/mark/extended.md")) - .expect("json"); - let step = STEPS[self.step]; - match step { - "themed" => { - flip(cx); - self.check(step, 2500, cx); - } - // Two quick shows: only the last may stay. - "race" => { - self.view.read(cx).run_js(&format!( - "ely.open('# OLD', 'visual', {TEXTS}, 1, location.href); \ - ely.open('# NEW', 'source', {TEXTS}, 1, location.href)" - )); - self.check(step, 2500, cx); - } - // Two modes asked at once: the last one shows. - "asked" => { - self.view.read(cx).run_js(&format!( - "ely.open('# M\\n', 'source', {TEXTS}, 1, location.href) \ - .then(() => {{ ely.mode('visual'); ely.mode('preview') }})" - )); - self.check(step, 3000, cx); - } - // A request while visual's shown is unanswered wins. - "stale" => { - self.view.read(cx).run_js(&format!( - "ely.open('# M\\n', 'source', {TEXTS}, 1, location.href) \ - .then(() => {{ ely.mode('visual'); ely.mode('preview') }})" - )); - self.check(step, 3000, cx); - } - // An old answer leaves a newer build's request alone. - "owner" => { - self.view.read(cx).run_js(&format!( - "ely.open('# M\\n', 'source', {TEXTS}, 1, location.href) \ - .then(() => ely.mode('visual'))" - )); - self.check(step, 3000, cx); - } - "find" => self.view.read(cx).run_js(&FIND.replace("TEXTS", TEXTS)), - "look" => self.view.read(cx).run_js(&LOOK.replace("TEXTS", TEXTS)), - "release" => { - let script = RELEASE.replace("BIG", &chains(3)).replace("TEXTS", TEXTS); - self.view.read(cx).run_js(&script); - } - // Changes land while the bundle still loads. - "language" | "midtheme" if !self.fresh => { - self.fresh = true; - self.view.read(cx).run_js("location.reload()"); - } - "language" | "midtheme" => { - self.fresh = false; - let show = format!("ely.open({}, 'visual', {TEXTS}, 1, location.href)", big()); - self.view.read(cx).run_js(&show); - } - mode => self.view.read(cx).run_js(&format!( - "ely.open({markdown}, '{mode}', {TEXTS}, 1, location.href)" - )), - } - } - - /// Checks after `wait`, when drawing is done. - fn check(&self, step: &'static str, wait: u64, cx: &mut Context) { - let view = self.view.clone(); - cx.spawn(async move |_, cx| { - cx.background_executor() - .timer(Duration::from_millis(wait)) - .await; - view.update(cx, |view, _| view.run_js(&CHECK.replace("STEP", step))); - }) - .detach(); - } - - fn heard( - &mut self, - _: &Entity, - event: &Event, - _: &mut Window, - cx: &mut Context, - ) { - match event { - Event::Loaded => self.begin(cx), - Event::Query(said, reply) => { - let read: serde_json::Value = serde_json::from_str(said).expect("json"); - // Some answers wait; meanwhile newer modes are asked. - let (wait, then) = match (STEPS.get(self.step), read["mode"].as_str()) { - (Some(&"stale"), Some("visual")) => (300, "ely.mode('source')"), - (Some(&"owner"), Some("visual")) => { - (300, "ely.mode('preview'); ely.mode('source')") - } - (Some(&"owner"), Some("preview")) => (600, ""), - _ => (0, ""), - }; - if !then.is_empty() { - self.view.read(cx).run_js(then); - } - let reply = reply.clone(); - cx.spawn(async move |_, cx| { - cx.background_executor() - .timer(Duration::from_millis(wait)) - .await; - reply.ok(""); - }) - .detach(); - if read["kind"] == "shown" { - println!("mark: shown {}", read["mode"]); - let Some(&step) = STEPS.get(self.step) else { - return; - }; - // Mid-draw: a new language, or a new theme. - match step { - "language" => self.view.read(cx).run_js(TO_RUST), - "midtheme" => return self.view.read(cx).run_js(PENDING), - _ if step == read["mode"] => {} - _ => return, - } - self.check(step, 5000, cx); - return; - } - if read["kind"] == "found" { - self.errors += read["error"].is_string() as usize; - self.totals.extend(read["total"].as_u64()); - return; - } - // Edits report too; the session tests them. - if ["changed", "text", "outline", "place"] - .contains(&read["kind"].as_str().unwrap_or_default()) - { - return; - } - if read["kind"] == "pending" { - // Its draw takes seconds; flip once it has begun. - println!("mark: pending"); - cx.spawn(async move |this, cx| { - cx.background_executor() - .timer(Duration::from_millis(600)) - .await; - this.update(cx, |probe, cx| { - flip(cx); - probe.check("midtheme", 6000, cx); - }) - .ok(); - }) - .detach(); - return; - } - let mut read = read; - if read["step"] == "find" { - read["errors"] = self.errors.into(); - read["totals"] = self.totals.clone().into(); - } - println!("mark: {read}"); - self.step += 1; - if self.step == STEPS.len() { - println!("mark: done"); - return cx.quit(); - } - // The first view stays a while for the window's pixels. - let wait = if self.step == 1 { 3000 } else { 0 }; - cx.spawn(async move |this, cx| { - cx.background_executor() - .timer(Duration::from_millis(wait)) - .await; - this.update(cx, |probe, cx| probe.begin(cx)).ok(); - }) - .detach(); - } - Event::Leave(url) => println!("mark: leave {url}"), - Event::Printed(path, ok) => println!("mark: printed {} {ok}", path.display()), - Event::Failed(why) => { - println!("mark: failed {why}"); - cx.quit(); - } - } - } -} - -impl Render for Probe { - fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - div().size_full().child(self.view.clone()) - } -} - -fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity { - let work = tempfile::tempdir().expect("a temp dir").keep(); - ely_web_content::start(&work, cx).expect("CEF starts"); - let content = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../engines/content/ely-mark"); - let view = WebView::open(&content.join("index.html"), &content, window, cx).expect("a view"); - let probe = cx.new(|cx| { - cx.subscribe_in(&view, window, Probe::heard).detach(); - Probe { - view, - step: 0, - fresh: false, - errors: 0, - totals: Vec::new(), - } - }); - ely_ui::root(app, Some(probe.into()), window, cx) -} - -fn main() { - if let Some(code) = ely_web_content::subprocess() { - std::process::exit(code); - } - ely_ui::run(AppId::Mark, open); -} diff --git a/apps/ely-mark/src/attach.rs b/apps/ely-mark/src/attach.rs deleted file mode 100644 index 1965454..0000000 --- a/apps/ely-mark/src/attach.rs +++ /dev/null @@ -1,163 +0,0 @@ -//! 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 { - 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) -> 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), - Png(Vec), -} - -impl MarkView { - /// Copies into the file's folder, then links at the caret. - pub fn attach(&mut self, what: Attach, cx: &mut Context) { - 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>, - cx: &mut Context, - ) { - 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"); - } -} diff --git a/apps/ely-mark/src/autosave.rs b/apps/ely-mark/src/autosave.rs index 481e524..a138443 100644 --- a/apps/ely-mark/src/autosave.rs +++ b/apps/ely-mark/src/autosave.rs @@ -10,7 +10,7 @@ use crate::desk::Desk; use crate::outside::Outside; impl Desk { - /// A page change restarts the wait; finished saves report. + /// A text change restarts the wait; finished saves report. pub fn autosave(&mut self, id: u64, changed: u64, cx: &mut Context) { let Some(tab) = self.tab_mut(id) else { return; @@ -24,7 +24,7 @@ impl Desk { if tab.session.path().is_some() { tab.extra.policy.set_has_path(); } - // A page reload restarts its count: any difference is news. + // Any count other than the last heard is news. if changed == tab.extra.heard { return; } diff --git a/apps/ely-mark/src/compare.rs b/apps/ely-mark/src/compare.rs index 0ea0346..4b0097b 100644 --- a/apps/ely-mark/src/compare.rs +++ b/apps/ely-mark/src/compare.rs @@ -30,10 +30,6 @@ pub struct Comparing { impl Desk { /// Mine, the base and the disk's, side by side. pub fn compare(&mut self, id: u64, cx: &mut Context) { - // 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; }; diff --git a/apps/ely-mark/src/desk.rs b/apps/ely-mark/src/desk.rs index 266cf6d..7035f62 100644 --- a/apps/ely-mark/src/desk.rs +++ b/apps/ely-mark/src/desk.rs @@ -22,13 +22,9 @@ use gpui::{ 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; +use crate::engine::Mark; +use crate::keys::{CheckDisk, PreviewMode, SourceMode, VisualMode}; +use crate::view::{MarkView, Mode}; pub struct Desk { pub core: Core, @@ -40,22 +36,16 @@ pub struct Desk { /// Files and folders opened lately, newest first. pub recent: Vec, pub recent_file: PathBuf, - /// A file asked to open at a place. - pub going: Option<(PathBuf, serde_json::Value)>, + /// A file asked to open at a line. + pub going: Option<(PathBuf, usize)>, /// The path each tab last put in the recent list. pub remembered: HashMap, side: Entity, - pub finder: Option, pub watcher: Option, /// The folders the watcher covers. pub watched: Vec, pub comparing: Option, pub keeping: crate::restore::Keeping, - /// The export dialog's choice, while it is open. - pub output: Option, - /// Pages printing to PDF, by job; several may run. - pub printing: HashMap, - pub print_jobs: u64, pub look: crate::look::Look, pub look_file: PathBuf, /// Git's word on the shown file, and which tab asked. @@ -113,8 +103,6 @@ impl Host for Desk { /// Save As or rename moves where images come from. fn observed(&mut self, id: u64, view: &Entity, cx: &mut Context) { self.watch_folders(cx); - let 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| { @@ -126,9 +114,7 @@ impl Host for Desk { if recheck { self.look(id, cx); } - if view.read(cx).shown { - self.restore_shown(id, cx); - } + self.restore_shown(id, cx); // A save changes what Git sees. let print = self.active_tab().and_then(|tab| tab.session.fingerprint()); if print != self.asked_print { @@ -139,7 +125,6 @@ impl Host for Desk { .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)); } @@ -149,9 +134,9 @@ impl Host for Desk { .going .as_ref() .is_some_and(|(file, _)| Some(file) == real.as_ref()) - && let Some((_, place)) = self.going.take() + && let Some((_, line)) = self.going.take() { - view.update(cx, |view, cx| view.reveal(place, cx)); + self.reveal_later(view.clone(), line, cx); } } cx.notify(); @@ -162,13 +147,8 @@ impl Host for Desk { pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity { let fail = |error: String| -> ! { startup::fail(app, &error) }; let data = ely_platform::data_dir(app).unwrap_or_else(|error| fail(format!("{error:#}"))); - 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. @@ -187,7 +167,7 @@ pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity { let this = cx.entity(); let side = cx.new(|cx| crate::side::Side::new(&this, window, cx)); Desk { - core: Core::new(pages, work, cx), + core: Core::new(Arc::new(Mutex::new(())), work, cx), naming: None, folder: None, listings: 0, @@ -196,14 +176,10 @@ pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity { going: None, remembered: HashMap::new(), side, - finder: None, watcher: None, watched: Vec::new(), comparing: None, keeping, - output: None, - printing: Default::default(), - print_jobs: 0, look: look.clone(), look_file, tracked: None, @@ -337,16 +313,23 @@ impl Desk { self.new_from_text("untitled.md", "", cx); } - fn table(&mut self, op: &str, cx: &mut Context) { + fn switch(&mut self, mode: Mode, window: &mut Window, cx: &mut Context) { if let Some(view) = self.active_view() { - view.update(cx, |view, cx| view.table(op, cx)); + view.update(cx, |view, cx| view.switch(mode, window, cx)); } } - fn switch(&mut self, mode: Mode, cx: &mut Context) { - if let Some(view) = self.active_view() { - view.update(cx, |view, cx| view.switch(mode, cx)); - } + /// Brings `line` into view from outside a window update. + pub fn reveal_later(&mut self, view: Entity, line: usize, cx: &mut Context) { + cx.spawn(async move |desk, cx| { + let shown = ely_office::in_window(&desk, cx, |_, window, cx| { + view.update(cx, |view, cx| view.reveal(line, window, cx)) + }); + if let Err(error) = shown { + log::warn!("mark: line {line} was not shown: {error:#}"); + } + }) + .detach(); } fn status(&self, cx: &mut Context) -> StatusBar { @@ -367,9 +350,9 @@ impl Desk { control.segment(mode.name(), crate::keys::label(mode, cx), Some(icon)) }, ); - let modes = modes.on_change(move |key, _, cx| { + let modes = modes.on_change(move |key, window, cx| { if let Some(mode) = Mode::parse(key) { - this.update(cx, |desk, cx| desk.switch(mode, cx)); + this.update(cx, |desk, cx| desk.switch(mode, window, cx)); } }); let bar = StatusBar::new().left(state); @@ -417,27 +400,21 @@ impl Render for Desk { .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, _: &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))) + .on_action(cx.listener(|desk, _: &VisualMode, window, cx| { + desk.switch(Mode::Visual, window, cx) + })) + .on_action(cx.listener(|desk, _: &SourceMode, window, cx| { + desk.switch(Mode::Source, window, cx) + })) + .on_action(cx.listener(|desk, _: &PreviewMode, window, cx| { + desk.switch(Mode::Preview, window, cx) + })) .child(shell) .children(self.core_dialog(cx)) .children(self.naming_element(cx)) - .children(self.find_bar(cx)) - .children(self.compare_dialog(cx)) - .children(self.export_dialog(cx)); + .children(self.compare_dialog(cx)); Self::look_actions(root, cx) } } diff --git a/apps/ely-mark/src/engine.rs b/apps/ely-mark/src/engine.rs index a9cedfe..c897105 100644 --- a/apps/ely-mark/src/engine.rs +++ b/apps/ely-mark/src/engine.rs @@ -9,17 +9,11 @@ use std::{ }; 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, @@ -28,6 +22,18 @@ pub struct Text { written: Option, } +impl Text { + /// A file as read, not yet edited. + pub fn new(source: Source) -> Text { + let current = source.text.clone(); + Text { + source, + current, + written: None, + } + } +} + pub type Doc = Arc>; /// A load: the file as read, and where it is. @@ -46,13 +52,14 @@ pub fn mark(text: &str) -> String { pub struct Mark; impl Engine for Mark { - type Worker = Pages; + /// Files read and written in place: no worker. + type Worker = (); type Doc = Doc; type View = MarkView; type Loaded = Opened; type Import = Infallible; - fn load(_: &mut Pages, from: Load) -> io::Result { + fn load(_: &mut (), from: Load) -> io::Result { let path = match from { Load::File(path, _) => path, Load::Import(_, never) => match *never {}, @@ -69,7 +76,7 @@ impl Engine for Mark { } fn open( - worker: &Arc>, + _: &Arc>, loaded: Opened, window: &mut Window, cx: &mut App, @@ -82,30 +89,20 @@ impl Engine for Mark { .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)); + let doc = Arc::new(Mutex::new(Text::new(loaded))); + let saved = mark.clone(); + let view = cx.new(|cx| MarkView::new(Arc::clone(&doc), saved, folder, window, cx)); Ok((doc, view, mark)) } /// After a write, what it wrote: later edits stay unsaved. - fn history(_: &mut Pages, doc: &Doc) -> io::Result { + fn history(_: &mut (), doc: &Doc) -> io::Result { 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<()> { + fn write(_: &mut (), doc: &Doc, path: &Path, format: &str) -> io::Result<()> { if !matches!(format, "md" | "markdown") { return Err(io::Error::new( io::ErrorKind::Unsupported, @@ -118,13 +115,13 @@ impl Engine for Mark { Ok(()) } - fn close(_: &mut Pages, _: Doc) -> io::Result<()> { + fn close(_: &mut (), _: Doc) -> io::Result<()> { Ok(()) } - fn retire(view: &Entity, cx: &mut App) -> ely_gpui_component::primitives::Retired { - let web = view.read(cx).web.clone(); - web.update(cx, |web, _| web.retire_all()) + /// Pictures leave with their elements; nothing to hand back. + fn retire(_: &Entity, _: &mut App) -> ely_gpui_component::primitives::Retired { + ely_gpui_component::primitives::Retired::default() } fn settled(view: &MarkView) -> bool { @@ -135,8 +132,8 @@ impl Engine for Mark { view.changed() } - fn edit_pending(view: &MarkView) -> bool { - !view.settled() + fn edit_pending(_: &MarkView) -> bool { + false } fn focus(view: &MarkView) -> FocusHandle { @@ -171,30 +168,22 @@ mod tests { use ely_office::Engine as _; - use super::{Mark, Pages, Text, mark}; + use super::{Mark, 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 doc = Arc::new(Mutex::new(Text::new(source))); let path = dir.path().join("save.md"); - Mark::write(&mut pages, &doc, &path, "md").expect("writes"); + Mark::write(&mut (), &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"); + let saved = Mark::history(&mut (), &doc).expect("a mark"); assert_eq!(saved, mark("Alpha.\n")); assert_eq!( - Mark::history(&mut pages, &doc).expect("a mark"), + Mark::history(&mut (), &doc).expect("a mark"), mark("xAlpha.\n") ); } diff --git a/apps/ely-mark/src/export.rs b/apps/ely-mark/src/export.rs deleted file mode 100644 index 022ff18..0000000 --- a/apps/ely-mark/src/export.rs +++ /dev/null @@ -1,482 +0,0 @@ -//! 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, Task, 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, - diagrams: Vec, - /// Each broken formula's line and KaTeX's message. - formulas: Vec<(u64, String)>, -} - -impl Lost { - pub fn of(images: Vec, 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, - _heard: Subscription, -} - -impl Desk { - pub fn on_export(&mut self, _: &commands::Export, _: &mut Window, cx: &mut Context) { - 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) { - let Some(id) = self.active_tab().map(|tab| tab.id) else { - return; - }; - self.render_pdf(id, None, cx); - } - - /// Asks where, renders the whole text, then writes it. - fn export_to(&mut self, output: Output, cx: &mut Context) { - 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 chosen = match chosen.await { - Ok(Ok(path)) => Ok(path), - Ok(Err(error)) => Err(error.to_string()), - Err(error) => Err(error.to_string()), - }; - let done = desk.update(cx, |desk, cx| match chosen { - Ok(Some(path)) => { - if let Err(error) = not_open(desk, &path) { - return desk.exported(Err(error), cx); - } - match output { - Output::Pdf => desk.render_pdf(id, Some(path), cx), - _ => desk.write_output(id, output, path, cx), - } - } - Ok(None) => log::info!("mark: the export panel was cancelled"), - Err(why) => desk.panel_failed(&why, cx), - }); - if done.is_err() { - log::info!("mark: the window closed before the export panel"); - } - }) - .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) { - 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) { - 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::>() - ); - 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, cx: &mut Context) { - 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::>() - .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, to: Option, cx: &mut Context) { - // Each job has its own files; none cancels another. - self.print_jobs += 1; - let n = self.print_jobs; - let pdf = self.core.work.join(format!("print-{n}.pdf")); - 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-{n}.html")); - let page = match rendered { - Ok(rendered) => { - let print = paper::Print { folder, style, name, page: staged.clone() }; - paper::stage(cx.background_executor(), rendered, print) - } - Err(why) => Task::ready(Err(io::Error::other(why))), - }; - cx.spawn(async move |cx| { - let page = page.await; - in_window(&desk, cx, |desk, window, cx| match page { - Ok(lost) => { - let job = Job { - pdf, - to, - open, - lost, - }; - desk.print_page(n, &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, n: u64, page: &Path, job: Job, window: &mut Window, cx: &mut Context) { - let root = page - .parent() - .expect("a staged page sits in a folder") - .to_path_buf(); - // A document's own scripts never run here. - let view = match WebView::open_inert(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) => { - log::info!("mark: job {n} printed {}, ok {ok}", path.display()); - desk.printing.remove(&n); - 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.remove(&n); - desk.exported(Err(io::Error::other(why.clone())), cx); - } - Event::Query(_, reply) => reply.ok(""), - Event::Leave(_) => {} - }); - self.printing.insert(n, Printing { - _view: view, - _heard: heard, - }); - } - - fn printed(&mut self, pdf: io::Result, job: Job, cx: &mut Context) { - 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); - }; - // 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) -> Option { - 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, - /// The tab's own file, never overwritten. - open: Option, - 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!("", 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!( - "", - 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))) -} diff --git a/apps/ely-mark/src/finding.rs b/apps/ely-mark/src/finding.rs deleted file mode 100644 index 96e2e94..0000000 --- a/apps/ely-mark/src/finding.rs +++ /dev/null @@ -1,189 +0,0 @@ -//! 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, - pub total: usize, - /// A pattern that does not compile. - pub error: Option, -} - -pub struct Finder { - query: Entity, - with: Entity, - options: FindOptions, - replacing: bool, - /// The tab last searched; another one is searched anew. - tab: Option, - _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) { - 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) -> Option { - 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) { - 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) { - 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) { - 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.finder = None; - // Every tab's page holds its own highlights. - let views: Vec<_> = self.core.tabs.iter().map(|tab| tab.view.clone()).collect(); - for view in views { - view.update(cx, |view, cx| view.unfind(cx)); - } - if let Some(view) = self.active_view() { - let focus = view.read(cx).focus.clone(); - window.focus(&focus, cx); - } - cx.notify(); - } - - pub fn find_bar(&self, cx: &mut Context) -> Option { - 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()) - } -} diff --git a/apps/ely-mark/src/keys.rs b/apps/ely-mark/src/keys.rs index 53381a4..9fd8218 100644 --- a/apps/ely-mark/src/keys.rs +++ b/apps/ely-mark/src/keys.rs @@ -3,24 +3,9 @@ use ely_ui::{commands, i18n::text}; use gpui::{App, KeyBinding, Menu, MenuItem, actions}; -use crate::page::Mode; +use crate::view::Mode; -actions!( - mark, - [ - VisualMode, - SourceMode, - PreviewMode, - InsertTable, - RowAbove, - RowBelow, - ColumnLeft, - ColumnRight, - DeleteRow, - DeleteColumn, - CheckDisk - ] -); +actions!(mark, [VisualMode, SourceMode, PreviewMode, CheckDisk]); pub fn bind(cx: &mut App) { let at = Some(commands::CONTEXT); @@ -28,7 +13,6 @@ pub fn bind(cx: &mut App) { 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), @@ -51,19 +35,7 @@ pub fn menus(look: &crate::look::Look, cx: &App) -> Vec { disabled: false, }; let item = |key: &str, action: Box| 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), diff --git a/apps/ely-mark/src/look.rs b/apps/ely-mark/src/look.rs index 780c580..1aa4cb6 100644 --- a/apps/ely-mark/src/look.rs +++ b/apps/ely-mark/src/look.rs @@ -66,12 +66,11 @@ pub fn load(file: &Path) -> io::Result { const SIZES: (u32, u32, u32) = (70, 200, 10); impl Desk { - /// Changes the look; pages, file and menus follow. + /// Changes the look; its file and menus follow. pub fn relook(&mut self, change: impl FnOnce(&mut Look), cx: &mut Context) { 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}"); @@ -86,15 +85,6 @@ impl Desk { cx.notify(); } - /// Every tab's page takes the look. - pub fn show_look(&mut self, cx: &mut Context) { - 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) -> gpui::Div { use gpui::InteractiveElement as _; let step = SIZES.2; diff --git a/apps/ely-mark/src/main.rs b/apps/ely-mark/src/main.rs index a09c136..f2c4b01 100644 --- a/apps/ely-mark/src/main.rs +++ b/apps/ely-mark/src/main.rs @@ -1,24 +1,18 @@ // Release builds open no console on Windows. #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -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 tally; mod text; mod vcs; mod view; @@ -26,9 +20,5 @@ 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); } diff --git a/apps/ely-mark/src/outside.rs b/apps/ely-mark/src/outside.rs index 06d6c17..025800d 100644 --- a/apps/ely-mark/src/outside.rs +++ b/apps/ely-mark/src/outside.rs @@ -40,7 +40,7 @@ pub struct Extra { /// The text as last loaded or saved: the merge base. pub base: String, pub policy: PolicyEngine, - /// The page change autosave last heard. + /// The text change autosave last heard. pub heard: u64, /// The policy revision an autosave writes. pub writing: Option, @@ -225,10 +225,6 @@ impl Desk { Ok(read) => read, Err(error) => return self.set_outside(id, Outside::Unread(error.to_string()), cx), }; - // Keys the page has not sent yet count first. - if !self.settled(id, cx) { - return self.when_settled(id, move |desk, _, cx| desk.disk_read(id, path, Ok((fingerprint, disk)), cx)); - } let current = tab.doc.lock().expect("the text lock").current.clone(); let kept = disk.text == tab.extra.base; let dirty = tab.dirty(cx); diff --git a/apps/ely-mark/src/page.rs b/apps/ely-mark/src/page.rs deleted file mode 100644 index 39aefa9..0000000 --- a/apps/ely-mark/src/page.rs +++ /dev/null @@ -1,113 +0,0 @@ -//! 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::{Value, json}; - -use crate::view::Heading; - -/// Beside the app, its Resources, else this build's. -pub fn content() -> Result { - let exe = std::env::current_exe()?; - let dir = exe.parent().unwrap_or(Path::new(".")); - let built = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../engines/content/ely-mark"); - for dir in [dir.join("content"), dir.join("../Resources/content"), 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::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), - 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 disk's text in place; reports start a new generation. -pub fn refresh(view: &WebView, markdown: &str, load: u64) { - view.run_js(&format!("ely.refresh({}, {load})", json!(markdown))); -} - -/// 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.(argument)` in the page. -pub fn call(view: &WebView, name: &str, argument: &serde_json::Value) { - view.run_js(&format!("ely.{name}({argument})")); -} - -/// Headings from the page; anything malformed is None. -pub fn headings(items: &Value) -> Option> { - 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() -} diff --git a/apps/ely-mark/src/paper.rs b/apps/ely-mark/src/paper.rs deleted file mode 100644 index 8eb74de..0000000 --- a/apps/ely-mark/src/paper.rs +++ /dev/null @@ -1,276 +0,0 @@ -//! 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!( - "\n\n\n\n{}\n{style}\n\n\n{body}\n\n\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 { - 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", - } -} - -/// What the print page is made of. -pub struct Print { - pub folder: std::path::PathBuf, - pub style: std::path::PathBuf, - pub name: String, - pub page: std::path::PathBuf, -} - -/// Writes the print page off the window thread, images inline. -pub fn stage( - background: &gpui::BackgroundExecutor, - rendered: serde_json::Value, - print: Print, -) -> gpui::Task> { - background.spawn(async move { - let (html, images) = Marked::read(&rendered)?.inline(&print.folder); - let style = format!("", std::fs::read_to_string(&print.style)?); - std::fs::write(&print.page, page(&print.name, &style, &html))?; - Ok(crate::export::Lost::of(images, &rendered)) - }) -} - -/// 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, - 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 { - 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::>()?; - 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, Vec) { - 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) { - 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) { - 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!("\"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": "

" })).is_err()); - } - - #[gpui::test] - fn the_print_page_is_written_off_the_window_thread(cx: &mut gpui::TestAppContext) { - let dir = tempfile::tempdir().expect("a temp dir"); - std::fs::write(dir.path().join("a.png"), b"png").expect("an image"); - std::fs::write(dir.path().join("export.css"), "p {}").expect("a style"); - let rendered = serde_json::json!({ - "html": "

hi

", - "token": "T", - "images": ["a.png", "gone.png"], - "failed": [3], - }); - let print = Print { - folder: dir.path().into(), - style: dir.path().join("export.css"), - name: "notes".into(), - page: dir.path().join("print-1.html"), - }; - let staging = stage(&cx.executor(), rendered, print); - assert!(!dir.path().join("print-1.html").exists(), "staged on the window thread"); - let lost = cx.foreground_executor().block_test(staging).expect("staged"); - let written = std::fs::read_to_string(dir.path().join("print-1.html")).expect("the page"); - assert!(written.contains("") && written.contains("data:image/png;base64,cG5n")); - assert_eq!(format!("{lost:?}"), r#"Lost { images: ["gone.png"], diagrams: [3], formulas: [] }"#); - } - - #[test] - fn titles_escape() { - assert!(page("a&", "", "").contains("a<b>&")); - } -} diff --git a/apps/ely-mark/src/places.rs b/apps/ely-mark/src/places.rs index 4159791..1ede9f2 100644 --- a/apps/ely-mark/src/places.rs +++ b/apps/ely-mark/src/places.rs @@ -108,30 +108,29 @@ impl Desk { } } - /// `place` in `tab`, else in `file` once open. + /// Line `line`, from zero, in `tab`, else in `file` once open. pub fn open_at( &mut self, tab: Option, file: PathBuf, - place: serde_json::Value, + line: usize, window: &mut Window, cx: &mut Context, ) { 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)); + if let Some(view) = self.tab(id).map(|open| open.view.clone()) { // 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)); + window.defer(cx, move |window, cx| { + view.update(cx, |view, cx| view.reveal(line, window, 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.going = Some((real, line)); } self.open_file(file, cx); } @@ -148,7 +147,7 @@ impl Desk { self.core.tabs.iter().find(held).map(|tab| tab.id) } - /// Tab `id`'s text as the page last sent it. + /// Tab `id`'s text now, unsaved edits included. pub fn text_of(&self, id: u64) -> Option { Some( self.tab(id)? diff --git a/apps/ely-mark/src/restore.rs b/apps/ely-mark/src/restore.rs index 161e54e..3d1b800 100644 --- a/apps/ely-mark/src/restore.rs +++ b/apps/ely-mark/src/restore.rs @@ -14,8 +14,7 @@ use serde::{Deserialize, Serialize}; use crate::desk::Desk; use crate::engine::mark; -use crate::page::Mode; -use crate::view::Place; +use crate::view::{Mode, Place}; /// A kept tab: file, mode, place, recovery folder. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -236,7 +235,16 @@ impl Desk { 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)); + // Modes take focus: in the window, after this update. + cx.spawn(async move |desk, cx| { + let shown = ely_office::in_window(&desk, cx, |_, window, cx| { + view.update(cx, |view, cx| view.reopen(mode, kept.place, back, window, cx)) + }); + if let Err(error) = shown { + log::warn!("mark: tab {id} did not restore: {error:#}"); + } + }) + .detach(); } /// Every ten seconds: unsaved text to points, tabs to disk. @@ -255,7 +263,7 @@ impl Desk { path: tab.session.path().map(Path::to_path_buf), name: tab.name.to_string(), mode: view.mode.name().to_owned(), - place: view.place, + place: Some(view.place(cx)), slot: tab.extra.slot.clone(), }); let current = tab.doc.lock().expect("the text lock").current.clone(); @@ -399,13 +407,13 @@ fn humantime(at: SystemTime) -> String { #[cfg(test)] mod tests { #[test] - fn a_place_without_a_column_reads_zero() { + fn a_place_without_a_column_or_top_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)); + assert_eq!((place.line, place.column, place.top), (3, 0, 0)); } #[test] @@ -415,7 +423,7 @@ mod tests { path: None, name: name.into(), mode: mode.into(), - place: Some(super::Place { line: slot.len() as u64, column: 1, scroll: 0.0 }), + place: Some(super::Place { line: slot.len() as u64, column: 1, top: 0 }), slot: slot.into(), }; let tabs = vec![kept("Untitled1", "visual", "a"), kept("Untitled2", "source", "bb")]; diff --git a/apps/ely-mark/src/side.rs b/apps/ely-mark/src/side.rs index dd22b65..e1e6922 100644 --- a/apps/ely-mark/src/side.rs +++ b/apps/ely-mark/src/side.rs @@ -16,7 +16,6 @@ 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}; @@ -234,7 +233,7 @@ impl Side { ) .into_any_element(); }; - let headings = view.read(cx).outline.clone(); + let headings = view.read(cx).outline(cx); if headings.is_empty() { return EmptyState::new( "mark-no-headings", @@ -251,12 +250,12 @@ impl Side { ); outline .on_select(move |key, window, cx| { - if let Ok(at) = key.parse::() { - 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); - } + let Some(heading) = key.parse::().ok().and_then(|at| headings.get(at)) else { + return log::error!("mark: the outline chose no heading"); + }; + // Keys go to the document, at the heading. + let line = heading.line; + view.update(cx, |view, cx| view.reveal(line, window, cx)); }) .into_any_element() } @@ -277,7 +276,7 @@ impl Side { }) }); let opener = desk.downgrade(); - let (hits, query) = (self.hits.clone(), self.searched.clone()); + let hits = self.hits.clone(); // Files not read come first: the results lack them. let missed = self.missed.iter().enumerate().map(|(at, (path, why))| { let name = path @@ -312,10 +311,10 @@ impl Side { let Some((tab, hit)) = key.parse::().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 }); + // Hits count lines from one. + let (tab, file, line) = (*tab, hit.file.clone(), hit.line - 1); opener - .update(cx, |desk, cx| desk.open_at(tab, file, place, window, cx)) + .update(cx, |desk, cx| desk.open_at(tab, file, line, window, cx)) .ok(); }); let more = (self.hits.len() == MOST_HITS) diff --git a/apps/ely-mark/src/tally.rs b/apps/ely-mark/src/tally.rs deleted file mode 100644 index e1e9ec9..0000000 --- a/apps/ely-mark/src/tally.rs +++ /dev/null @@ -1,151 +0,0 @@ -//! Which page reports count, by page load and by version. - -use std::collections::HashMap; - -/// One page load's change counts and the renders it owes. -pub struct Tally { - load: u64, - /// The newest change the page announced. - changed: u64, - /// The change whose text arrived. - reported: u64, - renders: HashMap, - asked: u64, - /// Why the page is down, until it loads again. - down: Option, -} - -impl Tally { - pub fn new() -> Self { - Tally { load: 0, changed: 0, reported: 0, renders: HashMap::new(), asked: 0, down: None } - } - - pub fn load(&self) -> u64 { - self.load - } - - pub fn changed(&self) -> u64 { - self.changed - } - - /// Every change's text has arrived. - pub fn settled(&self) -> bool { - self.reported == self.changed - } - - /// A reload or refresh: older reports lapse, owed renders end. - pub fn restart(&mut self) -> Vec { - self.load += 1; - (self.changed, self.reported) = (0, 0); - self.lapse() - } - - /// A page loaded: it answers again. - pub fn loaded(&mut self) -> Vec { - self.down = None; - self.restart() - } - - /// The page failed: owed and later renders end with `why`. - pub fn fail(&mut self, why: String) -> Vec { - self.down = Some(why); - self.lapse() - } - - fn lapse(&mut self) -> Vec { - self.renders.drain().map(|(_, render)| render).collect() - } - - /// Whether a report from page load `generation` still counts. - pub fn current(&self, generation: Option) -> bool { - generation.is_none_or(|load| load == self.load) - } - - pub fn change(&mut self, version: u64) { - self.changed = self.changed.max(version); - } - - /// Takes text `version` if it is the newest. - pub fn text(&mut self, version: u64) -> bool { - let newest = version >= self.reported; - if newest { - self.reported = version; - } - newest - } - - /// Owes `render`; its number goes to the page. - pub fn ask(&mut self, render: R) -> Result { - if let Some(why) = &self.down { - return Err((render, why.clone())); - } - self.asked += 1; - self.renders.insert(self.asked, render); - Ok(self.asked) - } - - pub fn answered(&mut self, ask: u64) -> Option { - self.renders.remove(&ask) - } -} - -#[cfg(test)] -mod tests { - use super::Tally; - - #[test] - fn a_key_from_before_a_refresh_leaves_it_settled() { - let mut tally = Tally::<()>::new(); - let old = tally.load(); - // The old page counted two versions. - tally.change(2); - assert!(tally.text(2)); - tally.restart(); - // The renderer's change for the old text lands late. - if tally.current(Some(old)) { - tally.change(3); - } - assert!(tally.settled(), "an old change does not count"); - assert_eq!(tally.changed(), 0, "versions start over"); - // The refreshed page's own edits count again. - tally.change(1); - assert!(!tally.settled()); - assert!(tally.text(1), "the new page's first text counts"); - assert!(tally.settled()); - } - - #[test] - fn a_load_or_failure_ends_every_owed_render() { - let mut tally = Tally::new(); - let (html, text) = (tally.ask("html").expect("up"), tally.ask("text").expect("up")); - let mut owed = tally.loaded(); - owed.sort(); - assert_eq!(owed, ["html", "text"]); - // Late answers find nobody waiting. - assert_eq!((tally.answered(html), tally.answered(text)), (None, None)); - let pdf = tally.ask("pdf").expect("up"); - assert_eq!(tally.fail("denied".into()), ["pdf"]); - assert_eq!(tally.answered(pdf), None); - } - - #[test] - fn a_failed_page_refuses_renders_until_it_loads() { - let mut tally = Tally::new(); - tally.fail("denied".into()); - assert_eq!(tally.ask("html"), Err(("html", "denied".to_string()))); - // A refresh is no load: the page is still down. - assert!(tally.restart().is_empty()); - assert_eq!(tally.ask("text"), Err(("text", "denied".to_string()))); - assert!(tally.loaded().is_empty()); - assert!(tally.ask("pdf").is_ok()); - } - - #[test] - fn only_the_newest_text_counts() { - let mut tally = Tally::<()>::new(); - tally.change(2); - assert!(tally.text(2)); - assert!(!tally.text(1), "an older text is dropped"); - assert!(tally.settled()); - } -} diff --git a/apps/ely-mark/src/vcs.rs b/apps/ely-mark/src/vcs.rs index b1fe949..b626f71 100644 --- a/apps/ely-mark/src/vcs.rs +++ b/apps/ely-mark/src/vcs.rs @@ -175,7 +175,8 @@ mod tests { #[test] fn spaces_stay_and_broken_repositories_say_why() { let dir = tempfile::tempdir().expect("a temp dir"); - let repo = dir.path().join("repo "); + // Windows names end in no space. + let repo = dir.path().join(if cfg!(windows) { "re po" } else { "repo " }); std::fs::create_dir(&repo).expect("the folder"); git(&repo, &["init", "-q"]); std::fs::write(repo.join("t.md"), "a").expect("a file"); diff --git a/apps/ely-mark/src/view.rs b/apps/ely-mark/src/view.rs index fbb58c0..e398db8 100644 --- a/apps/ely-mark/src/view.rs +++ b/apps/ely-mark/src/view.rs @@ -1,85 +1,85 @@ -//! 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; +//! One Markdown file in a document, its text kept for the shell. use std::path::PathBuf; -use crate::engine::{Doc, mark}; -use crate::page::Mode; +use ely_gpui_component::documents::{ + DocumentEvent, LARGEST_READ, MarkdownDocument, MarkdownMode, +}; +use gpui::{ + AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable as _, + InteractiveElement as _, IntoElement, ParentElement as _, Render, Styled as _, Window, div, +}; -/// The page reported a change or new text. +use crate::engine::{Doc, mark}; + +/// The text changed, or a new read of it is in. pub struct Reported; -/// One heading of the document's outline. +/// What went wrong, for the shell to say. +pub struct Failed(pub String); + +/// How a document shows (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::ALL.into_iter().find(|mode| mode.name() == name) + } + + fn shown(self) -> MarkdownMode { + match self { + Mode::Visual => MarkdownMode::Visual, + Mode::Source => MarkdownMode::Source, + Mode::Preview => MarkdownMode::Preview, + } + } +} + +/// One heading of the outline, and its line from zero. #[derive(Clone, Debug, PartialEq)] pub struct Heading { pub level: usize, pub title: String, + pub line: usize, } -/// Where the reader was: a file line and the scroll. + +/// Where the reader was: the caret, and the first line in view. #[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, + #[serde(default)] + pub top: u64, } -/// 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, &mut gpui::App)>; - -/// Renders the page will never answer end with `why`. -fn end_renders(pending: Vec, why: &str, cx: &mut gpui::App) { - if pending.is_empty() { - return; - } - log::warn!("mark: {} renders end: {why}", pending.len()); - let why = why.to_string(); - cx.defer(move |cx| { - for done in pending { - done(Err(why.clone()), cx); - } - }); -} - -/// The page failed; the shell shows why. -pub struct Failed(pub String); - pub struct MarkView { - pub(crate) web: Entity, + document: Entity, doc: Doc, - /// Page loads, changes and owed renders. - tally: crate::tally::Tally, + /// Text changes so far; autosave counts them. + changed: u64, /// The mark at the last load or save. saved: String, pub mode: Mode, - /// Headings as the shown editor reads them. - pub outline: Vec, - /// Shown since the last page load. - pub shown: bool, - /// A place to bring into view once shown. - waiting: Option, - /// Where the page's search stands, while one runs. - pub found: Option, - /// The file's folder: links and images resolve here. + /// The file's folder: pictures 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). + /// Too long to read: source alone, every byte saved (MARK-14). pub large: bool, - /// The caret's line and the scroll, as last reported. - pub place: Option, - /// The writing view, as the page applies it (MARK-09). - look: Value, pub focus: FocusHandle, } @@ -88,44 +88,52 @@ impl EventEmitter for MarkView {} impl MarkView { pub fn new( - web: Entity, doc: Doc, saved: String, folder: PathBuf, + window: &mut Window, cx: &mut Context, ) -> 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)); + let text = doc.lock().expect("the text lock").current.clone(); + // Past what the document reads: source alone (PERF-07). + let large = text.len() > LARGEST_READ; + let mode = if large { Mode::Source } else { Mode::Visual }; + let view = cx.weak_entity(); + let document = cx.new(|cx| { + let mut document = MarkdownDocument::new(text, mode.shown(), window, cx); + document.set_base(folder.as_path(), cx); + document.on_link(move |url, _, cx| { + view.update(cx, |view, cx| view.follow(url, cx)).ok(); + }); + document + }); + cx.subscribe(&document, Self::heard).detach(); + let focus = cx.focus_handle(); + // The view's focus is the document's. + cx.on_focus(&focus, window, |view, window, cx| { + let inner = view.document.focus_handle(cx); + window.focus(&inner, cx); + }) + .detach(); MarkView { - web, + document, doc, - tally: crate::tally::Tally::new(), + changed: 0, saved, - 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, + mode, folder, + large, focus, } } - /// Every change's text has arrived. + /// Every change is in the text at once. pub fn settled(&self) -> bool { - self.tally.settled() + true } - /// The newest change the page announced. pub fn changed(&self) -> u64 { - self.tally.changed() + self.changed } /// The text's size now, in bytes. @@ -143,7 +151,7 @@ impl MarkView { } /// The same text in `mode` (MARK-02). - pub fn switch(&mut self, mode: Mode, cx: &mut Context) { + pub fn switch(&mut self, mode: Mode, window: &mut Window, cx: &mut Context) { 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(); @@ -151,45 +159,43 @@ impl MarkView { } log::info!("mark: switches to {}", mode.name()); self.mode = mode; - crate::page::switch(self.web.read(cx), mode); + self.document.update(cx, |document, cx| { + document.set_mode(mode.shown(), window, cx) + }); cx.notify(); } - /// Searches the page for `spec` (MARK-06). - pub fn find(&mut self, spec: Value, cx: &mut Context) { - crate::page::call(self.web.read(cx), "find", &spec); + /// The headings as last read. + pub fn outline(&self, cx: &gpui::App) -> Vec { + let outline = self.document.read(cx).outline(cx); + outline + .into_iter() + .map(|(heading, line)| Heading { + level: usize::from(heading.depth), + title: heading.title, + line, + }) + .collect() } - /// Steps under `spec`, the box's own; tabs keep older ones. - pub fn step(&mut self, spec: Value, forward: bool, cx: &mut Context) { - let ask = serde_json::json!({ "spec": spec, "forward": forward }); - crate::page::call(self.web.read(cx), "step", &ask); + /// Line `line` in view and focus. + pub fn reveal(&mut self, line: usize, window: &mut Window, cx: &mut Context) { + self.document + .update(cx, |document, cx| document.reveal(line, window, cx)); } - pub fn replace(&mut self, spec: Value, all: bool, cx: &mut Context) { - 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) { - crate::page::call(self.web.read(cx), "table", &Value::from(op)); - } - - pub fn unfind(&mut self, cx: &mut Context) { - 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) { - match self.shown { - true => crate::page::call(self.web.read(cx), "reveal", &place), - false => self.waiting = Some(place), + /// The caret and the view, to keep across launches. + pub fn place(&self, cx: &gpui::App) -> Place { + let document = self.document.read(cx); + let (line, column) = document.caret(cx); + Place { + line: line as u64, + column: column as u64, + top: document.top_line(cx) as u64, } } - /// The disk's text replaces a clean one; the place stays. + /// The disk's text replaces a clean one; undo starts there. pub fn refresh(&mut self, source: crate::text::Source, cx: &mut Context) { let text = source.text.clone(); { @@ -198,105 +204,65 @@ impl MarkView { doc.source = source; } self.saved = mark(&text); - // Reports and renders from before belong to the old text. - let lapsed = self.tally.restart(); - end_renders(lapsed, "the text was replaced from disk", cx); - crate::page::refresh(self.web.read(cx), &text, self.tally.load()); + self.document + .update(cx, |document, cx| document.reset(text, cx)); 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) { - 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, &mut gpui::App) + 'static, - cx: &mut Context, - ) { - match self.tally.ask(Box::new(done)) { - Ok(ask) => { - let ask = serde_json::json!({ "kind": kind, "ask": ask }); - crate::page::call(self.web.read(cx), "render", &ask); - } - Err((done, why)) => end_renders(vec![done], &why, cx), - } - } - - /// `text` replaces the editor's, as one undoable edit. + /// `text` replaces the document's, as one undoable edit. pub fn adopt(&mut self, text: String, cx: &mut Context) { - crate::page::call(self.web.read(cx), "adopt", &Value::from(text)); + self.document + .update(cx, |document, cx| document.set_text(text, cx)); } - /// Shows again as `mode` at `place`; recovered text unsaved. + /// Shows as `mode` at `place`; recovered text unsaved. pub fn reopen( &mut self, mode: Mode, place: Option, text: Option, + window: &mut Window, cx: &mut Context, ) { - let mut doc = self.doc.lock().expect("the text lock"); if let Some(text) = text { - doc.current = text; + self.document + .update(cx, |document, cx| document.reset(text, cx)); } - let current = doc.current.clone(); - drop(doc); let mode = if self.large { Mode::Source } else { mode }; - self.mode = mode; - let at = (mode, self.tally.load(), place); - crate::page::open(self.web.read(cx), ¤t, at, &self.folder, cx); - cx.emit(Reported); + self.switch(mode, window, cx); + if let Some(place) = place { + self.document.update(cx, |document, cx| { + document.set_caret(place.line as usize, place.column as usize, cx); + document.set_top_line(place.top as usize, window, cx); + }); + } } - /// The file moved; images and links follow it. + /// The file moved; pictures follow it. pub fn moved(&mut self, folder: PathBuf, cx: &mut Context) { 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.document + .update(cx, |document, cx| document.set_base(folder.as_path(), cx)); self.folder = folder; } - fn heard(&mut self, web: Entity, event: &Event, cx: &mut Context) { - match event { - // A reload shows the last text that arrived. - Event::Loaded => { - let lapsed = self.tally.loaded(); - end_renders(lapsed, "the page reloaded", cx); - self.shown = false; - let text = self.doc.lock().expect("the text lock").current.clone(); - let at = (self.mode, self.tally.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) => self.follow(url, cx), - Event::Failed(why) => { - let lapsed = self.tally.fail(why.clone()); - end_renders(lapsed, why, cx); - cx.emit(Failed(why.clone())); - } - Event::Printed(path, _) => { - log::error!("mark: an editor page printed {}", path.display()) - } + fn heard( + &mut self, + document: Entity, + event: &DocumentEvent, + cx: &mut Context, + ) { + if *event == DocumentEvent::Changed { + let text = document.read(cx).text(cx); + self.doc.lock().expect("the text lock").current = text; + self.changed += 1; } + cx.emit(Reported); } - /// A clicked link: web and mail open outside. + /// A pressed link: web and mail open outside (M-05). fn follow(&mut self, url: &str, cx: &mut Context) { if ely_office::outward(url) { log::info!("mark: opens a link outside"); @@ -306,177 +272,14 @@ impl MarkView { let said = ely_ui::i18n::text(cx, "office.link.refused", &[]).to_string(); cx.emit(Failed(said)); } - - fn query(&mut self, said: &str, reply: &Reply, cx: &mut Context) { - 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 !self.tally.current(said["generation"].as_u64()) { - 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(); - } - if let Some(what) = said["broke"].as_str() { - log::error!("mark: the visual editor broke ({what}); source shows"); - cx.emit(Failed(ely_ui::i18n::text(cx, "mark.visual.broke", &[("what", what)]).to_string())); - } - 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("failed"), ..) => { - let Some(what) = said["what"].as_str() else { - log::error!("mark: the page sent a failure without its name"); - return reply.fail("bad failure"); - }; - log::error!("mark: the page built no editor ({what})"); - cx.emit(Failed(ely_ui::i18n::text(cx, "mark.page.broke", &[("what", what)]).to_string())); - } - (Some("missed"), ..) => match said["line"].as_u64() { - Some(line) => log::warn!("mark: nothing to reveal at line {line}"), - None => log::warn!("mark: no such heading to reveal"), - }, - (Some("outline"), ..) => match crate::page::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("link"), ..) => match said["href"].as_str() { - Some(url) => self.follow(url, cx), - None => { - log::error!("mark: the page sent a link without its address"); - return reply.fail("bad link"); - } - }, - (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.tally.answered(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.tally.change(version); - cx.emit(Reported); - } - // Only the newest text counts. - (Some("text"), Some(version), Some(text)) => { - if !self.tally.text(version) { - log::info!("mark: an older text was dropped"); - } else { - self.doc.lock().expect("the text lock").current = text.to_owned(); - cx.emit(Reported); - } - } - _ => { - 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 { - 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, - }) } impl Render for MarkView { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> 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()) + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .size_full() + .track_focus(&self.focus) + .child(self.document.clone()) } } diff --git a/apps/ely-mark/src/view/tests.rs b/apps/ely-mark/src/view/tests.rs index cccfa9b..3c78f93 100644 --- a/apps/ely-mark/src/view/tests.rs +++ b/apps/ely-mark/src/view/tests.rs @@ -1,18 +1,84 @@ -use std::{cell::RefCell, rc::Rc}; +use std::sync::{Arc, Mutex}; -use serde_json::Value; +use gpui::{Entity, TestAppContext, VisualTestContext}; + +use super::{MarkView, Mode}; +use crate::engine::{Text, mark}; +use crate::text::Source; + +fn open<'a>( + text: &str, + cx: &'a mut TestAppContext, +) -> (Entity, &'a mut VisualTestContext) { + cx.update(ely_gpui_component::init_for_tests); + cx.update(|cx| { + ely_ui::settings::install_defaults(cx); + ely_ui::i18n::install(cx); + }); + let source = Source::decode(text.as_bytes().to_vec()).expect("reads"); + let saved = mark(&source.text); + let doc = Arc::new(Mutex::new(Text::new(source))); + let folder = std::env::temp_dir(); + let (view, cx) = + cx.add_window_view(move |window, cx| MarkView::new(doc, saved, folder, window, cx)); + cx.update(|window, _| window.activate_window()); + cx.run_until_parked(); + (view, cx) +} #[gpui::test] -fn each_owed_render_ends_once_with_the_reason(cx: &mut gpui::TestAppContext) { - let got = Rc::new(RefCell::new(Vec::<(u8, Result)>::new())); - let pending = (0..2) - .map(|n| { - let got = got.clone(); - Box::new(move |rendered, _: &mut gpui::App| got.borrow_mut().push((n, rendered))) as super::Rendered - }) - .collect(); - cx.update(|cx| super::end_renders(pending, "denied", cx)); +fn typing_reaches_the_text_the_shell_saves(cx: &mut TestAppContext) { + let (view, cx) = open("# A\n\nB\n", cx); + let outline: Vec<(usize, String, usize)> = view.read_with(cx, |view, cx| { + view.outline(cx) + .into_iter() + .map(|heading| (heading.level, heading.title, heading.line)) + .collect() + }); + assert_eq!(outline, [(1, "A".to_string(), 0)]); + view.update_in(cx, |view, window, cx| view.switch(Mode::Source, window, cx)); + let focus = view.read_with(cx, |view, _| view.focus.clone()); + cx.update(|window, cx| window.focus(&focus, cx)); cx.run_until_parked(); - let denied = || Err("denied".to_string()); - assert_eq!(*got.borrow(), [(0, denied()), (1, denied())]); + cx.simulate_input("x"); + let (bytes, modified, changed) = view.read_with(cx, |view, _| { + (view.bytes(), view.modified(), view.changed()) + }); + assert_eq!(bytes, "x# A\n\nB\n".len()); + assert!(modified && changed > 0); +} + +#[gpui::test] +fn a_refresh_is_saved_text_and_keeps_no_undo(cx: &mut TestAppContext) { + let (view, cx) = open("one\n", cx); + let disk = Source::decode(b"two\n".to_vec()).expect("reads"); + view.update(cx, |view, cx| view.refresh(disk, cx)); + cx.run_until_parked(); + assert!(!view.read_with(cx, |view, _| view.modified())); + assert_eq!(view.read_with(cx, |view, _| view.bytes()), 4); +} + +#[gpui::test] +fn a_large_file_stays_in_source(cx: &mut TestAppContext) { + let large = ely_gpui_component::documents::LARGEST_READ / 2 + 1; + let (view, cx) = open(&"a\n".repeat(large), cx); + assert_eq!(view.read_with(cx, |view, _| view.mode), Mode::Source); + view.update_in(cx, |view, window, cx| view.switch(Mode::Visual, window, cx)); + assert_eq!(view.read_with(cx, |view, _| view.mode), Mode::Source); +} + +#[gpui::test] +fn an_edit_keeps_the_rest_of_the_file_byte_for_byte(cx: &mut TestAppContext) { + let bytes = b"\xEF\xBB\xBF# A\r\n\r\nB\r\n".to_vec(); + let (view, cx) = open(std::str::from_utf8(&bytes).expect("UTF-8"), cx); + view.update_in(cx, |view, window, cx| view.switch(Mode::Source, window, cx)); + let focus = view.read_with(cx, |view, _| view.focus.clone()); + cx.update(|window, cx| window.focus(&focus, cx)); + cx.run_until_parked(); + cx.simulate_input("x"); + let written = view.read_with(cx, |view, _| { + let doc = view.doc.lock().expect("the text lock"); + doc.source.encode(&doc.current) + }); + assert_eq!(written, b"\xEF\xBB\xBFx# A\r\n\r\nB\r\n"); } diff --git a/crates/ely-ui/src/i18n/mark.rs b/crates/ely-ui/src/i18n/mark.rs index 394f537..b4d9aec 100644 --- a/crates/ely-ui/src/i18n/mark.rs +++ b/crates/ely-ui/src/i18n/mark.rs @@ -1,13 +1,6 @@ -//! Words for ElyMark's page. +//! Words for ElyMark. pub(super) const EN: &[(&str, &str)] = &[ - ("mark.search_language", "Search languages"), - ("mark.no_result", "No match"), - ("mark.copy", "Copy"), - ("mark.preview", "Preview"), - ("mark.loading", "Loading…"), - ("mark.edit", "Edit"), - ("mark.hide", "Hide"), ("mark.pane.files", "Files"), ("mark.pane.outline", "Outline"), ("mark.pane.search", "Search"), @@ -83,11 +76,6 @@ pub(super) const EN: &[(&str, &str)] = &[ ("mark.git.failed", "ElyTree did not open: {error}"), ("mark.large", "This file is {size} MB: it shows as source. Visual and preview are off; saving keeps every byte."), ("mark.large.modes", "Large files stay in source mode"), - ( - "mark.visual.broke", - "The visual editor failed ({what}); the file shows as source", - ), - ("mark.page.broke", "The editor did not open ({what})"), ("mark.mode.visual", "Visual"), ("mark.mode.source", "Source"), ("mark.mode.preview", "Preview"), @@ -102,13 +90,6 @@ pub(super) const EN: &[(&str, &str)] = &[ ]; pub(super) const ZH_CN: &[(&str, &str)] = &[ - ("mark.search_language", "搜索语言"), - ("mark.no_result", "无匹配"), - ("mark.copy", "拷贝"), - ("mark.preview", "预览"), - ("mark.loading", "正在载入…"), - ("mark.edit", "编辑"), - ("mark.hide", "隐藏"), ("mark.pane.files", "文件"), ("mark.pane.outline", "大纲"), ("mark.pane.search", "搜索"), @@ -184,11 +165,6 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[ ("mark.git.failed", "ElyTree 没能打开:{error}"), ("mark.large", "此文件 {size} MB,以源码显示。可视与预览已关闭;保存时保留全部内容。"), ("mark.large.modes", "大文件只用源码模式"), - ( - "mark.visual.broke", - "可视编辑器出错({what}),文件以源码显示", - ), - ("mark.page.broke", "编辑器没有打开({what})"), ("mark.mode.visual", "可视"), ("mark.mode.source", "源码"), ("mark.mode.preview", "预览"), diff --git a/tasks/TASKS.md b/tasks/TASKS.md index e239fee..1e1f620 100644 --- a/tasks/TASKS.md +++ b/tasks/TASKS.md @@ -101,7 +101,7 @@ - [x] C14.1 组件库:`MarkdownRenderer`、`MarkdownEditor`、Markdown 着色、块范围、大纲与链接查找改用 markdown-rs(`documents` 的 `tree`、`markdown`、`render`);`BlockEditor` 的导入导出不属 ElyMark,仍用 pulldown-cmark;原有测试照过,改了语义的测试写明 markdown-rs 的读法;推送,ElyOffice 钉新提交 — R2 ok - [~] C14.2 组件库:公式与 Mermaid(MARK-04)。`typography::Latex` 改由 RaTeX 排出;Mermaid 由 mermaid-rs-renderer 画成图;画不出即显示错误与原文;`MarkdownRenderer` 用二者,本地图片按属主给的文件夹读,网络图片不自动载入 — R1 FAIL 六项已修:版面失败把公式写进日志(只记固定文字);资源与图集从不释放(`primitives::held` 持有,换内容或离开即释放);公式失败丢了原文与位置(显示原因、位置与原文);零边距裁掉字形(按墨迹重取画框);`file:` 地址把片段当文件名、把主机当相对路径(解析主机,去查询与片段);Mermaid 节点字色未取 Ely 前景。R2 FAIL 一项已修:一个持有者离开即逐出共享资源(按资源计数)。R3 FAIL 两项已修(不再送审):最后离开的持有者若未收到图,图集里的图不释放(图存于共享记录,由最后离开者释放,单测 `the_last_holder_frees_the_image_another_painted`);一行注释 11 词(已缩短) - [~] C14.3 组件库:文件级 Markdown 文档组件。一个 `CodeEditor` 持全文与撤销;可视(变高虚拟列表,逐块渲染,按下的块开成源码,写回为一步撤销)、源码(`CodeEditor` 加 Markdown 着色)、预览(只读);属主可读写文本、模式、插入点行列、滚动,可跳到行;改动发事件 — R1 FAIL 七项已修:写回后按下别的块用旧范围(按偏移换算所按的块,结构变了不开);插入点偏移撤销后落在字符中间(改用编辑器选区为唯一插入点);预览收撤销重做(预览不挂动作);末尾新块的插入点漏算接缝;无插入点与滚动的设定(`set_caret`、`set_top_line`,换模式保持顶行);输入法组字与撤销重做不受 64 MiB 限制(每条路径都限,含重做);块不可用键盘到达(Tab 停靠点,Enter、空格打开)。R2 FAIL 三项已修:长文后台解析期间写回后按下仍用旧范围(解析文字与编辑器文字相同才可打开);块内滚动位置丢失(按块高比例);换文字后立即设顶行用旧块(要求等其文字解析后落定)。R3 FAIL 四项已修(不再送审):迟到的帧回调完成了新的要求(要求带编号,只完成自己的);等待中的顶行越过换成的短文字而崩溃(放时按当下行数夹住);换文字后的 `reveal` 用旧块(`reveal` 也是等解析的要求);测试不跑帧回调(`frames` 调 `simulate_next_frame`,并核对要求已完成,块内比例改为零时测试失败) -- [ ] C14.4 ElyMark 文件会话改为原生(MARK-01,§14.3,QA-21):视图换成 C14.3 的组件,文本即文件原文,无序列化;BOM、CRLF 照旧;新建、打开、保存、另存、重命名照旧;应用不再依赖 `ely-web-content` +- [ ] C14.4 ElyMark 文件会话改为原生(MARK-01,§14.3,QA-21):视图换成 C14.3 的组件,文本即文件原文,无序列化;BOM、CRLF 照旧;新建、打开、保存、另存、重命名照旧;应用不再依赖 `ely-web-content`。网页实现的功能随之删去,在各自的任务里原生重做:查找替换与表格(C14.9)、附件(C14.10)、书写视图的应用(C14.12,`look.json` 与菜单照旧)、导出与打印(C14.14);其文案留到那时。依赖网页的真窗口脚本 `tests/scripts/mark-*` 删去,ElyMark 的行为由 gpui 窗口测试覆盖 - [ ] C14.5 删去 CEF:`crates/ely-web-content`、`scripts/fetch-cef.sh`、`scripts/build-mark-content.sh`、`apps/ely-mark/web`、打包里的 CEF 辅助应用与许可、CEF 的测试脚本;PRD 与 `agents.md` 写明新路线,ElyMail 正文不用 CEF 与 Electron - [ ] C14.6 编辑模式与大文件(MARK-02、14,PERF-07):可视、源码、预览按标签切换,切换不改文件,撤销跨模式;超过阈值的文件只开源码并说明;1 MB 文档 2 秒内可编辑,实测记录 - [ ] C14.7 Markdown 与扩展语法(MARK-03、04):CommonMark 与 GFM、脚注、公式、Mermaid、front matter(不进可视,原样在上方);文档旁图片;链接照 M-05(可视 Cmd/Ctrl 单击、预览单击交应用) diff --git a/tests/scripts/mark-broken.mjs b/tests/scripts/mark-broken.mjs deleted file mode 100644 index 9e5374c..0000000 --- a/tests/scripts/mark-broken.mjs +++ /dev/null @@ -1,80 +0,0 @@ -// ElyMark's page: a failed editor says so (M-13). -import { createRequire } from 'node:module' -import { existsSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' - -const root = join(dirname(fileURLToPath(import.meta.url)), '../..') -const page = join(root, 'engines/content/ely-mark/index.html') -if (!existsSync(page)) { - console.log('SKIP: no page; run scripts/build-mark-content.sh') - process.exit(0) -} -let chromium -try { - ;({ chromium } = createRequire(join(root, 'site/package.json'))('@playwright/test')) -} catch { - console.log('SKIP: no Playwright; run pnpm install in site') - process.exit(0) -} -const fail = (why) => { - console.error(`FAIL: ${why}`) - process.exit(1) -} - -const browser = await chromium.launch() -const tab = await browser.newPage() -await tab.addInitScript(() => { - window.asked = [] - window.elyQuery = ({ request, onSuccess }) => { - window.asked.push(JSON.parse(request)) - onSuccess('') - } - // Setting a role throws; `spare` keeps CodeMirror's. - const set = Element.prototype.setAttribute - window.breaks = (spare) => { - Element.prototype.setAttribute = function (name, value) { - const kept = spare && this.classList.contains('cm-content') - if (name === 'role' && !kept) throw new Error('broken') - return set.call(this, name, value) - } - } - window.mends = () => { - Element.prototype.setAttribute = set - } -}) -await tab.goto(pathToFileURL(page).href) -// What the app heard since the last look. -let seen = 0 -const heard = async () => { - await tab.waitForTimeout(800) - const all = await tab.evaluate(() => window.asked.filter((a) => a.kind === 'shown' || a.kind === 'failed')) - const fresh = all.slice(seen) - seen = all.length - return JSON.stringify(fresh) -} -const holders = () => tab.evaluate(() => document.querySelectorAll('#document > div').length) -const shown = () => tab.evaluate(() => document.querySelector('#document .cm-content')?.textContent ?? '') -const open = (mode) => tab.evaluate((mode) => window.ely.open('# Kept\n', mode, {}, 1, '', null), mode) - -await tab.evaluate(() => window.breaks(true)) -await open('visual') -const fell = JSON.stringify([{ kind: 'shown', mode: 'source', fallback: true, broke: 'Error' }]) -if ((await heard()) !== fell) fail('a broken visual editor did not fall back to source') -if ((await shown()) !== '# Kept') fail(`the fallback shows ${JSON.stringify(await shown())}`) - -await tab.evaluate(() => window.breaks(false)) -const failed = JSON.stringify([{ kind: 'failed', generation: 1, what: 'Error' }]) -for (let n = 0; n < 3; n++) { - await open('visual') - if ((await heard()) !== failed) fail(`two broken editors, try ${n}: the app was not told`) -} -if ((await holders()) !== 0) fail(`failed builds left ${await holders()} holders`) - -await tab.evaluate(() => window.mends()) -await tab.evaluate(() => window.ely.mode('source')) -const back = JSON.stringify([{ kind: 'shown', mode: 'source', fallback: false, broke: null }]) -if ((await heard()) !== back) fail('a mode after the failure did not rebuild') -if ((await shown()) !== '# Kept' || (await holders()) !== 1) fail('the rebuilt editor lost the text') -await browser.close() -console.log('mark-broken: fallback, told failures, no leftover holders, a mode rebuilds') diff --git a/tests/scripts/mark-content.sh b/tests/scripts/mark-content.sh deleted file mode 100755 index 1d1838a..0000000 --- a/tests/scripts/mark-content.sh +++ /dev/null @@ -1,123 +0,0 @@ -#!/usr/bin/env bash -# ElyMark's built page renders each package offline, live. -set -euo pipefail - -root=$(cd "$(dirname "$0")/../.." && pwd) -[ "$(uname)" = Darwin ] || { echo "SKIP: macOS only"; exit 0; } -[ -d "${ELY_CEF:-$root/engines/cef}/Chromium Embedded Framework.framework" ] || { - echo "SKIP: no CEF; run scripts/fetch-cef.sh" - exit 0 -} -command -v npm >/dev/null || { echo "SKIP: no npm"; exit 0; } -work=$(mktemp -d) -probe= -trap 'if [ -n "$probe" ]; then kill "$probe" 2>/dev/null || true; fi; rm -rf "$work"' EXIT -fail() { - echo "FAIL: $*" >&2 - exit 1 -} - -"$root/scripts/build-mark-content.sh" >"$work/build" 2>&1 || fail "build: $(tail -n 5 "$work/build")" -content=$root/engines/content/ely-mark -for name in @milkdown/kit @codemirror/view katex mermaid; do - grep -q "^$name " "$content/THIRD-PARTY-NOTICES.txt" || fail "no notice for $name" -done -cargo build -q -p ely-mark --example markprobe --manifest-path "$root/Cargo.toml" -swiftc -O -o "$work/share" "$root/tests/scripts/window-share.swift" -ELY_LOG=info ELY_LOCALE=en ELY_SMOKE=hold HOME="$work" XDG_DATA_HOME="$work/data" \ - perl -e 'alarm 120; exec @ARGV' "$root/target/debug/examples/markprobe" >"$work/out" 2>"$work/err" & -probe=$! -for _ in $(seq 240); do - grep -q '"step":"visual"' "$work/out" && break - kill -0 "$probe" 2>/dev/null || break - sleep 0.25 -done -grep -q '"step":"visual"' "$work/out" || fail "no visual report: $(tail -n 5 "$work/err")" -# Less than all background: the page is drawn. -bg=$(grep '"step":"visual"' "$work/out" | grep -oE '"bg":"rgba\([0-9]+, [0-9]+, [0-9]+' | grep -oE '[0-9]+, [0-9]+, [0-9]+$' | tr -d ' ') -share=$("$work/share" "$probe" "$bg") -case $share in - denied) fail "needs Screen Recording permission" ;; - none) fail "the probe has no window" ;; -esac -awk -v s="$share" 'BEGIN { exit !(s < 0.97) }' || fail "the page is not on screen (background share $share)" -wait "$probe" || fail "the probe failed: $(tail -n 5 "$work/err")" -probe= -grep -qxF "mark: done" "$work/out" || fail "unfinished: $(cat "$work/out")" -# The earlier of two quick shows never reports. -shows=$(grep '^mark: shown' "$work/out" | tr -d '"' | cut -d' ' -f3 | tr '\n' ' ') -[ "$shows" = "visual preview source source visual visual preview source source visual preview source visual source source visual preview source source source visual visual visual " ] || fail "shows reported: $shows" -python3 - "$work/out" <<'PY' -import json, sys -seen = {} -for line in open(sys.argv[1]): - if line.startswith("mark: {"): - read = json.loads(line[6:]) - seen[read["step"]] = read -def want(step, key, value): - if seen[step][key] != value: - sys.exit(f"FAIL: {step} {key} is {seen[step][key]!r}, not {value!r}") -languages = ["rust", "LaTeX", "LaTeX", "mermaid", "mermaid"] -for step in ("visual", "themed", "preview"): - for key, value in [("title", "Title"), ("heading", True), ("inline", True), ("display", True), ("tasks", 2), - ("cells", 2), ("notes", 1), ("font", True), ("editors", 1)]: - want(step, key, value) - # Every task box: text-sized, centred on its line. - for width, height, off, drawn in seen[step]["boxes"]: - if not drawn or abs(width - 1) > 0.01 or abs(height - 1) > 0.01 or abs(off) > 1: - sys.exit(f"FAIL: {step} task boxes are {seen[step]['boxes']}") - blocks = seen[step]["blocks"] - if [block["language"] for block in blocks] != languages: - sys.exit(f"FAIL: {step} blocks are {blocks}") - # Preview shows a block's preview alone, when it has one. - for at, block in enumerate(blocks): - if block["editors"] != 1 or block["code"] != (step != "preview" or at == 0): - sys.exit(f"FAIL: {step} block {at} code is not shown: {block}") - width, height = blocks[3]["diagram"] - if not blocks[1]["math"] or not blocks[3]["drawn"] or width < 100 or height < 80: - sys.exit(f"FAIL: {step} math or diagram is not shown: {blocks}") - if "(9)" not in blocks[2]["failure"] or "line 3" not in blocks[4]["failure"]: - sys.exit(f"FAIL: {step} errors lack source or place: {blocks}") - editable = "true" if step != "preview" else "false" - if any(block["editable"] != editable for block in blocks): - sys.exit(f"FAIL: {step} code blocks are not {editable}: {blocks}") -fills = [seen[step]["blocks"][3]["fill"] for step in ("visual", "themed")] -if not fills[0] or fills[0] == fills[1]: - sys.exit(f"FAIL: the diagram kept its colors: {fills}") -want("visual", "editable", "true") -want("preview", "editable", "false") -want("source", "first", "# Title") -want("source", "source", True) -want("source", "blocks", []) -want("race", "mode", "source") -want("race", "first", "# NEW") -want("race", "editors", 1) -want("asked", "mode", "preview") -want("stale", "mode", "source") -want("owner", "mode", "source") -# Two broken patterns per mode error; empty ones open nothing. -if seen["find"]["panels"] != {"source": 0, "visual": 0} or seen["find"]["errors"] != 4: - sys.exit(f"FAIL: find: {seen['find']}") -# A zero-width match after an emoji: seven hits, per mode. -if seen["find"]["totals"].count(7) != 2: - sys.exit(f"FAIL: zero-width find: {seen['find']}") -look = seen["look"] -# 36rem at 120 percent of 16px: 691.2px. -if look["dim"] != look["blocks"] - 1 or look["bright"] != "1" or not look["centered"] or look["width"] != "691.2px" or not look["serif"]: - sys.exit(f"FAIL: the writing view: {look}") -if "OLD" in seen["race"]["text"]: - sys.exit("FAIL: the earlier show stayed") -# A diagram begun before a change never lands after. -language = seen["language"]["blocks"][0] -if language["language"].lower() != "rust" or language["drawn"] or language["diagram"] != [0, 0]: - sys.exit(f"FAIL: an old diagram stayed in a Rust block: {language}") -late = seen["midtheme"]["blocks"][0] -if not late["drawn"] or {late["fill"], late["first"]} != {seen["visual"]["blocks"][3]["fill"]}: - sys.exit(f"FAIL: a diagram drawn mid-change showed the old theme: {late}") -# Draws for a gone editor never run. -if seen["release"]["long"] > 1: - sys.exit(f"FAIL: {seen['release']['long']} long stalls after leaving the diagrams") -PY -! grep -q "refused" "$work/err" || fail "the page reached outside: $(grep -m3 refused "$work/err")" -! grep -q "panicked" "$work/err" || fail "panics: $(grep -m3 panicked "$work/err")" -echo "ok: mark content (background share $share)" diff --git a/tests/scripts/mark-links.mjs b/tests/scripts/mark-links.mjs deleted file mode 100644 index 84dca42..0000000 --- a/tests/scripts/mark-links.mjs +++ /dev/null @@ -1,76 +0,0 @@ -// ElyMark's page: which clicks on links reach the app (M-05). -import { createRequire } from 'node:module' -import { existsSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' - -const root = join(dirname(fileURLToPath(import.meta.url)), '../..') -const page = join(root, 'engines/content/ely-mark/index.html') -if (!existsSync(page)) { - console.log('SKIP: no page; run scripts/build-mark-content.sh') - process.exit(0) -} -let chromium -try { - ;({ chromium } = createRequire(join(root, 'site/package.json'))('@playwright/test')) -} catch { - console.log('SKIP: no Playwright; run pnpm install in site') - process.exit(0) -} -const fail = (why) => { - console.error(`FAIL: ${why}`) - process.exit(1) -} - -const browser = await chromium.launch() -const tab = await browser.newPage() -await tab.addInitScript(() => { - window.asked = [] - window.elyQuery = ({ request, onSuccess }) => { - window.asked.push(JSON.parse(request)) - onSuccess('') - } -}) -const left = [] -await tab.route('https://example.com/**', (route) => { - left.push(route.request().url()) - route.abort() -}) -await tab.goto(pathToFileURL(page).href) -const doc = 'Go [web](https://example.com/) or [ftp](ftp://x.example/) and [one](https://one.example/)[two](https://two.example/) end.\n\n' + - '[local](file:///outside/file.md) [data](data:text/plain,hello) [empty]()\n' -const links = async () => (await tab.evaluate(() => window.asked)).filter((a) => a.kind === 'link').map((a) => a.href) -const opener = (await tab.evaluate(() => navigator.platform)).startsWith('Mac') ? 'Meta' : 'Control' -const anchor = (text) => tab.locator('a', { hasText: new RegExp(`^${text}$`) }) -// Clicks `dx` from the anchor's left edge, mid-height. -const at = async (text, dx, modifiers) => { - const box = await anchor(text).boundingBox() - await tab.mouse.move(box.x + dx, box.y + box.height / 2) - for (const key of modifiers) await tab.keyboard.down(key) - await tab.mouse.down() - await tab.mouse.up() - for (const key of modifiers) await tab.keyboard.up(key) -} - -await tab.evaluate((doc) => window.ely.open(doc, 'visual', {}, 1, '', null), doc) -await anchor('web').click() -if ((await links()).length) fail('a plain click in the editor opened a link') -await at('web', 1, [opener]) -await at('web', (await anchor('web').boundingBox()).width + 3, [opener]) -await at('two', 1, [opener]) -await anchor('ftp').click({ modifiers: [opener] }) -const asked = await links() -const want = 'https://example.com/ https://two.example/ ftp://x.example/' -if (asked.join(' ') !== want) fail(`the editor asked ${JSON.stringify(asked)}, not ${want}`) - -await tab.evaluate((doc) => window.ely.open(doc, 'preview', {}, 2, '', null), doc) -const before = (await links()).length -const here = tab.url() -for (const text of ['web', 'local', 'data', 'empty']) await anchor(text).click() -await tab.waitForTimeout(500) -const opened = (await links()).slice(before) -const sent = 'https://example.com/ file:///outside/file.md data:text/plain,hello ' -if (opened.join(' ') !== sent) fail(`preview asked ${JSON.stringify(opened)}, not ${JSON.stringify(sent)}`) -if (left.length || tab.url() !== here) fail(`preview navigated: ${JSON.stringify(left)} ${tab.url()}`) -await browser.close() -console.log('mark-links: an editor click edits, the opener key asks the app, preview asks the app') diff --git a/tests/scripts/mark-modes.sh b/tests/scripts/mark-modes.sh deleted file mode 100755 index 53a517a..0000000 --- a/tests/scripts/mark-modes.sh +++ /dev/null @@ -1,179 +0,0 @@ -#!/usr/bin/env bash -# MARK-01 to 04 live: modes, images, new and rename. -set -euo pipefail - -root=$(cd "$(dirname "$0")/../.." && pwd) -[ "$(uname)" = Darwin ] || { echo "SKIP: macOS only"; exit 0; } -[ -d "${ELY_CEF:-$root/engines/cef}/Chromium Embedded Framework.framework" ] || { - echo "SKIP: no CEF; run scripts/fetch-cef.sh" - exit 0 -} -command -v npm >/dev/null || { echo "SKIP: no npm"; exit 0; } -work=$(mktemp -d) -app= -trap 'if [ -n "$app" ]; then kill "$app" 2>/dev/null || true; wait "$app" 2>/dev/null || true; fi; rm -rf "$work"' EXIT -fail() { - echo "FAIL: $*" >&2 - exit 1 -} - -"$root/scripts/build-mark-content.sh" >"$work/build" 2>&1 || fail "build: $(tail -n 5 "$work/build")" -cargo build -q -p ely-mark --manifest-path "$root/Cargo.toml" -swiftc -O -o "$work/share" "$root/tests/scripts/window-share.swift" -# A magenta picture beside the document. -python3 - "$work/pic.png" <<'PY' -import struct, sys, zlib -w, h = 240, 120 -rows = b"".join(b"\0" + b"\xff\x00\xaa" * w for _ in range(h)) -def chunk(kind, data): - return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data)) -png = b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0)) -png += chunk(b"IDAT", zlib.compress(rows)) + chunk(b"IEND", b"") -open(sys.argv[1], "wb").write(png) -PY -text=$'---\ntitle: Modes\n---\n# Modes\n\nFirst line.\n\n![pic](pic.png)\n\n- [ ] task\n\nLast line.\n' -printf '%s' "$text" >"$work/doc.md" -cp "$work/doc.md" "$work/orig.md" -ELY_LOG=info ELY_LOCALE=en ELY_NO_DIALOG=1 HOME="$work" XDG_DATA_HOME="$work/data" \ - "$root/target/debug/ely-mark" "$work/doc.md" >"$work/out" 2>"$work/err" & -app=$! -shows() { grep -c "mark: shown as" "$work/err" || true; } -# Waits for show number `n`, as `mode`. -wait_shown() { - for _ in $(seq 120); do - [ "$(shows)" -ge "$1" ] && break - kill -0 "$app" 2>/dev/null || fail "ElyMark quit: $(tail -n 5 "$work/err")" - sleep 0.25 - done - [ "$(grep "mark: shown as" "$work/err" | sed -n "$1p" | grep -c "\"$2\"")" = 1 ] || - fail "show $1 is not $2: $(grep 'mark: shown' "$work/err")" -} -key() { osascript -e "tell application \"System Events\" to $1" -e 'delay 0.8' >/dev/null; } -saved() { - local before - before=$(grep -c "office: saved" "$work/err" || true) - key 'keystroke "s" using command down' - for _ in $(seq 40); do - [ "$(grep -c "office: saved" "$work/err" || true)" -gt "$before" ] && return 0 - sleep 0.25 - done - fail "no save" -} -same() { cmp -s "$work/orig.md" "$1" || fail "$2: $(cat "$1")"; } -wait_shown 1 visual -osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $app) to true" -e 'delay 1' >/dev/null -share=$("$work/share" "$app" 255,0,170) -case $share in denied) fail "needs Screen Recording permission" ;; none) fail "no window" ;; esac -awk -v s="$share" 'BEGIN { exit !(s > 0.003) }' || fail "the picture beside the file did not show ($share)" -# Switching alone changes nothing. -key 'keystroke "2" using command down' -wait_shown 2 source -key 'keystroke "3" using command down' -wait_shown 3 preview -key 'keystroke "1" using command down' -wait_shown 4 visual -saved -same "$work/doc.md" "switching modes changed the file" -# Preview takes no typing. -key 'keystroke "3" using command down' -wait_shown 5 preview -key 'keystroke "z"' -key 'keystroke "1" using command down' -wait_shown 6 visual -saved -same "$work/doc.md" "preview took typing" -# Visual edits undo after a trip through preview. -key 'keystroke "y"' -key 'keystroke "3" using command down' -wait_shown 7 preview -key 'keystroke "1" using command down' -wait_shown 8 visual -key 'keystroke "z" using command down' -wait_shown 9 visual -saved -same "$work/doc.md" "undo did not survive the trip" -# A source edit shows in visual and saves. -key 'keystroke "2" using command down' -wait_shown 10 source -key 'keystroke "x"' -key 'keystroke "1" using command down' -wait_shown 11 visual -saved -[ "$(cat "$work/doc.md"; echo .)" = "x$text." ] || fail "source edit: $(cat "$work/doc.md")" -# One timeline: undo walks back through both modes, redo returns. -key 'keystroke "v"' -key 'keystroke "2" using command down' -wait_shown 12 source -key 'keystroke "s"' -key 'keystroke "1" using command down' -wait_shown 13 visual -key 'keystroke "z" using command down' -wait_shown 14 visual -key 'keystroke "z" using command down' -wait_shown 15 visual -saved -[ "$(cat "$work/doc.md"; echo .)" = "x$text." ] || fail "undo across modes: $(cat "$work/doc.md")" -key 'keystroke "z" using {command down, shift down}' -wait_shown 16 visual -saved -[ "$(cat "$work/doc.md"; echo .)" = "vx$text." ] || fail "redo across modes: $(cat "$work/doc.md")" -key 'keystroke "z" using command down' -wait_shown 17 visual -saved -# Rename refuses a taken name, moves to a free one. -printf 'other\n' >"$work/taken.md" -key 'key code 120' -key 'keystroke "a" using command down' -key 'keystroke "taken.md"' -key 'key code 36' -for _ in $(seq 20); do grep -q "did not rename" "$work/err" && break; sleep 0.25; done -grep -q "did not rename" "$work/err" || fail "a rename onto a file went through" -[ "$(cat "$work/taken.md")" = other ] || fail "the taken file changed" -key 'key code 120' -key 'keystroke "a" using command down' -key 'keystroke "moved.md"' -key 'key code 36' -for _ in $(seq 20); do grep -q "renamed .*moved.md" "$work/err" && break; sleep 0.25; done -[ -f "$work/moved.md" ] && [ ! -e "$work/doc.md" ] || fail "rename: $(ls "$work")" -recent=$(find "$work/Library" -name recent.json) -grep -q 'moved.md' "$recent" && ! grep -q 'doc.md' "$recent" || fail "recent after rename: $(cat "$recent")" -key 'keystroke "w"' -saved -[ "$(cat "$work/moved.md"; echo .)" = "wx$text." ] || fail "save after rename: $(cat "$work/moved.md")" -# Undone steps still redo after a mode trip. -key 'keystroke "2" using command down' -wait_shown 18 source -key 'keystroke "q"' -key 'keystroke "z" using command down' -key 'keystroke "3" using command down' -wait_shown 19 preview -key 'keystroke "2" using command down' -wait_shown 20 source -key 'keystroke "z" using {command down, shift down}' -wait_shown 21 source -saved -[ "$(cat "$work/moved.md"; echo .)" = "qwx$text." ] || fail "source redo after a trip: $(cat "$work/moved.md")" -key 'keystroke "1" using command down' -wait_shown 22 visual -key 'keystroke "p"' -key 'keystroke "z" using command down' -key 'keystroke "3" using command down' -wait_shown 23 preview -key 'keystroke "1" using command down' -wait_shown 24 visual -key 'keystroke "z" using {command down, shift down}' -wait_shown 25 visual -saved -[ "$(cat "$work/moved.md"; echo .)" = "pqwx$text." ] || fail "visual redo after a trip: $(cat "$work/moved.md")" -# New opens an empty visual document. -key 'keystroke "n" using command down' -wait_shown 26 visual -fresh=$(find "$work/Library" -name untitled.md) -[ -n "$fresh" ] && [ ! -s "$fresh" ] || fail "the new document is not empty: $fresh" - -key 'keystroke "q" using command down' -key 'key code 36' -for _ in $(seq 40); do kill -0 "$app" 2>/dev/null || break; sleep 0.25; done -app= -! grep -q "panicked" "$work/err" || fail "panics: $(grep -m3 panicked "$work/err")" -echo "ok: mark modes (picture share $share)" diff --git a/tests/scripts/mark-output.sh b/tests/scripts/mark-output.sh deleted file mode 100755 index d01283d..0000000 --- a/tests/scripts/mark-output.sh +++ /dev/null @@ -1,229 +0,0 @@ -#!/usr/bin/env bash -# MARK-09, 11, 13, 14 live: look, output, Git, large files. -set -euo pipefail - -root=$(cd "$(dirname "$0")/../.." && pwd) -[ "$(uname)" = Darwin ] || { echo "SKIP: macOS only"; exit 0; } -[ -d "${ELY_CEF:-$root/engines/cef}/Chromium Embedded Framework.framework" ] || { - echo "SKIP: no CEF; run scripts/fetch-cef.sh" - exit 0 -} -command -v npm >/dev/null || { echo "SKIP: no npm"; exit 0; } -command -v pdftotext >/dev/null || { echo "SKIP: no pdftotext"; exit 0; } -[ -x "$root/engines/git/bin/git" ] || export ELY_GIT=${ELY_GIT:-$(command -v git)} -work=$(mktemp -d) -app= -trap 'if [ -n "$app" ]; then kill "$app" 2>/dev/null || true; wait "$app" 2>/dev/null || true; fi; pkill -f "$work" 2>/dev/null || true; rm -rf "$work"' EXIT -fail() { - echo "FAIL: $*" >&2 - exit 1 -} - -"$root/scripts/build-mark-content.sh" >"$work/build" 2>&1 || fail "build: $(tail -n 5 "$work/build")" -cargo build -q -p ely-mark -p ely-tree -p ely-pdf --manifest-path "$root/Cargo.toml" -for tool in ax-values ax-press; do - swiftc -O -o "$work/$tool" "$root/tests/scripts/$tool.swift" -done -repo=$work/repo -mkdir -p "$repo/assets" -python3 -c 'import struct,zlib,sys -c=lambda t,d: struct.pack(">I",len(d))+t+d+struct.pack(">I",zlib.crc32(t+d)) -open(sys.argv[1],"wb").write(b"\x89PNG\r\n\x1a\n"+c(b"IHDR",struct.pack(">IIBBBBB",1,1,8,2,0,0,0))+c(b"IDAT",zlib.compress(b"\0\xff\0\0"))+c(b"IEND",b""))' "$repo/assets/pic.png" -cp "$repo/assets/pic.png" "$repo/assets/high.png" -doc=$repo/t.md -printf '%s\n' '# Title' '' 'Some $x^2$ math.' '' '![pic](assets/pic.png)' '' "" '' \ - "a > b" '' '```mermaid' 'graph TD; A-->B' '```' '' \ - '```mermaid' 'graph TD; SECRET_WORDS -->' '```' '' '
RAW TEXT
TWO
' '' \ - "" '' "" '' \ - "" '' \ - '$\privateDocumentToken$' '' '![gone](missing.png)' '' 'END MARK' >"$doc" -git -C "$repo" init -q -git -C "$repo" -c user.name=t -c user.email=t@e.x add -A -git -C "$repo" -c user.name=t -c user.email=t@e.x commit -qm one -original=$(cat "$doc") -launch() { - ELY_LOG=info ELY_LOCALE=en ELY_NO_DIALOG=1 HOME="$work" ELY_PRINT_TO="$work/printed.pdf" \ - "$root/target/debug/ely-mark" "$@" >"$work/log" 2>&1 & - app=$! - waits "mark: shown as" - osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $app) to true" -e 'delay 2' >/dev/null -} -waits() { - for _ in $(seq 160); do - grep -qF "$1" "$work/log" && return 0 - kill -0 "$app" 2>/dev/null || fail "ElyMark quit: $(tail -n 5 "$work/log")" - sleep 0.25 - done - fail "never saw '$1': $(grep -E 'mark:|office:' "$work/log" | tail -n 5)" -} -count() { grep -c "$1" "$work/log" || true; } -front() { osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $app) to true" -e 'delay 1' >/dev/null; } -key() { osascript -e "tell application \"System Events\" to $1" -e 'delay 0.8' >/dev/null; } -# The window's own titles; system menus list recent files too. -seen() { "$work/ax-values" "$app" | grep -v '^AXMenu' | grep -qF "title=$1"; } -press() { - for _ in $(seq 40); do - [ "$("$work/ax-press" "$app" "$@")" = pressed ] && { sleep 0.8; return 0; } - sleep 0.25 - done - fail "could not press $*" -} -quit() { - kill "$app" - wait "$app" 2>/dev/null || true - app= -} -# Asks a `kind` export beside the document, maybe named `$2`. -ask_export() { - front - key 'keystroke "e" using command down' - press "$1" - press "Export…" AXButton - # The panel opens in the document's folder, the stem chosen. - sleep 3 - [ -z "${2:-}" ] || key "keystroke \"$2\"" - # The panel's own button: Return alone did not always save. - press Save AXButton -} -# Exports `kind`; waits for its log line. -export_as() { - local before failed - before=$(count "mark: exported") - failed=$(count "export failed") - ask_export "$@" - for _ in $(seq 160); do - [ "$(count "mark: exported")" -gt "$before" ] && return 0 - [ "$(count "export failed")" -gt "$failed" ] && fail "export: $(grep "export failed" "$work/log" | tail -n 1)" - sleep 0.25 - done - fail "no $1 export: $(grep -E "mark:|office:|dialog|panel" "$work/log" | tail -n 4)" -} - -launch "$doc" -[ "$("$work/ax-press" $$ none)" != untrusted ] || fail "needs Accessibility permission" -# Git sees the file; its item opens ElyTree there. -for _ in $(seq 40); do seen "Git: unchanged" && break; sleep 0.25; done -seen "Git: unchanged" || fail "no Git state: $(grep 'git says' "$work/log" | tail -n 1)" -key 'keystroke "f" using command down' -key 'key code 53' -key 'key code 125 using command down' -key 'keystroke "Z"' -for _ in $(seq 60); do seen "Git: changed" && break; sleep 0.25; done -seen "Git: changed" || fail "Git missed the change: $(grep -E "git says|autosave|saved" "$work/log" | tail -n 4)" -printf '%s\n' "$original" >"$doc" -press "Git: changed" -for _ in $(seq 40); do pgrep -f "target/debug/ely-tree .*${repo#/private}" >/dev/null && break; sleep 0.25; done -pgrep -f "target/debug/ely-tree .*${repo#/private}" >/dev/null || fail "ElyTree did not open the repository" -# ElyTree takes the file itself and shows its history (§16). -waits "tree: file history of t.md" -pkill -f "target/debug/ely-tree .*${repo#/private}" || true -sleep 2 -front -# Focus mode folds the chrome; the look outlives the app. -press "Focus Mode" -! seen "t.md" || fail "focus mode kept the tabs" -press "Serif Font" -quit -grep -q '"focus": true' "$work/Library/Application Support/ElyOffice/ely-mark/look.json" || fail "the look was not kept" -[ "$(cat "$doc")" = "$original" ] || fail "the look changed the file: $(cat "$doc")" -printf '# Other\n' >"$repo/b.md" -launch "$repo/b.md" "$doc" -! seen "t.md" || fail "focus mode did not come back" -press "Focus Mode" -seen "t.md" || fail "focus mode stayed" -# Tabs open in any order; t.md's page takes the keys. -press t.md -key 'keystroke "f" using command down' -key 'key code 53' -# Each export holds the whole document. -export_as HTML -html=$repo/t.html -grep -q 'class="katex' "$html" && grep -q '/dev/null && break; sleep 0.25; done -pgrep -f "target/debug/ely-pdf .*t.pdf" >/dev/null || fail "Open did not show the PDF in ElyPDF" -pkill -f "target/debug/ely-pdf .*t.pdf" || true -front -pdftotext "$repo/t.pdf" - | grep -q "END MARK" || fail "the PDF lacks text" -[ "$(grep -c 'without 1 images' "$work/log")" -ge 3 ] || fail "the PDF hid a missing image" -export_as "Plain Text" -grep -q 'x^2' "$repo/t.txt" && grep -qx 'RAW TEXT' "$repo/t.txt" && grep -qx 'TWO' "$repo/t.txt" && grep -q 'END MARK' "$repo/t.txt" || fail "the text: $(cat "$repo/t.txt")" -# Print saves each job to a file here. -done_ok() { count "office: print done, printed true"; } -printed=$(done_ok) -key 'keystroke "p" using command down' -for _ in $(seq 60); do [ "$(done_ok)" -gt "$printed" ] && break; sleep 0.25; done -[ "$(done_ok)" -gt "$printed" ] || fail "print made no job: $(grep -E "mark: job|print" "$work/log" | tail -n 4)" -pdftotext "$work/printed.pdf" - | grep -q "END MARK" || fail "the printed job lacks the text" -# Two prints at once: each job renders and prints. -rm "$work/printed.pdf" -printed=$(done_ok) -jobs=$(count "mark: job .* ok true") -osascript -e 'tell application "System Events" to keystroke "p" using command down' -e 'tell application "System Events" to keystroke "p" using command down' >/dev/null -for _ in $(seq 80); do [ "$(done_ok)" -ge $((printed + 2)) ] && break; sleep 0.25; done -[ "$(done_ok)" -ge $((printed + 2)) ] || fail "a second print cancelled the first: $(grep -E "mark: job|print" "$work/log" | tail -n 6)" -[ "$(grep -o "mark: job [0-9]* printed .*, ok true" "$work/log" | tail -n 2 | cut -d' ' -f3 | sort -u | wc -l)" -eq 2 ] || fail "the two prints were not two jobs" -[ "$(count "mark: job .* ok true")" -ge $((jobs + 2)) ] || fail "a job did not render" -pdftotext "$work/printed.pdf" - | grep -q "END MARK" || fail "the last job lacks the text" -# Rich copy carries HTML; it overwrites the clipboard. -if [ "${ELY_CLIPBOARD_TEST:-}" = 1 ]; then - key 'keystroke "f" using command down' - key 'key code 53' - key 'keystroke "a" using command down' - key 'keystroke "c" using command down' - osascript -e 'clipboard info' | grep -q 'HTML' || fail "the copy carried no HTML: $(osascript -e 'clipboard info')" -fi -quit -# A large file: source alone, every byte kept and exported. -big=$work/big.md -python3 -c 'import sys -open(sys.argv[1],"w").write("".join(f"Line {n} of a large file.\n\n" for n in range(120000))+"LAST LINE\n")' "$big" -size=$(wc -c <"$big") -launch "$big" -waits 'mark: shown as "source"' -key 'keystroke "1" using command down' -waits "a large file stays in source" -key 'key code 125 using command down' -# A digit: an input method never holds it. -key 'keystroke "9"' -for _ in $(seq 40); do grep -q 'LAST LINE' "$big" && [ "$(wc -c <"$big")" -gt "$size" ] && break; sleep 0.25; done -[ "$(wc -c <"$big")" -gt "$size" ] && grep -q 'LAST LINE' "$big" && grep -q '^Line 0 of' "$big" || fail "the large file lost bytes" -export_as "Plain Text" -grep -q 'LAST LINE' "$work/big.txt" || fail "a large export was cut" -quit -echo "ok: mark output" diff --git a/tests/scripts/mark-outside.sh b/tests/scripts/mark-outside.sh deleted file mode 100755 index c32883c..0000000 --- a/tests/scripts/mark-outside.sh +++ /dev/null @@ -1,457 +0,0 @@ -#!/usr/bin/env bash -# MARK-08, MARK-12, QA-22 live: outside changes, autosave, restore. -set -euo pipefail - -root=$(cd "$(dirname "$0")/../.." && pwd) -[ "$(uname)" = Darwin ] || { echo "SKIP: macOS only"; exit 0; } -[ -d "${ELY_CEF:-$root/engines/cef}/Chromium Embedded Framework.framework" ] || { - echo "SKIP: no CEF; run scripts/fetch-cef.sh" - exit 0 -} -command -v npm >/dev/null || { echo "SKIP: no npm"; exit 0; } -work=$(mktemp -d) -app= -trap 'if [ -n "$app" ]; then kill "$app" 2>/dev/null || true; wait "$app" 2>/dev/null || true; fi; rm -rf "$work"' EXIT -fail() { - echo "FAIL: $*" >&2 - exit 1 -} - -"$root/scripts/build-mark-content.sh" >"$work/build" 2>&1 || fail "build: $(tail -n 5 "$work/build")" -cargo build -q -p ely-mark --manifest-path "$root/Cargo.toml" -for tool in ax-values ax-press; do - swiftc -O -o "$work/$tool" "$root/tests/scripts/$tool.swift" -done -mkdir "$work/notes" -doc=$work/notes/t.md -printf 'one\n\ntwo\n' >"$doc" -launch() { - ELY_LOG=info ELY_LOCALE=en ELY_NO_DIALOG=1 HOME="$work" \ - "$root/target/debug/ely-mark" "$@" >"$work/out" 2>"$work/err" & - app=$! - waits "ElyMark window open" - osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $app) to true" -e 'delay 2' >/dev/null -} -waits() { - for _ in $(seq 120); do - grep -qF "$1" "$work/err" && return 0 - kill -0 "$app" 2>/dev/null || fail "ElyMark quit: $(tail -n 5 "$work/err")" - sleep 0.25 - done - fail "never saw '$1': $(grep -E 'mark|office' "$work/err" | tail -n 5)" -} -count() { grep -c "$1" "$work/err" || true; } -# Waits for `$1` to show more than `$2` times. -more() { - for _ in $(seq 120); do - [ "$(count "$1")" -gt "$2" ] && return 0 - sleep 0.25 - done - fail "no new '$1': $(grep -E 'mark:|recovery' "$work/err" | tail -n 6)" -} -# Types unpaused: a write lands within the autosave second. -now() { osascript -e "tell application \"System Events\" to keystroke \"$1\"" >/dev/null; } -key() { osascript -e "tell application \"System Events\" to $1" -e 'delay 0.8' >/dev/null; } -press() { - for _ in $(seq 20); do - [ "$("$work/ax-press" "$app" "$1")" = pressed ] && { sleep 0.8; return 0; } - sleep 0.25 - done - fail "could not press $1" -} -# The window's own titles; system menus list recent files too. -seen() { "$work/ax-values" "$app" | grep -v '^AXMenu' | grep -qF "title=$1"; } -# Waits for an autosave past the `n` seen so far. -autosaved() { - for _ in $(seq 40); do - [ "$(count "autosaves")" -gt "$1" ] && sleep 1 && return 0 - sleep 0.25 - done - fail "no autosave: $(tr '\n' '/' <"$doc") $(grep -E 'mark:|office:' "$work/err" | tail -n 5)" -} -is() { [ "$(cat "$1")" = "$2" ] || fail "$3: $(cat "$1")"; } - -launch "$doc" -waits 'mark: shown as "visual"' -[ "$("$work/ax-press" $$ none)" != untrusted ] || fail "needs Accessibility permission" -# A clean file follows an atomic replace; typing autosaves. -printf 'one\n\ntwo\n\nthree\n' >"$doc.tmp" -mv "$doc.tmp" "$doc" -waits "changed outside; unsaved false" -sleep 1 -before=$(count autosaves) -key 'key code 125 using command down' -key 'keystroke "Z"' -autosaved "$before" -is "$doc" $'one\n\ntwo\n\nthreeZ' "reload then autosave" -# Unsaved text meets an outside write: nothing is overwritten. -now X -printf 'outside\n' >"$doc" -waits "changed outside; unsaved true" -sleep 2 -is "$doc" "outside" "an outside write was overwritten" -press "Compare…" -seen "Use the Merge" || fail "no compare dialog" -press "Keep Yours" -waits "settles with Mine" -sleep 1 -is "$doc" $'one\n\ntwo\n\nthreeZX' "keep yours" -# Autosave runs again once the user chose. -before=$(count autosaves) -key 'keystroke "K"' -autosaved "$before" -is "$doc" $'one\n\ntwo\n\nthreeZXK' "autosave after keep" -# Unsent keys survive a touch that keeps the text. -before=$(count autosaves) -changed=$(count "changed outside") -now QQ -touch "$doc" -more "changed outside" "$changed" -autosaved "$before" -is "$doc" $'one\n\ntwo\n\nthreeZXKQQ' "keys typed before a touch were lost" -before=$(count autosaves) -key 'key code 51' -key 'key code 51' -autosaved "$before" -is "$doc" $'one\n\ntwo\n\nthreeZXK' "keys taken back" -# Same text, new bytes: saves keep the new bytes. -lf=$(od -An -tx1 "$doc" | tr -d ' \n') -python3 -c 'import sys; d = open(sys.argv[1], "rb").read(); open(sys.argv[2], "wb").write(b"\xef\xbb\xbf" + d.replace(b"\n", b"\r\n"))' "$doc" "$work/crlf" -# BOM, only CRLF line ends, and `$1` in it. -kept() { python3 -c 'import sys; d = open(sys.argv[1], "rb").read(); sys.exit(not (d.startswith(b"\xef\xbb\xbf") and d.count(b"\n") == d.count(b"\r\n") and sys.argv[2].encode() in d))' "$doc" "$1"; } -changed=$(count "changed outside") -cp "$work/crlf" "$doc" -more "changed outside" "$changed" -before=$(count autosaves) -now W -autosaved "$before" -kept W || fail "a save lost the new bytes: $(od -c "$doc" | head -3)" -before=$(count autosaves) -key 'key code 51' -autosaved "$before" -cmp -s "$doc" "$work/crlf" || fail "the save is not the new bytes: $(od -c "$doc" | head -3)" -changed=$(count "changed outside") -printf '%s' "$lf" | xxd -r -p >"$doc" -more "changed outside" "$changed" -# A conflict the disk takes back resumes autosave. -cp "$doc" "$work/saved" -conflicts=$(count "unsaved true") -now X -printf 'external\n' >"$doc" -more "unsaved true" "$conflicts" -cp "$work/saved" "$doc" -waits "local edits stay" -before=$(count autosaves) -now Y -autosaved "$before" -is "$doc" $'one\n\ntwo\n\nthreeZXKXY' "autosave after the disk came back" -before=$(count autosaves) -key 'key code 51' -key 'key code 51' -autosaved "$before" -is "$doc" $'one\n\ntwo\n\nthreeZXK' "keys taken back again" -# Unsaved keys meet the saved text in new bytes. -cp "$doc" "$work/saved" -python3 -c 'import sys; d = open(sys.argv[1], "rb").read(); open(sys.argv[2], "wb").write(b"\xef\xbb\xbf" + d.replace(b"\n", b"\r\n"))' "$doc" "$work/crlf" -stays=$(count "local edits stay") -before=$(count autosaves) -now V -cp "$work/crlf" "$doc" -more "local edits stay" "$stays" -autosaved "$before" -kept V || fail "edits kept lost the new bytes: $(od -c "$doc" | head -3)" -before=$(count autosaves) -key 'key code 51' -autosaved "$before" -cmp -s "$doc" "$work/crlf" || fail "not the new bytes: $(od -c "$doc" | head -3)" -changed=$(count "changed outside") -cp "$work/saved" "$doc" -more "changed outside" "$changed" -# Unsaved text written outside as is: the tab is clean. -python3 -c 'import sys; d = open(sys.argv[1], "rb").read(); open(sys.argv[2], "wb").write(d[:-1] + b"S\n" if d.endswith(b"\n") else d + b"S")' "$doc" "$work/typed" -cp "$doc" "$work/saved" -changed=$(count "changed outside") -now S -cp "$work/typed" "$doc" -more "changed outside" "$changed" -sleep 2 -clean=$(count "unsaved false") -printf 'gamma\n' >>"$doc" -more "unsaved false" "$clean" -sleep 1 -grep -q gamma "$doc" || fail "the outside change was not taken" -changed=$(count "changed outside") -cp "$work/saved" "$doc" -more "changed outside" "$changed" -# A save that meets the same text keeps saving after. -python3 -c 'import sys; d = open(sys.argv[1], "rb").read(); open(sys.argv[2], "wb").write(d[:-1] + b"T\n" if d.endswith(b"\n") else d + b"T")' "$doc" "$work/same" -before=$(count autosaves) -now T -for _ in $(seq 200); do [ "$(count autosaves)" -gt "$before" ] && break; sleep 0.01; done -cp "$work/same" "$doc" -sleep 2 -before=$(count autosaves) -now U -autosaved "$before" -grep -q TU "$doc" || fail "the next edit after a same-text save was not saved: $(tr '\n' '/' <"$doc")" -before=$(count autosaves) -key 'key code 51' -key 'key code 51' -autosaved "$before" -# Edits apart merge; a disk change mid-way compares again. -key 'key code 126 using command down' -conflicts=$(count "unsaved true") -now A -printf 'one\n\ntwo\n\nthreeZXK\n\ntail\n' >"$doc" -more "unsaved true" "$conflicts" -press "Compare…" -compares=$(count "compares tab") -printf 'one\n\ntwo\n\nthreeZXK\n\ntail\n\nmore\n' >"$doc" -press "Use the Merge" -waits "during the comparison" -more "compares tab" "$compares" -is "$doc" $'one\n\ntwo\n\nthreeZXK\n\ntail\n\nmore' "a stale merge was written" -press "Use the Merge" -waits "settles with Merged" -before=$(count autosaves) -autosaved "$before" -is "$doc" $'Aone\n\ntwo\n\nthreeZXK\n\ntail\n\nmore' "merge" -# A rename outside: the tab follows, saves the new name. -mv "$doc" "$work/notes/u.md" -waits "was renamed outside" -seen "u.md" || fail "the tab kept its old name" -before=$(count autosaves) -key 'keystroke "R"' -autosaved "$before" -[ ! -e "$doc" ] || fail "the old name came back" -grep -q '^RAone' "$work/notes/u.md" || fail "rename save: $(cat "$work/notes/u.md")" -# An unreadable change says so; a fresh look recovers. -clean=$(count "unsaved false") -printf 'locked\n' >"$work/notes/u.md" -chmod 000 "$work/notes/u.md" -for _ in $(seq 40); do seen "Check Again" && break; sleep 0.25; done -seen "Check Again" || fail "no offer to look again" -chmod 644 "$work/notes/u.md" -press "Check Again" -more "unsaved false" "$clean" -# A delete keeps the text and never recreates the file. -rm "$work/notes/u.md" -waits "was deleted outside" -seen "Save As…" || fail "no Save As offer" -before=$(count autosaves) -key 'keystroke "D"' -sleep 3 -[ "$(count autosaves)" = "$before" ] || fail "autosave ran on a deleted file" -[ ! -e "$work/notes/u.md" ] || fail "autosave recreated a deleted file" -kill "$app" -wait "$app" 2>/dev/null || true -app= -! grep -q "panicked" "$work/err" || fail "panics: $(grep -m3 panicked "$work/err")" -# The killed run above left a crash; start clean. -rm -rf "$work/Library/Application Support/ElyOffice/ely-mark/"{session.json,session.lock,recovery} -# A clean quit keeps the tabs, mode, line and column. -printf 'kept\n\nsecond\n\nthird\n' >"$doc" -gone=$work/notes/w.md -printf 'other\n' >"$gone" -launch "$gone" "$doc" -more 'mark: shown as "visual"' 1 -press t.md -key 'keystroke "f" using command down' -key 'key code 53' -key 'keystroke "2" using command down' -waits 'mark: shown as "source"' -key 'key code 125 using command down' -key 'key code 126' -key 'key code 123 using command down' -key 'key code 124' -key 'key code 124' -sleep 1 -key 'keystroke "q" using command down' -for _ in $(seq 40); do kill -0 "$app" 2>/dev/null || break; sleep 0.25; done -! kill -0 "$app" 2>/dev/null || fail "Cmd+Q did not quit" -app= -launch -waits "restores tab" -waits 'mark: shown as "source"' -sleep 1 -# Restored tabs open in any order; pick t.md's page. -press t.md -key 'keystroke "f" using command down' -key 'key code 53' -before=$(count autosaves) -key 'keystroke "Q"' -autosaved "$before" -is "$doc" $'kept\n\nsecond\n\nthQird' "the restored caret" -# Unsaved text, even a deleted file's, returns as copies. -points=$(count "recovery: a point") -press t.md -conflicts=$(count "unsaved true") -now Y -printf 'disk\n' >"$doc" -more "unsaved true" "$conflicts" -press w.md -# Closing the find box hands the keys to the page. -key 'keystroke "f" using command down' -key 'key code 53' -conflicts=$(count "unsaved true") -now V -printf 'gone\n' >"$gone" -more "unsaved true" "$conflicts" -rm "$gone" -more "recovery: a point" "$((points + 1))" -kill -9 "$app" -wait "$app" 2>/dev/null || true -app= -launch -waits "ended Crashed" -waits "did not open" -for _ in $(seq 40); do [ "$(count ": unsaved text from .* is back")" -ge 2 ] && break; sleep 0.25; done -[ "$(count ": unsaved text from .* is back")" = 2 ] || fail "recovered copies: $(grep "unsaved text from" "$work/err")" -sleep 3 -is "$doc" "disk" "recovered text went into the file" -[ ! -e "$gone" ] || fail "a deleted file came back" -# Each copy holds its own text, past the first sweep. -rec="$work/Library/Application Support/ElyOffice/ely-mark/recovery" -session="$work/Library/Application Support/ElyOffice/ely-mark/session.json" -sleep 12 -withy=$(grep -rlF Y "$rec" --include='*.bin' | head -n 1 || true) -withv=$(grep -rlF V "$rec" --include='*.bin' | head -n 1 || true) -[ -n "$withy" ] && [ -n "$withv" ] || fail "a copy lost its text: $(ls -R "$rec")" -! grep -qF V "$withy" && ! grep -qF Y "$withv" || fail "a copy took another's text" -# A cancelled Save panel stops the quit; the list follows. -key 'keystroke "q" using command down' -for _ in $(seq 20); do seen "Don't Save" && break; sleep 0.25; done -[ "$("$work/ax-press" "$app" Save AXButton)" = pressed ] || fail "no Save button" -sleep 2 -key 'key code 53' -key 'keystroke "n" using command down' -sleep 2 -points=$(count "recovery: a point") -key 'keystroke "third"' -more "recovery: a point" "$points" -for _ in $(seq 60); do [ "$(grep -c '"slot"' "$session")" = 3 ] && ! grep -q '"path": "' "$session" && break; sleep 0.5; done -[ "$(grep -c '"slot"' "$session")" = 3 ] && ! grep -q '"path": "' "$session" || fail "the kept list froze: $(tr -d " \n" <"$session")" -# A discard holds even through a crash right after. -key 'keystroke "q" using command down' -for _ in $(seq 20); do seen "Don't Save" && break; sleep 0.25; done -press "Don't Save" -for _ in $(seq 20); do seen "Don't Save" && break; sleep 0.25; done -press Cancel -kill -9 "$app" -wait "$app" 2>/dev/null || true -app= -launch -waits "ended Crashed" -sleep 4 -[ "$(count ": unsaved text from .* is back")" = 2 ] || fail "after a discard: $(grep ": unsaved text from .* is back" "$work/err")" -key 'keystroke "q" using command down' -for _ in $(seq 4); do - for _ in $(seq 20); do seen "Don't Save" && break; sleep 0.25; done - seen "Don't Save" || break - press "Don't Save" -done -for _ in $(seq 40); do kill -0 "$app" 2>/dev/null || break; sleep 0.25; done -app= -# A discard is not a crash: nothing comes back. -launch -sleep 4 -! grep -q "unsaved text from" "$work/err" || fail "discarded text came back" -kill "$app" -wait "$app" 2>/dev/null || true -app= -# A tab discarded, then a crash: it stays gone. -launch -key 'keystroke "n" using command down' -sleep 2 -points=$(count "recovery: a point") -key 'keystroke "solo"' -more "recovery: a point" "$points" -[ "$("$work/ax-press" "$app" Close AXButton)" = pressed ] || fail "no tab Close" -for _ in $(seq 20); do seen "Don't Save" && break; sleep 0.25; done -press "Don't Save" -kill -9 "$app" -wait "$app" 2>/dev/null || true -app= -launch -waits "ended Crashed" -sleep 4 -! grep -q "unsaved text from" "$work/err" || fail "a discarded tab came back" -kill "$app" -wait "$app" 2>/dev/null || true -app= -# A quit's conflict reloaded stops it; the list follows. -printf 'base\n' >"$doc" -launch "$doc" -waits 'mark: shown as "visual"' -conflicts=$(count "unsaved true") -now L -printf 'other\n' >"$doc" -more "unsaved true" "$conflicts" -key 'keystroke "q" using command down' -for _ in $(seq 20); do seen "Don't Save" && break; sleep 0.25; done -[ "$("$work/ax-press" "$app" Save AXButton)" = pressed ] || fail "no Save button" -for _ in $(seq 20); do seen "Reload from Disk" && break; sleep 0.25; done -press "Reload from Disk" -sleep 2 -key 'keystroke "n" using command down' -sleep 2 -points=$(count "recovery: a point") -key 'keystroke "afterreload"' -more "recovery: a point" "$points" -for _ in $(seq 60); do [ "$(grep -c '"slot"' "$session")" = 2 ] && break; sleep 0.5; done -[ "$(grep -c '"slot"' "$session")" = 2 ] || fail "the list froze after a reload: $(tr -d " \n" <"$session")" -key 'keystroke "q" using command down' -for _ in $(seq 20); do seen "Don't Save" && break; sleep 0.25; done -press "Don't Save" -for _ in $(seq 40); do kill -0 "$app" 2>/dev/null || break; sleep 0.25; done -app= -# A quit's first save keeps its file next time. -rm -f "$session" -launch -key 'keystroke "n" using command down' -sleep 2 -key 'keystroke "savedtext"' -key 'keystroke "q" using command down' -for _ in $(seq 20); do seen "Don't Save" && break; sleep 0.25; done -[ "$("$work/ax-press" "$app" Save AXButton)" = pressed ] || fail "no Save button" -sleep 2 -key 'keystroke "g" using {command down, shift down}' -key "keystroke \"$work/notes\"" -key 'key code 36' -key 'key code 36' -for _ in $(seq 40); do kill -0 "$app" 2>/dev/null || break; sleep 0.25; done -! kill -0 "$app" 2>/dev/null || fail "the quit did not finish after saving" -app= -saved=$(grep -lF savedtext "$work/notes"/*.md || true) -[ -n "$saved" ] || fail "nothing saved: $(ls "$work/notes")" -launch -waits "restores tab" -kill "$app" -wait "$app" 2>/dev/null || true -app= -# A session kept before columns were still opens. -printf '{"tabs":[{"path":"%s","name":"t.md","mode":"source","place":{"line":1,"scroll":0},"slot":""}]}' "$doc" >"$session" -rm -f "$work/Library/Application Support/ElyOffice/ely-mark/session.lock" -launch -waits "restores tab" -waits 'mark: shown as "source"' -kill "$app" -wait "$app" 2>/dev/null || true -app= -# A clean tab with the disk's text closes without asking. -clean=$work/notes/c.md -printf 'clean\n' >"$clean" -launch "$clean" -waits 'mark: shown as "visual"' -changes=$(count "unsaved false") -printf 'outside\n' >"$clean.tmp" -mv "$clean.tmp" "$clean" -more "unsaved false" "$changes" -sleep 1 -[ "$("$work/ax-press" "$app" Close AXButton)" = pressed ] || fail "no tab Close" -sleep 2 -! seen "Don't Save" || fail "a clean tab that took the disk's text asked to save" -kill "$app" -wait "$app" 2>/dev/null || true -app= -echo "ok: mark outside" diff --git a/tests/scripts/mark-places.sh b/tests/scripts/mark-places.sh deleted file mode 100755 index 215ee47..0000000 --- a/tests/scripts/mark-places.sh +++ /dev/null @@ -1,188 +0,0 @@ -#!/usr/bin/env bash -# MARK-05, MARK-10 live: folder tree, outline, search, recent. -set -euo pipefail - -root=$(cd "$(dirname "$0")/../.." && pwd) -[ "$(uname)" = Darwin ] || { echo "SKIP: macOS only"; exit 0; } -[ -d "${ELY_CEF:-$root/engines/cef}/Chromium Embedded Framework.framework" ] || { - echo "SKIP: no CEF; run scripts/fetch-cef.sh" - exit 0 -} -command -v npm >/dev/null || { echo "SKIP: no npm"; exit 0; } -work=$(mktemp -d) -app= -trap 'if [ -n "$app" ]; then kill "$app" 2>/dev/null || true; wait "$app" 2>/dev/null || true; fi; rm -rf "$work"' EXIT -fail() { - echo "FAIL: $*" >&2 - exit 1 -} - -"$root/scripts/build-mark-content.sh" >"$work/build" 2>&1 || fail "build: $(tail -n 5 "$work/build")" -cargo build -q -p ely-mark --manifest-path "$root/Cargo.toml" -for tool in ax-values ax-press click; do - swiftc -O -o "$work/$tool" "$root/tests/scripts/$tool.swift" -done -mkdir -p "$work/notes/sub" -# A heading inside a quote Milkdown rewrites: one raw block. -printf '# Alpha\n\nSome text.\n\n> ## Beta ##\n>\n> More words here.\n' >"$work/notes/a.md" -# A hit hidden in a link's address comes first. -printf '# Other\n\nwords [link](words)\nwords too\n' >"$work/notes/sub/b.md" -printf 'banana\nana too\n\n[link](words)\nwords two' >"$work/notes/d.md" -printf -- '---\ntitle: T\n---\n# Heading\n\nneedle here.\n\n# Other\n\nother paragraph.\n' >"$work/notes/c.md" -printf 'words\n' >"$work/notes/skip.txt" -# A file the search cannot read. -printf 'words\n' >"$work/notes/bad.md" -chmod 000 "$work/notes/bad.md" -launch() { - ELY_LOG=info ELY_LOCALE=en ELY_NO_DIALOG=1 HOME="$work" \ - "$root/target/debug/ely-mark" "$@" >"$work/out" 2>"$work/err" & - app=$! -} -waits() { - for _ in $(seq 120); do - grep -qF "$1" "$work/err" && return 0 - kill -0 "$app" 2>/dev/null || fail "ElyMark quit: $(tail -n 5 "$work/err")" - sleep 0.25 - done - fail "never saw '$1': $(grep -E 'mark|office' "$work/err" | tail -n 5)" -} -# Sets x and y to element `$1`'s center. -at() { - local found - for _ in $(seq 40); do - found=$("$work/ax-values" "$app" | awk -F'\t' -v want="title=$1" '$1 != "AXMenuItem" && $3 == want { sub("at=", "", $5); print $5; exit }') - if [ -n "$found" ]; then - IFS=, read -r x y <<<"$found" - return 0 - fi - sleep 0.25 - done - fail "no '$1' in the AX tree" -} -key() { osascript -e "tell application \"System Events\" to $1" -e 'delay 0.8' >/dev/null; } -press() { [ "$("$work/ax-press" "$app" "$1")" = pressed ] || fail "could not press $1"; sleep 0.8; } -saves() { - local before - before=$(grep -c "office: saved" "$work/err" || true) - key 'keystroke "s" using command down' - for _ in $(seq 40); do - [ "$(grep -c "office: saved" "$work/err" || true)" -gt "$before" ] && return 0 - sleep 0.25 - done - fail "no save" -} - -launch "$work/notes" "$work/notes/a.md" -waits 'mark: shown as' -[ "$("$work/ax-press" $$ none)" != untrusted ] || fail "needs Accessibility permission" -osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $app) to true" -e 'delay 1' >/dev/null -# Tree rows are tree items; Return opens a picked one. -at b.md -"$work/click" "$x" "$y" -key 'key code 36' -waits "opened b.md" -# An outline heading takes the caret there. -press a.md -press Outline -at Outline -"$work/click" "$((x - 64))" "$((y + 64))" -key 'keystroke "Z"' -saves -grep -qx '> ## ZBeta ##' "$work/notes/a.md" || fail "outline jump: $(cat "$work/notes/a.md")" -# Folder search finds lines; a hit opens there. -press Search -at Folder -"$work/click" "$x" "$y" -press "This file" -"$work/click" "$x" "$((y - 37))" -key 'keystroke "words"' -key 'key code 36' -press Folder -at "> More words here." -at "words too" -at "bad.md was not searched" -! "$work/ax-values" "$app" | grep -q "title=words " || fail "a .txt file was searched" -chmod 600 "$work/notes/bad.md" -# Inside a paragraph the caret lands on the hit's line. -press "words too" -key 'key code 36' -key 'keystroke "Q"' -saves -[ "$(cat "$work/notes/sub/b.md")" = $'# Other\n\nwords [link](words)\nQwords too' ] || fail "search hit: $(cat "$work/notes/sub/b.md")" -# Lines count from the file's top, past front matter. -press Search -at Folder -"$work/click" "$x" "$((y - 37))" -key 'keystroke "a" using command down' -key 'keystroke "needle"' -key 'key code 36' -at "needle here." -press "needle here." -key 'key code 36' -waits "opened c.md" -sleep 1 -key 'keystroke "Q"' -saves -grep -qx 'Qneedle here.' "$work/notes/c.md" || fail "front matter offset: $(cat "$work/notes/c.md")" -# This file's results follow the shown tab. -press Search -press "This file" -at "Qneedle here." -press b.md -sleep 1.5 -! "$work/ax-values" "$app" | grep -q "title=Qneedle here." || fail "results stayed with the last tab" -# Overlapping hits; a hit hidden in a link's address. -press Search -at Folder -"$work/click" "$x" "$((y - 37))" -key 'keystroke "a" using command down' -key 'keystroke "ana"' -key 'key code 36' -press Folder -press "ana too" -key 'key code 36' -waits "opened d.md" -sleep 1 -key 'keystroke "Q"' -saves -[ "$(cat "$work/notes/d.md")" = $'banana\nQana too\n\n[link](words)\nwords two' ] || fail "overlapping hit: $(cat "$work/notes/d.md")" -press Search -at Folder -"$work/click" "$x" "$((y - 37))" -key 'keystroke "a" using command down' -key 'keystroke "words"' -key 'key code 36' -press Folder -press "[link](words)" -key 'key code 36' -sleep 1 -key 'keystroke "R"' -saves -[ "$(cat "$work/notes/d.md")" = $'banana\nQana too\n\n[Rlink](words)\nwords two' ] || fail "hidden hit: $(cat "$work/notes/d.md")" -# A rename shows in the folder's tree. -press Files -key 'key code 120' -key 'keystroke "a" using command down' -key 'keystroke "moved.md"' -key 'key code 36' -waits "renamed" -at moved.md -! "$work/ax-values" "$app" | grep -q "^AXRow.*title=d.md " || fail "the tree kept the old name" -kill "$app" -wait "$app" 2>/dev/null || true -app= -# The recent list outlives the app. -python3 -c 'import json,sys; l=json.load(open(sys.argv[1])); sys.exit(0 if any(p.endswith("notes") for p in l) and any(p.endswith("a.md") for p in l) else 1)' \ - "$(find "$work/Library" -name recent.json)" || fail "recent: $(cat "$(find "$work/Library" -name recent.json)")" -launch -waits 'ElyMark window open' -at notes -press notes -key 'key code 36' -waits "opens folder" -at Outline -kill "$app" -wait "$app" 2>/dev/null || true -app= -! grep -q "panicked" "$work/err" || fail "panics: $(grep -m3 panicked "$work/err")" -echo "ok: mark places" diff --git a/tests/scripts/mark-session.sh b/tests/scripts/mark-session.sh deleted file mode 100755 index f3d11ae..0000000 --- a/tests/scripts/mark-session.sh +++ /dev/null @@ -1,180 +0,0 @@ -#!/usr/bin/env bash -# QA-21: a save keeps bytes no edit touched. -set -euo pipefail - -root=$(cd "$(dirname "$0")/../.." && pwd) -[ "$(uname)" = Darwin ] || { echo "SKIP: macOS only"; exit 0; } -[ -d "${ELY_CEF:-$root/engines/cef}/Chromium Embedded Framework.framework" ] || { - echo "SKIP: no CEF; run scripts/fetch-cef.sh" - exit 0 -} -command -v npm >/dev/null || { echo "SKIP: no npm"; exit 0; } -work=$(mktemp -d) -app= -trap 'if [ -n "$app" ]; then kill "$app" 2>/dev/null || true; wait "$app" 2>/dev/null || true; fi; chmod -R u+rw "$work"; rm -rf "$work"' EXIT -fail() { - echo "FAIL: $*" >&2 - exit 1 -} - -"$root/scripts/build-mark-content.sh" >"$work/build" 2>&1 || fail "build: $(tail -n 5 "$work/build")" -cargo build -q -p ely-mark --manifest-path "$root/Cargo.toml" -swiftc -O -o "$work/press" "$root/tests/scripts/ax-press.swift" -waits() { - for _ in $(seq 200); do - grep -qF "$1" "$work/err" && return 0 - kill -0 "$app" 2>/dev/null || break - sleep 0.25 - done - fail "never saw '$1': $(grep -E 'mark|office' "$work/err" | tail -n 5)" -} -# Starts ElyMark on `file`; it must show visually. -launch() { - : >"$work/err" - ELY_LOG=info ELY_LOCALE=en ELY_NO_DIALOG=1 HOME="$work" XDG_DATA_HOME="$work/data" \ - "$root/target/debug/ely-mark" "$1" >"$work/out" 2>"$work/err" & - app=$! -} -shown() { - waits "mark: shown as" - grep -qF 'mark: shown as "visual", fallback false' "$work/err" || - fail "not shown visually: $(grep 'mark: shown' "$work/err")" - osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $app) to true" \ - -e 'delay 0.5' >/dev/null -} -type() { osascript -e "tell application \"System Events\" to $1" -e 'delay 1' >/dev/null; } -save_quit() { - type 'keystroke "s" using command down' - waits "office: saved" - osascript -e 'tell application "System Events" to keystroke "q" using command down' >/dev/null - for _ in $(seq 40); do - kill -0 "$app" 2>/dev/null || break - sleep 0.25 - done - ! kill -0 "$app" 2>/dev/null || fail "ElyMark did not quit" - app= - ! grep -q "panicked" "$work/err" || fail "panics: $(grep -m3 panicked "$work/err")" -} -# Saves `name` after `keys`; it must equal `want`. -check() { - local name=$1 text=$2 keys=$3 want=$4 - printf '%s' "$text" >"$work/$name" - launch "$work/$name" - shown - [ -z "$keys" ] || type "$keys" - save_quit - [ "$(cat "$work/$name"; echo .)" = "$want." ] || fail "$name saved as: $(cat "$work/$name")" -} - -# BOM, CRLF, front matter and syntax Milkdown rewrites. -python3 - "$work/keep.md" <<'PY' -import sys -text = """--- -title: Keep -tags: [a, b] ---- -First paragraph. - -Setext -====== - -1) paren list -2) second - -* star -* list - -
- html -
- -|a|b| -|-|-| -|1|2| - -See [[Wiki Link]], ~sub~ and [a ref][r]. -next line - -[r]: https://example.com "T" - -Last paragraph. -""" -open(sys.argv[1], "wb").write(b"\xef\xbb\xbf" + text.replace("\n", "\r\n").encode()) -PY -cp "$work/keep.md" "$work/plain.md" -launch "$work/plain.md" -shown -save_quit -cmp -s "$work/keep.md" "$work/plain.md" || fail "an unedited save changed bytes" -cp "$work/keep.md" "$work/edited.md" -launch "$work/edited.md" -shown -type 'keystroke "x"' -save_quit -python3 - "$work/keep.md" "$work/edited.md" <<'PY' -import sys -before, after = (open(path, "rb").read() for path in sys.argv[1:]) -want = before.replace(b"First paragraph.", b"xFirst paragraph.", 1) -if after != want: - sys.exit(f"FAIL: the edit rewrote more than its block:\n{after!r}") -PY - -# Blocks Milkdown would rewrite are edited as raw text. -check wiki.md $'Alpha with [[Wiki Link]].\n' 'keystroke "x"' $'xAlpha with [[Wiki Link]].\n' -quote=$'> First.\n>\n> Setext\n> ======\n>\n> Last with [[Wiki]].\n' -check quote.md "$quote" 'keystroke "x"' "x$quote" -check defs.md $'[r]: https://example.com\n' 'keystroke "x"' $'x[r]: https://example.com\n' -# A compact table stays a table; edits keep its style. -check table.md $'|a|b|\n|-|-|\n|1|2|\n' 'keystroke "x"' $'|xa|b|\n|-|-|\n|1|2|\n' -check dashes.md $'| a | b |\n|---|---|\n| 1 | 2 |\n' 'keystroke "x"' $'| xa | b |\n| - | - |\n| 1 | 2 |\n' -check quoted.md $'> |alpha|beta|\n> |---|---|\n> |1|2|\n' 'keystroke "x"' $'> |xalpha|beta|\n> |-|-|\n> |1|2|\n' -check listed.md $'- item\n\n |alpha|beta|\n |---|---|\n |1|2|\n' 'keystroke "x"' $'- xitem\n\n |alpha|beta|\n |-|-|\n |1|2|\n' -# Items open with a paragraph; a leading table stays raw. -check leading.md $'- |alpha|beta|\n |---|---|\n' 'keystroke "x"' $'x- |alpha|beta|\n |---|---|\n' -check short.md $'|a|b|c|\n|-|-|-|\n|1|2|\n' 'keystroke "x"' $'|xa|b|c|\n|-|-|-|\n|1|2||\n' -# Cells Milkdown would rewrite keep the table raw. -check wikicell.md $'|a|[[Wiki Link]]|\n|-|-|\n|1|2|\n' 'keystroke "x"' $'x|a|[[Wiki Link]]|\n|-|-|\n|1|2|\n' -# Deleting forward at a hidden definition moves nothing. -gap=$'Alpha.\n\n[r]: /path " T " \n\nBeta.\n\nTail.\n' -check gap.md "$gap" 'key code 119' "$gap" -printf '%s' "$gap" >"$work/gap2.md" -launch "$work/gap2.md" -shown -type 'key code 119' -type 'key code 117' -save_quit -[ "$(cat "$work/gap2.md"; echo .)" = "$gap." ] || fail "the definition moved: $(cat "$work/gap2.md")" - -# A reloaded page's edits still save. -printf 'Alpha.\n' >"$work/crash.md" -launch "$work/crash.md" -shown -# Two versions first: the new page counts from one. -type 'keystroke "x"' -type 'keystroke "z"' -pkill -P "$app" -f -- "--type=renderer" || fail "no renderer to stop" -pressed= -for _ in $(seq 40); do - [ "$("$work/press" "$app" Reload)" = pressed ] && pressed=1 && break - sleep 0.25 -done -[ -n "$pressed" ] || fail "no Reload button" -for _ in $(seq 80); do - [ "$(grep -c 'mark: shown as' "$work/err")" -ge 2 ] && break - sleep 0.25 -done -osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $app) to true" >/dev/null -type 'keystroke "y"' -save_quit -grep -q "y" "$work/crash.md" || fail "an edit after reload was lost: $(cat "$work/crash.md")" - -# An unreadable file says so; no password is asked. -printf 'secret\n' >"$work/denied.md" -chmod 000 "$work/denied.md" -launch "$work/denied.md" -waits "did not open" -grep -q "Permission denied" "$work/err" || fail "the reason is missing: $(grep 'did not open' "$work/err")" -! grep -q "locked" "$work/err" || fail "taken for a locked file" -kill "$app" -app= -echo "ok: mark session" diff --git a/tests/scripts/mark-tools.sh b/tests/scripts/mark-tools.sh deleted file mode 100755 index e5f07f8..0000000 --- a/tests/scripts/mark-tools.sh +++ /dev/null @@ -1,223 +0,0 @@ -#!/usr/bin/env bash -# MARK-06, MARK-07 live: find, replace, Tab, lists, tables, images. -set -euo pipefail - -root=$(cd "$(dirname "$0")/../.." && pwd) -[ "$(uname)" = Darwin ] || { echo "SKIP: macOS only"; exit 0; } -[ -d "${ELY_CEF:-$root/engines/cef}/Chromium Embedded Framework.framework" ] || { - echo "SKIP: no CEF; run scripts/fetch-cef.sh" - exit 0 -} -command -v npm >/dev/null || { echo "SKIP: no npm"; exit 0; } -work=$(mktemp -d) -app= -trap 'if [ -n "$app" ]; then kill "$app" 2>/dev/null || true; wait "$app" 2>/dev/null || true; fi; rm -rf "$work"' EXIT -fail() { - echo "FAIL: $*" >&2 - exit 1 -} - -"$root/scripts/build-mark-content.sh" >"$work/build" 2>&1 || fail "build: $(tail -n 5 "$work/build")" -cargo build -q -p ely-mark --manifest-path "$root/Cargo.toml" -for tool in ax-values ax-press click drop; do - swiftc -O -o "$work/$tool" "$root/tests/scripts/$tool.swift" -done -mkdir "$work/notes" -printf 'alpha beta alpha\n\ngamma alpha\n' >"$work/notes/t.md" -# A two-pixel PNG. -python3 -c 'import struct,zlib,sys -c=lambda t,d: struct.pack(">I",len(d))+t+d+struct.pack(">I",zlib.crc32(t+d)) -open(sys.argv[1],"wb").write(b"\x89PNG\r\n\x1a\n"+c(b"IHDR",struct.pack(">IIBBBBB",2,2,8,2,0,0,0))+c(b"IDAT",zlib.compress(b"\0"+b"\xff\0\0"*2+b"\0"+b"\0\xff\0"*2))+c(b"IEND",b""))' "$work/red pic.png" -launch() { - ELY_LOG=info ELY_LOCALE=en ELY_NO_DIALOG=1 HOME="$work" \ - "$root/target/debug/ely-mark" "$@" >"$work/out" 2>"$work/err" & - app=$! -} -waits() { - for _ in $(seq 120); do - grep -qF "$1" "$work/err" && return 0 - kill -0 "$app" 2>/dev/null || fail "ElyMark quit: $(tail -n 5 "$work/err")" - sleep 0.25 - done - fail "never saw '$1': $(grep -E 'mark|office' "$work/err" | tail -n 5)" -} -key() { osascript -e "tell application \"System Events\" to $1" -e 'delay 0.8' >/dev/null; } -press() { - for _ in $(seq 20); do - [ "$("$work/ax-press" "$app" "$1")" = pressed ] && { sleep 0.8; return 0; } - sleep 0.25 - done - fail "could not press $1" -} -# Clicks the find box's `$1`th text field, top first. -field() { - local found - found=$("$work/ax-values" "$app" | awk -F'\t' '$1 == "AXTextField" { sub("at=", "", $5); print $5 }' | sort -t, -k2 -n | sed -n "$1p") - [ -n "$found" ] || fail "no find field $1" - IFS=, read -r x y <<<"$found" - "$work/click" "$x" "$y" - sleep 0.5 -} -saves() { - local before - before=$(grep -c "office: saved" "$work/err" || true) - key 'keystroke "s" using command down' - for _ in $(seq 40); do - [ "$(grep -c "office: saved" "$work/err" || true)" -gt "$before" ] && return 0 - sleep 0.25 - done - fail "no save" -} -is() { [ "$(cat "$work/notes/t.md")" = "$1" ] || fail "$2: $(cat "$work/notes/t.md")"; } - -launch "$work/notes/t.md" -waits 'mark: shown as "visual"' -[ "$("$work/ax-press" $$ none)" != untrusted ] || fail "needs Accessibility permission" -osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $app) to true" -e 'delay 2' >/dev/null -# Replace all in the visual editor. -key 'keystroke "f" using command down' -key 'keystroke "alpha"' -press "Toggle replace" -field 2 -key 'keystroke "omega"' -press "Replace all" -saves -is $'omega beta omega\n\ngamma omega' "replace all" -# A regular expression in the source editor. -press Source -waits 'mark: shown as "source"' -field 1 -key 'keystroke "a" using command down' -key 'keystroke "g.mma"' -press "Regular expression" -field 2 -key 'keystroke "a" using command down' -key 'keystroke "delta"' -press Replace -saves -is $'omega beta omega\n\ndelta omega' "regex replace" -# Tab indents the selected line; Esc closes the box first. -field 1 -key 'key code 53' -key 'key code 48' -saves -grep -qx '[[:space:]]\{1,\}delta omega' "$work/notes/t.md" || fail "tab: $(cat "$work/notes/t.md")" -key 'key code 48 using shift down' -saves -is $'omega beta omega\n\ndelta omega' "shift-tab" -# A list item continues on Return. -key 'key code 125 using command down' -key 'key code 36' -key 'keystroke "- one"' -key 'key code 36' -key 'keystroke "two"' -saves -grep -qx -- '- two' "$work/notes/t.md" || fail "list: $(cat "$work/notes/t.md")" -# Tables: insert, then a column and a row. -press Visual -waits 'mark: shown as "visual"' -# Closing the find box hands the keys to the page. -key 'keystroke "f" using command down' -key 'key code 53' -# Command arrows move though AppKit sends no edit command. -key 'key code 125 using command down' -press "Insert Table" -press "Add Column Right" -press "Add Row Below" -saves -header=$(grep -m1 '^ *|' "$work/notes/t.md" || true) -[ "$(tr -cd '|' <<<"$header" | wc -c | tr -d ' ')" = 5 ] || fail "table columns: $(cat "$work/notes/t.md")" -[ "$(grep -c '^ *|' "$work/notes/t.md")" = 5 ] || fail "table rows: $(cat "$work/notes/t.md")" -[ "$(head -n 1 "$work/notes/t.md")" = "omega beta omega" ] || fail "table not at the end: $(cat "$work/notes/t.md")" -press "Delete Column" -saves -header=$(grep -m1 '^ *|' "$work/notes/t.md" || true) -[ "$(tr -cd '|' <<<"$header" | wc -c | tr -d ' ')" = 4 ] || fail "delete column: $(cat "$work/notes/t.md")" -# A dropped image is copied beside the file, name escaped. -window=$("$work/ax-values" "$app" | awk -F'\t' '$1 == "AXWindow" { sub("at=", "", $5); print $5; exit }') -IFS=, read -r x y <<<"$window" -[ "$("$work/drop" "$work/red pic.png" "$x" "$y")" = dropped ] || fail "the drop was refused" -waits "mark: attached 1 images" -saves -cmp -s "$work/red pic.png" "$work/notes/assets/red pic.png" || fail "dropped copy: $(ls -R "$work/notes")" -grep -qF '](assets/red%20pic.png)' "$work/notes/t.md" || fail "drop link: $(cat "$work/notes/t.md")" -# A pasted image lands in assets beside the file. -if [ "${ELY_CLIPBOARD_TEST:-}" = 1 ]; then - osascript -e "set the clipboard to (read (POSIX file \"$work/red pic.png\") as «class PNGf»)" - key 'key code 125 using command down' - key 'key code 36' - key 'keystroke "v" using command down' - saves - png=$(ls "$work/notes/assets"/pasted-*.png 2>/dev/null | head -n 1) - [ -n "$png" ] || fail "no pasted image: $(ls -R "$work/notes")" - grep -qF "](assets/$(basename "$png"))" "$work/notes/t.md" || fail "image link: $(cat "$work/notes/t.md")" -fi -# Saved elsewhere mid-copy: no link to nowhere. -mkfifo "$work/slow.png" -mkdir "$work/elsewhere" -window=$("$work/ax-values" "$app" | awk -F'\t' '$1 == "AXWindow" { sub("at=", "", $5); print $5; exit }') -IFS=, read -r x y <<<"$window" -[ "$("$work/drop" "$work/slow.png" "$x" "$y")" = dropped ] || fail "the slow drop was refused" -press "Save As…" -key 'keystroke "g" using {command down, shift down}' -key "keystroke \"$work/elsewhere\"" -key 'key code 36' -key 'key code 36' -waits "elsewhere/t.md" -(printf 'x' >"$work/slow.png" &) -waits "the file moved while images copied" -! grep -q "slow" "$work/elsewhere/t.md" || fail "a link to the old folder: $(cat "$work/elsewhere/t.md")" -kill "$app" -wait "$app" 2>/dev/null || true -app= -! grep -q "panicked" "$work/err" || fail "panics: $(grep -m3 panicked "$work/err")" -# Two tabs; typing a replacement keeps the hit. -printf 'alpha beta alpha beta alpha\n' >"$work/notes/v.md" -printf 'alpha beta\n' >"$work/notes/w.md" -cp "$work/red pic.png" "$work/a]b.png" -launch "$work/notes/v.md" "$work/notes/w.md" -waits 'mark: shown as "visual"' -osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $app) to true" -e 'delay 2' >/dev/null -press v.md -sleep 1 -key 'keystroke "f" using command down' -key 'keystroke "alpha"' -press "Toggle replace" -field 2 -key 'keystroke "XY"' -press Replace -saves -[ "$(cat "$work/notes/v.md")" = "XY beta alpha beta alpha" ] || fail "the hit moved: $(cat "$work/notes/v.md")" -# Each tab follows the box, not its last query. -press w.md -field 1 -key 'keystroke "a" using command down' -key 'keystroke "beta"' -field 2 -key 'keystroke "a" using command down' -key 'keystroke "Y"' -press v.md -field 1 -key 'keystroke "a" using command down' -key 'keystroke "alpha"' -field 2 -key 'keystroke "a" using command down' -key 'keystroke "Z"' -press w.md -press "Replace all" -saves -[ "$(cat "$work/notes/w.md")" = "Z beta" ] || fail "a stale query: $(cat "$work/notes/w.md")" -# Alt text in source closes no brackets. -press Source -waits 'mark: shown as "source"' -window=$("$work/ax-values" "$app" | awk -F'\t' '$1 == "AXWindow" { sub("at=", "", $5); print $5; exit }') -IFS=, read -r x y <<<"$window" -[ "$("$work/drop" "$work/a]b.png" "$x" "$y")" = dropped ] || fail "the bracket drop was refused" -waits "mark: attached 1 images" -saves -grep -qF '![a\]b](assets/a%5Db.png)' "$work/notes/w.md" || fail "alt escape: $(cat "$work/notes/w.md")" -kill "$app" -wait "$app" 2>/dev/null || true -app= -! grep -q "panicked" "$work/err" || fail "panics: $(grep -m3 panicked "$work/err")" -echo "ok: mark tools"