ElyMark: the file in MarkdownDocument, no web page
The view holds the component library's MarkdownDocument: the file's text is the document's text, saved as read when unedited. Find, tables, attachments, the look and export, which ran in the page, go until their own tasks rebuild them natively. The window scripts that drove the page go with it; gpui window tests cover the view. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
540637cada
commit
1f2920020a
34 files changed
+342
-4144
No files matched your search
@@ -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 被拒,第一份的工作目录不动
|
||||
|
||||
@@ -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
|
||||
@@ -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<String> = (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<WebView>,
|
||||
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<u64>,
|
||||
}
|
||||
|
||||
impl Probe {
|
||||
/// Starts the current step; its check follows.
|
||||
fn begin(&mut self, cx: &mut Context<Self>) {
|
||||
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<Self>) {
|
||||
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<WebView>,
|
||||
event: &Event,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
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<Self>) -> impl IntoElement {
|
||||
div().size_full().child(self.view.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<ely_ui::Root> {
|
||||
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);
|
||||
}
|
||||
@@ -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<PathBuf> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let stamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_err(io::Error::other)?
|
||||
.as_millis();
|
||||
let mut n = 0;
|
||||
loop {
|
||||
let name = match n {
|
||||
0 => format!("pasted-{stamp}.png"),
|
||||
n => format!("pasted-{stamp}-{n}.png"),
|
||||
};
|
||||
let path = dir.join(name);
|
||||
match std::fs::File::create_new(&path) {
|
||||
Ok(mut file) => {
|
||||
file.write_all(png)?;
|
||||
file.flush()?;
|
||||
return Ok(path);
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => n += 1,
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The paste hook: an image on the clipboard is attached.
|
||||
pub fn paste(view: WeakEntity<MarkView>) -> impl Fn(&mut Window, &mut App) -> bool {
|
||||
move |_, cx| match clipboard::read() {
|
||||
Ok(Some(Clip::Png(png))) => {
|
||||
view.update(cx, |view, cx| view.attach(Attach::Png(png), cx))
|
||||
.ok();
|
||||
true
|
||||
}
|
||||
Ok(_) => false,
|
||||
Err(error) => {
|
||||
log::error!("mark: the clipboard did not read: {error:#}");
|
||||
let why = format!("{error:#}");
|
||||
view.update(cx, |_, cx| cx.emit(Failed(why))).ok();
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum Attach {
|
||||
Files(Vec<PathBuf>),
|
||||
Png(Vec<u8>),
|
||||
}
|
||||
|
||||
impl MarkView {
|
||||
/// Copies into the file's folder, then links at the caret.
|
||||
pub fn attach(&mut self, what: Attach, cx: &mut Context<Self>) {
|
||||
let refused = match (&what, self.attachable, self.mode) {
|
||||
(_, false, _) => Some("mark.attach.untitled"),
|
||||
(_, _, crate::page::Mode::Preview) => Some("mark.attach.preview"),
|
||||
(Attach::Files(files), ..) if !files.iter().all(|file| image(file)) => {
|
||||
Some("mark.attach.images")
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(key) = refused {
|
||||
return cx.emit(Failed(text(cx, key, &[]).to_string()));
|
||||
}
|
||||
let folder = self.folder.clone();
|
||||
let dir = folder.join(FOLDER);
|
||||
cx.spawn(async move |view, cx| {
|
||||
let copied = cx
|
||||
.background_executor()
|
||||
.spawn(async move {
|
||||
match what {
|
||||
Attach::Files(files) => files
|
||||
.iter()
|
||||
.map(|file| ely_files::import_attachment(file, &dir))
|
||||
.collect(),
|
||||
Attach::Png(png) => save_png(&dir, &png).map(|path| vec![path]),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
view.update(cx, |view, cx| view.attached(&folder, copied, cx))
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn attached(
|
||||
&mut self,
|
||||
folder: &Path,
|
||||
copied: io::Result<Vec<PathBuf>>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let copied = match copied {
|
||||
Ok(copied) => copied,
|
||||
Err(error) => {
|
||||
log::error!("mark: an image did not copy: {error}");
|
||||
return cx.emit(Failed(error.to_string()));
|
||||
}
|
||||
};
|
||||
// Links resolve from the file's folder; it moved meanwhile.
|
||||
if self.folder != folder {
|
||||
log::warn!("mark: the file moved while images copied; no links");
|
||||
let at = folder.join(FOLDER).display().to_string();
|
||||
return cx.emit(Failed(
|
||||
text(cx, "mark.attach.moved", &[("folder", &at)]).to_string(),
|
||||
));
|
||||
}
|
||||
log::info!("mark: attached {} images", copied.len());
|
||||
let images: Vec<_> = copied
|
||||
.iter()
|
||||
.filter_map(|path| {
|
||||
let name = path.file_name()?.to_string_lossy().into_owned();
|
||||
let alt = path.file_stem()?.to_string_lossy().into_owned();
|
||||
Some(json!({ "src": link(&name), "alt": alt }))
|
||||
})
|
||||
.collect();
|
||||
crate::page::call(self.web.read(cx), "insert", &json!(images));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn links_escape_and_names_never_repeat() {
|
||||
assert_eq!(super::link("a b#1.png"), "assets/a%20b%231.png");
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let first = super::save_png(dir.path(), b"x").expect("saves");
|
||||
let second = super::save_png(dir.path(), b"y").expect("saves");
|
||||
assert_ne!(first, second);
|
||||
assert_eq!(std::fs::read(first).expect("reads"), b"x");
|
||||
}
|
||||
}
|
||||
@@ -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<Self>) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<Self>) {
|
||||
// The page's newest text first.
|
||||
if !self.settled(id, cx) {
|
||||
return self.when_settled(id, move |desk, _, cx| desk.compare(id, cx));
|
||||
}
|
||||
let Some(tab) = self.tab(id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
+34
-57
@@ -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<Desk>,
|
||||
@@ -40,22 +36,16 @@ pub struct Desk {
|
||||
/// Files and folders opened lately, newest first.
|
||||
pub recent: Vec<PathBuf>,
|
||||
pub recent_file: PathBuf,
|
||||
/// A file asked to open at a place.
|
||||
pub going: Option<(PathBuf, serde_json::Value)>,
|
||||
/// A file asked to open at a line.
|
||||
pub going: Option<(PathBuf, usize)>,
|
||||
/// The path each tab last put in the recent list.
|
||||
pub remembered: HashMap<u64, PathBuf>,
|
||||
side: Entity<crate::side::Side>,
|
||||
pub finder: Option<crate::finding::Finder>,
|
||||
pub watcher: Option<notify::RecommendedWatcher>,
|
||||
/// The folders the watcher covers.
|
||||
pub watched: Vec<PathBuf>,
|
||||
pub comparing: Option<crate::compare::Comparing>,
|
||||
pub keeping: crate::restore::Keeping,
|
||||
/// The export dialog's choice, while it is open.
|
||||
pub output: Option<crate::export::Output>,
|
||||
/// Pages printing to PDF, by job; several may run.
|
||||
pub printing: HashMap<u64, crate::export::Printing>,
|
||||
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<MarkView>, cx: &mut Context<Self>) {
|
||||
self.watch_folders(cx);
|
||||
let look = serde_json::to_value(&self.look).expect("a look serializes");
|
||||
view.update(cx, |view, cx| view.set_look(look, cx));
|
||||
let changed = view.read(cx).changed();
|
||||
self.autosave(id, changed, cx);
|
||||
let recheck = self.tab_mut(id).is_some_and(|tab| {
|
||||
@@ -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<Root> {
|
||||
let fail = |error: String| -> ! { startup::fail(app, &error) };
|
||||
let data = ely_platform::data_dir(app).unwrap_or_else(|error| fail(format!("{error:#}")));
|
||||
if let Err(error) = ely_web_content::start(&data, cx) {
|
||||
fail(format!("{error:#}"));
|
||||
}
|
||||
let dir = crate::page::content().unwrap_or_else(|error| fail(format!("{error:#}")));
|
||||
let work = ely_office::fresh_work(&data).unwrap_or_else(|error| fail(format!("{error}")));
|
||||
crate::keys::bind(cx);
|
||||
let pages = Arc::new(Mutex::new(Pages { dir }));
|
||||
let recent_file = data.join("recent.json");
|
||||
let look_file = data.join("look.json");
|
||||
// An unreadable look is reported; the default shows.
|
||||
@@ -187,7 +167,7 @@ pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
|
||||
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<Root> {
|
||||
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<Self>) {
|
||||
fn switch(&mut self, mode: Mode, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(view) = self.active_view() {
|
||||
view.update(cx, |view, cx| view.table(op, cx));
|
||||
view.update(cx, |view, cx| view.switch(mode, window, cx));
|
||||
}
|
||||
}
|
||||
|
||||
fn switch(&mut self, mode: Mode, cx: &mut Context<Self>) {
|
||||
if let Some(view) = self.active_view() {
|
||||
view.update(cx, |view, cx| view.switch(mode, cx));
|
||||
}
|
||||
/// Brings `line` into view from outside a window update.
|
||||
pub fn reveal_later(&mut self, view: Entity<MarkView>, line: usize, cx: &mut Context<Self>) {
|
||||
cx.spawn(async move |desk, cx| {
|
||||
let shown = ely_office::in_window(&desk, cx, |_, window, cx| {
|
||||
view.update(cx, |view, cx| view.reveal(line, window, cx))
|
||||
});
|
||||
if let Err(error) = shown {
|
||||
log::warn!("mark: line {line} was not shown: {error:#}");
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn status(&self, cx: &mut Context<Self>) -> StatusBar {
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+32
-43
@@ -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<String>,
|
||||
}
|
||||
|
||||
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<Mutex<Text>>;
|
||||
|
||||
/// 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<Infallible>) -> io::Result<Opened> {
|
||||
fn load(_: &mut (), from: Load<Infallible>) -> io::Result<Opened> {
|
||||
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<Mutex<Pages>>,
|
||||
_: &Arc<Mutex<()>>,
|
||||
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<String> {
|
||||
fn history(_: &mut (), doc: &Doc) -> io::Result<String> {
|
||||
let mut text = doc.lock().expect("the text lock");
|
||||
Ok(text.written.take().unwrap_or_else(|| mark(&text.current)))
|
||||
}
|
||||
|
||||
fn write(_: &mut Pages, doc: &Doc, path: &Path, format: &str) -> io::Result<()> {
|
||||
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<MarkView>, 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<MarkView>, _: &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")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
diagrams: Vec<u64>,
|
||||
/// Each broken formula's line and KaTeX's message.
|
||||
formulas: Vec<(u64, String)>,
|
||||
}
|
||||
|
||||
impl Lost {
|
||||
pub fn of(images: Vec<String>, rendered: &Value) -> Lost {
|
||||
let diagrams = rendered["failed"].as_array().into_iter().flatten();
|
||||
let formulas = rendered["formulas"].as_array().into_iter().flatten();
|
||||
Lost {
|
||||
images,
|
||||
diagrams: diagrams.filter_map(Value::as_u64).collect(),
|
||||
formulas: formulas
|
||||
.filter_map(|f| Some((f["line"].as_u64()?, f["error"].as_str()?.to_owned())))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn whole(&self) -> bool {
|
||||
self.images.is_empty() && self.diagrams.is_empty() && self.formulas.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// No export lands on a file a tab holds (FILE-06).
|
||||
fn not_open(desk: &Desk, path: &Path) -> io::Result<()> {
|
||||
match desk.holder(path)? {
|
||||
Some(_) => Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
format!("{} is open in ElyMark", file_name(path)),
|
||||
)),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A page printing to PDF in a view nobody sees.
|
||||
pub struct Printing {
|
||||
_view: Entity<WebView>,
|
||||
_heard: Subscription,
|
||||
}
|
||||
|
||||
impl Desk {
|
||||
pub fn on_export(&mut self, _: &commands::Export, _: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.active_tab().is_some() {
|
||||
self.output = Some(Output::Page);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_print(&mut self, _: &commands::Print, _: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(id) = self.active_tab().map(|tab| tab.id) else {
|
||||
return;
|
||||
};
|
||||
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<Self>) {
|
||||
let Some(tab) = self.active_tab() else {
|
||||
return;
|
||||
};
|
||||
let id = tab.id;
|
||||
let stem = tab
|
||||
.name
|
||||
.rsplit_once('.')
|
||||
.map_or(tab.name.as_ref(), |(stem, _)| stem)
|
||||
.to_owned();
|
||||
let dir = ely_office::folder(tab).unwrap_or_else(std::env::temp_dir);
|
||||
let chosen = cx.prompt_for_new_path(&dir, Some(&format!("{stem}.{}", output.extension())));
|
||||
cx.spawn(async move |desk, cx| {
|
||||
let 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<Self>) {
|
||||
let Some(tab) = self.tab(id) else {
|
||||
return;
|
||||
};
|
||||
let (view, folder) = (tab.view.clone(), tab.view.read(cx).folder.clone());
|
||||
let open = tab.session.path().map(Path::to_path_buf);
|
||||
let style = self.pages_dir().join("export.css");
|
||||
let kind = if output == Output::Text {
|
||||
"text"
|
||||
} else {
|
||||
"html"
|
||||
};
|
||||
let desk = cx.weak_entity();
|
||||
view.update(cx, |view, cx| {
|
||||
view.render(
|
||||
kind,
|
||||
move |rendered, cx: &mut App| {
|
||||
// Checked again: tabs may have opened meanwhile.
|
||||
let free = desk.update(cx, |desk, _| not_open(desk, &path));
|
||||
let free = free.map_err(io::Error::other).and_then(|free| free);
|
||||
let job = cx.background_spawn(async move {
|
||||
free?;
|
||||
let rendered = rendered.map_err(io::Error::other)?;
|
||||
write(output, &rendered, &folder, &style, &path, open.as_deref())
|
||||
});
|
||||
cx.spawn(async move |cx| {
|
||||
let written = job.await;
|
||||
desk.update(cx, |desk, cx| desk.exported(written, cx)).ok();
|
||||
})
|
||||
.detach();
|
||||
},
|
||||
cx,
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
fn exported(&mut self, written: io::Result<(PathBuf, Lost)>, cx: &mut Context<Self>) {
|
||||
match written {
|
||||
Ok((path, lost)) if lost.whole() => {
|
||||
log::info!("mark: exported {}", path.display());
|
||||
let said = text(cx, "mark.export.done", &[("name", &file_name(&path))]);
|
||||
self.toast_output(said, path, cx);
|
||||
}
|
||||
Ok((path, lost)) => {
|
||||
// Counts only: image names come from the text.
|
||||
log::warn!(
|
||||
"mark: exported {} without {} images and {} diagrams; formulas failed at lines {:?}",
|
||||
path.display(),
|
||||
lost.images.len(),
|
||||
lost.diagrams.len(),
|
||||
lost.formulas.iter().map(|(line, _)| line).collect::<Vec<_>>()
|
||||
);
|
||||
self.warn_lost(&lost, Some(path), cx);
|
||||
}
|
||||
Err(error) => {
|
||||
log::error!("mark: an export failed: {error}");
|
||||
let said = text(cx, "mark.export.failed", &[("error", &error.to_string())]);
|
||||
self.toast(said, Severity::Danger, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Says which images, diagrams and formulas an output lacks.
|
||||
fn warn_lost(&mut self, lost: &Lost, output: Option<PathBuf>, cx: &mut Context<Self>) {
|
||||
let mut said = Vec::new();
|
||||
if !lost.images.is_empty() {
|
||||
said.push(text(
|
||||
cx,
|
||||
"mark.export.missing",
|
||||
&[("list", &lost.images.join(", "))],
|
||||
));
|
||||
}
|
||||
if !lost.diagrams.is_empty() {
|
||||
said.push(text(
|
||||
cx,
|
||||
"mark.export.diagrams",
|
||||
&[(
|
||||
"list",
|
||||
&lost
|
||||
.diagrams
|
||||
.iter()
|
||||
.map(u64::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
)],
|
||||
));
|
||||
}
|
||||
for (line, error) in &lost.formulas {
|
||||
let line = line.to_string();
|
||||
said.push(text(
|
||||
cx,
|
||||
"mark.export.formula",
|
||||
&[("line", &line), ("error", error)],
|
||||
));
|
||||
}
|
||||
// Names and messages quote the text: no log.
|
||||
let said: gpui::SharedString = said.join(" ").into();
|
||||
match (output, self.core.root.upgrade()) {
|
||||
(Some(path), Some(root)) => root.update(cx, |root, cx| root.toast_output(said, Severity::Warning, path, cx)),
|
||||
_ => self.toast_unlogged(said, Severity::Warning, cx),
|
||||
}
|
||||
}
|
||||
|
||||
fn pages_dir(&self) -> PathBuf {
|
||||
self.core.worker.lock().expect("the pages lock").dir.clone()
|
||||
}
|
||||
|
||||
/// The whole text as PDF; then to `to`, else printed.
|
||||
fn render_pdf(&mut self, id: u64, to: Option<PathBuf>, cx: &mut Context<Self>) {
|
||||
// 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<Self>) {
|
||||
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<PathBuf>, job: Job, cx: &mut Context<Self>) {
|
||||
let pdf = match pdf {
|
||||
Ok(pdf) => pdf,
|
||||
Err(error) => return self.exported(Err(error), cx),
|
||||
};
|
||||
let Some(to) = job.to else {
|
||||
if !job.lost.whole() {
|
||||
self.warn_lost(&job.lost, None, cx);
|
||||
}
|
||||
return ely_office::print_later(pdf, cx);
|
||||
};
|
||||
// Never over the open file, not even through a link.
|
||||
let moved = not_open(self, &to)
|
||||
.and_then(|()| std::fs::read(&pdf))
|
||||
.and_then(|bytes| ely_files::export(&to, job.open.as_deref(), &bytes, 0).map(|_| ()));
|
||||
let _ = std::fs::remove_file(&pdf);
|
||||
self.exported(moved.map(|()| (to, job.lost)), cx);
|
||||
}
|
||||
|
||||
pub fn export_dialog(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
|
||||
let chosen = self.output?;
|
||||
let this = cx.entity();
|
||||
let kinds = Output::ALL.into_iter().fold(
|
||||
SegmentedControl::new("mark-export-kind", chosen.key()),
|
||||
|control, output| {
|
||||
control.segment(
|
||||
output.key(),
|
||||
text(cx, &format!("mark.export.{}", output.key()), &[]),
|
||||
None,
|
||||
)
|
||||
},
|
||||
);
|
||||
let kinds = kinds.on_change({
|
||||
let this = this.clone();
|
||||
move |key, _, cx| {
|
||||
let picked = Output::ALL
|
||||
.into_iter()
|
||||
.find(|output| key.as_ref() == output.key());
|
||||
this.update(cx, |desk, cx| {
|
||||
desk.output = picked;
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
});
|
||||
let close = {
|
||||
let this = this.clone();
|
||||
move |_: &mut Window, cx: &mut App| {
|
||||
this.update(cx, |desk, cx| {
|
||||
desk.output = None;
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
};
|
||||
let label = text(cx, "mark.export.go", &[]);
|
||||
let dialog = Dialog::new("mark-export", text(cx, "mark.export", &[]), close)
|
||||
.child(kinds)
|
||||
.action(cancel(cx))
|
||||
.action(move |close| {
|
||||
Button::new("mark-export-go", label)
|
||||
.variant(ButtonVariant::Primary)
|
||||
.on_click(move |_, window, cx| {
|
||||
close(window, cx);
|
||||
this.update(cx, |desk, cx| desk.export_to(chosen, cx));
|
||||
})
|
||||
});
|
||||
Some(dialog.into_any_element())
|
||||
}
|
||||
}
|
||||
|
||||
/// A PDF in flight: where printed, where bound, what lacks.
|
||||
#[derive(Clone)]
|
||||
struct Job {
|
||||
pdf: PathBuf,
|
||||
to: Option<PathBuf>,
|
||||
/// The tab's own file, never overwritten.
|
||||
open: Option<PathBuf>,
|
||||
lost: Lost,
|
||||
}
|
||||
|
||||
/// Writes `output` at `path`; and what it left out.
|
||||
fn write(
|
||||
output: Output,
|
||||
rendered: &Value,
|
||||
folder: &Path,
|
||||
style: &Path,
|
||||
path: &Path,
|
||||
open: Option<&Path>,
|
||||
) -> io::Result<(PathBuf, Lost)> {
|
||||
let title = path
|
||||
.file_stem()
|
||||
.map(|stem| stem.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let (bytes, missing) = match output {
|
||||
Output::Text => (
|
||||
rendered["text"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.as_bytes()
|
||||
.to_vec(),
|
||||
Vec::new(),
|
||||
),
|
||||
Output::Page => {
|
||||
let (html, missing) = paper::Marked::read(rendered)?.inline(folder);
|
||||
let style = format!("<style>{}</style>", std::fs::read_to_string(style)?);
|
||||
(paper::page(&title, &style, &html).into_bytes(), missing)
|
||||
}
|
||||
Output::Linked => {
|
||||
let assets = format!("{title}_files");
|
||||
let place = path.with_file_name(&assets);
|
||||
let (html, copies, missing) = paper::Marked::read(rendered)?.linked(folder, &assets);
|
||||
// A folder already there is never written into.
|
||||
std::fs::create_dir(&place)?;
|
||||
let copied = copies
|
||||
.iter()
|
||||
.try_for_each(|(from, name)| std::fs::copy(from, place.join(name)).map(|_| ()))
|
||||
.and_then(|()| std::fs::copy(style, place.join("export.css")).map(|_| ()));
|
||||
if let Err(error) = copied {
|
||||
let _ = std::fs::remove_dir_all(&place);
|
||||
return Err(error);
|
||||
}
|
||||
let style = format!(
|
||||
"<link rel=\"stylesheet\" href=\"{}/export.css\">",
|
||||
paper::segment(&assets)
|
||||
);
|
||||
(paper::page(&title, &style, &html).into_bytes(), missing)
|
||||
}
|
||||
Output::Pdf => unreachable!("PDF prints in a page"),
|
||||
};
|
||||
if let Err(error) = ely_files::export(path, open, &bytes, 0) {
|
||||
if output == Output::Linked {
|
||||
let _ = std::fs::remove_dir_all(path.with_file_name(format!("{title}_files")));
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
Ok((path.to_path_buf(), Lost::of(missing, rendered)))
|
||||
}
|
||||
@@ -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<usize>,
|
||||
pub total: usize,
|
||||
/// A pattern that does not compile.
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
pub struct Finder {
|
||||
query: Entity<TextInput>,
|
||||
with: Entity<TextInput>,
|
||||
options: FindOptions,
|
||||
replacing: bool,
|
||||
/// The tab last searched; another one is searched anew.
|
||||
tab: Option<u64>,
|
||||
_follow: Subscription,
|
||||
}
|
||||
|
||||
impl Desk {
|
||||
/// Opens the find box, or takes focus to it.
|
||||
pub fn on_find(&mut self, _: &Find, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(finder) = &self.finder {
|
||||
let focus = finder.query.focus_handle(cx);
|
||||
return window.focus(&focus, cx);
|
||||
}
|
||||
let query = cx.new(|cx| TextInput::new(window, cx));
|
||||
let with = cx.new(|cx| TextInput::new(window, cx));
|
||||
cx.subscribe(&query, |desk, _, event: &InputEvent, cx| match event {
|
||||
InputEvent::Changed => desk.seek(cx),
|
||||
InputEvent::Submit => desk.step(true, cx),
|
||||
_ => {}
|
||||
})
|
||||
.detach();
|
||||
cx.subscribe(&with, |desk, _, event: &InputEvent, cx| match event {
|
||||
InputEvent::Changed => desk.seek(cx),
|
||||
InputEvent::Submit => desk.replace(false, cx),
|
||||
_ => {}
|
||||
})
|
||||
.detach();
|
||||
let focus = query.focus_handle(cx);
|
||||
let follow = cx.observe_self(|desk, cx| {
|
||||
let shown = desk.active_tab().map(|tab| tab.id);
|
||||
if desk
|
||||
.finder
|
||||
.as_ref()
|
||||
.is_some_and(|finder| finder.tab != shown)
|
||||
{
|
||||
desk.seek(cx);
|
||||
}
|
||||
});
|
||||
self.finder = Some(Finder {
|
||||
query,
|
||||
with,
|
||||
options: FindOptions::default(),
|
||||
replacing: false,
|
||||
tab: None,
|
||||
_follow: follow,
|
||||
});
|
||||
window.focus(&focus, cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The query and options as the page's search spec.
|
||||
fn spec(&self, cx: &Context<Self>) -> Option<serde_json::Value> {
|
||||
let finder = self.finder.as_ref()?;
|
||||
Some(json!({
|
||||
"search": finder.query.read(cx).text(),
|
||||
"replace": finder.with.read(cx).text(),
|
||||
"caseSensitive": finder.options.case,
|
||||
"regexp": finder.options.regex,
|
||||
"wholeWord": finder.options.word,
|
||||
}))
|
||||
}
|
||||
|
||||
fn seek(&mut self, cx: &mut Context<Self>) {
|
||||
let shown = self.active_tab().map(|tab| tab.id);
|
||||
if let Some(finder) = self.finder.as_mut() {
|
||||
finder.tab = shown;
|
||||
}
|
||||
if let (Some(spec), Some(view)) = (self.spec(cx), self.active_view()) {
|
||||
view.update(cx, |view, cx| view.find(spec, cx));
|
||||
}
|
||||
}
|
||||
|
||||
fn step(&mut self, forward: bool, cx: &mut Context<Self>) {
|
||||
if let (Some(spec), Some(view)) = (self.spec(cx), self.active_view()) {
|
||||
view.update(cx, |view, cx| view.step(spec, forward, cx));
|
||||
}
|
||||
}
|
||||
|
||||
fn replace(&mut self, all: bool, cx: &mut Context<Self>) {
|
||||
if let (Some(spec), Some(view)) = (self.spec(cx), self.active_view()) {
|
||||
view.update(cx, |view, cx| view.replace(spec, all, cx));
|
||||
}
|
||||
}
|
||||
|
||||
fn close_find(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.finder = None;
|
||||
// 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<Self>) -> Option<AnyElement> {
|
||||
let finder = self.finder.as_ref()?;
|
||||
let found = self
|
||||
.active_view()
|
||||
.and_then(|view| view.read(cx).found.clone());
|
||||
let (current, total) = found
|
||||
.as_ref()
|
||||
.map_or((None, 0), |found| (found.current, found.total));
|
||||
let this = cx.entity();
|
||||
let widget = FindWidget::new("mark-find", &finder.query, current, total)
|
||||
.options(finder.options)
|
||||
.on_options({
|
||||
let this = this.clone();
|
||||
move |options, _, cx| {
|
||||
this.update(cx, |desk, cx| {
|
||||
if let Some(finder) = desk.finder.as_mut() {
|
||||
finder.options = options;
|
||||
}
|
||||
desk.seek(cx);
|
||||
})
|
||||
}
|
||||
})
|
||||
.on_step({
|
||||
let this = this.clone();
|
||||
move |forward, _, cx| this.update(cx, |desk, cx| desk.step(forward, cx))
|
||||
})
|
||||
.on_replace({
|
||||
let this = this.clone();
|
||||
move |all, _, cx| this.update(cx, |desk, cx| desk.replace(all, cx))
|
||||
})
|
||||
.on_toggle_replace({
|
||||
let this = this.clone();
|
||||
move |open, _, cx| {
|
||||
this.update(cx, |desk, cx| {
|
||||
if let Some(finder) = desk.finder.as_mut() {
|
||||
finder.replacing = open;
|
||||
}
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
})
|
||||
.on_close(move |window, cx| this.update(cx, |desk, cx| desk.close_find(window, cx)));
|
||||
let widget = match finder.replacing {
|
||||
true => widget.replace(&finder.with),
|
||||
false => widget,
|
||||
};
|
||||
let widget = match found.and_then(|found| found.error) {
|
||||
Some(error) => widget.error(error),
|
||||
None => widget,
|
||||
};
|
||||
// Over the page's top right, as elsewhere.
|
||||
let place = div()
|
||||
.absolute()
|
||||
.top_2()
|
||||
.right_4()
|
||||
.block_mouse_except_scroll()
|
||||
.child(widget);
|
||||
Some(raise("mark-find-layer", place).into_any_element())
|
||||
}
|
||||
}
|
||||
@@ -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<Menu> {
|
||||
disabled: false,
|
||||
};
|
||||
let item = |key: &str, action: Box<dyn gpui::Action>| checked(key, action, false);
|
||||
let table = Menu::new(text(cx, "mark.menu.table", &[])).items([
|
||||
item("mark.table.insert", Box::new(InsertTable)),
|
||||
MenuItem::separator(),
|
||||
item("mark.table.row_above", Box::new(RowAbove)),
|
||||
item("mark.table.row_below", Box::new(RowBelow)),
|
||||
item("mark.table.column_left", Box::new(ColumnLeft)),
|
||||
item("mark.table.column_right", Box::new(ColumnRight)),
|
||||
MenuItem::separator(),
|
||||
item("mark.table.delete_row", Box::new(DeleteRow)),
|
||||
item("mark.table.delete_column", Box::new(DeleteColumn)),
|
||||
]);
|
||||
vec![
|
||||
table,
|
||||
Menu::new(text(cx, "menu.view", &[])).items([
|
||||
MenuItem::action(label(Mode::Visual, cx), VisualMode),
|
||||
MenuItem::action(label(Mode::Source, cx), SourceMode),
|
||||
|
||||
@@ -66,12 +66,11 @@ pub fn load(file: &Path) -> io::Result<Look> {
|
||||
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<Self>) {
|
||||
change(&mut self.look);
|
||||
self.look.size = self.look.size.clamp(SIZES.0, SIZES.1);
|
||||
log::info!("mark: the look is now {:?}", self.look);
|
||||
self.show_look(cx);
|
||||
let json = serde_json::to_vec_pretty(&self.look).expect("a look serializes");
|
||||
if let Err(error) = ely_files::save_atomic(&self.look_file, &json, 0) {
|
||||
log::error!("mark: the look did not save: {error}");
|
||||
@@ -86,15 +85,6 @@ impl Desk {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Every tab's page takes the look.
|
||||
pub fn show_look(&mut self, cx: &mut Context<Self>) {
|
||||
let look = serde_json::to_value(&self.look).expect("a look serializes");
|
||||
for tab in &self.core.tabs {
|
||||
let look = look.clone();
|
||||
tab.view.update(cx, |view, cx| view.set_look(look, cx));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn look_actions(element: gpui::Div, cx: &mut Context<Self>) -> gpui::Div {
|
||||
use gpui::InteractiveElement as _;
|
||||
let step = SIZES.2;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<u64>,
|
||||
@@ -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);
|
||||
|
||||
@@ -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<PathBuf> {
|
||||
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> {
|
||||
Mode::ALL.into_iter().find(|mode| mode.name() == name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows `markdown` as `mode` after load `load`, maybe placed.
|
||||
pub fn open(
|
||||
view: &WebView,
|
||||
markdown: &str,
|
||||
(mode, load, place): (Mode, u64, Option<crate::view::Place>),
|
||||
folder: &Path,
|
||||
cx: &App,
|
||||
) {
|
||||
let text = |key| ely_ui::i18n::text(cx, key, &[]).to_string();
|
||||
let texts = json!({
|
||||
"searchLanguage": text("mark.search_language"),
|
||||
"noResult": text("mark.no_result"),
|
||||
"copy": text("mark.copy"),
|
||||
"preview": text("mark.preview"),
|
||||
"loading": text("mark.loading"),
|
||||
"edit": text("mark.edit"),
|
||||
"hide": text("mark.hide"),
|
||||
});
|
||||
// Relative links and images resolve in the document's folder.
|
||||
let base = format!("{}/", ely_web_content::file_url(folder));
|
||||
view.run_js(&format!(
|
||||
"ely.open({}, '{}', {texts}, {load}, {}, {})",
|
||||
json!(markdown),
|
||||
mode.name(),
|
||||
json!(base),
|
||||
json!(place)
|
||||
));
|
||||
}
|
||||
|
||||
/// The 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.<name>(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<Vec<Heading>> {
|
||||
let heading = |item: &Value| {
|
||||
let level = item["level"]
|
||||
.as_u64()
|
||||
.filter(|level| (1..=6).contains(level))?;
|
||||
let title = item["title"].as_str()?.to_owned();
|
||||
Some(Heading {
|
||||
level: level as usize,
|
||||
title,
|
||||
})
|
||||
};
|
||||
items.as_array()?.iter().map(heading).collect()
|
||||
}
|
||||
@@ -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!(
|
||||
"<!doctype html>\n<html>\n<head>\n<meta charset=\"utf-8\">\n<title>{}</title>\n{style}\n</head>\n<body>\n{body}\n</body>\n</html>\n",
|
||||
escape(title)
|
||||
)
|
||||
}
|
||||
|
||||
fn escape(text: &str) -> String {
|
||||
text.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
/// `%XX` decoded, as links to files are written.
|
||||
fn decoded(text: &str) -> String {
|
||||
let bytes = text.as_bytes();
|
||||
let (mut out, mut at) = (Vec::with_capacity(bytes.len()), 0);
|
||||
while at < bytes.len() {
|
||||
let hex = bytes
|
||||
.get(at + 1..at + 3)
|
||||
.and_then(|pair| u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok());
|
||||
match (bytes[at], hex) {
|
||||
(b'%', Some(byte)) => (out.push(byte), at += 3),
|
||||
(byte, _) => (out.push(byte), at += 1),
|
||||
};
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
/// One path segment for a URL: reserved bytes percent-escaped.
|
||||
pub fn segment(name: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for byte in name.bytes() {
|
||||
match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
|
||||
out.push(byte as char)
|
||||
}
|
||||
_ => out.push_str(&format!("%{byte:02X}")),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A file image's path; web links stay as they are.
|
||||
fn local(src: &str, folder: &Path) -> Option<PathBuf> {
|
||||
if src.starts_with("data:") || src.starts_with('#') {
|
||||
return None;
|
||||
}
|
||||
if let Some(path) = src.strip_prefix("file://") {
|
||||
return Some(PathBuf::from(decoded(path)));
|
||||
}
|
||||
if src.contains("://") {
|
||||
return None;
|
||||
}
|
||||
let path = PathBuf::from(decoded(src.split(['?', '#']).next().unwrap_or(src)));
|
||||
Some(if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
folder.join(path)
|
||||
})
|
||||
}
|
||||
|
||||
fn kind(path: &Path) -> &'static str {
|
||||
let extension = path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or_default();
|
||||
match extension.to_lowercase().as_str() {
|
||||
"png" => "image/png",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"gif" => "image/gif",
|
||||
"webp" => "image/webp",
|
||||
"svg" => "image/svg+xml",
|
||||
"bmp" => "image/bmp",
|
||||
"avif" => "image/avif",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<std::io::Result<crate::export::Lost>> {
|
||||
background.spawn(async move {
|
||||
let (html, images) = Marked::read(&rendered)?.inline(&print.folder);
|
||||
let style = format!("<style>{}</style>", 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<String>,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
impl Marked {
|
||||
/// From the page's answer; anything missing is an error.
|
||||
pub fn read(rendered: &serde_json::Value) -> std::io::Result<Marked> {
|
||||
let bad = || std::io::Error::other("the page's render lacks its parts");
|
||||
let html = rendered["html"].as_str().ok_or_else(bad)?.to_owned();
|
||||
let token = rendered["token"].as_str().ok_or_else(bad)?.to_owned();
|
||||
let images = rendered["images"].as_array().ok_or_else(bad)?;
|
||||
let images = images
|
||||
.iter()
|
||||
.map(|src| src.as_str().map(str::to_owned).ok_or_else(bad));
|
||||
let images = images.collect::<std::io::Result<_>>()?;
|
||||
Ok(Marked {
|
||||
html,
|
||||
images,
|
||||
token,
|
||||
})
|
||||
}
|
||||
|
||||
/// Each mark swapped for `each` of its file; unread listed.
|
||||
fn rewrite(
|
||||
&self,
|
||||
folder: &Path,
|
||||
mut each: impl FnMut(&Path) -> Option<String>,
|
||||
) -> (String, Vec<String>) {
|
||||
let (mut html, mut missing) = (self.html.clone(), Vec::new());
|
||||
for (n, src) in self.images.iter().enumerate() {
|
||||
let new = match local(src, folder) {
|
||||
None => src.clone(),
|
||||
Some(path) => each(&path).unwrap_or_else(|| {
|
||||
missing.push(src.clone());
|
||||
src.clone()
|
||||
}),
|
||||
};
|
||||
// Quoted, so mark 1 never matches inside mark 10.
|
||||
let mark = format!("\"{}{n}\"", self.token);
|
||||
html = html.replacen(&mark, &format!("\"{}\"", escape(&new)), 1);
|
||||
}
|
||||
(html, missing)
|
||||
}
|
||||
|
||||
/// Images as data URLs: one file carries all (self-contained).
|
||||
pub fn inline(&self, folder: &Path) -> (String, Vec<String>) {
|
||||
self.rewrite(folder, |path| {
|
||||
let bytes = std::fs::read(path).ok()?;
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||
Some(format!("data:{};base64,{data}", kind(path)))
|
||||
})
|
||||
}
|
||||
|
||||
/// Images linked into the `assets` folder; and the copies.
|
||||
pub fn linked(
|
||||
&self,
|
||||
folder: &Path,
|
||||
assets: &str,
|
||||
) -> (String, Vec<(PathBuf, String)>, Vec<String>) {
|
||||
let mut copies: Vec<(PathBuf, String)> = Vec::new();
|
||||
let (html, missing) = self.rewrite(folder, |path| {
|
||||
if !path.is_file() {
|
||||
return None;
|
||||
}
|
||||
if let Some((_, name)) = copies.iter().find(|(from, _)| from == path) {
|
||||
return Some(format!("{}/{}", segment(assets), segment(name)));
|
||||
}
|
||||
let file = path.file_name()?.to_string_lossy().into_owned();
|
||||
// Same names from different folders stay apart.
|
||||
let name = format!("{}-{file}", copies.len() + 1);
|
||||
let url = format!("{}/{}", segment(assets), segment(&name));
|
||||
copies.push((path.to_path_buf(), name));
|
||||
Some(url)
|
||||
});
|
||||
(html, copies, missing)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// HTML as the page marks it.
|
||||
fn marked(images: &[&str]) -> Marked {
|
||||
let token = "ely-image-t-".to_owned();
|
||||
let html = (0..images.len())
|
||||
.map(|n| format!("<img alt=\"a > b src='x'\" src=\"{token}{n}\">"))
|
||||
.collect();
|
||||
Marked {
|
||||
html,
|
||||
images: images.iter().map(|src| src.to_string()).collect(),
|
||||
token,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_inline_and_links_stay() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
std::fs::create_dir(dir.path().join("assets")).expect("assets");
|
||||
std::fs::write(dir.path().join("assets/a b.png"), b"png").expect("an image");
|
||||
let page = marked(&["assets/a%20b.png", "https://e.x/y.png", "gone.png"]);
|
||||
let (out, missing) = page.inline(dir.path());
|
||||
assert!(out.contains(r#"src="data:image/png;base64,cG5n""#), "{out}");
|
||||
assert!(out.contains(r#"src="https://e.x/y.png""#));
|
||||
assert!(
|
||||
out.contains(r#"alt="a > b src='x'""#),
|
||||
"alt text stays: {out}"
|
||||
);
|
||||
assert_eq!(missing, vec!["gone.png".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_images_copy_once_under_encoded_names() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
std::fs::write(dir.path().join("a#b.png"), b"1").expect("an image");
|
||||
let mut page = marked(&["a%23b.png"; 11]);
|
||||
page.images[10] = "a%23b.png".into();
|
||||
let (out, copies, missing) = page.linked(dir.path(), "report#1_files");
|
||||
assert_eq!(
|
||||
out.matches("src=\"report%231_files/1-a%23b.png\"").count(),
|
||||
11,
|
||||
"{out}"
|
||||
);
|
||||
assert_eq!(copies.len(), 1);
|
||||
assert!(missing.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_lacking_its_parts_is_an_error() {
|
||||
assert!(Marked::read(&serde_json::json!({ "html": "<p>" })).is_err());
|
||||
}
|
||||
|
||||
#[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": "<p>hi</p><img src=\"T0\"><img src=\"T1\">",
|
||||
"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("<style>p {}</style>") && 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<b>&", "", "").contains("<title>a<b>&</title>"));
|
||||
}
|
||||
}
|
||||
@@ -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<u64>,
|
||||
file: PathBuf,
|
||||
place: serde_json::Value,
|
||||
line: usize,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let held = tab.or_else(|| self.tab_of(&file));
|
||||
if let Some(id) = held.filter(|id| self.tab(*id).is_some()) {
|
||||
self.core.active = Some(id);
|
||||
let view = self.tab(id).map(|open| open.view.clone());
|
||||
if let Some(view) = view {
|
||||
view.update(cx, |view, cx| view.reveal(place, cx));
|
||||
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<String> {
|
||||
Some(
|
||||
self.tab(id)?
|
||||
|
||||
@@ -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")];
|
||||
|
||||
+11
-12
@@ -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::<usize>() {
|
||||
view.update(cx, |view, cx| view.reveal(json!({ "heading": at }), cx));
|
||||
// Keys go to the document, at the heading.
|
||||
let focus = view.read(cx).focus.clone();
|
||||
window.focus(&focus, cx);
|
||||
}
|
||||
let Some(heading) = key.parse::<usize>().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::<usize>().ok().and_then(|at| hits.get(at)) else {
|
||||
return;
|
||||
};
|
||||
let (tab, file, line) = (*tab, hit.file.clone(), hit.line);
|
||||
let place = json!({ "line": line, "query": query });
|
||||
// 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)
|
||||
|
||||
@@ -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<R> {
|
||||
load: u64,
|
||||
/// The newest change the page announced.
|
||||
changed: u64,
|
||||
/// The change whose text arrived.
|
||||
reported: u64,
|
||||
renders: HashMap<u64, R>,
|
||||
asked: u64,
|
||||
/// Why the page is down, until it loads again.
|
||||
down: Option<String>,
|
||||
}
|
||||
|
||||
impl<R> Tally<R> {
|
||||
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<R> {
|
||||
self.load += 1;
|
||||
(self.changed, self.reported) = (0, 0);
|
||||
self.lapse()
|
||||
}
|
||||
|
||||
/// A page loaded: it answers again.
|
||||
pub fn loaded(&mut self) -> Vec<R> {
|
||||
self.down = None;
|
||||
self.restart()
|
||||
}
|
||||
|
||||
/// The page failed: owed and later renders end with `why`.
|
||||
pub fn fail(&mut self, why: String) -> Vec<R> {
|
||||
self.down = Some(why);
|
||||
self.lapse()
|
||||
}
|
||||
|
||||
fn lapse(&mut self) -> Vec<R> {
|
||||
self.renders.drain().map(|(_, render)| render).collect()
|
||||
}
|
||||
|
||||
/// Whether a report from page load `generation` still counts.
|
||||
pub fn current(&self, generation: Option<u64>) -> 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<u64, (R, String)> {
|
||||
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<R> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
+150
-347
@@ -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> {
|
||||
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<dyn FnOnce(Result<Value, String>, &mut gpui::App)>;
|
||||
|
||||
/// Renders the page will never answer end with `why`.
|
||||
fn end_renders(pending: Vec<Rendered>, 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<WebView>,
|
||||
document: Entity<MarkdownDocument>,
|
||||
doc: Doc,
|
||||
/// Page loads, changes and owed renders.
|
||||
tally: crate::tally::Tally<Rendered>,
|
||||
/// 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<Heading>,
|
||||
/// Shown since the last page load.
|
||||
pub shown: bool,
|
||||
/// A place to bring into view once shown.
|
||||
waiting: Option<Value>,
|
||||
/// Where the page's search stands, while one runs.
|
||||
pub found: Option<crate::finding::Found>,
|
||||
/// The file's folder: links and images resolve here.
|
||||
/// 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<Place>,
|
||||
/// The writing view, as the page applies it (MARK-09).
|
||||
look: Value,
|
||||
pub focus: FocusHandle,
|
||||
}
|
||||
|
||||
@@ -88,44 +88,52 @@ impl EventEmitter<Failed> for MarkView {}
|
||||
|
||||
impl MarkView {
|
||||
pub fn new(
|
||||
web: Entity<WebView>,
|
||||
doc: Doc,
|
||||
saved: String,
|
||||
folder: PathBuf,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let focus = web.read(cx).focus_handle(cx);
|
||||
let large = doc.lock().expect("the text lock").current.len() > LARGE;
|
||||
cx.subscribe(&web, Self::heard).detach();
|
||||
let paste = crate::attach::paste(cx.weak_entity());
|
||||
web.update(cx, |web, _| web.intercept_paste(paste));
|
||||
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<Self>) {
|
||||
pub fn switch(&mut self, mode: Mode, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.large && mode != Mode::Source {
|
||||
log::info!("mark: a large file stays in source");
|
||||
let said = ely_ui::i18n::text(cx, "mark.large.modes", &[]).to_string();
|
||||
@@ -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<Self>) {
|
||||
crate::page::call(self.web.read(cx), "find", &spec);
|
||||
/// The headings as last read.
|
||||
pub fn outline(&self, cx: &gpui::App) -> Vec<Heading> {
|
||||
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<Self>) {
|
||||
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>) {
|
||||
self.document
|
||||
.update(cx, |document, cx| document.reveal(line, window, cx));
|
||||
}
|
||||
|
||||
pub fn replace(&mut self, spec: Value, all: bool, cx: &mut Context<Self>) {
|
||||
let ask = serde_json::json!({ "spec": spec, "all": all });
|
||||
crate::page::call(self.web.read(cx), "replace", &ask);
|
||||
}
|
||||
|
||||
/// A table step: insert, rows and columns (MARK-06).
|
||||
pub fn table(&mut self, op: &str, cx: &mut Context<Self>) {
|
||||
crate::page::call(self.web.read(cx), "table", &Value::from(op));
|
||||
}
|
||||
|
||||
pub fn unfind(&mut self, cx: &mut Context<Self>) {
|
||||
self.found = None;
|
||||
crate::page::call(self.web.read(cx), "unfind", &Value::Null);
|
||||
}
|
||||
|
||||
/// Brings `{"heading": n}` or `{"line": n}` into view.
|
||||
pub fn reveal(&mut self, place: Value, cx: &mut Context<Self>) {
|
||||
match self.shown {
|
||||
true => crate::page::call(self.web.read(cx), "reveal", &place),
|
||||
false => self.waiting = Some(place),
|
||||
/// The 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<Self>) {
|
||||
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<Self>) {
|
||||
if look == self.look {
|
||||
return;
|
||||
}
|
||||
if self.shown {
|
||||
crate::page::call(self.web.read(cx), "look", &look);
|
||||
}
|
||||
self.look = look;
|
||||
}
|
||||
|
||||
/// The whole text as `kind`; `done` takes it.
|
||||
pub fn render(
|
||||
&mut self,
|
||||
kind: &str,
|
||||
done: impl FnOnce(Result<Value, String>, &mut gpui::App) + 'static,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
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<Self>) {
|
||||
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<Place>,
|
||||
text: Option<String>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
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<Self>) {
|
||||
if folder == self.folder {
|
||||
return;
|
||||
}
|
||||
if let Err(error) = self.web.read(cx).allow_images(&folder) {
|
||||
log::error!("mark: images cannot follow the file: {error:#}");
|
||||
return cx.emit(Failed(format!("{error:#}")));
|
||||
}
|
||||
crate::page::folder(self.web.read(cx), &folder);
|
||||
self.document
|
||||
.update(cx, |document, cx| document.set_base(folder.as_path(), cx));
|
||||
self.folder = folder;
|
||||
}
|
||||
|
||||
fn heard(&mut self, web: Entity<WebView>, event: &Event, cx: &mut Context<Self>) {
|
||||
match event {
|
||||
// A reload shows the last text that arrived.
|
||||
Event::Loaded => {
|
||||
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<MarkdownDocument>,
|
||||
event: &DocumentEvent,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
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<Self>) {
|
||||
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<Self>) {
|
||||
let said: Value = match serde_json::from_str(said) {
|
||||
Ok(said) => said,
|
||||
Err(error) => {
|
||||
log::error!("mark: the page sent bad JSON: {error}");
|
||||
return reply.fail("bad JSON");
|
||||
}
|
||||
};
|
||||
if !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<crate::finding::Found> {
|
||||
if let Some(error) = said["error"].as_str() {
|
||||
let error = Some(error.to_owned());
|
||||
return Some(crate::finding::Found {
|
||||
current: None,
|
||||
total: 0,
|
||||
error,
|
||||
});
|
||||
}
|
||||
let total = said["total"].as_u64()? as usize;
|
||||
let current = match &said["current"] {
|
||||
Value::Null => None,
|
||||
at => Some(at.as_u64()? as usize),
|
||||
};
|
||||
Some(crate::finding::Found {
|
||||
current,
|
||||
total,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
impl Render for MarkView {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
// Dropped files: images go beside the document.
|
||||
let drop = cx.listener(|view, dropped: &ExternalPaths, _, cx| {
|
||||
view.attach(crate::attach::Attach::Files(dropped.paths().to_vec()), cx)
|
||||
});
|
||||
div().size_full().on_drop(drop).child(self.web.clone())
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.size_full()
|
||||
.track_focus(&self.focus)
|
||||
.child(self.document.clone())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<MarkView>, &'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<Value, String>)>::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");
|
||||
}
|
||||
@@ -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", "预览"),
|
||||
|
||||
+1
-1
@@ -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 单击、预览单击交应用)
|
||||
|
||||
@@ -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')
|
||||
@@ -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)"
|
||||
@@ -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')
|
||||
@@ -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\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)"
|
||||
@@ -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.' '' '' '' "<img src='assets/pic.png'>" '' \
|
||||
"<img alt='a > b' src='assets/pic.png'>" '' '```mermaid' 'graph TD; A-->B' '```' '' \
|
||||
'```mermaid' 'graph TD; SECRET_WORDS -->' '```' '' '<div>RAW TEXT</div><div>TWO</div>' '' \
|
||||
"<img src='assets/pic.png' srcset='assets/high.png 1x'>" '' "<img srcset=' assets/high.png 1x, assets/pic.png 2x'>" '' \
|
||||
"<picture><source srcset='assets/high.png'><img src='assets/pic.png'></picture>" '' \
|
||||
'$\privateDocumentToken$' '' '' '' '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 '<svg' "$html" && grep -q 'END MARK' "$html" ||
|
||||
fail "the HTML lacks a part"
|
||||
# Both image forms inline; what was left out is said.
|
||||
[ "$(grep -o 'data:image/png;base64' "$html" | wc -l | tr -d ' ')" = 6 ] || fail "an image stayed outside"
|
||||
! grep -qE 'srcset|assets/' "$html" || fail "a responsive source stayed outside"
|
||||
grep -q "without 1 images and 1 diagrams; formulas failed at lines \[27\]" "$work/log" ||
|
||||
fail "the lost parts went unsaid: $(grep 'mark: exported' "$work/log" | tail -n 1)"
|
||||
! grep -qE 'SECRET_WORDS|privateDocumentToken|missing\.png' "$work/log" || fail "the log holds document text: $(grep -oE '.{0,80}(SECRET_WORDS|privateDocumentToken)' "$work/log" | head -n 1)"
|
||||
# A name with `#` still links its folder.
|
||||
export_as "HTML and Folder" "report#1"
|
||||
linked=$repo/report#1.html
|
||||
grep -q 'href="report%231_files/export.css"' "$linked" && grep -q 'src="report%231_files/1-pic.png"' "$linked" &&
|
||||
[ -s "$repo/report#1_files/export.css" ] || fail "the folder export: $(grep -o 'href="[^"]*"' "$linked")"
|
||||
! grep -qE 'srcset|assets/' "$linked" && [ -s "$repo/report#1_files/2-high.png" ] &&
|
||||
[ "$(grep -o 'src="report%231_files/[^"]*png"' "$linked" | wc -l | tr -d ' ')" = 6 ] ||
|
||||
fail "the folder lacks a responsive image: $(ls "$repo/report#1_files")"
|
||||
# A PDF never lands on the open file via links.
|
||||
ln -s b.md "$repo/other.pdf"
|
||||
ask_export PDF other
|
||||
for _ in $(seq 20); do seen Replace && break; sleep 0.25; done
|
||||
press Replace AXButton
|
||||
for _ in $(seq 80); do grep -q "is open in ElyMark" "$work/log" && break; sleep 0.25; done
|
||||
grep -q "is open in ElyMark" "$work/log" || fail "the export wrote over another open tab"
|
||||
[ "$(cat "$repo/b.md")" = "# Other" ] || fail "another open file was overwritten"
|
||||
rm "$repo/other.pdf"
|
||||
ln -s t.md "$repo/alias.pdf"
|
||||
ask_export PDF alias
|
||||
for _ in $(seq 20); do seen Replace && break; sleep 0.25; done
|
||||
press Replace AXButton
|
||||
for _ in $(seq 80); do grep -q "export failed" "$work/log" && break; sleep 0.25; done
|
||||
grep -q "export failed" "$work/log" || fail "the export wrote through the link"
|
||||
grep -q '^# Title' "$doc" || fail "the open file was overwritten: $(head -c 20 "$doc")"
|
||||
rm "$repo/alias.pdf"
|
||||
export_as PDF
|
||||
[ "$(head -c 5 "$repo/t.pdf")" = "%PDF-" ] || fail "no PDF"
|
||||
# The toast's Open shows the PDF in ElyPDF (§16).
|
||||
press Open AXButton
|
||||
for _ in $(seq 40); do pgrep -f "target/debug/ely-pdf .*t.pdf" >/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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
<div align="center">
|
||||
<b>html</b>
|
||||
</div>
|
||||
|
||||
|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"
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user