ElyMark: dropped and pasted pictures copy into assets and link at the caret (C14.10)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-12 07:29:57 -04:00
1 parent 72e9441399
commit b74f4e4e72
12 files changed
+357 -63

No files matched your search

Generated
+1 -1
View File
@@ -1922,7 +1922,7 @@ dependencies = [
[[package]]
name = "ely-gpui-component"
version = "0.1.1"
source = "git+https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components?rev=cd16b204f0ba56473b9ffb831bf2483bc232a6bb#cd16b204f0ba56473b9ffb831bf2483bc232a6bb"
source = "git+https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components?rev=d126a7e7f7977dddae598486dd99aba79ce1f5f0#d126a7e7f7977dddae598486dd99aba79ce1f5f0"
dependencies = [
"alacritty_terminal",
"anyhow",
+1 -1
View File
@@ -19,7 +19,7 @@ jiff = "0.2"
anyhow = "1"
directories = "6"
dunce = "1"
ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "cd16b204f0ba56473b9ffb831bf2483bc232a6bb" }
ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "d126a7e7f7977dddae598486dd99aba79ce1f5f0" }
env_logger = "0.11"
gpui = { package = "gpui-pre", version = "=0.3.8", default-features = false, features = ["stacker"] }
gpui_platform = { package = "gpui-pre-platform", version = "=0.3.8", features = ["font-kit", "wayland", "x11"] }
+2 -2
View File
@@ -40,7 +40,7 @@ site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木
## 组件映射
依赖 `ely-gpui-component`(pin cd16b20,gpui 为 crates.io 的 `gpui-pre` =0.3.8)。名字取自 `reference/Ely-GPUI-Components/src`。gpui 用法规则见该仓库 `AGENTS.md`。`tests/scripts/agents_md.py` 核对下表每个名字都是组件库公开项。
依赖 `ely-gpui-component`(pin d126a7e,gpui 为 crates.io 的 `gpui-pre` =0.3.8)。名字取自 `reference/Ely-GPUI-Components/src`。gpui 用法规则见该仓库 `AGENTS.md`。`tests/scripts/agents_md.py` 核对下表每个名字都是组件库公开项。
| 需要 | 用 |
| --- | --- |
@@ -283,4 +283,4 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
- 2026-10-11:Linux 包的实测(L1.6)。本机(Omarchy,Hyprland 0.56)装上六个 Arch 包与六个 AppImage,`linux-installed.sh` 两种都过;deb、rpm、Arch 包在 Ubuntu 26.04、Fedora 44、Arch 容器里装上、全部 ELF 找得到库;deb 与 rpm 的六个应用在各自容器的 Xvfb 下画出窗口(`linux-distros.sh`)。截图按窗口(`grim -T` 加 Hyprland 的 `stableId`):按区域截时用户切换工作区或别的窗口盖住即截错。ElyMark 的文件监视略去读取后不会自激:重读前先比指纹(长度与修改时间),不打开文件。
- 2026-10-11(用户定:替换 v0.1.3 的 Linux 包):v0.1.3 发布里原有的六个 AppImage(无头,打不开窗口)已删去,换成本机容器里建出的六个应用各四种包(共 24 个),出自 L1.7 的提交(晚于该标签);上传后逐个核对大小,ElyTree 的 AppImage 下载回来散列相同。`package.sh` 把每个 RUNPATH 按所在 ELF 解析(`$ORIGIN` 或 `${ORIGIN}` 开头,规范化后须在 AppDir 内),否则拒绝(如未修正的 cairo):CI 用的是别处建好的引擎,旧构建会在这里报错而不是带进包里。替换的 Linux 包对应的源码随之上传到同一发布(`source.sh`,文件名带构建提交号),发布说明写明 Linux 包出自哪个提交。
- 2026-10-11:`ci.sh` 用 `ELY_SHARE` 时,CI 克隆里的 `engines`、`reference` 须是指向共享克隆的链接(Windows 为目录联接,不查);是本克隆自建的目录或指向别处即报错,不混用。`.gitignore` 的 `/engines`、`/reference` 不带斜杠,链接也被忽略(`source.sh` 要求工作区干净)。
- 2026-10-11(用户定:ElyMark 不用 CEF,不用 Electron):ElyMark 改为原生。界面用组件库的 Markdown 组件(`documents::MarkdownEditor` 一族,缺的在组件库补并推到 Gitea),解析用 markdown-rs(`markdown` 1.0.0),公式用 RaTeX,Mermaid 用 mermaid-rs-renderer,PDF 用 MuPDF Story;CEF 随之删去,ElyMail 的正文也不用 CEF 与 Electron。进度在 `tasks/TASKS.md` 的 C14。组件库 fcad0ff 与 508cc55:`MarkdownRenderer`、`MarkdownEditor`、Markdown 着色、块范围与链接查找改读 markdown-rs 的语法树(GitHub 风格加公式),引用式链接与图片按定义解析,图片把所在段落分开,单行 `$$…$$` 为块公式,软换行(LF、CRLF、CR)读作空格,嵌套在引用与列表里的定义同样解析,强调与链接里的图片仍是图片(链接里的图片可按);`BlockEditor` 的导入导出不属 ElyMark,仍用 pulldown-cmark。markdown-rs 读嵌套图片时只留外层的说明。组件库 d5ecc5a:块公式经 `typography::Latex` 由 RaTeX 排出(字形为轮廓,按窗口缩放画),```` ```mermaid ```` 块经 `documents::Diagram` 由 mermaid-rs-renderer 画出(Mermaid 自己的深浅主题,底色与字色取 Ely 色板),都作为 gpui 资源在后台排版,RaTeX 或 Mermaid 对已解析的输入 panic 时显示为错误;`MarkdownRenderer::base` 给出文档所在文件夹,相对与 file 地址的图片从中读取并按自身大小、不超过栏宽显示,`data:` 地址解码,网络图片不载入(写明)。组件库 ab9a14c:每个公式、图与图片元素经 `primitives::held` 持有其资源,按资源计数持有者(8b9820b;画出的图存在共享的记录里,9036c92 起由最后离开的持有者释放,哪怕它自己还没收到图),最后一个持有者换了内容或离开窗口才从 gpui 资源缓存移除、在本帧之后从图集释放,共用同一内容的元素(含 gpui 缓存的视图)不受影响;公式按字形实际范围取画框,字形不被裁;公式失败显示原因、RaTeX 给的位置与原文,日志不含公式;`file:` 地址去掉查询与片段,`localhost` 为本机,Windows 上别的主机为共享路径,他处拒绝;Mermaid 的节点与饼图文字取 Ely 的前景色。组件库 2b6f551:`documents::MarkdownDocument` 是文件级的 Markdown 文档:全文与撤销在一个 `CodeEditor` 里(`CodeEditor::highlighter` 以 `markdown_highlights` 着色,无行号);可视以 gpui `list` 逐块画出文件的顶层块(链接定义、HTML、front matter 照原文显示,每个字节都在块里或块间),按下的块开成源码(`TextInput`),其键入即刻算作文档的文字,写回为一步撤销,上下键越过块边进入相邻块,Esc 写回;Cmd(他处 Ctrl)按下交给链接;预览只读;源码为编辑器本身;128 KiB 以内当场解析,更长的在后台解析,解析中按下块不打开(日志);新解析保留两端相同的块,滚动不跳。`CodeEditor` 的撤销历史最多留 64 MiB 文字,超出先丢最旧的步;`CodeEditor::reset` 换文字且撤销重新开始;`MarkdownMode` 加 `Preview`。组件库 fc181b4:一个插入点通用于各模式(编辑器的选区,写回的块移动它);`set_caret` 与 `set_top_line` 分别设插入点与视图,换模式保持顶行;按下的块在前一块写回后仍是所按的块(按偏移换算,结构变了即不开并记日志);块是 Tab 停靠点,Enter 或空格打开;预览不收按下、撤销与重做;撤销与重做每次记录、输入法组字、撤销、重做后都限在 64 MiB 内(先丢最旧的撤销,再丢最远的重做)。组件库 433c38f:只有对当下文字的解析才能打开块(解析的文字与编辑器相同),否则拒绝;要求的顶行在其文字解析后才落定:先到所在块,下一帧按块高的比例到块内位置,再有新的要求即接着放下一个;源码与列表之间切换保持顶行,可视与预览共用一个列表,切换不动滚动。组件库 abac8b4:顶行的要求带编号,迟到的帧回调只完成自己的那个;文字一换即按新文字重新放,放时按当下文字的行数夹住;`reveal` 是同样的要求(插入点设在该行,该行放到顶上),新文字解析后才落定。组件库 f59c9f7:文字超过读取上限(2 MiB)即丢掉旧的读取,大纲与块为空。ElyMark(C14.4):视图即一个 `MarkdownDocument`,文本即文件原文(BOM 与 CRLF 照 `text.rs` 存回),新建、打开、保存、另存、重命名经外壳照旧;视图在建成后的下一轮更新向外壳报一次载入(文档在 `new` 里当场解析,外壳此时还未订阅);文字超过 2 MiB 即只用源码并转去源码(随当前文字,不只看打开时);网页实现的查找替换、表格、附件、书写视图的应用、导出与打印Line truncated
- 2026-10-11(用户定:ElyMark 不用 CEF,不用 Electron):ElyMark 改为原生。界面用组件库的 Markdown 组件(`documents::MarkdownEditor` 一族,缺的在组件库补并推到 Gitea),解析用 markdown-rs(`markdown` 1.0.0),公式用 RaTeX,Mermaid 用 mermaid-rs-renderer,PDF 用 MuPDF Story;CEF 随之删去,ElyMail 的正文也不用 CEF 与 Electron。进度在 `tasks/TASKS.md` 的 C14。组件库 fcad0ff 与 508cc55:`MarkdownRenderer`、`MarkdownEditor`、Markdown 着色、块范围与链接查找改读 markdown-rs 的语法树(GitHub 风格加公式),引用式链接与图片按定义解析,图片把所在段落分开,单行 `$$…$$` 为块公式,软换行(LF、CRLF、CR)读作空格,嵌套在引用与列表里的定义同样解析,强调与链接里的图片仍是图片(链接里的图片可按);`BlockEditor` 的导入导出不属 ElyMark,仍用 pulldown-cmark。markdown-rs 读嵌套图片时只留外层的说明。组件库 d5ecc5a:块公式经 `typography::Latex` 由 RaTeX 排出(字形为轮廓,按窗口缩放画),```` ```mermaid ```` 块经 `documents::Diagram` 由 mermaid-rs-renderer 画出(Mermaid 自己的深浅主题,底色与字色取 Ely 色板),都作为 gpui 资源在后台排版,RaTeX 或 Mermaid 对已解析的输入 panic 时显示为错误;`MarkdownRenderer::base` 给出文档所在文件夹,相对与 file 地址的图片从中读取并按自身大小、不超过栏宽显示,`data:` 地址解码,网络图片不载入(写明)。组件库 ab9a14c:每个公式、图与图片元素经 `primitives::held` 持有其资源,按资源计数持有者(8b9820b;画出的图存在共享的记录里,9036c92 起由最后离开的持有者释放,哪怕它自己还没收到图),最后一个持有者换了内容或离开窗口才从 gpui 资源缓存移除、在本帧之后从图集释放,共用同一内容的元素(含 gpui 缓存的视图)不受影响;公式按字形实际范围取画框,字形不被裁;公式失败显示原因、RaTeX 给的位置与原文,日志不含公式;`file:` 地址去掉查询与片段,`localhost` 为本机,Windows 上别的主机为共享路径,他处拒绝;Mermaid 的节点与饼图文字取 Ely 的前景色。组件库 2b6f551:`documents::MarkdownDocument` 是文件级的 Markdown 文档:全文与撤销在一个 `CodeEditor` 里(`CodeEditor::highlighter` 以 `markdown_highlights` 着色,无行号);可视以 gpui `list` 逐块画出文件的顶层块(链接定义、HTML、front matter 照原文显示,每个字节都在块里或块间),按下的块开成源码(`TextInput`),其键入即刻算作文档的文字,写回为一步撤销,上下键越过块边进入相邻块,Esc 写回;Cmd(他处 Ctrl)按下交给链接;预览只读;源码为编辑器本身;128 KiB 以内当场解析,更长的在后台解析,解析中按下块不打开(日志);新解析保留两端相同的块,滚动不跳。`CodeEditor` 的撤销历史最多留 64 MiB 文字,超出先丢最旧的步;`CodeEditor::reset` 换文字且撤销重新开始;`MarkdownMode` 加 `Preview`。组件库 fc181b4:一个插入点通用于各模式(编辑器的选区,写回的块移动它);`set_caret` 与 `set_top_line` 分别设插入点与视图,换模式保持顶行;按下的块在前一块写回后仍是所按的块(按偏移换算,结构变了即不开并记日志);块是 Tab 停靠点,Enter 或空格打开;预览不收按下、撤销与重做;撤销与重做每次记录、输入法组字、撤销、重做后都限在 64 MiB 内(先丢最旧的撤销,再丢最远的重做)。组件库 433c38f:只有对当下文字的解析才能打开块(解析的文字与编辑器相同),否则拒绝;要求的顶行在其文字解析后才落定:先到所在块,下一帧按块高的比例到块内位置,再有新的要求即接着放下一个;源码与列表之间切换保持顶行,可视与预览共用一个列表,切换不动滚动。组件库 abac8b4:顶行的要求带编号,迟到的帧回调只完成自己的那个;文字一换即按新文字重新放,放时按当下文字的行数夹住;`reveal` 是同样的要求(插入点设在该行,该行放到顶上),新文字解析后才落定。组件库 f59c9f7:文字超过读取上限(2 MiB)即丢掉旧的读取,大纲与块为空。ElyMark(C14.4):视图即一个 `MarkdownDocument`,文本即文件原文(BOM 与 CRLF 照 `text.rs` 存回),新建、打开、保存、另存、重命名经外壳照旧;视图在建成后的下一轮更新向外壳报一次载入(文档在 `new` 里当场解析,外壳此时还未订阅);文字超过 2 MiB 即只用源码并转去源码(随当前文字,不只看打开时);网页实现的查找替换、表格、附件、书写视图的应用、导出与打印Line truncated
+299
View File
@@ -0,0 +1,299 @@
//! Pictures dropped or pasted, copied beside the file (MARK-07).
use std::{
io,
path::{Path, PathBuf},
};
use ely_gpui_component::documents::Attachment;
use ely_ui::i18n::text;
use gpui::{App, Context, ImageFormat};
use crate::view::{Failed, MarkView, Mode};
const PICTURES: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"];
/// What goes into `assets/`: a file, or pasted bytes.
enum Source {
File(PathBuf),
Bytes(String, Vec<u8>),
}
fn said(key: &str, cx: &App) -> Failed {
Failed(text(cx, key, &[]).to_string())
}
impl MarkView {
/// Copies pictures off the window, then links them.
pub(crate) fn attach(&mut self, attached: &Attachment, cx: &mut Context<Self>) {
if self.mode == Mode::Preview {
return cx.emit(said("mark.attach.preview", cx));
}
let Some(folder) = self.folder.clone() else {
return cx.emit(said("mark.attach.untitled", cx));
};
let sources: Vec<Source> = match attached {
Attachment::Files(paths) => {
let (pictures, others): (Vec<&PathBuf>, Vec<&PathBuf>) =
paths.iter().partition(|path| picture(path));
if !others.is_empty() {
cx.emit(said("mark.attach.images", cx));
}
pictures.into_iter().cloned().map(Source::File).collect()
}
Attachment::Picture(image) => {
let millis = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("the clock is past 1970")
.as_millis();
let name = format!("pasted-{millis}.{}", extension(image.format()));
vec![Source::Bytes(name, image.bytes().to_vec())]
}
};
if sources.is_empty() {
return;
}
log::info!("mark: {} pictures copy in", sources.len());
let assets = folder.join("assets");
cx.spawn(async move |view, cx| {
let copied = cx
.background_executor()
.spawn(async move {
sources
.into_iter()
.map(|source| match source {
Source::File(path) => ely_files::import_attachment(&path, &assets),
Source::Bytes(name, bytes) => {
ely_files::write_attachment(&name, &bytes, &assets)
}
})
.collect::<io::Result<Vec<PathBuf>>>()
})
.await;
view.update(cx, |view, cx| view.attached(folder, copied, cx))
.ok();
})
.detach();
}
/// Links the copies, unless the file moved meanwhile.
fn attached(
&mut self,
folder: PathBuf,
copied: io::Result<Vec<PathBuf>>,
cx: &mut Context<Self>,
) {
let copied = match copied {
Ok(copied) => copied,
Err(error) => {
log::error!("mark: a picture did not copy: {error}");
let said = text(cx, "office.error.engine", &[("error", &error.to_string())]);
return cx.emit(Failed(said.to_string()));
}
};
if self.folder.as_ref() != Some(&folder) {
log::info!("mark: the file moved while pictures copied");
let assets = folder.join("assets").display().to_string();
let said = text(cx, "mark.attach.moved", &[("folder", &assets)]);
return cx.emit(Failed(said.to_string()));
}
let links: Vec<String> = copied.iter().map(|copy| link(&folder, copy)).collect();
let written = self
.document()
.clone()
.update(cx, |document, cx| document.insert(&links.join("\n"), cx));
if !written {
cx.emit(said("mark.attach.preview", cx));
}
}
}
fn picture(path: &Path) -> bool {
path.extension()
.is_some_and(|ext| PICTURES.contains(&ext.to_string_lossy().to_ascii_lowercase().as_str()))
}
fn extension(format: ImageFormat) -> &'static str {
match format {
ImageFormat::Png => "png",
ImageFormat::Jpeg => "jpg",
ImageFormat::Gif => "gif",
ImageFormat::Webp => "webp",
ImageFormat::Svg => "svg",
ImageFormat::Bmp => "bmp",
ImageFormat::Tiff => "tiff",
ImageFormat::Ico => "ico",
ImageFormat::Pnm => "pnm",
}
}
/// The Markdown link to `copy`, relative to `folder`.
fn link(folder: &Path, copy: &Path) -> String {
let relative = copy.strip_prefix(folder).expect("copied under the folder");
let address: Vec<String> = relative
.components()
.map(|part| escaped(&part.as_os_str().to_string_lossy()))
.collect();
let stem = copy.file_stem().unwrap_or_default().to_string_lossy();
let alt = stem
.replace('\\', "\\\\")
.replace('[', "\\[")
.replace(']', "\\]");
format!("![{alt}]({})", address.join("/"))
}
/// A path part, every byte but plain ones escaped.
fn escaped(part: &str) -> String {
part.bytes()
.map(|byte| match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
(byte as char).to_string()
}
other => format!("%{other:02X}"),
})
.collect()
}
#[cfg(test)]
mod tests {
use std::{cell::RefCell, path::Path, rc::Rc};
use ely_gpui_component::documents::Attachment;
use gpui::{Entity, Image, ImageFormat, TestAppContext, VisualTestContext};
use super::link;
use crate::desk::{
Desk,
tests::{open, start},
};
use crate::view::{Failed, MarkView};
/// What the view says, in order.
type Said = Rc<RefCell<Vec<String>>>;
/// A saved file open in ElyMark, and what it says.
fn saved(
cx: &mut TestAppContext,
) -> (
Entity<MarkView>,
&'static mut VisualTestContext,
[tempfile::TempDir; 2],
Said,
) {
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
let path = files.path().join("a.md");
std::fs::write(
&path, "a
",
)
.expect("a file");
let (desk, cx) = start(cx, data.path());
open(&desk, &path, cx);
let view = desk.read_with(cx, |desk: &Desk, _| desk.active_view().expect("a tab"));
let said = Rc::new(RefCell::new(Vec::new()));
let heard = said.clone();
cx.update(|_, cx| {
cx.subscribe(&view, move |_, failed: &Failed, _| {
heard.borrow_mut().push(failed.0.clone())
})
.detach()
});
(view, cx, [data, files], said)
}
fn attach(view: &Entity<MarkView>, attached: Attachment, cx: &mut VisualTestContext) {
let document = view.read_with(cx, |view, _| view.document().clone());
document.update(cx, |_, cx| cx.emit(attached));
cx.run_until_parked();
}
fn current(view: &Entity<MarkView>, cx: &mut VisualTestContext) -> String {
view.read_with(cx, |view, cx| view.document().read(cx).text(cx))
}
#[gpui::test]
fn a_dropped_picture_is_copied_beside_and_linked(cx: &mut TestAppContext) {
let (view, cx, [_, files], said) = saved(cx);
let shot = files.path().join("my shot.png");
std::fs::write(&shot, "png").expect("a picture");
attach(&view, Attachment::Files(vec![shot]), cx);
assert!(files.path().join("assets/my shot.png").exists());
assert!(
current(&view, cx).contains("![my shot](assets/my%20shot.png)"),
"{}",
current(&view, cx)
);
assert!(said.borrow().is_empty());
}
#[gpui::test]
fn a_pasted_picture_is_written_and_linked(cx: &mut TestAppContext) {
let (view, cx, [_, files], _) = saved(cx);
attach(
&view,
Attachment::Picture(Image::from_bytes(ImageFormat::Png, vec![1])),
cx,
);
let assets: Vec<_> = std::fs::read_dir(files.path().join("assets"))
.expect("assets")
.collect();
assert_eq!(assets.len(), 1);
assert!(current(&view, cx).contains("](assets/pasted-"));
}
#[gpui::test]
fn other_files_and_untitled_text_take_no_picture(cx: &mut TestAppContext) {
let (view, cx, [_, files], said) = saved(cx);
let note = files.path().join("note.txt");
std::fs::write(&note, "text").expect("a file");
attach(&view, Attachment::Files(vec![note]), cx);
assert!(!files.path().join("assets").exists());
assert_eq!(said.borrow().len(), 1, "other files are refused");
view.update(cx, |view, _| view.folder = None);
attach(
&view,
Attachment::Picture(Image::from_bytes(ImageFormat::Png, vec![1])),
cx,
);
assert_eq!(said.borrow().len(), 2, "untitled text is refused");
assert_eq!(
current(&view, cx),
"a
"
);
}
#[gpui::test]
fn a_file_moved_while_copying_gets_no_link(cx: &mut TestAppContext) {
let (view, cx, [_, files], said) = saved(cx);
let document = view.read_with(cx, |view, _| view.document().clone());
document.update(cx, |_, cx| {
cx.emit(Attachment::Picture(Image::from_bytes(
ImageFormat::Png,
vec![1],
)))
});
let elsewhere = files.path().join("elsewhere");
view.update(cx, |view, cx| view.moved(elsewhere, cx));
cx.run_until_parked();
assert_eq!(
current(&view, cx),
"a
"
);
assert_eq!(said.borrow().len(), 1, "the move is said");
}
#[test]
fn links_escape_their_address_and_alt() {
let folder = Path::new("/notes");
let copy = folder.join("assets").join("a b[1].png");
assert_eq!(
link(folder, &copy),
"![a b\\[1\\]](assets/a%20b%5B1%5D.png)"
);
}
}
+6 -23
View File
@@ -4,7 +4,7 @@ use std::{
convert::Infallible,
hash::{DefaultHasher, Hash as _, Hasher as _},
io,
path::{Path, PathBuf},
path::Path,
sync::{Arc, Mutex},
};
@@ -36,12 +36,6 @@ impl Text {
pub type Doc = Arc<Mutex<Text>>;
/// A load: the file as read, and where it is.
pub struct Opened {
pub source: Source,
pub path: PathBuf,
}
/// The history mark: this text's length and hash.
pub fn mark(text: &str) -> String {
let mut hasher = DefaultHasher::new();
@@ -56,10 +50,10 @@ impl Engine for Mark {
type Worker = ();
type Doc = Doc;
type View = MarkView;
type Loaded = Opened;
type Loaded = Source;
type Import = Infallible;
fn load(_: &mut (), from: Load<Infallible>) -> io::Result<Opened> {
fn load(_: &mut (), from: Load<Infallible>) -> io::Result<Source> {
let path = match from {
Load::File(path, _) => path,
Load::Import(_, never) => match *never {},
@@ -69,30 +63,19 @@ impl Engine for Mark {
io::ErrorKind::PermissionDenied => io::Error::other(error),
_ => error,
})?;
Ok(Opened {
source: Source::decode(bytes)?,
path: path.to_path_buf(),
})
Source::decode(bytes)
}
fn open(
_: &Arc<Mutex<()>>,
loaded: Opened,
loaded: Source,
window: &mut Window,
cx: &mut App,
) -> io::Result<(Doc, Entity<MarkView>, String)> {
let Opened {
source: loaded,
path,
} = loaded;
let folder = path
.parent()
.expect("a file sits in a folder")
.to_path_buf();
let mark = mark(&loaded.text);
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));
let view = cx.new(|cx| MarkView::new(Arc::clone(&doc), saved, window, cx));
Ok((doc, view, mark))
}
+1
View File
@@ -1,6 +1,7 @@
// Release builds open no console on Windows.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod attach;
mod autosave;
mod compare;
mod desk;
+9 -14
View File
@@ -74,8 +74,8 @@ pub struct MarkView {
/// The mark at the last load or save.
saved: String,
pub mode: Mode,
/// The file's folder: pictures resolve here.
pub(crate) folder: PathBuf,
/// A saved file's folder: pictures resolve and go here.
pub(crate) folder: Option<PathBuf>,
/// The mode first took input (PERF-07).
pub(crate) shown: bool,
pub focus: FocusHandle,
@@ -85,13 +85,7 @@ impl EventEmitter<Reported> for MarkView {}
impl EventEmitter<Failed> for MarkView {}
impl MarkView {
pub fn new(
doc: Doc,
saved: String,
folder: PathBuf,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
pub fn new(doc: Doc, saved: String, window: &mut Window, cx: &mut Context<Self>) -> Self {
let text = doc.lock().expect("the text lock").current.clone();
let mode = match text.len() > LARGEST_READ {
true => Mode::Source,
@@ -100,13 +94,14 @@ impl MarkView {
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_in(&document, window, Self::heard).detach();
cx.subscribe(&document, |view, _, attached, cx| view.attach(attached, cx))
.detach();
// The shell hears the load.
cx.defer_in(window, |view, _, cx| {
view.show_once(cx);
@@ -125,7 +120,7 @@ impl MarkView {
changed: 0,
saved,
mode,
folder,
folder: None,
shown: false,
focus,
}
@@ -251,14 +246,14 @@ impl MarkView {
}
}
/// The file moved; pictures follow it.
/// The saved file's folder; pictures follow it.
pub fn moved(&mut self, folder: PathBuf, cx: &mut Context<Self>) {
if folder == self.folder {
if self.folder.as_ref() == Some(&folder) {
return;
}
self.document
.update(cx, |document, cx| document.set_base(folder.as_path(), cx));
self.folder = folder;
self.folder = Some(folder);
}
fn heard(
+2 -5
View File
@@ -18,9 +18,7 @@ fn open<'a>(
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));
let (view, cx) = cx.add_window_view(move |window, cx| MarkView::new(doc, saved, window, cx));
cx.update(|window, _| window.activate_window());
cx.run_until_parked();
(view, cx)
@@ -166,8 +164,7 @@ fn the_shell_hears_a_load_it_subscribed_to(cx: &mut TestAppContext) {
let source = Source::decode(b"# Opened\n".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.new(|cx| MarkView::new(doc, saved, folder, window, cx));
let view = cx.new(|cx| MarkView::new(doc, saved, window, cx));
// As the shell: subscribed after the view is made.
let count = count.clone();
cx.subscribe(&view, move |_, _, _: &Reported, _| {
+1
View File
@@ -19,6 +19,7 @@ pub use task::RecoveryTask;
pub use save::{
SaveError, Saved, atomic_capable, claim_all, export, import_attachment, same_file, save,
write_attachment,
save_as, save_atomic, save_overwrite, save_with_backup, save_with_probe,
};
pub use session::{Fingerprint, Open, SaveState, SaveTicket, Session, Sessions, StaleTicket};
+25 -16
View File
@@ -212,24 +212,38 @@ pub fn same_file(a: &Path, b: &Path) -> io::Result<bool> {
/// Copies an attachment in; the source is never touched (FILE-07).
pub fn import_attachment(source: &Path, dir: &Path) -> io::Result<PathBuf> {
std::fs::create_dir_all(dir).map_err(|e| with_path(dir, e))?;
let name = source.file_name().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("{} has no file name", source.display()),
)
})?;
let mut target = dir.join(name);
let stem = Path::new(name)
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
let ext = Path::new(name)
let mut from = std::fs::File::open(source).map_err(|e| with_path(source, e))?;
let target = attach(dir, Path::new(name), |to| io::copy(&mut from, to).map(drop))?;
log::info!("files: imported {} to {}", source.display(), target.display());
Ok(target)
}
/// Writes `bytes` into `dir` as `name`, or a free variant.
pub fn write_attachment(name: &str, bytes: &[u8], dir: &Path) -> io::Result<PathBuf> {
let target = attach(dir, Path::new(name), |to| to.write_all(bytes))?;
log::info!("files: wrote {} bytes to {}", bytes.len(), target.display());
Ok(target)
}
/// Claims a free name like `name`, then fills it.
fn attach(
dir: &Path,
name: &Path,
fill: impl FnOnce(&mut std::fs::File) -> io::Result<()>,
) -> io::Result<PathBuf> {
std::fs::create_dir_all(dir).map_err(|e| with_path(dir, e))?;
let stem = name.file_stem().unwrap_or_default().to_string_lossy().into_owned();
let ext = name
.extension()
.map(|ext| ext.to_string_lossy().into_owned())
.unwrap_or_default();
let mut from = std::fs::File::open(source).map_err(|e| with_path(source, e))?;
let mut target = dir.join(name);
let mut counter = 1;
// Claims the name; another writer's file is never replaced.
let mut to = loop {
@@ -242,17 +256,12 @@ pub fn import_attachment(source: &Path, dir: &Path) -> io::Result<PathBuf> {
Err(e) => return Err(with_path(&target, e)),
}
};
let copied = io::copy(&mut from, &mut to).and_then(|_| to.flush());
if let Err(e) = copied {
let filled = fill(&mut to).and_then(|()| to.flush());
if let Err(e) = filled {
// A half copy is not left behind.
std::fs::remove_file(&target).ok();
return Err(with_path(&target, e));
}
log::info!(
"files: imported {} to {}",
source.display(),
target.display()
);
Ok(target)
}
+9
View File
@@ -180,6 +180,15 @@ fn an_import_copies_and_never_deletes() {
assert_eq!(read(&second), "png-bytes");
}
#[test]
fn written_bytes_take_a_free_name() {
let dir = tempfile::tempdir().expect("a temp dir");
let first = write_attachment("pasted.png", b"one", dir.path()).expect("the first write");
let second = write_attachment("pasted.png", b"two", dir.path()).expect("the second write");
assert_ne!(first, second);
assert_eq!((read(&first), read(&second)), ("one".into(), "two".into()));
}
#[test]
fn a_numbered_import_name_fits_long_file_names() {
let dir = tempfile::tempdir().expect("a temp dir");
+1 -1
View File
@@ -36,7 +36,7 @@ fetch() { # name url rev [sparse paths...]
echo "fetch $name $rev"
}
fetch Ely-GPUI-Components https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components.git cd16b204f0ba56473b9ffb831bf2483bc232a6bb
fetch Ely-GPUI-Components https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components.git d126a7e7f7977dddae598486dd99aba79ce1f5f0
fetch zed https://github.com/zed-industries/zed.git 1a28cff4b409169bac058bca40dfbfeb7621d19b
fetch libreoffice-core https://github.com/LibreOffice/core.git 8221e31b3ac356a1623c672912a3d2b492f7e3d1 \
include/LibreOfficeKit libreofficekit desktop/source/lib desktop/inc \