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:
1 parent
72e9441399
commit
b74f4e4e72
12 files changed
+357
-63
No files matched your search
Generated
+1
-1
@@ -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
@@ -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"] }
|
||||
|
||||
@@ -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
|
||||
@@ -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!("", 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(""),
|
||||
"{}",
|
||||
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(¬e, "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, ©),
|
||||
"![a b\\[1\\]](assets/a%20b%5B1%5D.png)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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, _| {
|
||||
|
||||
@@ -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};
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 \
|
||||
|
||||
Reference in new issue
Block a user