306 Commits
Author SHA1 Message Date
ZacharyZhang-NYandClaude Opus 5.5 26c0a64665 R3.3: the LOK view's pointer follows the kernel; an arrow over cells
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

[skip ci]
2026-10-12 23:31:28 -04:00
ZacharyZhang-NYandClaude Opus 5.5 ee1b8ad375 R3.2: Ctrl+wheel zooms one step per notch; version 0.1.4 [skip ci]
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 23:15:47 -04:00
ZacharyZhang-NYandClaude Opus 5.5 c1880288a5 R3.1 passes review
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 19:07:01 -04:00
ZacharyZhang-NY ecf29cd6b5 R3.1: a saved session keeps the plain path; writes keep \?
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 19:03:37 -04:00
ZacharyZhang-NYandClaude Opus 5.5 d929a807c4 R3.1: keep \?\ for file IO; simplify a save target only as a whole
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 18:56:57 -04:00
ZacharyZhang-NYandClaude Opus 5.5 d0b5aede95 R3.1: real paths without \?\ on Windows (dunce); old recent lists read plain
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 18:46:37 -04:00
ZacharyZhang-NYandClaude Opus 5.5 1066b41cb0 PERF-07 on Linux: P50 297 ms, P95 582 ms
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:18:55 -04:00
ZacharyZhang-NYandClaude Opus 5.5 39955cdf66 C14.R done (R3 ok): the eight items fixed after round 3 are reviewed
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:18:04 -04:00
ZacharyZhang-NYandClaude Opus 5.5 94af561068 C14.R r2: comments within ten words (library pinned at the fix)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:11:02 -04:00
ZacharyZhang-NYandClaude Opus 5.5 9d5f04a973 C14.R r1: Shift-Tab outdents by columns across spaces and tabs (library b39097c)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:00:41 -04:00
ZacharyZhang-NYandClaude Opus 5.5 5011e5116f C14.R: one review over the fixes made after round 3 (user's choice)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 16:42:41 -04:00
ZacharyZhang-NYandClaude Opus 5.5 9c4787ceb2 C14.16 done (R3 ok)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 12:54:14 -04:00
ZacharyZhang-NYandClaude Opus 5.5 1e629da3fc C14.16 r2: measuring and export use gpui's bundled faces and generic mapping (library 58227cd)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 12:48:32 -04:00
ZacharyZhang-NYandClaude Opus 5.5 17efd61ce3 C14.16 r1: fonts chosen as gpui draws for measuring and export (library e0b4e29); Git test commits unsigned
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 12:31:33 -04:00
ZacharyZhang-NYandClaude Opus 5.5 aab0cb5408 C14.16: SVG sizes read with fonts (library 50a59d2); a large export stays whole; acceptance record
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 12:09:00 -04:00
ZacharyZhang-NYandClaude Opus 5.5 5efa1e0dfa C14.16: Git status tests: committed, changed, ignored, and the item after a save
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 11:46:38 -04:00
ZacharyZhang-NYandClaude Opus 5.5 8b1927e005 C14.15 done (R3 ok)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 11:45:29 -04:00
ZacharyZhang-NYandClaude Opus 5.5 f5faca7575 C14.15 r2: failures shown once per kind of error; a recovered tab keeps its point's time
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 11:39:04 -04:00
ZacharyZhang-NYandClaude Opus 5.5 906d64cf52 C14.15 r1: failed recovery and session writes show in the window, once each, with the last kept time
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 11:30:49 -04:00
ZacharyZhang-NYandClaude Opus 5.5 e1b889cb7f C14.15: the kept mode is typed, an unknown one does not read; relaunch and crash restore tested in windows
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 11:21:23 -04:00
ZacharyZhang-NYandClaude Opus 5.5 d9dadb3d0e C14.14 r3: formulas framed after outlining, a file per print job, no source selection in rendered modes (library 8234fcd)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 11:18:07 -04:00
ZacharyZhang-NYandClaude Opus 5.5 8fc5ee2ab2 C14.14 r2: shorter comments
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 10:59:35 -04:00
ZacharyZhang-NYandClaude Opus 5.5 bfd507ae2e C14.14 r2: links judged as browsers read them; only a folder this export made is removed; formula text outlined or refused (library 3bbbd05)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 10:59:27 -04:00
ZacharyZhang-NYandClaude Opus 5.5 88b0f8f5fc C14.14 r1: HTML from the syntax tree; pictures claimed by create_new; one scratch per PDF; diagram glyphs checked; rich copy reports
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 10:39:36 -04:00
ZacharyZhang-NYandClaude Opus 5.5 d7ded183ae C14.14: export HTML, HTML and folder, PDF (MuPDF story in the PDF worker) and plain text; print; rich copy
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 10:16:11 -04:00
ZacharyZhang-NYandClaude Opus 5.5 f1cb7eff99 C14.13 done (R2 ok)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 09:48:00 -04:00
ZacharyZhang-NYandClaude Opus 5.5 b80bf48ccd C14.13 r1: query and line fold case the same way; final sigma folds to sigma
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 09:42:16 -04:00
ZacharyZhang-NYandClaude Opus 5.5 cd0c740ded C14.13: a shorter comment
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 09:33:09 -04:00
ZacharyZhang-NYandClaude Opus 5.5 10b9e7b694 C14.13: a search hit puts the caret on the match, not the line start
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 09:33:02 -04:00
ZacharyZhang-NYandClaude Opus 5.5 584e08d8ad C14.12 r3: centering keys on view and line heights; placements clear the editor's record (library 7ed4ea8)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 09:30:29 -04:00
ZacharyZhang-NYandClaude Opus 5.5 75a1ea06e6 C14.12 r2: typewriter room as list rows; a mode switch re-centers (library cf9cc19)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 09:15:28 -04:00
ZacharyZhang-NYandClaude Opus 5.5 edad4bac61 C14.12 r1: open blocks scale, typewriter room at both ends, resizes re-center (library f102ea0)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 08:56:22 -04:00
ZacharyZhang-NYandClaude Opus 5.5 cb228769a7 ElyMark: the writing view reaches the document - width, font, size, focus, typewriter (C14.12)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 08:28:45 -04:00
ZacharyZhang-NYandClaude Opus 5.5 b459cea4a2 C14.11 passes review
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 08:10:31 -04:00
ZacharyZhang-NYandClaude Opus 5.5 e2cb83ceaa C14.11 r1: fingerprints carry the file's identity; stale disk reads never land
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 08:04:18 -04:00
ZacharyZhang-NYandClaude Opus 5.5 1dc0d31615 agents.md: C14.9 to C14.11 in order
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 07:52:02 -04:00
ZacharyZhang-NYandClaude Opus 5.5 ea5f47d74e ElyMark: outside changes tested through the window; a reload keeps its place (C14.11)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 07:51:44 -04:00
ZacharyZhang-NYandClaude Opus 5.5 7529d39d5a C14.10 passes review
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 07:44:54 -04:00
ZacharyZhang-NYandClaude Opus 5.5 cae61129a0 C14.10 r1: no line break in an image's alt text; names that are no text are refused before copying
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 07:38:41 -04:00
ZacharyZhang-NYandClaude Opus 5.5 b74f4e4e72 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>
2026-10-12 07:29:57 -04:00
ZacharyZhang-NYandClaude Opus 5.5 72e9441399 C14.9 r3: empty tasks end lists, Shift-Tab takes tabs out, tables replace blank lines (library cd16b20); C14.9 [~]
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 07:19:40 -04:00
ZacharyZhang-NYandClaude Opus 5.5 277d39fbdb C14.9 r2: lists go on in quotes and after tabs; waiting matches lapse (library e162082)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 07:04:36 -04:00
ZacharyZhang-NYandClaude Opus 5.5 2aa4a080bb C14.9 r1: tables keep extra cells and quote marks; preview refuses edits; lists only in list items; replace moves past its text (library edc328a)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 06:43:23 -04:00
ZacharyZhang-NYandClaude Opus 5.5 d4a7d3ebd7 ElyMark: find and replace, list continuation, indent, table menu, fence colors (C14.9)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 06:20:25 -04:00
ZacharyZhang-NYandClaude Opus 5.5 dbd841c786 C14.8 passes review
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 05:59:28 -04:00
ZacharyZhang-NYandClaude Opus 5.5 1539c3a018 C14.8 r1: the outline scrolls in its pane
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 05:53:35 -04:00
ZacharyZhang-NYandClaude Opus 5.5 53ce8eec0b ElyMark: navigation tested through the whole window (C14.8)
desk::open_in takes the data folder, so gpui tests start ElyMark in a
temp folder: open, outline, jump, recent list, tabs, folder listing.
The web page strings, left from C14.5, go.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 05:44:05 -04:00
ZacharyZhang-NYandClaude Opus 5.5 71c5d809f5 C14.7 r3: inline GIFs end at a bad frame too; C14.7 [~]
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 05:38:47 -04:00
ZacharyZhang-NYandClaude Opus 5.5 37e2dfd5e3 C14.7 r2: pictures shrink in narrow cells; library files within limits (library 2e196a1)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 05:25:28 -04:00
ZacharyZhang-NYandClaude Opus 5.5 69b26a868f C14.7 r1: gpui ends a GIF or WebP load at a bad frame, no path in its error; pictures in table cells
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 05:14:32 -04:00
ZacharyZhang-NYandClaude Opus 5.5 3b41f299fa agents.md: the C14.7 record
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 04:47:00 -04:00
ZacharyZhang-NYandClaude Opus 5.5 0986bcb3fa ElyMark: Markdown and extended syntax, links per M-05, no link addresses logged (C14.7)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 04:46:51 -04:00
ZacharyZhang-NYandClaude Opus 5.5 33daae02bc C14.6 r3: one undo history in the document (library 639521e); C14.6 [~]
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 04:40:51 -04:00
ZacharyZhang-NYandClaude Opus 5.5 d6ec128811 C14.6 r2: block history bounds undo and redo; typing lands after undo empties the document
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 04:30:09 -04:00
ZacharyZhang-NYandClaude Opus 5.5 a75949f285 C14.6 r1: visual after preview opens an empty end; undo in an untouched block steps the document
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 04:16:15 -04:00
ZacharyZhang-NYandClaude Opus 5.5 ca3af59222 Restore the tracked bench cache file a local run rewrote
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 04:00:07 -04:00
ZacharyZhang-NYandClaude Opus 5.5 5f5a9bbf97 ElyMark: a new file takes typing in visual; modes change no byte; PERF-07 timed (C14.6)
The component library opens an empty visual document's end on focus.
The view logs once when its mode first takes input; 1 MB opens to
visual in 341 ms P50 on Windows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 04:00:00 -04:00
ZacharyZhang-NYandClaude Opus 5.5 b7bd95c3ab C14.5 is [~]: fixed after round 3, not reviewed again
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 03:48:44 -04:00
ZacharyZhang-NYandClaude Opus 5.5 63ec87fa24 C14.5 r3: the storage test types in source mode; C14.5 done
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 03:48:39 -04:00
ZacharyZhang-NYandClaude Opus 5.5 27d623395c C14.5 r2: the storage test crashes ElyMark so only the mark clears its copies
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 03:40:14 -04:00
ZacharyZhang-NYandClaude Opus 5.5 52f8b84270 C14.5 r1: help states the native picture rules; no deleted test is cited
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 03:29:46 -04:00
ZacharyZhang-NYandClaude Opus 5.5 8a942e6d80 Remove CEF: the web content crate, ElyMark's page, their packaging, licences and tests (C14.5)
PRD and agents.md name the native route; ElyMail's body will not use
CEF or Electron. ElyMark's package carries the KaTeX fonts' OFL.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 03:15:30 -04:00
ZacharyZhang-NYandClaude Opus 5.5 2e8379b0b1 C14.4 passes review
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 03:06:10 -04:00
ZacharyZhang-NYandClaude Opus 5.5 e1ae303a1f ElyMark: the shell hears each load; the size limit follows the text (C14.4 r1)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 02:53:34 -04:00
ZacharyZhang-NYandClaude Opus 5.5 35923c124b agents.md: Mark maps to MarkdownDocument
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 02:38:02 -04:00
ZacharyZhang-NYandClaude Opus 5.5 1f2920020a 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>
2026-10-12 02:37:53 -04:00
ZacharyZhang-NYandClaude Opus 5.5 540637cada Pin the component library at 69b11d7: typed source reads after a pause, texts past 2 MiB unread
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 02:34:40 -04:00
ZacharyZhang-NYandClaude Opus 5.5 8d5f0a8dcc Pin the component library at abac8b4: MarkdownDocument (C14.3)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 02:03:41 -04:00
ZacharyZhang-NYandClaude Opus 5.5 dd269f0729 Pin the component library at 433c38f: MarkdownDocument round 3
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 01:42:02 -04:00
ZacharyZhang-NYandClaude Opus 5.5 5ef9550090 Pin the component library: MarkdownDocument round 2
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 01:14:57 -04:00
ZacharyZhang-NYandClaude Opus 5.5 1031b893e6 Pin the component library at 2b6f551: MarkdownDocument
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 00:45:14 -04:00
ZacharyZhang-NYandClaude Opus 5.5 53bcf09b0b Pin the component library at 9036c92: RaTeX formulas, Mermaid diagrams, pictures from the document's folder
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 00:23:04 -04:00
ZacharyZhang-NYandClaude Opus 5.5 4183f729d1 Pin the component library at 508cc55: Markdown reads with markdown-rs
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 23:23:27 -04:00
ZacharyZhang-NYandClaude Opus 5.5 ef6dd7e06b ElyMark goes native: task list, markion and markdown-rs references, reviews at effort high
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 23:02:18 -04:00
ZacharyZhang-NYandClaude Opus 5.5 60d82d0c5e macOS CI: build from the dev clone's engines, never from scratch past the 3h runner limit (L1.8)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:06:01 -04:00
ZacharyZhang-NYandClaude Opus 5.5 5accf492ba Linux: RUNPATH guard, packaging in the container on CI, source archives per commit (L1.7)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:32:36 -04:00
ZacharyZhang-NYandClaude Opus 5.5 a9f8136e46 Linux: packages tested on Omarchy, Ubuntu and Fedora; ElyMark stays idle (L1.6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:29:59 -04:00
ZacharyZhang-NYandClaude Opus 5.5 15106b641f Linux: AppImage, deb, rpm and Arch packages from one AppDir, installed to /opt/ely-office (L1.5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:55:14 -04:00
ZacharyZhang-NYandClaude Opus 5.5 e3bfe8ade4 Linux: engines and apps build in an Ubuntu 26.04 container, glibc 2.43 at most (L1.4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:00:48 -04:00
ZacharyZhang-NYandClaude Opus 5.5 c2f16bc899 ely-ui: the settings test sets menus first, as apps do; passes on Linux (L1.3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:33:07 -04:00
ZacharyZhang-NYandClaude Opus 5.5 b2e13230b5 Linux: file watchers skip reads, so idle apps stop rereading theme, settings and repos (L1.2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:28:29 -04:00
ZacharyZhang-NYandClaude Opus 5.5 c62d548688 Linux: gpui opens real Wayland and X11 windows, not headless (L1.1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:07:48 -04:00
ZacharyZhang-NYandClaude Opus 5.5 178625b6f3 Site: v0.1.3 is out for macOS, Windows and Linux; a download link in the hero
Downloads go to the v0.1.3 release page.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 01:18:38 -04:00
ZacharyZhang-NY 62f9839170 agents.md: the verbatim prefix is \?
release / macos (push) Failing after 3h0m1s
release / windows (push) Successful in 17m15s
release / linux (push) Successful in 4m47s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 00:51:34 -04:00
ZacharyZhang-NYandClaude Opus 5.5 bfa2f1908c ElyTree: no Git version in the status bar; plain real paths on Windows
The component pin moves to bd01470 (long change-list names end before the
row's buttons). Real paths come from dunce, so Windows shows C:\... rather
than \?\C:\..., and 36 more ElyTree tests pass there. The lock returns to
windows-sys 0.61, which the last pin change had resolved down.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 00:51:22 -04:00
ZacharyZhang-NYandClaude Opus 5.5 d3cfff86db Pin the component library at 3eb4d34: Linux title-bar buttons click
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 00:37:47 -04:00
ZacharyZhang-NYandClaude Opus 5.5 d6ddd3c3e1 gpui: the topmost window control wins the hit test (Windows caption buttons)
The title bar's drag area is painted before its buttons and was matched
first, so Windows saw a caption over minimize, maximize and close and
started a window drag on every click.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 00:30:21 -04:00
ZacharyZhang-NYandClaude Opus 5.5 48a66ca6c1 Vendor gpui-pre 0.3.8 unmodified (crates.io source)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 00:25:10 -04:00
ZacharyZhang-NYandClaude Opus 5.5 ba5c0798cb AppImage: CEF lives in usr/lib, beside the libcef the binary loads
linuxdeploy points binaries at usr/lib and copied libcef.so there,
away from CEF's data: two 1.45 GB copies and a crash on ICU data.
The CEF runtime now moves to usr/lib, and linuxdeploy runs with the
AppDir's lib path, so libcef.so is found in place and kept once.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 00:09:37 -04:00
ZacharyZhang-NYandClaude Opus 5.5 09d2a6b681 Windows: no console, app icons, Calc tiles paint, theme switch no edit
release / windows (push) Successful in 14m57s
release / linux (push) Failing after 4m8s
release / macos (push) Canceled after 56m10s
Release apps use the GUI subsystem; workers, Git and cmd start with
CREATE_NO_WINDOW. Each exe embeds its icon as resource 1, which gpui
also loads for the window. Patch 0044: Win32 devices take no client
buffer, so tiles and slide layers paint at full size, then copy.
ely.xcd gives both schemes one notes colour: Calc rewrote the comment
style on each switch and left an undo step. lo-patches.sh keeps LF.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 23:40:55 -04:00
ZacharyZhang-NYandClaude Opus 5.5 f3bf816c40 licenses.sh: Linux takes OpenSSL's Debian copyright and the Apache-2.0 text
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:32:14 -04:00
ZacharyZhang-NYandClaude Opus 5.5 1972bc1d49 licenses.sh: Linux MuPDF fonts are objects under resources/fonts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:30:18 -04:00
ZacharyZhang-NYandClaude Opus 5.5 d84e8265b0 Release workflow: wsl --exec, so WSL's shell leaves the script's variables alone
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:18:10 -04:00
ZacharyZhang-NYandClaude Opus 5.5 d16ba7f9c3 ci.sh is executable (the Mac runner refused it)
release / windows (push) Successful in 21m44s
release / linux (push) Failing after 1s
release / macos (push) Canceled after 41m39s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:50:31 -04:00
ZacharyZhang-NYandClaude Opus 5.5 d7d59cd10f ci.sh: Windows junctions through PowerShell (MSYS rewrote mklink's /J)
release / macos (push) Failing after 1s
release / linux (push) Canceled after 0s
release / windows (push) Canceled after 2m11s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:48:16 -04:00
ZacharyZhang-NYandClaude Opus 5.5 a3ff244f97 Version 0.1.3
release / windows (push) Failing after 4s
release / linux (push) Skipped
release / macos (push) Failing after 31s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:43:50 -04:00
ZacharyZhang-NYandClaude Opus 5.5 3466f108c4 gpui Windows: no calls on destroyed windows, no deferred report after close
The window procedure records WM_DESTROY, revokes drag-drop while the
handle is valid and drops the activation callback; Drop leaves a
destroyed window alone. A dispatch after quit logs at debug.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:15:53 -04:00
ZacharyZhang-NYandClaude Opus 5.5 09479941ff Vendor gpui-pre-windows 0.3.8 unmodified (crates.io source)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:12:28 -04:00
ZacharyZhang-NYandClaude Opus 5.5 3b23a92b3c PDF shim: fz_var for locals set inside fz_try (gcc -Wclobbered on Linux)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:05:25 -04:00
ZacharyZhang-NYandClaude Opus 5.5 76b78782d5 ElyPDF's licences on Windows come from MuPDF's MSBuild projects
mupdf-parts.py reads which third parties and font families the
Windows build compiled in; Make builds still list their folders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:00:47 -04:00
ZacharyZhang-NYandClaude Opus 5.5 e48035cdd5 Windows kernel no longer hangs on a second start; CI for three platforms
Lightproof, the Python grammar checker in the English dictionaries,
hung the Windows worker whenever a profile was reused; macOS and
Linux carry no Python, so it never ran there. The build now drops it.
A killed worker's profile and document locks hung the next start or
load: the worker runs with SAL_NOLOCK_PROFILE and ely.xcd turns off
LibreOffice's locking; Ely checks conflicts itself.

release.yml runs scripts/ci.sh for each platform on tag push: the
Mac mini builds its engines once in a lasting clone, the Windows
runner links this machine's engines, and Linux builds in WSL on that
runner. makensis reads the script as UTF-8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:39:51 -04:00
ZacharyZhang-NYandClaude Opus 5.5 126175e589 Probe: where the Mac runner keeps Rust and the engines
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:16:52 -04:00
ZacharyZhang-NYandClaude Opus 5.5 c4495beb39 Windows installer and Linux AppImage carry their engines; a runner probe
Both build on packaging/stage.sh. The NSIS installer takes the staged
folder and removes only what it laid out; installer.sh drives it. The
AppImage gives linuxdeploy the app, the workers and libcef.so.
ElyPDF's test window installs the app's menus first, as ElyTree's
does. probe.yml reports what each Gitea runner holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:16:13 -04:00
ZacharyZhang-NYandClaude Opus 5.5 9c9f9ab8f9 Zero warnings on Windows and Linux, clippy included (W-02, L-02)
rustc and clippy are clean for the whole workspace, every target, on
both. clippy --fix moved test modules last and took the shorter
forms; long types got named aliases; ElyTree's form takes its title
and button words as one pair.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:36:06 -04:00
ZacharyZhang-NYandClaude Opus 5.5 c96d39a342 LOK runs on Windows and Linux; packaging stages engines (W-01, K-01)
The kernel is the merged library on every platform; Windows and
Linux find it beside the app in libreoffice/program. All three run
unipoll: macOS through GCD, Linux by answering one request per poll,
Windows through a message-only window on the loop's thread (LOK's
own main thread had deadlocked Calc loads). Patch 0043 flushes
callbacks inline everywhere, so a request sees its result; 0042 stops
MSVC's Boost auto-link. Linux paints with cairo, BGRA; the start-up
check now paints red text and finds red in byte two on every
platform, since getTileMode does not see Skia.

ely-lok on Windows: 180 of 189 pass; on Linux 187. The rest are
listed after 0.1.3 (spelling, Calc dark tiles, show layers and
fields, a11y selection). Tests build file URLs portably and accept
CRLF where Windows writes it.

packaging/stage.sh lays out an app with its engines for Windows and
Linux; licenses.sh finds each licence on all three. ElyMark finds its
page beside the app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:27:53 -04:00
ZacharyZhang-NYandClaude Opus 5.5 dc4c1ed0fe Release first: P0 order for 0.1.3 across three platforms
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:01:31 -04:00
ZacharyZhang-NYandClaude Opus 5.5 30801ad24b ElyTree: an unborn HEAD is told from a broken one (G-03, in review)
born() takes a HEAD as new only when it names a branch under
refs/heads/ that no ref holds yet; a broken ref, a tag target or any
other state is an error in Git's words. The branch name keeps every
byte. Round 3 is fixed; round 4 waits until 0.1.3 ships.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:00:55 -04:00
ZacharyZhang-NYandClaude Opus 5.5 6d1373fe16 ElyTree: an unread stop says why; git am skips and aborts (G-02)
A failed read of what Git is doing shows as a danger alert in Git's
words. git am's Skip and Abort had no operation name in either
catalog and panicked; both catalogs now hold it. Tests cover the
unread stop and both buttons through their confirm to Git.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:36:59 -04:00
ZacharyZhang-NYandClaude Opus 5.5 818491ad9f ElyTree: any new pick leaves a file history or blame (G-01)
The branch list's click goes through Tree::pick_ref, which clears the
look like every other pick; the test covers file, commit, side,
branch and remote picks. The test window installs the app's menus
first, as tool_root does: Windows and Linux draw the menu bar in the
window and panicked on an empty one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:21:13 -04:00
ZacharyZhang-NYandClaude Opus 5.5 d9056c3564 ElyMark: the print page runs none of the document's scripts (M-15)
WebView::open_inert makes a browser with JavaScript off; ElyMark's
staged print page uses it. webprobe opens one page both ways: the
scripted copy must report, the inert one never.

The CEF router drops a query that lacks onFailure, and queries made
before the router is ready are lost; agents.md says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:59:46 -04:00
ZacharyZhang-NYandClaude Opus 5.5 6247771f24 ElyMark: the print page is staged off the window thread (M-14)
paper::stage takes the background executor and inlines images, reads
the style sheet and writes the page there. A gpui test checks nothing
is written before the executor runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:14:56 -04:00
ZacharyZhang-NYandClaude Opus 5.5 ca5ac5bb45 ElyMark: a failed editor is told, leaves nothing, and can rebuild (M-13)
A failed build removes its holder. When both editors fail, the page
tells the app and keeps the text; the next mode request rebuilds it.
mark-broken.mjs breaks the editors through setAttribute and checks
the fallback, the told failures, no leftover holders and the rebuild.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:03:53 -04:00
ZacharyZhang-NYandClaude Opus 5.5 0d4dd64640 Web pages: browsers are made at once, so none outlives its view (M-12)
make_browsers creates each browser synchronously and adopts it in the
same call, so no view waits on a browser: it leaves before creation
(skipped) or has a browser to close. The late-close branch in
on_after_created is gone. webprobe drops 5 views before the pump and
10 within 27 ms; webview.sh wants all 35 freed before the hold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:47:45 -04:00
ZacharyZhang-NYandClaude Opus 5.5 c5cb153f72 Page warnings log their line only, never words or source (M-11)
A document can forge both a console message and its source (KaTeX's
\errmessage, data: scripts, sourceURL, encoded paths), so the display
handler logs a line number and nothing the page wrote. The page's own
diagnostics reach the app over elyQuery: a missed reveal sends its
line; the visual failure and diagram lines already did. A test calls
the real handler and checks the captured log.

Round 5 found only the missing test; it is added and mutation-checked,
with no sixth review under the five-round cap. M-15 is new: the print
page runs a document's scripts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:02:06 -04:00
ZacharyZhang-NYandClaude Opus 5.5 4a2dcbb222 ElyMark: closing find clears every tab's highlights (M-10)
Reviewed as landed in d59159c. Reviewers keep their logs under the
project's .work/ folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:07:58 -04:00
ZacharyZhang-NYandClaude Opus 5.5 11a85d1a99 ElyMark: an export panel that fails says why (M-09)
export_to splits picked, cancelled and failed as open_picked does;
a failure reaches panel_failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:02:55 -04:00
ZacharyZhang-NYandClaude Opus 5.5 22d2d04d1e ElyMark: renders asked of a failed page end at once (M-08)
The tally remembers why the page went down; until a load succeeds,
render() ends each new request with that reason instead of calling a
dead page. end_renders is a free function with a gpui test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:59:47 -04:00
ZacharyZhang-NYandClaude Opus 5.5 2c19d027a6 ElyMark: untitled copies pair by the tab made for them (M-07)
restore_shown takes its entry through Keeping::take; the test runs a
crashed session through start, untitled, made and take, the second
tab first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:42:29 -04:00
ZacharyZhang-NYandClaude Opus 5.5 ad97f25ea4 ElyMark: an unasked end keeps the lock and points (M-06)
GPUI clears windows after the quit observers, so the desk's release
ran close() and wiped what on_app_quit had kept. hold() lets the
lock go unclosed; release then finds nothing to close.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:31:40 -04:00
ZacharyZhang-NYandClaude Opus 5.5 c5f614d095 ElyMark: the page hands every link click to the app (M-05)
Preview clicks and Cmd/Ctrl clicks in the editor go through one
click handler; it stops the browser and sends the link mark's own
address, since Milkdown blanks unsafe hrefs in the DOM. Reviews run
at effort xhigh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:24:05 -04:00
ZacharyZhang-NYandClaude Opus 5.5 fa5c0b7a75 ElyMark: each print job logs its outcome; the test checks both jobs (M-04)
The window test now saves print jobs to a file (ELY_PRINT_TO) and
requires two distinct jobs printed ok and readable PDF text, not
a count of Chromium's log lines. It runs on macOS only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 04:27:06 -04:00
ZacharyZhang-NYandClaude Opus 5.5 d5f94e6e7a ElyMark: a merge taken from disk leaves the tab saved (M-03)
Every path that takes the disk text as saved goes through
adopt_disk, which sets the tab's mark and the view's baseline
together; the merge branch no longer repeats it by hand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 04:19:53 -04:00
ZacharyZhang-NYandClaude Opus 5.5 42bbc4e8fb Reviews for M-02; its test now proves a refresh restarts the counts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 04:04:59 -04:00
ZacharyZhang-NYandClaude Opus 5.5 68b5af76e0 Windows and Linux build environments; check clean on both (E-01)
MuPDF builds with MSBuild on Windows (/MT, OpenSSL from vcpkg);
Git for Windows' MinGit stands in for the source build there. CEF
loads from where libcef lives and sets its API version first on
every platform. File identity on Windows compares volume and index.
The checkout and reference/ keep LF on Windows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 03:53:56 -04:00
ZacharyZhang-NY d59159cea2 Fixes for M-02 to M-14, G-01 to G-14, K-02 to K-04, X-04 to X-14, not yet reviewed
Committed before review so Windows and WSL builds can start.
Each fix was tested and mutation-checked here; the codex review
of each item is still to come, so tasks/BUGS.md stays unticked.

M-02 is mid-review: round 1 findings are fixed (apps/ely-mark/src/tally.rs).
K-01 stays deferred. The ElyMark window test mark-outside.sh failed
once at its crash-recovery step in the last run; not yet looked into.
2026-10-10 02:29:19 -04:00
ZacharyZhang-NY 6505f35669 Reviews for D-08 2026-10-10 02:17:18 -04:00
ZacharyZhang-NY 92b515cbf8 Print is off where it is not built; no blocked command runs from the palette (D-08) 2026-10-10 02:17:18 -04:00
ZacharyZhang-NY 58e55cf35a Reviews for D-07 2026-10-10 01:55:39 -04:00
ZacharyZhang-NY 1488d615d1 ElyDoc: a hidden navigator reads nothing on each cursor move (D-07) 2026-10-10 01:55:39 -04:00
ZacharyZhang-NY 0d3d3603c8 Reviews for D-06 2026-10-10 01:50:29 -04:00
ZacharyZhang-NY db9b3975fe ElyDoc: an outline click goes to the heading drawn under it, even after a reread (D-06) 2026-10-10 01:50:28 -04:00
ZacharyZhang-NY 0b478701b5 Reviews for D-05 2026-10-10 01:36:37 -04:00
ZacharyZhang-NY d34f9c72b6 ElyDoc: a failed mail-merge record still closes its kernel document (D-05) 2026-10-10 01:36:37 -04:00
ZacharyZhang-NY 796d6a91aa Reviews for D-04 2026-10-10 01:27:21 -04:00
ZacharyZhang-NY c13787f71d ElyDoc: one unreadable note time spares the other notes and the changes; each is named, read aloud and bounded (D-04) 2026-10-10 01:27:20 -04:00
ZacharyZhang-NY 530a0cd261 Reviews for D-03 2026-10-10 01:08:36 -04:00
ZacharyZhang-NY 46ade03dde ElyDoc: kernel reads for styles, fonts, bookmarks and sections leave the UI thread; a late dialog read never replaces a newer dialog (D-03) 2026-10-10 01:08:36 -04:00
ZacharyZhang-NY baba4593c0 Reviews for P-14 2026-10-10 00:29:44 -04:00
ZacharyZhang-NY ca4a32f2fa ElyPDF worker: text blocks are always valid JSON, whatever the text or font name holds (P-14) 2026-10-10 00:29:44 -04:00
ZacharyZhang-NY dff9ed3bf4 Reviews for P-13 2026-10-10 00:17:21 -04:00
ZacharyZhang-NY a354340237 ElyPDF: an edited document's export does not quote the file's size (P-13) 2026-10-10 00:17:21 -04:00
ZacharyZhang-NY aad589a6ab Reviews for P-12 2026-10-10 00:12:31 -04:00
ZacharyZhang-NY 8429d9e421 ElyPDF worker: kept links are counted as they are kept (P-12) 2026-10-10 00:12:31 -04:00
ZacharyZhang-NY 90f6d17bec Reviews for P-11 2026-10-10 00:09:26 -04:00
ZacharyZhang-NY 6b3ba569dd ElyPDF worker: a failed undo re-reads the history mark (P-11) 2026-10-10 00:09:26 -04:00
ZacharyZhang-NY bc133169e3 Reviews for P-10 2026-10-10 00:02:40 -04:00
ZacharyZhang-NY 3d20036bff ElyPDF: a preview overtaken by an edit says so (P-10) 2026-10-10 00:02:40 -04:00
ZacharyZhang-NY 7e479a96f8 Reviews for P-09 2026-10-09 23:31:19 -04:00
ZacharyZhang-NY 86bb0d4e1c ElyPDF: a refused OCR text layer reports no success (P-09) 2026-10-09 23:31:19 -04:00
ZacharyZhang-NY 2182b71f1f Reviews for P-08 2026-10-09 23:26:40 -04:00
ZacharyZhang-NY bd7bc46d8c ElyPDF: failed reads stay unread until an edit; a save stales a signature read (P-08) 2026-10-09 23:26:40 -04:00
ZacharyZhang-NY 547dbf2cc5 Reviews for P-07 2026-10-09 22:51:33 -04:00
ZacharyZhang-NY a28e618c07 ElyPDF: text blocks with ligatures can be edited (P-07) 2026-10-09 22:51:33 -04:00
ZacharyZhang-NY 15a54bcf2e Reviews for P-06 2026-10-09 22:18:33 -04:00
ZacharyZhang-NY a49b0b5db3 ElyPDF: a document font that cannot take new text asks for another font (P-06) 2026-10-09 22:18:33 -04:00
ZacharyZhang-NY 301365a7e6 Reviews for P-05 2026-10-09 22:02:59 -04:00
ZacharyZhang-NY d7a4c37c1f ElyPDF: quick strokes and clicks queue behind the last edit instead of being refused (P-05) 2026-10-09 22:02:50 -04:00
ZacharyZhang-NY 4cec71703b ElyShow: a refused show start always says why (S-10) 2026-10-09 21:47:31 -04:00
ZacharyZhang-NY f01150be05 ElyShow: a slide size the kernel did not take stops showing (S-09) 2026-10-09 21:23:25 -04:00
ZacharyZhang-NY da81ebd777 ElyTable: test that a failed print says printing failed (T-13 follow-up) 2026-10-09 21:09:47 -04:00
ZacharyZhang-NY b46fedb327 ElyShow: typed notes survive a refused send and a read while typing (S-08) 2026-10-09 21:02:57 -04:00
ZacharyZhang-NY 33df906449 ElyShow: check rows do not jump among masters (S-07) 2026-10-09 20:36:11 -04:00
ZacharyZhang-NY b7e2c56272 ElyShow: a typed 0 in the slide range no longer exports every slide (S-06) 2026-10-09 20:31:13 -04:00
ZacharyZhang-NY 5c345b6f7c ElyShow: an unreadable comment time no longer empties the comments column (S-05) 2026-10-09 20:27:51 -04:00
ZacharyZhang-NY 41a99962aa ElyShow: an open dialog is never pushed aside; a stuck show start is gone (S-04) 2026-10-09 20:17:16 -04:00
ZacharyZhang-NY fd6e651478 ElyTable: a text file's default encoding follows the app's language (T-15) 2026-10-09 19:56:59 -04:00
ZacharyZhang-NY f27e73575d ElyTable: the import scan keeps one row at a time, not the whole file (T-14) 2026-10-09 19:53:50 -04:00
ZacharyZhang-NY 843657a6b3 Printing failures say printing failed, in every app (T-13) 2026-10-09 19:49:08 -04:00
ZacharyZhang-NY 18158a8282 ElyTable: page setup applies with whole-column or whole-row print areas (T-12) 2026-10-09 19:24:50 -04:00
ZacharyZhang-NY 759d84e20b ElyTable: closing a tab closes its dialog too (T-11) 2026-10-09 19:12:08 -04:00
ZacharyZhang-NY 345e248ff7 ElyTable: a CSV import pushed aside by another dialog comes back with its choices (T-10) 2026-10-09 19:06:57 -04:00
ZacharyZhang-NY 6145b5cea2 ElyTable: a large text file no longer freezes the window before its import dialog (T-09) 2026-10-09 18:42:39 -04:00
ZacharyZhang-NY 0c87b0bbf7 ElyTable: a column filter button sorts and filters its own range (T-08) 2026-10-09 18:36:23 -04:00
ZacharyZhang-NY 4d7ec45027 ElyTable: the wheel scrolls past the data without waiting for the kernel (T-06) 2026-10-09 18:30:52 -04:00
ZacharyZhang-NY 798e00571a ElyTable: header clicks and row or column sizes work right after a cell click (T-07) 2026-10-09 18:20:28 -04:00
ZacharyZhang-NY 7a12a28b67 ElyTable: the sheet's size follows every scale change; its end stays reachable after a zoom (T-05) 2026-10-09 18:11:39 -04:00
ZacharyZhang-NY 201aa4fc5a Bug list: UPD-1's last two fixes pass their review (UPD-2) 2026-10-09 16:54:57 -04:00
ZacharyZhang-NY 40cd554953 Bug list: a review item for UPD-1's last two fixes 2026-10-09 16:45:36 -04:00
ZacharyZhang-NY 022978bb9b Tasks: 0.1.2 published on git.zacharyzhang.com 2026-10-09 16:45:15 -04:00
ZacharyZhang-NY fde97a01a9 Version 0.1.2 2026-10-09 16:34:52 -04:00
ZacharyZhang-NY b03c9f2853 Updates: opt-in launch check, Check for Updates…, install after a normal quit, from the Gitea release; dmgs carry an Applications link (UPD-1, REL-1) 2026-10-09 16:34:32 -04:00
ZacharyZhang-NY fa273aff13 Merge remote-tracking branch 'origin/main' 2026-10-09 16:04:54 -04:00
ZacharyZhang-NY 096b7f8fd4 Site: the release comment names no host 2026-10-09 16:04:45 -04:00
ZacharyZhang-NY 9b1b64884b docs: add AGit pull request instructions 2026-10-09 20:03:10 +00:00
ZacharyZhang-NY 623bc085f6 Hosting moves to git.zacharyzhang.com: component dependency, site links, references; GitHub workflows removed 2026-10-09 15:58:26 -04:00
ZacharyZhang-NY 383a1ea467 One copy per app: a second is refused and names the running process (X-03) 2026-10-09 14:57:30 -04:00
ZacharyZhang-NY 6bf5d6c7e6 ElyMark: keys typed before an outside write survive; same text adopts the file; autosave resumes (M-01) 2026-10-09 14:39:58 -04:00
ZacharyZhang-NY c9bfccc91c ElyPDF: the edit tool checks a page's text blocks in one pass (P-04) 2026-10-09 13:16:01 -04:00
ZacharyZhang-NY 4d8d5a6ef5 ElyPDF: text under a covering picture is aimed; a shaky click no longer moves it (P-03) 2026-10-09 13:05:48 -04:00
ZacharyZhang-NY 04040a2a03 ElyPDF: page pictures stay through an edit until they repaint (P-02) 2026-10-09 12:50:11 -04:00
ZacharyZhang-NY 139e8f6250 ElyDoc: mail merge keeps the template chosen with the data; previews are never templates (D-02) 2026-10-09 12:27:13 -04:00
ZacharyZhang-NY 1f4807c81c ElyDoc: closing a tab takes its export or print dialog, no crash (D-01) 2026-10-09 12:06:47 -04:00
ZacharyZhang-NY ec87e28a46 Doc, Table, Show start past unreadable settings; the newest author name signs, restarts too (X-02) 2026-10-09 11:59:26 -04:00
ZacharyZhang-NY 8a915ea354 Quit from any window, Settings too, asks about unsaved tabs in front (X-01) 2026-10-09 11:37:56 -04:00
ZacharyZhang-NY 943411fb1a ElyPDF: an OS permission error shows as an error, never a missing text key (P-01); component library f834f55 2026-10-09 10:58:47 -04:00
ZacharyZhang-NY ab85673eea ElyShow: a deck fits the slide once; a zoom then stays (S-03); per-deck state moves to extra.rs (S-02) 2026-10-09 10:39:45 -04:00
ZacharyZhang-NY f9bf82e3f8 Bug list: fatal and high first, then auto-update and 0.1.2 2026-10-09 10:26:35 -04:00
ZacharyZhang-NY a6d8a1de43 ElyTable: a sheet starts at the left, even before its geometry arrives (T-03) 2026-10-09 10:16:11 -04:00
ZacharyZhang-NY ecf977364b ElyTable: a cell scrolls in by the least amount; a drag never scrolls under the pointer (T-02) 2026-10-09 09:47:10 -04:00
ZacharyZhang-NY c52e1a3b16 Bug list progress file; reviews find bug items 2026-10-09 09:32:01 -04:00
ZacharyZhang-NY 63bf0cc828 Bug list from the static audit (branch claude/gracious-franklin-l5vhbv) 2026-10-09 09:29:06 -04:00
ZacharyZhang-NY 380e6783d6 Tasks: 0.1.1 published 2026-10-09 09:28:58 -04:00
ZacharyZhang-NY e4ca134655 Source archives leave out the internal rules, requirements and task records 2026-10-09 09:26:35 -04:00
ZacharyZhang-NY 4efe14bce7 ElyMark: the probe requires each task box laid out and visible (R2.14) 2026-10-09 09:19:15 -04:00
ZacharyZhang-NY eee3b9e288 ElyTable: the chart search uses its own sheet, scans the sheet as a last resort, and fails cleanly with no room (R2.10) 2026-10-09 09:10:28 -04:00
ZacharyZhang-NY b3fc6c9c82 ElyMark: the probe also requires each task box drawn (R2.14) 2026-10-09 09:01:13 -04:00
ZacharyZhang-NY 8a7e45d901 Version 0.1.1 2026-10-09 08:51:42 -04:00
ZacharyZhang-NY 47b05b52fa Shorten a comment in patch 0041 (R2.10) 2026-10-09 08:51:14 -04:00
ZacharyZhang-NY 81487fb32d ElyTable: the chart search skips merged cells, looks four ways, and never throws (R2.10) 2026-10-09 08:49:39 -04:00
ZacharyZhang-NY 326fe24cba Video: the check compares both renderers at the cut and every exit frame's recordings (R2.3) 2026-10-09 08:23:16 -04:00
ZacharyZhang-NY 35165139a8 ElyShow: the fullscreen check also requires the menu bar and Dock hidden (R2.5) 2026-10-09 08:22:31 -04:00
ZacharyZhang-NY 9a126611a2 ElyMark: the probe checks every task box, ticked or not (R2.14) 2026-10-09 08:09:54 -04:00
ZacharyZhang-NY 511fb8d4eb Tasks: R2.20 and R2.7 done; R2.6, R2.8, R2.12 at the round limit 2026-10-09 07:31:31 -04:00
ZacharyZhang-NY b243515190 ElyTree: per-language presets for diffs; component library b0dbfd4 (R2.12) 2026-10-09 07:31:13 -04:00
ZacharyZhang-NY ff790e31cf ElyTable: figures split at field names; a format's own semicolons stay (R2.8) 2026-10-09 07:27:19 -04:00
ZacharyZhang-NY cc589a07b3 ElyPDF: the old-tile test checks painted area covers the shown page (R2.6); shorter comments in patch 0041 2026-10-09 07:25:55 -04:00
ZacharyZhang-NY 3c6faf9a6e ElyTree: diffs color per language; component library c771248 (R2.12) 2026-10-09 06:53:36 -04:00
ZacharyZhang-NY f4dad1d269 ElyTable: a new chart goes in empty cells beside its data, off the view if need be, and scrolls into view (R2.10) 2026-10-09 06:47:31 -04:00
ZacharyZhang-NY 409805e863 Shorten a comment (R2.5) 2026-10-09 06:43:14 -04:00
ZacharyZhang-NY ecd849601b ElyShow: a real-window check that the show covers the whole screen (R2.5) 2026-10-09 06:43:11 -04:00
ZacharyZhang-NY 65d79295ec ElyMark: short table rows compare padded, as GFM reads them (R2.13) 2026-10-09 06:38:35 -04:00
ZacharyZhang-NY 301890485f Video: one slab list for montage and exit; a render check for the recordings at the cut (R2.3) 2026-10-09 06:36:27 -04:00
ZacharyZhang-NY 5b81c5acf2 Tasks: R2.11 done 2026-10-09 05:56:16 -04:00
ZacharyZhang-NY 6445f3144d Zoom reads the current centring offset, so bunched zooms hold the anchor (R2.7); figures pass kernel values through, tested (R2.8) 2026-10-09 05:56:15 -04:00
ZacharyZhang-NY 2c3f3894dd ElyShow: the large-layer test checks the shape lands past the first piece (R2.20) 2026-10-09 05:55:06 -04:00
ZacharyZhang-NY 03bcbab5ce ElyPDF: only content edits keep old tiles and known sizes; old tiles paint and free with their tab (R2.6) 2026-10-09 05:51:44 -04:00
ZacharyZhang-NY 1bf693f7ab Video: leaving screens share the montage's geometry; renderer tests for names and recordings (R2.3) 2026-10-09 05:19:34 -04:00
ZacharyZhang-NY 44a61443fa ElyMark: tables compare by alignment and cell source, in quotes and lists too; rewritten cells stay raw (R2.13) 2026-10-09 05:15:42 -04:00
ZacharyZhang-NY 844d98559b ElyTree: diff lines color by their file's syntax; component library 5caf5aa (R2.12) 2026-10-09 05:11:22 -04:00
ZacharyZhang-NY db1e889f15 Counts: tests read the real catalogs; the ElyTree summary is tested at 0, 1 and 2 (R2.11) 2026-10-09 05:06:24 -04:00
ZacharyZhang-NY 2537472bd3 Documents: zoom anchors past the centring offset; key zoom keeps the page's left edge (R2.7) 2026-10-09 04:40:12 -04:00
ZacharyZhang-NY 3f567bf4a0 ElyTable: selection figures show as the kernel formats them for the cell (R2.8) 2026-10-09 04:36:18 -04:00
ZacharyZhang-NY b870ba9d41 ElyPDF: old tiles stay under their page through edits and rescales until new ones land (R2.6) 2026-10-09 04:34:51 -04:00
ZacharyZhang-NY 50e18d835e ElyShow: a layer comes in 16 MiB pieces; closing a deck drops its waiting layers (R2.20) 2026-10-09 04:28:04 -04:00
ZacharyZhang-NY b591aa8625 ElyTable: a new chart lands beside its data (R2.10) 2026-10-09 04:02:32 -04:00
ZacharyZhang-NY efe5d4fc5d ElyShow: the show is native fullscreen off macOS, simple fullscreen on it (R2.5) 2026-10-09 04:02:29 -04:00
ZacharyZhang-NY c77bd2a281 ElyMark: task boxes are drawn at text size on the first line (R2.14) 2026-10-09 03:57:56 -04:00
ZacharyZhang-NY f28768634f ElyMark: a table stays a table whatever its spacing; edits keep the document's table style (R2.13) 2026-10-09 03:50:52 -04:00
ZacharyZhang-NY 7049a6a4aa Tasks: R2.9 done 2026-10-09 03:49:29 -04:00
ZacharyZhang-NY be33bb28aa Patch 0040: blank line before the diff 2026-10-09 03:44:29 -04:00
ZacharyZhang-NY cc67055ccc ElyTable: a filtered range keeps its rows only for a cell inside it (R2.9) 2026-10-09 03:43:29 -04:00
ZacharyZhang-NY e85884212b Tasks: R2.17 done 2026-10-09 03:39:03 -04:00
ZacharyZhang-NY c5e19eb0b5 ElyTree: prose diffs read plain; component library 7f2ac3e (R2.12) 2026-10-09 03:28:39 -04:00
ZacharyZhang-NY 5a6c17757e ElyPDF: the document's font reads codes as text extraction does, partial ToUnicode included (R2.17) 2026-10-09 03:22:39 -04:00
ZacharyZhang-NY 0ce468a3ad ElyTable: under LOK an AutoFilter range keeps its rows through list, apply, toggle, undo and reopen (R2.9) 2026-10-09 03:20:07 -04:00
ZacharyZhang-NY f4c1a1665b ElyPDF: the document's font writes through its own object and codes; shared glyphs keep their letters (R2.17) 2026-10-09 03:17:08 -04:00
ZacharyZhang-NY fbcf654812 Review: R2.17 round 1 2026-10-09 03:07:20 -04:00
ZacharyZhang-NY 6f90a7dc53 Counts read in the singular for one thing; parts build two-count messages (R2.11) 2026-10-09 03:07:13 -04:00
ZacharyZhang-NY d4c5f90c87 ElyTable: an AutoFilter on marked rows keeps the total below out (R2.9) 2026-10-09 02:53:55 -04:00
ZacharyZhang-NY 69b299f2db Tasks: R2.16 done 2026-10-09 02:50:06 -04:00
ZacharyZhang-NY bb3120ebe4 Shorten a comment (R2.8) 2026-10-09 02:40:34 -04:00
ZacharyZhang-NY e1950f9121 ElyTable: plain figures show ten significant digits (R2.8) 2026-10-09 02:40:31 -04:00
ZacharyZhang-NY 1cf81dcb44 Sheet scroll checks run on a real sheet with its geometry (R2.16) 2026-10-09 02:39:47 -04:00
ZacharyZhang-NY 05c3eab2f8 Shorten a comment (R2.7) 2026-10-09 02:38:50 -04:00
ZacharyZhang-NY 1b1148a410 ElyDoc: a page stays centred and zoom stays on the page; only a sheet grows past its size (R2.7) 2026-10-09 02:38:46 -04:00
ZacharyZhang-NY 9fe28f072b ElyPDF: an edit keeps the page drawn until its repaint lands (R2.6) 2026-10-09 02:34:32 -04:00
ZacharyZhang-NY 5e2cf83da8 Video: app names clear before the flatten; montage screens play while they lift (R2.3) 2026-10-09 02:31:47 -04:00
ZacharyZhang-NY 48e83a9b45 ElyShow: show layers come one per reply, so large slides play (R2.20) 2026-10-09 02:31:47 -04:00
ZacharyZhang-NY 56855acfb4 ElyTable: zoom and DPI keep the scroll in print twips; drag and zoom tests far down a sheet (R2.16) 2026-10-09 02:29:46 -04:00
ZacharyZhang-NY eb844cc72c ElyShow: the show hides the menu bar and Dock (R2.5) 2026-10-09 02:23:57 -04:00
ZacharyZhang-NY a3d29bec81 Shorten a comment (R2.17) 2026-10-09 02:20:41 -04:00
ZacharyZhang-NY 371b1e81c8 ElyPDF: write in a document's subset font through the page's own glyph codes (R2.17) 2026-10-09 02:20:36 -04:00
ZacharyZhang-NY 286631daf9 Tasks: R2.4 done 2026-10-09 02:16:34 -04:00
ZacharyZhang-NY ee98f6d7c0 ElyShow: test paints slide two's pixels after a switch (R2.4) 2026-10-09 02:11:58 -04:00
ZacharyZhang-NY be31260753 ElyShow: switching slides repaints the canvas (R2.4) 2026-10-09 02:05:45 -04:00
ZacharyZhang-NY 0d529e1b0d ElyTable: clicks and drags land on the cell under them far down a sheet (R2.16) 2026-10-09 02:04:13 -04:00
ZacharyZhang-NY 148885248b Tasks: R2.2 done 2026-10-09 01:47:15 -04:00
ZacharyZhang-NY 78a0158956 Site: names use the SVG's screen transform; resize tests use CSS size (R2.2) 2026-10-09 01:45:00 -04:00
ZacharyZhang-NY 4f4bf5c35c Tasks: R2.18 done 2026-10-09 01:43:53 -04:00
ZacharyZhang-NY c078d3d0a7 Site: drop dead screenshot text, screen() and images (R2.18) 2026-10-09 01:41:23 -04:00
ZacharyZhang-NY 92702273df Site: downloads go to the GitHub release page (R2.18) 2026-10-09 01:37:38 -04:00
ZacharyZhang-NY cf04a100ff Tasks: R2.15 done 2026-10-09 01:36:29 -04:00
ZacharyZhang-NY 034cbfce02 Tasks: user-reported R2.16–R2.19 2026-10-09 01:36:20 -04:00
ZacharyZhang-NY f6f4b95108 Inert step refuses an unreadable plist; guard runs it on a fake bundle (R2.15) 2026-10-09 01:34:07 -04:00
ZacharyZhang-NY b5d490c49a Inert step tolerates only 'not registered'; guard fails on query or plist errors; test asserts the tile rejection (R2.15) 2026-10-09 01:31:08 -04:00
ZacharyZhang-NY 1745ee237d Shorten a comment (R2.15) 2026-10-09 01:23:13 -04:00
ZacharyZhang-NY 466afe20a0 No LibreOffice in Open With: built bundle claims nothing, no image is mounted (R2.15) 2026-10-09 01:23:06 -04:00
ZacharyZhang-NY 5a36ffc272 Site: names placed from the rest pose, follow model resizes; two-axis tests with motion (R2.2) 2026-10-09 01:15:27 -04:00
ZacharyZhang-NY 41fe64c74d Site: shown names follow the camera; touch tests fail on console and request errors (R2.2) 2026-10-09 01:11:59 -04:00
ZacharyZhang-NY a9f093ca26 Tasks: R2.1 done 2026-10-09 01:10:19 -04:00
ZacharyZhang-NY f5455ecd36 Site: contrast check counts element and ancestor opacity (R2.1) 2026-10-09 01:08:06 -04:00
ZacharyZhang-NY 9df123ff94 Site: alpha-aware contrast check; test the open steps (R2.1) 2026-10-09 01:05:26 -04:00
ZacharyZhang-NY 5162692902 Site: source archives, macOS 15 open steps, contrast test in both themes (R2.1) 2026-10-09 01:02:15 -04:00
ZacharyZhang-NY aeeccb4696 Tasks: R2 fixes found after release 2026-10-09 01:01:33 -04:00
ZacharyZhang-NY ff92f26af0 Review notes for site items S1–S5 2026-10-09 00:37:25 -04:00
ZacharyZhang-NY 4d8bd05633 Video: voxel titles, new opening with world turn, app punch-ins and callouts, six-screen montage, props sequence; site carries the new film 2026-10-09 00:36:35 -04:00
ZacharyZhang-NY 70cd57bd5d Site: scroll tour with live clips, beat-reactive desk and speaker, road playground, night wave, film on an isometric screen 2026-10-09 00:33:25 -04:00
ZacharyZhang-NY b6b882a8a1 Video: final edit, checks; V4–V6 done 2026-10-09 00:06:21 -04:00
ZacharyZhang-NY f017ef38c4 Site: e2e for language switch, link targets and revealed downloads 2026-10-09 00:03:57 -04:00
ZacharyZhang-NY 0a01845db9 Site: props text on one level, GitHub links 2026-10-08 23:59:12 -04:00
ZacharyZhang-NY d0d890359b agents.md: site stack, directory and deploy; site task list 2026-10-08 23:58:20 -04:00
ZacharyZhang-NY 07719a1fd3 Site: app regions with screenshots, props, downloads, English and Chinese, narrow layout; hit layer follows draw order 2026-10-08 23:57:34 -04:00
ZacharyZhang-NY 29e4e7f70f Site: separate hover, focus and press; still hit areas; one-cell lift; e2e tests 2026-10-08 23:47:27 -04:00
ZacharyZhang-NY 0f62a36182 Site: buildings lift on hover and focus, sink on press; ordered keyboard links 2026-10-08 23:40:54 -04:00
ZacharyZhang-NY 366f7529ba Site: S3 passed review; V3 recordings done 2026-10-08 23:39:15 -04:00
ZacharyZhang-NY 6619e6e1fb Site: close road gaps, ElyMark exports to PDF, test road links; recording fixes 2026-10-08 23:35:05 -04:00
ZacharyZhang-NY 3b75617599 Site: hero model of six apps, roads, beat-timed growth 2026-10-08 23:29:21 -04:00
ZacharyZhang-NY b991d59b21 Site: isometric geometry; comment checks scan JS, TS and CSS by tokens 2026-10-08 23:26:32 -04:00
ZacharyZhang-NY 570bb176e8 Comment check: multi-line block comments in .ts and .css 2026-10-08 23:20:51 -04:00
ZacharyZhang-NY e96bea98d3 Site: shorter test comment; comment check covers the site 2026-10-08 23:17:01 -04:00
ZacharyZhang-NY 4b76473fde Site: order only blocks whose projections overlap; ordering tests 2026-10-08 23:13:19 -04:00
ZacharyZhang-NY 623554fa77 Site: isometric blocks in SVG with an outline under the faces; separating-axis draw order 2026-10-08 23:07:10 -04:00
ZacharyZhang-NY 01397eb1e5 Site: isometric geometry, blocks shaded on three faces 2026-10-08 22:59:44 -04:00
ZacharyZhang-NY 1178aaa4f2 Site: Vite 8 skeleton for Workers static assets; task lists 2026-10-08 22:52:25 -04:00
ZacharyZhang-NY ca6782d37d Add the project rules, requirements and task records 2026-10-08 22:32:28 -04:00
ZacharyZhang-NY 570671abc3 Release workflow runs for a chosen tag 2026-10-08 22:23:14 -04:00
1282 changed files with 157299 additions and 16122 deletions

No files matched your search

-4
View File
@@ -1,6 +1,2 @@
[target.'cfg(all(windows, target_env = "msvc"))']
rustflags = ["-C", "target-feature=+crt-static"]
[env]
# cef-dll-sys builds against the CEF that scripts/fetch-cef.sh fetched.
CEF_PATH = { value = "engines/cef", relative = true }
+5
View File
@@ -0,0 +1,5 @@
# Internal records stay out of source archives.
agents.md export-ignore
CLAUDE.md export-ignore
PRD.md export-ignore
tasks export-ignore
+59
View File
@@ -0,0 +1,59 @@
# A tag builds, packages and uploads all three platforms.
name: release
on:
push:
tags: ['v*']
jobs:
macos:
runs-on: macos-arm64
timeout-minutes: 720
steps:
- name: Build, package and upload for macOS
shell: bash
env:
ELY_RELEASE_TOKEN: ${{ secrets.GITEA_TOKEN }}
# The dev clone's engines: LibreOffice from scratch outlasts the runner.
ELY_SHARE: /Users/zacharyzhang/Documents/GitHub/ElyOfficeSuite
ELY_PATH: /Users/zacharyzhang/.cargo/bin:/opt/homebrew/bin
run: |
dir=$HOME/ely-ci/ElyOffice
[ -d "$dir/.git" ] || git clone https://git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice.git "$dir"
git -C "$dir" fetch --force --tags origin
git -C "$dir" checkout --force "${{ github.sha }}"
"$dir/scripts/ci.sh" macos "${{ github.ref_name }}"
windows:
runs-on: windows
timeout-minutes: 360
steps:
- name: Build, package and upload for Windows
shell: bash
env:
ELY_RELEASE_TOKEN: ${{ secrets.GITEA_TOKEN }}
ELY_SHARE: /c/Users/zhang/Documents/Github/ElyOffice
ELY_PATH: /c/Program Files/CMake/bin:/c/Users/zhang/AppData/Local/Microsoft/WinGet/Packages/Ninja-build.Ninja_Microsoft.Winget.Source_8wekyb3d8bbwe
run: |
dir=$HOME/ely-ci/ElyOffice
[ -d "$dir/.git" ] || git clone https://git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice.git "$dir"
git -C "$dir" fetch --force --tags origin
git -C "$dir" checkout --force "${{ github.sha }}"
"$dir/scripts/ci.sh" windows "${{ github.ref_name }}"
linux:
# WSL on the Windows runner keeps a built kernel.
runs-on: windows
needs: windows
timeout-minutes: 360
steps:
- name: Build, package and upload for Linux
shell: bash
env:
ELY_RELEASE_TOKEN: ${{ secrets.GITEA_TOKEN }}
WSLENV: ELY_RELEASE_TOKEN
run: |
wsl -d Ubuntu --exec bash -lc '
set -euo pipefail
dir=$HOME/ely-ci/ElyOffice
[ -d "$dir/.git" ] || git clone https://git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice.git "$dir"
git -C "$dir" fetch --force --tags origin
git -C "$dir" checkout --force "${{ github.sha }}"
ELY_SHARE=$HOME/Documents/Github/ElyOffice "$dir/scripts/ci.sh" linux "${{ github.ref_name }}"
'
-77
View File
@@ -1,77 +0,0 @@
# Builds the patched engines once; release.yml downloads them.
name: engines
on:
workflow_dispatch:
permissions:
contents: write
jobs:
macos:
strategy:
fail-fast: false
matrix:
include:
- runner: macos-15
arch: arm64
- runner: macos-15-intel
arch: x86_64
runs-on: ${{ matrix.runner }}
timeout-minutes: 355
env:
GH_TOKEN: ${{ github.token }}
CCACHE_DIR: ${{ github.workspace }}/.ccache
CCACHE_MAXSIZE: 6G
steps:
- uses: actions/checkout@v4
- name: Key
id: key
run: echo "key=$(scripts/engines-key.sh)" >> "$GITHUB_OUTPUT"
- name: Already built?
id: done
run: |
if gh release view "engines-${{ steps.key.outputs.key }}" --json assets \
--jq '.assets[].name' 2>/dev/null | grep -qx "engines-macos-${{ matrix.arch }}.tar.zst"; then
echo "built=true" >> "$GITHUB_OUTPUT"
fi
- name: Tools
if: steps.done.outputs.built != 'true'
run: brew install make gnu-sed bison flex gettext autoconf automake libtool ccache nasm zstd
- name: Compiler cache
if: steps.done.outputs.built != 'true'
uses: actions/cache/restore@v4
with:
path: .ccache
key: ccache-macos-${{ matrix.arch }}-${{ steps.key.outputs.key }}-${{ github.run_id }}
restore-keys: ccache-macos-${{ matrix.arch }}-
- name: Upstream sources
if: steps.done.outputs.built != 'true'
run: scripts/fetch-references.sh
- name: LibreOffice (patched)
if: steps.done.outputs.built != 'true'
run: scripts/build-libreoffice.sh
- name: MuPDF (patched), Git, git-lfs, OCR data, CEF
if: steps.done.outputs.built != 'true'
run: |
scripts/build-mupdf.sh
scripts/build-git.sh
scripts/fetch-git-lfs.sh
scripts/fetch-tessdata.sh
scripts/fetch-cef.sh
- name: Keep the compiler cache, even after a timeout
if: always() && steps.done.outputs.built != 'true'
uses: actions/cache/save@v4
with:
path: .ccache
key: ccache-macos-${{ matrix.arch }}-${{ steps.key.outputs.key }}-${{ github.run_id }}
- name: Publish
if: steps.done.outputs.built != 'true'
run: |
archive=engines-macos-${{ matrix.arch }}.tar.zst
tar -C engines -cf - lo-src/instdir mupdf-build mupdf-src git git-src/COPYING git-src/LGPL-2.1 cef tessdata |
zstd -19 -T0 -o "$archive"
tag=engines-${{ steps.key.outputs.key }}
gh release view "$tag" >/dev/null 2>&1 ||
gh release create "$tag" --prerelease --title "$tag" --notes "Pinned, patched engines; built by engines.yml."
gh release upload "$tag" "$archive" --clobber
-55
View File
@@ -1,55 +0,0 @@
# On a version tag: macOS apps attached to its release.
name: release
on:
push:
tags: ["v*"]
workflow_dispatch:
permissions:
contents: write
jobs:
macos:
strategy:
fail-fast: false
matrix:
include:
- runner: macos-15
arch: arm64
- runner: macos-15-intel
arch: x86_64
runs-on: ${{ matrix.runner }}
timeout-minutes: 120
env:
GH_TOKEN: ${{ github.token }}
steps:
- uses: actions/checkout@v4
- name: Engines
run: |
brew install zstd
tag=engines-$(scripts/engines-key.sh)
gh release download "$tag" --pattern "engines-macos-${{ matrix.arch }}.tar.zst" ||
{ echo "::error::no $tag for ${{ matrix.arch }}; run the engines workflow first"; exit 1; }
mkdir -p engines
zstd -dc "engines-macos-${{ matrix.arch }}.tar.zst" | tar -C engines -xf -
- name: Upstream sources and ElyMark's page
run: |
scripts/fetch-references.sh
scripts/build-mark-content.sh
- name: Tests that need no window
run: |
tests/scripts/types.sh
tests/scripts/icons.sh
python3 tests/scripts/comments.py
- name: Packages
run: packaging/macos/release.sh 1
- name: Attach to the release
if: startsWith(github.ref, 'refs/tags/')
run: |
version=${GITHUB_REF_NAME}
gh release view "$version" >/dev/null 2>&1 || gh release create "$version" --title "Ely Office $version" --notes "Built by release.yml."
gh release upload "$version" dist/*.dmg --clobber
if [ "${{ matrix.arch }}" = arm64 ]; then
gh release upload "$version" dist/ElyOffice-*-source.tar.gz dist/ElyOffice-*-upstream-source.tar.gz --clobber
fi
+6 -10
View File
@@ -1,24 +1,20 @@
# Build output and downloads
/target/
/dist/
/engines/
/reference/
/engines
/reference
node_modules/
.DS_Store
.ruff_cache/
# Internal working files, kept local
/agents.md
/CLAUDE.md
/PRD.md
/tasks/
# Local only
/tasks/reviews/.pane
/.pi/
/markdown-sample.md
/docs/engineering-risks.md
/docs/qa.md
/docs/acceptance.md
/docs/test-map.md
/scripts/review.sh
/scripts/test-map.py
/tests/scripts/agents_md.py
/assets/papirus_icons-master.zip
/Gitea.md
/.work/
+1
View File
@@ -0,0 +1 @@
@agents.md
+15
View File
@@ -0,0 +1,15 @@
# Contributing
## Opening a pull request on git.zacharyzhang.com
Forking is disabled on this server. Open pull requests with AGit instead:
1. Register an account at https://git.zacharyzhang.com
2. Clone, commit your changes, then push to a `refs/for/<target-branch>` ref:
```bash
git clone https://git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice.git
git push origin HEAD:refs/for/main -o topic=<short-name> -o title="<PR title>" -o description="<PR body>"
```
3. To update the pull request, push again with the same `topic`.
Generated
+571 -259
View File
File diff suppressed because it is too large. Load diff
+10 -6
View File
@@ -5,22 +5,24 @@ members = ["apps/*", "crates/*"]
exclude = ["engines", "reference"]
[workspace.package]
version = "0.1.0"
version = "0.1.4"
edition = "2024"
rust-version = "1.95"
license = "AGPL-3.0-or-later"
publish = false
[workspace.dependencies]
embed-resource = "=3.0.11"
csv = "1"
encoding_rs = "0.8"
jiff = "0.2"
anyhow = "1"
directories = "6"
ely-gpui-component = { git = "https://github.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "69265b4fd4da888e2d8e1268021af4d358e67afb" }
dunce = "1"
ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "8fd3e431db3c57c67328869cffdcd43d5f7fd056" }
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"] }
gpui_platform = { package = "gpui-pre-platform", version = "=0.3.8", features = ["font-kit", "wayland", "x11"] }
futures = "0.3"
libc = "0.2"
log = "0.4"
@@ -40,14 +42,13 @@ serde_json = "1"
sha2 = "0.10"
tempfile = "3"
ureq = "3"
windows-sys = { version = "0.60", features = ["Win32_UI_WindowsAndMessaging", "Win32_Globalization", "Win32_Storage_FileSystem", "Win32_Security", "Win32_System_Threading", "Win32_System_IO", "Win32_System_LibraryLoader", "Win32_System_Com", "Win32_UI_Shell", "Win32_Security_Authentication_Identity"] }
windows-sys = { version = "0.60", features = ["Win32_UI_WindowsAndMessaging", "Win32_Graphics_Gdi", "Win32_Globalization", "Win32_Storage_FileSystem", "Win32_Security", "Win32_System_Threading", "Win32_System_IO", "Win32_System_LibraryLoader", "Win32_System_Com", "Win32_UI_Shell", "Win32_Security_Authentication_Identity"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["NSLocale", "NSPathUtilities", "NSString", "NSURL", "NSDictionary", "NSValue", "NSError", "NSRunLoop", "NSDate", "NSUserDefaults"] }
objc2-av-foundation = { version = "0.3", default-features = false, features = ["std", "AVPlayer", "AVPlayerItem", "AVPlayerItemOutput", "AVAsset", "objc2-core-media", "objc2-core-video", "objc2-core-foundation"] }
objc2-core-media = { version = "0.3", default-features = false, features = ["std", "CMTime"] }
objc2-core-video = { version = "0.3", default-features = false, features = ["std", "CVBuffer", "CVImageBuffer", "CVPixelBuffer"] }
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFString", "CFCGTypes"] }
core-video = "0.5"
cef = { git = "https://github.com/tauri-apps/cef-rs", rev = "a712ad9bacd234c050631a95b9745b27d346b7cc", default-features = false }
core-foundation = "0.10"
objc2 = "0.6"
security-framework = "3.7"
@@ -59,8 +60,11 @@ ely-lok = { path = "crates/ely-lok" }
ely-office = { path = "crates/ely-office" }
ely-pdf-engine = { path = "crates/ely-pdf-engine" }
ely-platform = { path = "crates/ely-platform" }
ely-web-content = { path = "crates/ely-web-content" }
ely-ui = { path = "crates/ely-ui" }
[workspace.lints.clippy]
all = { level = "warn", priority = -1 }
[patch.crates-io]
gpui-pre = { path = "vendor/gpui-pre" }
gpui-pre-windows = { path = "vendor/gpui-pre-windows" }
+935
View File
@@ -0,0 +1,935 @@
# Ely Office 产品需求文档
版本:1.0
日期:2026-10-01
状态:产品与工程实施基线
目标平台:Linux、Windows、macOS
产品形态:开源、离线优先、可独立安装和运行的桌面应用套件
本文中的“必须”是发布要求。“默认”是初始设置,用户可以按文中规定修改。功能表中的所有条目均属于首个正式版本,不代表分阶段计划。性能数值是验收目标,不是已有测试结果。第三方项目提供的能力与 Ely 已完成的能力分开记录。
## 1. 产品定义
Ely Office 提供八个桌面应用。用户可以写文档、计算表格、制作演示、编辑 PDF、收发邮件、管理 Git 仓库、编辑 Markdown 和管理 Agent 会话。
办公文件保存在用户选择的目录。应用不要求注册 Ely 账号,不要求上传文件,不依赖在线文档服务器。全部应用使用 Ely GPUI Components 的设计体系。编辑、解析、计算和协议处理复用现有项目,不重写专业内核。
### 1.1 应用构成
| 应用 | 职责 | 内核或后端基线 | 主要数据 |
| --- | --- | --- | --- |
| ElyDoc | 分页文档编辑 | LibreOfficeKit,简称 LOK | DOCX、ODT |
| ElyTable | 电子表格编辑与计算 | LOK | XLSX、ODS、CSV |
| ElyShow | 演示文稿编辑与放映 | LOK | PPTX、ODP |
| ElyPDF | PDF 阅读、编辑、批注、表单和页面处理 | MuPDF;OCR 复用 Tesseract | PDF |
| ElyMail | 邮件收发、本地存储和搜索 | MailCore 2,加本地同步与存储 | 邮件、附件、草稿 |
| ElyTree | Git 图形化版本管理 | Git CLI | 本地 Git 仓库 |
| ElyMark | 本地 Markdown 编辑 | Ely-GPUI-Components 的 Markdown 文档组件,markdown-rs 解析;参考 MarkText、ColaMD、Markion | Markdown 与本地附件 |
| ElyAgent | 独立的 Agent 配置与会话管理 | 嵌入的 Kigi CLI Rust 代码 | 配置、会话记录、运行状态 |
LOK、MuPDF 和 GPUI 是已确定的方向。MailCore 2、Markdown 文档组件、邮件正文渲染和下文的发行默认值,是本 PRD 采用的工程基线,仍须通过三端接入验收。不得把未测试的接口写成已支持功能。
ElyAgent 参考 Magpie 的独立工具定位,但不要求复制其全部功能。它不是其他七个应用的聊天侧栏,也不是整个套件的控制中枢。[S19]
### 1.2 固定决策
1. 首个正式版交付全部八个应用及本文定义的功能,不发布只有入口和占位界面的套件。
2. Doc、Table、Show 共用 LOK 适配实现。PDF 使用 MuPDF。不开设替代内核路线。
3. GPUI 负责应用界面。专业内容区域可以使用内核渲染结果,但外观和行为必须遵守 Ely 规范;不用 CEF 与 Electron。
4. 应用可以独立启动、更新和卸载。安装 ElyMark、ElyTree 或 ElyAgent 不得强制安装 LOK。
5. 用户文件采用通用格式。应用数据库只保存索引、配置和运行记录,不取代用户文件。
6. 不建设云端文档平台、微服务系统、统一业务数据库或跨应用 Agent 编排系统。
### 1.3 成功标准
用户在断网电脑上安装发行包后,可以创建、修改、保存、重新打开和打印本文范围内的本地文件。文件中的受支持内容不丢失。界面不会切回上游产品的工具栏或高级设置窗口。邮件、远程 Git 和远程模型连接失败时,本地工作仍可继续。
“对标 Office”指完成本文列出的日常工作,不代表兼容微软所有历史格式、宏、插件和企业服务。不以组件数量、文件扩展名数量或界面截图代替验收。
## 2. 用户与工作场景
面向处理本地文件的个人、开发者和小团队。用户可能只安装一个应用,也可能安装整套产品。首次使用不要求建立工作区、创建账号或完成教程。
| 场景 | 用户操作 | 完成条件 |
| --- | --- | --- |
| 审阅合同 | 打开 DOCX,修改条款,添加批注,保存并导出 PDF | 原有表格、编号、页眉页脚和修订记录按支持范围保留 |
| 整理数据 | 导入 CSV,编辑公式,筛选、汇总并绘制图表 | 导入规则可检查,计算结果可复现,导出不静默丢失工作表 |
| 制作汇报 | 修改 PPTX 母版、图文和备注,在外接屏幕放映 | 编辑对象仍可修改,观众屏与演讲者视图分离 |
| 处理 PDF | 修改文字对象、整理页面、填写表单并删除敏感内容 | 保存后的 PDF 可在独立阅读器打开,删除内容不能从输出文件恢复 |
| 离线处理邮件 | 阅读已缓存邮件,检索附件,写回复 | 草稿和发件队列落盘;联网后的发送状态有证据 |
| 管理仓库 | 查看差异、分块暂存、提交、合并并处理冲突 | Git 仓库与终端操作结果一致,危险操作先说明影响 |
| 编写 Markdown | 编辑本地文档,插入图片,与外部编辑器共同使用 | 未修改内容不被无故重写,外部变更不覆盖未保存内容 |
| 管理 Agent | 配置后端,在多个目录启动会话,查看执行和产物 | 会话状态与后端一致,重启后的恢复能力如实显示 |
## 3. 产品范围与离线边界
### 3.1 离线能力
| 应用 | 无网络时必须可用 | 需要连接时才可执行 |
| --- | --- | --- |
| Doc、Table、Show | 本地编辑、搜索、保存、导出、打印、放映 | 用户主动更新外部链接或打开远程资源 |
| PDF | 本地编辑、OCR、批注、表单、页面处理、导出 | 在线证书吊销查询、时间戳服务等外部请求 |
| Mail | 读取已缓存内容、搜索、写草稿、管理待发送邮件 | 首次授权、收取新邮件、下载未缓存内容、发送 |
| Tree | 查看已有历史、差异、暂存、提交、分支、合并、变基 | 访问远程仓库、下载尚未缓存的 LFS 对象 |
| Mark | 编辑、预览、搜索、公式与图表渲染、PDF/HTML 导出 | 用户主动加载远程图片或打开外部链接 |
| Agent | 查看本地配置和历史;运行依赖均已安装的本地后端 | 远程模型、远程工具及其授权 |
应用资源、帮助、默认模板、Markdown 渲染资源和 PDF 中英文 OCR 语言数据随对应发行包提供。不从 CDN 加载脚本、字体或样式。断网不能阻止应用启动,也不能触发登录弹窗。
本地模型及第三方 Agent 不强制打包。离线使用它们的前提是用户已安装对应程序、模型和依赖。应用必须检查这些条件,不能把“能打开会话界面”显示成“模型可离线运行”。
### 3.2 不纳入产品范围
不建设实时多人协同、云盘、在线 Office 服务、组织管理后台、应用商店、计费系统、项目管理系统或完整日历产品。
不自研文字排版、公式计算、PDF 解析、邮件协议、Git 存储、模型推理和通用 Agent 执行框架。不承诺执行 VBA、ActiveX、COM 插件、Power Query、Power Pivot 或微软专有协作功能。
不提供远程桌面、跨电脑 Agent 调度、自动代发邮件、自动跨应用修改文件或无限制后台任务。用户可通过系统文件和明确的“用其他应用打开”操作连接应用。
这些是产品边界,不是推迟交付的清单。
## 4. 平台与发行
### 4.1 支持目标
以下版本是本项目的支持目标,不是上游库已经通过验证的结论。
| 平台 | 首版架构与验收环境 | 发行方式 |
| --- | --- | --- |
| Windows | Windows 11,x86_64 | 离线安装程序;支持按用户安装 |
| macOS | macOS 14 及以上,Apple Silicon 与 Intel | 按架构提供签名和公证后的应用包 |
| Linux | x86_64;Ubuntu 24.04 LTS;固定日期的 Arch/Omarchy 测试快照 | AppImage;提供原生包构建说明 |
Linux 必须覆盖 Wayland 和 X11。macOS 两种架构分别测试,不以另一架构模拟运行代替。Windows ARM、Linux ARM、移动端和浏览器版不在首版支持清单。同时,针对 Omarchy Linux,需要适配 Omarchy Linux 的 Theme Sync 功能
必须发布图形后端、系统库和最低运行环境说明。不承诺任意虚拟机或无图形加速环境可运行;无法初始化图形后端时显示原因,不留下空白窗口。
### 4.2 安装与更新
全套发行包包含八个应用。每个应用也有独立发行包,并附带其所需运行库。用户不必安装开发工具、Node.js、Python、Docker 或文档服务器。开发和构建依赖不受此限制。
各应用共享同一套发布版本与设计规范。独立安装包自带所需运行库,允许必要的重复打包,不建设全局运行库管理器。升级一个应用不得替换其他应用依赖的内核版本。
自动检查更新默认关闭。提供手动检查、下载和离线安装。更新前要求保存编辑内容;更新失败可继续使用原版本。Linux 包管理器安装的版本不绕过包管理器自行覆盖文件。
卸载应用保留用户文档。删除缓存、账号或本地历史必须单独确认。不得把用户选择的工作目录视为应用缓存删除。
## 5. 统一 UI/UX
### 5.1 组件使用
应用窗口、菜单、导航、标签页、工具栏、属性面板、对话框、通知和设置使用 Ely GPUI Components。优先复用现有组件。新增通用交互回到组件库实现,不在八个应用里复制。
仓库已有文档、Git、邮件、Agent 等组件分类,但任务清单中的 `host`、`prove` 等标签说明仍有宿主数据或接入要求。组件展示与应用能力分开验收。[S1][S2]
不使用上游 Office 的窗口、菜单和侧栏作为正式界面。不得用颜色覆盖掩盖两套交互。平台原生文件选择器、打印对话框和凭据授权窗口属于允许的系统界面。
### 5.2 布局与外观
采用同一套浅色、深色和跟随系统主题。沿用组件库的颜色、字体、间距、圆角、边框和图标 token,不新建每应用主题。不使用渐变、霓虹、装饰性玻璃效果、大面积品牌色或多层卡片。
窗口主体按需要使用标题与标签区、左侧导航、中央内容区、右侧属性区和底部状态区。无内容的侧栏不占位。属性区只显示当前选择对象的属性。专业工具不同,但相同操作的位置、名称和图标相同。
ElyMark 默认只显示必要的标签、文件和编辑入口。ElyMail 使用文件夹、邮件列表和正文。ElyTree 使用仓库导航、提交或改动列表与差异区。不得为了布局相同而强迫所有应用常驻三栏。
界面主题不修改文档本身的字体、背景、单元格填色或打印颜色。深色阅读显示与输出内容分开。
### 5.3 共用交互
| 编号 | 要求 | 验收方式 |
| --- | --- | --- |
| UX-01 | 文件菜单中的新建、打开、保存、另存为、导出和打印使用统一命名 | 在各适用应用逐项检查 |
| UX-02 | 相同操作使用相同快捷键语义;macOS 使用 Command,其他平台使用 Ctrl | 从菜单和快捷键得到相同结果 |
| UX-03 | Tab 顺序可预测,焦点可见;Esc 关闭当前浮层,不连带退出文档 | 仅用键盘完成主要工作流程 |
| UX-04 | 禁用操作说明原因;混合选择显示“混合”,不冒用首个对象属性 | 多选不同格式内容后检查属性栏 |
| UX-05 | 长任务显示范围、状态、错误和可用的取消操作 | 导出、OCR、同步、Git 操作不阻塞窗口 |
| UX-06 | 失败提示保留用户输入,并提供重试或另存路径 | 模拟权限不足、断网和磁盘不足 |
| UX-07 | 危险操作说明具体对象和影响,不只显示“确定吗” | 检查删除、覆盖、硬重置和内容删除 |
| UX-08 | 支持简体中文与英文;错误正文不用未解释的内部错误码替代 | 切换语言后检查主要界面 |
| UX-09 | 中文输入法组合输入、候选框、撤销与跨区域焦点一致 | 三端分别测试拼音输入和中英文混排 |
| UX-10 | 多屏缩放变化不造成点击偏移、选区漂移或浮层遮挡 | 100%、150%、200% 缩放下跨屏拖动 |
| UX-11 | 正文、标签、错误和状态不只通过颜色表达 | 检查灰度显示和高对比主题 |
| UX-12 | 使用系统辅助技术所需的名称、角色、状态和文本信息 | 三端完成键盘与屏幕阅读器检查 |
同一窗口只允许一个活动编辑焦点。菜单操作可作用于编辑器当前选区,但不得先清除选区。内核快捷键与 GPUI 快捷键不得重复执行同一操作。
## 6. 文件、保存与恢复
### 6.1 文件会话
Doc、Table、Show、PDF 和 Mark 的每个打开文件都有独立会话。会话记录文件位置、最后加载的文件指纹、编辑修订号、保存状态、光标或选区、缩放和恢复副本位置。
同一个文件再次打开时聚焦已有会话,或在用户要求下打开明确标注的只读视图。不允许两个窗口在不知情的情况下交替覆盖同一文件。
| 编号 | 要求 |
| --- | --- |
| FILE-01 | 新建文件在首次保存前没有隐含目标路径;关闭前处理未保存内容 |
| FILE-02 | 显示加载中、已保存、未保存、保存中、只读、外部已修改、保存失败等状态 |
| FILE-03 | 保存任务记录对应修订号;旧修订保存完成不得把更新的编辑标为已保存 |
| FILE-04 | 写入临时文件并检查写入结果,再替换目标;失败保留原文件与未保存内容 |
| FILE-05 | 保存前检查外部修改;冲突提供重新加载、保留本地副本和比较,不静默覆盖 |
| FILE-06 | 导出默认生成独立文件,不改变正在编辑文件的格式和路径 |
| FILE-07 | 复制、粘贴、拖放和附件导入失败时不删除原始数据 |
| FILE-08 | 保存、导出、打印都针对可识别的文档修订,不输出一半新一半旧的内容 |
| FILE-09 | 路径支持空格、中文、非 ASCII 字符和长文件名;保留原有大小写 |
| FILE-10 | 对损坏、加密、缺少字体和不支持的内容给出可执行提示 |
本地文件系统采用原子替换能力。网络盘、云盘占位文件或不支持原子替换的目标必须保留上一份文件,并提示相应限制,不把本地原子保存保证套用于所有存储介质。
### 6.2 默认保存行为
Doc、Table、Show、PDF 默认显式保存,后台只生成恢复副本,不擅自覆盖源文件。用户可以对普通未签名文件启用自动保存。检测到外部变更或兼容风险后,停止自动覆盖并提示。
ElyMark 对已有路径文件默认自动保存:输入停止 1 秒后提交保存任务。无路径文件先生成恢复副本,首次保存仍由用户选择位置。自动保存失败后保持未保存状态。
恢复副本默认每 10 秒生成一次,采用后台任务。同一文档只允许一个恢复任务在运行。大文件无法完成时显示最后成功恢复时间,不宣称已经持久化。保留最近 20 个恢复点,最多 7 天;用户可更改容量上限。恢复内容不得同步到外部服务。
关闭时提供保存、放弃和取消。显式放弃后记录结果,不在下次启动时把它误判为崩溃恢复。删除敏感内容相关临时文件遵守 PDF 章节的单独规则。
### 6.3 崩溃与恢复
内核崩溃不得带走其他应用。当前应用显示故障、最近一次恢复时间和重新打开入口。恢复生成新副本,不先覆盖源文件。
能重新打开恢复文件,不等于恢复了崩溃前的全部撤销历史。界面必须区分内容恢复、光标恢复和历史恢复。未具备后端支持时不宣称三者全部恢复。
## 7. LOK 共用接入要求
LOK 提供 C/C++ 接口、分块渲染、输入事件、编辑命令和回调。相关编辑 API 中存在不稳定接口,必须锁定版本并维护回归测试。[S3][S4]
LOK 负责文档模型、布局、计算、内容编辑与文件转换。GPUI 负责操作界面与显示内核输出。不得先转成另一份简单 JSON,再用组件库重新实现页面、表格或幻灯片编辑。
| 编号 | 要求 |
| --- | --- |
| LOK-01 | 使用随应用交付的固定 LOK 构建,隔离用户已安装的 LibreOffice 配置 |
| LOK-02 | 每项界面操作有明确的内核命令、参数、结果和状态回调映射 |
| LOK-03 | 处理内容区域失效通知,只更新必要图块;旧修订的画面不得覆盖新画面 |
| LOK-04 | 统一转换文档坐标、视图缩放、滚动和平台 DPI;输入与选区使用同一坐标规则 |
| LOK-05 | 字体、字号、样式、选择对象、修改状态和撤销可用性以内核为准 |
| LOK-06 | 复制粘贴覆盖纯文本、富文本、图片和表格;不只实现键盘事件转发 |
| LOK-07 | 中文输入法包含组合开始、更新、提交和取消;不拆成普通按键模拟输入 |
| LOK-08 | 页面设置、图表、样式、批注等需要的对话框由 Ely 实现;上游原生对话框不得泄露 |
| LOK-09 | 保存与导出只通过内核支持的过滤器;错误码不能被当成成功 |
| LOK-10 | 关闭文档释放内核对象、图块和临时文件;不终止其他应用的会话 |
| LOK-11 | 不执行宏和自动更新外部链接;打开后明确显示这些内容的处理状态 |
| LOK-12 | 维护功能到接口的映射表和三端测试,不以“LOK 本身支持”作为交付证据 |
图块渲染不自动提供完整无障碍文本树。辅助技术需要的文档文本、光标和选择信息必须单独接入。无法完成某项必需交互时属于发布阻断问题,不以只读截图代替编辑。
## 8. ElyDoc
### 8.1 定位与布局
用于合同、报告、方案、说明书和信函。默认采用分页视图。左侧放置大纲、搜索与页面导航,中央显示文档,右侧放置文字、段落、页面、对象或审阅属性。
新建默认格式为 DOCX,用户可以改为 ODT。已有文件保留原格式。格式转换必须由用户触发。
### 8.2 功能要求
| 编号 | 功能 | 完成要求 |
| --- | --- | --- |
| DOC-01 | 新建与模板 | 提供空白、报告和信函模板;支持从本地模板新建,不修改模板原件 |
| DOC-02 | 文字编辑 | 支持输入、选择、复制粘贴、撤销重做、字体、字号、粗斜体、上下标、颜色和高亮 |
| DOC-03 | 段落 | 支持对齐、缩进、行距、段前段后、制表位、项目符号与多级编号 |
| DOC-04 | 样式 | 支持应用、创建和修改字符及段落样式;标题样式驱动大纲和目录 |
| DOC-05 | 页面与分节 | 支持纸张、方向、页边距、分页符、分节、分栏;分页由内核负责 |
| DOC-06 | 页眉页脚 | 支持页码、首页不同、奇偶页不同;分节关系可查看和修改 |
| DOC-07 | 表格 | 支持插入、行列调整、合并拆分、边框底色、重复标题行与跨页控制 |
| DOC-08 | 图片与对象 | 支持插入、替换、裁剪、缩放、锚点和文字环绕;保留对象顺序 |
| DOC-09 | 引用结构 | 支持目录、脚注、尾注、书签、超链接、题注和交叉引用 |
| DOC-10 | 公式 | 使用内核已有公式能力插入与修改;导出后保留可编辑表示或说明格式限制 |
| DOC-11 | 审阅 | 支持批注、回复、修订记录、接受或拒绝修改;批注可定位到内容 |
| DOC-12 | 查找与检查 | 支持查找替换、大小写选项、字数统计;提供随包词典支持的离线拼写检查 |
| DOC-13 | 文档比较 | 对两个本地版本生成差异或审阅结果;保留两个源文件,不自动合并 |
| DOC-14 | 邮件合并文档 | 从 CSV 选择字段并预览,生成独立文档或 PDF;不自动发送邮件 |
| DOC-15 | 打印与导出 | 支持打印预览、页面范围、DOCX/ODT 保存与 PDF 导出;批注和修订输出方式可选择 |
| DOC-16 | 长文档导航 | 大纲、页码、搜索和书签能跳转;跳转后可回到原位置 |
### 8.3 关键流程与限制
打开合同后先检查密码、字体和可识别的兼容风险。用户修改条款、添加批注后保存。另用目标 Office 应用打开保存结果,检查内容、编号、表格、修订和页码。截图相近但修订丢失,仍判失败。
不执行 VBA、嵌入式程序或 ActiveX。对不能保证往返保存的宏文件、受保护对象和专有嵌入内容,打开时进入只读或风险状态。转换为 DOCX/ODT 时另存新文件,不覆盖原件。
## 9. ElyTable
### 9.1 定位与布局
用于清单、预算、数据整理、公式计算、图表和汇总分析。上方提供名称框与公式栏,中间是内核表格视图,下方是工作表标签和选择统计。属性面板按单元格、图表或数据区域变化。
新建默认格式为 XLSX。公式、单元格值、工作表和依赖关系只由 LOK 维护。组件库中的表格逻辑不作为第二个计算内核。
### 9.2 功能要求
| 编号 | 功能 | 完成要求 |
| --- | --- | --- |
| TABLE-01 | 工作簿 | 新建、保存、重命名、复制、移动、隐藏和删除工作表;删除前说明引用影响 |
| TABLE-02 | 区域编辑 | 支持单元格和区域选择、插入删除、行列尺寸、合并、冻结、隐藏和填充柄 |
| TABLE-03 | 公式输入 | 支持公式栏、引用选取、函数提示、错误定位及相对、绝对、混合和跨表引用 |
| TABLE-04 | 公式计算 | 覆盖算术、逻辑、条件统计、文本、日期、查找和动态数组;使用 LOK 计算结果 |
| TABLE-05 | 计算控制 | 支持自动计算、手动重算、循环引用提示;加载和保存后保持所选计算规则 |
| TABLE-06 | 名称与引用 | 支持命名区域、范围更新、引用跳转和引用错误提示 |
| TABLE-07 | 格式 | 支持数字、货币、百分比、日期、自定义格式、对齐、边框、填色和自动换行 |
| TABLE-08 | 数据整理 | 支持排序、筛选、去重、查找替换、分列和粘贴为值或公式 |
| TABLE-09 | 规则 | 支持数据验证、下拉选择、输入提示、无效数据提示和条件格式 |
| TABLE-10 | 汇总分析 | 支持数据透视表的创建、字段调整、聚合方式和刷新;显示源数据范围 |
| TABLE-11 | 图表 | 支持柱、线、条、散点和饼图;编辑数据范围、轴、图例和标题 |
| TABLE-12 | CSV/TSV | 导入前选择编码、分隔符、引号与列类型;保留账号、编号和前导零 |
| TABLE-13 | 导出 | 保存 XLSX/ODS;CSV/TSV 导出明确只含哪个工作表,并提示公式和样式损失 |
| TABLE-14 | 打印 | 支持打印区域、重复标题、分页、方向、缩放和 PDF 导出 |
| TABLE-15 | 保护 | 支持内核已实现的工作表保护、锁定单元格和编辑许可;不绕过密码 |
| TABLE-16 | 大数据浏览 | 只渲染可视区域;滚动不为所有空单元格创建 UI 对象 |
公式验收至少包含 SUM、AVERAGE、COUNT、SUMIF/SUMIFS、COUNTIF/COUNTIFS、IF/IFS、IFERROR、ROUND、文本与日期函数、INDEX/MATCH、VLOOKUP/XLOOKUP、FILTER、SORT 和 UNIQUE。该清单是产品目标;必须确认所锁定 LOK 版本及文件过滤器能完成计算和往返保存,不能因函数名可输入就认定已支持。
### 9.3 数据正确性
日期系统、时区相关输入、区域小数点和分隔符应有明确处理规则。数值和显示格式分开比较。导入 CSV 不自动把长编号转换成科学计数法,也不把所有文本都作为公式执行。
不支持的函数显示错误或兼容提示,并保留原公式和可用的缓存值;不能用 0、空值或旧缓存冒充重算结果。外部数据链接离线时保留可识别的旧值,显示未刷新状态。
不纳入 Power Query、Power Pivot、Excel VBA、外部数据库连接管理和专有插件。包含这些内容的文件按兼容规则处理,不承诺原样编辑往返。
## 10. ElyShow
### 10.1 定位与布局
用于创建、编辑和播放演示文稿。左侧显示幻灯片缩略图,中间显示编辑画布,右侧显示当前对象或页面属性,下方提供备注。新建默认格式为 PPTX。
### 10.2 功能要求
| 编号 | 功能 | 完成要求 |
| --- | --- | --- |
| SHOW-01 | 幻灯片管理 | 支持新建、复制、删除、重排、隐藏、章节管理和页面尺寸设置 |
| SHOW-02 | 模板与母版 | 支持主题、母版、布局与占位符;修改母版后更新其引用页面 |
| SHOW-03 | 文字 | 支持文本框、段落、列表、字体、颜色、尺寸和自动适配规则 |
| SHOW-04 | 图形 | 支持基本图形、连接线、填充、描边、旋转、透明度和阴影 |
| SHOW-05 | 图片 | 支持本地插入、替换、裁剪、缩放和图层顺序 |
| SHOW-06 | 对象操作 | 支持多选、对齐、分布、组合、锁定、吸附、参考线和对象列表 |
| SHOW-07 | 表格与图表 | 插入并修改表格和常用图表;数据与对象在保存后仍可编辑 |
| SHOW-08 | 备注与审阅 | 支持演讲备注、批注和定位;备注不出现在观众屏上 |
| SHOW-09 | 动画与切换 | 支持淡入淡出等基础动画、顺序、持续时间和页面切换;提供预览 |
| SHOW-10 | 媒体 | 插入并播放本地音视频;丢失链接和不支持的编码有提示 |
| SHOW-11 | 放映 | 支持全屏、从当前页播放、跳页、黑屏、激光指示和退出 |
| SHOW-12 | 多屏 | 支持选择观众屏和演讲者视图,显示当前页、下一页、备注与计时 |
| SHOW-13 | 输出 | 支持 PPTX/ODP、PDF、页面图片和讲义打印;导出范围可选择 |
| SHOW-14 | 演示准备 | 检查缺失字体、缺失媒体、外部链接和不支持对象,并给出文件位置 |
### 10.3 放映与兼容
放映使用内核能力及必要的平台媒体支持,不把页面截图轮播当成完整放映。缩略图与动画预览不阻塞编辑。
基础动画、媒体播放和演讲者视图必须分别验证 LOK 接口与平台实现。上游桌面应用具备某功能,不表示 LOK 接入已经完成。未完成这些功能不得以隐藏按钮方式通过首版验收。
首版媒体验收覆盖本地 MP4 的 H.264/AAC 组合与常用音频文件。发行前检查解码依赖及许可;媒体不能播放时保留文件和对象,显示原因。嵌入媒体随文档保存;外部链接媒体按链接处理,不伪装成已嵌入。
不承诺任意 PowerPoint 高级特效和专有对象的等效行为。不能编辑的对象采用兼容提示,不能静默栅格化后继续宣称可编辑。
## 11. ElyPDF
### 11.1 定位与内核
提供 PDF 阅读、对象编辑、批注、表单、页面组织、OCR、内容删除和签名相关操作。MuPDF 通过 C 接口接入 Rust;不为使用 MuPDF 强制引入 Python。MuPDF 是底层库,编辑器交互由 Ely 实现。[S6][S7]
左侧提供缩略图、书签和附件,中央显示页面,右侧显示编辑、批注或表单属性。所有模式使用同一套选择、撤销、保存和状态规则。
### 11.2 功能要求
| 编号 | 功能 | 完成要求 |
| --- | --- | --- |
| PDF-01 | 阅读 | 支持连续、单页、双页、缩放、旋转显示、书签、文本选择与全文搜索 |
| PDF-02 | 页面组织 | 支持插入、删除、重排、旋转、裁剪、拆分、合并和提取;修改后更新页码与引用 |
| PDF-03 | 文字编辑 | 支持编辑可识别的现有文字对象、插入文字、调整字体字号颜色和文本框;不通过白块遮盖冒充编辑 |
| PDF-04 | 图像与对象 | 支持插入、替换、移动、缩放和删除图片及受支持对象 |
| PDF-05 | 批注 | 支持高亮、下划线、删除线、便签、形状、手绘、印章与批注列表 |
| PDF-06 | 表单 | 创建和编辑基础 AcroForm 字段;支持文本、复选、单选、选择框和填写后保存 |
| PDF-07 | 文档结构 | 支持书签、链接、元数据和附件的查看与管理 |
| PDF-08 | OCR | 对扫描页进行本地中英文识别,生成可搜索文字层;可选页面范围、语言和覆盖策略 |
| PDF-09 | 内容删除 | 标记区域或文本,预览后执行真正删除;输出文件不保留被删除对象与旧修订内容 |
| PDF-10 | 文档清理 | 提供清除元数据、批注、附件和隐藏内容的明确选项;显示处理范围 |
| PDF-11 | 密码 | 在用户提供授权密码后打开;支持输出加密文件和权限设置;不破解未知密码 |
| PDF-12 | 签名 | 插入可视签名;使用用户提供的本地证书创建数字签名;显示已签文件的验证状态 |
| PDF-13 | 输出 | 保存 PDF、导出页面图片与文本、打印;可检查压缩和图像降采样设置 |
| PDF-14 | 文件恢复 | 支持修复后打开的副本和错误报告,不覆盖损坏原件 |
OCR 复用 Tesseract API 和现成语言数据。识别结果是新增的文字层,不把扫描图片自动宣称为可无损重排的段落。[S21]
### 11.3 文字编辑边界
PDF 编辑以现有页面与对象为单位。支持文本框内修改和必要的局部重排,不承诺 Word 式跨页重排。无法识别为文字的路径或扫描内容,不显示“直接编辑原文字”。
字体缺失、字形不全或原字体不能用于新增内容时,提示用户选择可用字体。替换可能改变布局时提供预览。不得先删除原内容,再因新文字绘制失败留下空白。
### 11.4 内容删除与签名规则
真正删除包括应用删除操作、重新写出文件及清理不再引用的对象。敏感内容删除不得使用保留原始修订的增量保存方式。验收同时检查文本提取、对象内容和图片区域,不能只看页面上的黑色矩形。
内容删除默认输出新文件。明确告知用户:原件、旧备份、外部同步历史和已经发送的副本仍可能包含原内容。清理 Ely 自己生成的临时副本时说明范围;不声称完成了存储介质级安全擦除。
可视签名不等于数字签名。离线验证须分别显示签名完整性、本地证书信任情况以及吊销状态是否核验。无法联网检查时显示“未核验吊销状态”,不显示笼统的“全部有效”。
修改已签文件前说明对签名的影响,默认另存未签修改副本。创建新的数字签名后不得再用普通自动保存改写文件。
XFA 动态表单、任意嵌入脚本执行、专业印前分色及 PDF 到 Office 的无损逆向转换不在范围内。
## 12. ElyMail
### 12.1 定位与后端
提供多账号邮件客户端,不建设邮件托管服务。MailCore 2 负责 IMAP、POP、SMTP 和邮件解析等能力。Ely 负责账号流程、本地存储、搜索、同步队列和界面。MailCore 2 官方列出 C++ API 与三端支持,但发行前仍须验证实际构建和依赖。[S10]
默认布局为账号与文件夹、邮件列表、阅读区。写信可在独立窗口打开。HTML 正文和富文本撰写区不用 CEF 与 Electron,渲染方式随 ElyMail 任务另定;不能把正文 HTML 解析当成完整页面渲染。
### 12.2 功能要求
| 编号 | 功能 | 完成要求 |
| --- | --- | --- |
| MAIL-01 | 账号 | 支持多个 IMAP/SMTP 账号、POP3 账号、服务器配置、TLS 与连通性检查 |
| MAIL-02 | 授权 | 提供 Gmail 与 Microsoft 邮箱的 OAuth 流程;支持服务商允许的应用密码或自定义认证配置 |
| MAIL-03 | 邮箱视图 | 支持统一收件箱、账号独立视图、文件夹、未读、标记和附件筛选 |
| MAIL-04 | 阅读 | 支持纯文本、HTML、内嵌图片、附件、原始邮件头与会话线程 |
| MAIL-05 | 写信 | 支持收件人、抄送、密送、回复、回复全部、转发、签名、附件和纯文本/富文本撰写 |
| MAIL-06 | 草稿 | 编辑中保存本地草稿;可选择同步到服务端草稿箱;恢复不依赖网络 |
| MAIL-07 | 发送 | 提供持久化发件队列、重试、取消待发送、附件大小提示与发送结果 |
| MAIL-08 | 组织 | 支持已读未读、标记、移动、归档、删除与恢复;映射到服务端实际能力 |
| MAIL-09 | 搜索 | 搜索本地已缓存的发件人、收件人、主题、正文、日期和附件文件名 |
| MAIL-10 | 附件 | 支持下载、离线保留、另存、预览和用 Ely 应用打开;显示缓存状态 |
| MAIL-11 | 规则 | 支持按发件人、收件人、主题设置本地过滤规则,执行移动、标记或归档 |
| MAIL-12 | 联系人 | 维护本地地址补全和联系人,支持 vCard 导入导出,不建设完整通讯录服务 |
| MAIL-13 | 迁移 | 支持 EML 查看与导出、MBOX 导入导出;导入数据不直接写入远程邮箱 |
| MAIL-14 | 同步控制 | 支持手动刷新、运行期间定时同步、暂停和按文件夹离线下载 |
| MAIL-15 | 通知 | 支持新邮件通知与静音;锁屏通知默认不显示正文 |
Gmail 和 Microsoft 的 IMAP/SMTP OAuth 有各自的应用注册、权限与服务端条件。正式版内置登录必须完成适用的注册及审核。不得把用户自行申请开发者凭据作为普通账号接入的默认步骤,也不得承诺绕过组织禁用 IMAP/SMTP 的策略。[S11][S12]
### 12.3 缓存与同步
首次连接默认下载邮件头及最近 90 天的正文。附件按需下载,用户可为文件夹启用完整离线保留。界面显示最后同步时间、下载范围、尚未缓存的内容和失败原因。搜索结果注明只覆盖本地缓存。
IMAP 消息身份包含账号、邮箱、UIDVALIDITY 与 UID,不能只用主题或 Message-ID 作为同步主键。线程结合邮件引用关系组织,不把同主题的所有邮件强行合成一组。
离线移动、删除和标记先进入本地待同步队列。服务端状态已变化时重新核对,不无限重试已不存在的消息。POP3 的“保留服务端副本”默认开启,不提供伪造的 IMAP 文件夹同步能力。
附件编辑先创建工作副本。修改附件不覆盖原始 MIME 邮件;用户需要发送修改结果时,将工作副本添加到新草稿。
### 12.4 发送状态
发件状态必须区分:草稿、排队、正在发送、服务器已接受、失败、结果未知和已取消。
SMTP 返回成功后才标记服务器已接受。这不等于收件人已收到或已阅读。服务器已接受但保存“已发送”副本失败时,只重试保存副本,不重发邮件。
传输结束后连接中断、无法确认服务器是否接受时,进入结果未知。保留 Message-ID 和发送记录,允许检查后处理,不自动重发造成重复邮件。
用户在离线状态点击发送,即授权该队列项在恢复连接后尝试发送;界面当时必须说明,并允许取消。应用重启后,对遗留的结果未知项不得自动再次发送。关闭应用时说明未发送队列是否仍会运行;没有后台进程时不声称会继续发送。
HTML 邮件默认阻止远程图片和跟踪资源,禁用脚本。用户可以对当前邮件或发件人授权加载。邮件内容无法调用编辑器的本地文件桥接接口。
完整日历、Exchange 本地部署专有协议、企业归档、S/MIME 与 PGP 密钥管理不在范围内。
## 13. ElyTree
### 13.1 定位与后端
提供独立 Git 桌面客户端。使用 Git CLI 执行仓库操作,使用现有 Git、差异和树形组件呈现结果。不重写 Git 对象存储、索引和合并算法。
通过参数数组启动 Git,不拼接 shell 命令字符串。状态读取使用适合程序处理的格式,例如 `git status --porcelain=v2 -z`,不解析终端彩色输出。[S13]
### 13.2 功能要求
| 编号 | 功能 | 完成要求 |
| --- | --- | --- |
| TREE-01 | 仓库 | 支持初始化、打开、克隆、最近仓库和多仓库切换;显示实际路径 |
| TREE-02 | 工作区 | 区分未跟踪、未暂存、已暂存、冲突和忽略文件;响应外部 Git 操作 |
| TREE-03 | 差异 | 支持行内/并排差异、空白选项、文件重命名和图片差异预览 |
| TREE-04 | 暂存 | 支持按文件、差异块及可验证的行选择暂存和取消暂存;提交前显示实际索引内容 |
| TREE-05 | 提交 | 支持提交、修改最近提交、提交信息、身份与已有 Git 签名设置 |
| TREE-06 | 历史 | 支持提交图、提交详情、文件历史、Blame 和提交搜索 |
| TREE-07 | 分支与标签 | 支持创建、切换、重命名、删除分支,以及创建和删除标签 |
| TREE-08 | 合并与变基 | 支持 merge、rebase、cherry-pick、revert,以及继续、中止和错误恢复 |
| TREE-09 | 冲突 | 显示 base、当前侧和另一侧,提供块级选择与手动编辑;保存后明确标记已解决 |
| TREE-10 | 暂存工作 | 支持 stash 创建、查看、应用、弹出和删除;应用失败保留原 stash |
| TREE-11 | 远程 | 支持远程地址管理、fetch、pull、push、上游分支和进度取消 |
| TREE-12 | 仓库结构 | 支持 worktree、submodule 的状态与基础管理;显示 LFS 指针及对象可用状态 |
| TREE-13 | 恢复与清理 | 提供 reflog 查看、恢复引用、丢弃改动和清理未跟踪文件;危险操作先预览 |
| TREE-14 | 外部工具 | 支持打开终端、文件管理器和指定编辑器;可在 ElyMark 打开 Markdown |
### 13.3 操作规则
同一仓库的写操作串行执行。外部 Git 进程占用锁时等待或提示,不擅自删除锁文件。用户从终端切换分支后,应用必须重新读取工作区、索引和 HEAD。
强制推送默认采用带租约保护的方式,显示远端、分支及待覆盖影响。普通按钮不得隐含硬重置。丢弃工作区文件、清理未跟踪文件和删除分支的确认不可复用为以后所有危险操作的永久授权。
合并与变基中的两侧名称按当前操作解释,不能固定把“ours”标为用户期望保留的一侧。冲突编辑结果与暂存动作分开。取消差异查看不改变仓库。
认证复用 Git credential helper、系统 SSH 和用户已有签名工具。密钥不写入应用数据库。终端交互、凭据提示和 hook 失败必须可见,不隐藏输出后只给出“操作失败”。
Git 与必要的 LFS 运行程序随 ElyTree 发行包提供,允许用户切换到已验证的系统版本。显示当前路径和版本。代理、凭据和签名遵守所选 Git 环境,不维护一套不可见的替代配置。
DOCX、XLSX、PPTX 和 PDF 默认按二进制文件展示变更。可提供预览,但不承诺文本级合并。ElyTree 不强制其他应用把文件放入 Git 仓库。
Git 托管、PR 审核平台、Issue 系统、CI 平台和远程开发环境不在范围内。
## 14. ElyMark
### 14.1 定位与编辑框架
提供以本地 Markdown 文件为中心的编辑器。支持可视化编辑、源码编辑和只读预览。文件可由其他编辑器、Git 或 Agent 修改,不要求导入专有知识库。
界面用 Ely-GPUI-Components 的 Markdown 文档组件,全部原生 GPUI:全文只在一个源码编辑器里,可视模式按块渲染,按下的块以原文编辑;源码与预览共用同一份文本。解析用 markdown-rs,公式用 RaTeX,Mermaid 用 mermaid-rs-renderer。文本即文件原文,不经编辑模型序列化。[S14][S15][S16]
参考 MarkText 的 Markdown 编辑行为、ColaMD 的本地文件同步和内容保留规则、Markion 的 GPUI 原生 Markdown 编辑,不复制其整个应用窗口。不用 Electron,不用 CEF 或其他网页内容宿主。[S17][S18]
### 14.2 功能要求
| 编号 | 功能 | 完成要求 |
| --- | --- | --- |
| MARK-01 | 本地文件 | 新建、打开、保存、重命名和另存;支持直接打开文件或文件夹 |
| MARK-02 | 编辑模式 | 可视化、源码和只读预览可切换;切换本身不修改文件内容 |
| MARK-03 | Markdown | 支持 CommonMark 与 GFM 常用语法:标题、列表、任务、引用、链接、图片、表格和代码块 |
| MARK-04 | 扩展语法 | 支持脚注、数学公式、Mermaid 与 YAML front matter;资源随包提供 |
| MARK-05 | 导航 | 提供文件树、文档大纲、多标签、最近文件和按标题跳转 |
| MARK-06 | 编辑工具 | 支持查找替换、源码正则搜索、缩进、列表续写、表格行列操作和代码高亮 |
| MARK-07 | 附件 | 支持拖放和粘贴图片,默认复制到文档旁的资源目录并写入相对路径 |
| MARK-08 | 外部修改 | 监视文件内容、删除、重命名和原子替换;未保存内容参与冲突处理 |
| MARK-09 | 书写视图 | 支持专注模式、打字机滚动、正文宽度和字体设置;不改变 Markdown 语义 |
| MARK-10 | 搜索 | 提供当前文件与用户打开文件夹内的搜索;不默认扫描整盘 |
| MARK-11 | 输出 | 导出自包含或带资源目录的 HTML、PDF 和纯文本;支持打印及富文本复制 |
| MARK-12 | 会话恢复 | 恢复打开标签、未保存内容、光标、滚动和视图设置 |
| MARK-13 | Git 配合 | 在仓库内显示文件是否有改动,并可调用 ElyTree 查看;不内置第二套 Git 客户端 |
| MARK-14 | 大文件 | 大文件采用延迟渲染或源码视图,说明限制,不截断保存内容 |
数学与 Mermaid 默认仅渲染文档中已保存的源代码,不执行任意本地命令。语法错误显示原文与错误位置,不清空代码块。
### 14.3 原文与保存规则
未发生编辑时,打开、切换视图和保存不得改写原文件。保留 BOM 状态、换行风格、front matter 原文、代码块内容和不认识的扩展语法。
可视化编辑允许规范化实际修改的语法块,但不得借机重写整篇文档。框架无法保留的结构应显示为原文块,或提示在源码模式编辑,不能删除后重建。
模式切换前提交当前编辑事务,保留内容历史与当前修订。撤销只作用于本文件;不能撤销另一个标签的编辑,也不能把视图切换变成一次内容重写。
换行解释采用明确规则,默认遵循所选 CommonMark/GFM 语义。提供单换行显示为换行的阅读选项时,不自动在源文件插入 `<br>` 或额外空格。
### 14.4 外部变更
文件未修改时,外部写入触发重载,并尽量保持滚动和光标。文件有未保存内容时,不自动覆盖;提供磁盘版本、本地版本与共同基线的比较。
文件被外部删除后,仍保留内存内容并提示另存。原子保存导致的重命名事件不能被误判为永久删除。网络目录和监听失效时提供手动刷新,不声称所有文件系统都支持相同监听能力。
AI 或脚本修改文件与其他外部修改使用同一套处理规则。ElyMark 不需要依赖 ElyAgent 才能响应文件变化。
知识图谱、双向链接数据库、笔记同步服务、多人协同和插件市场不在范围内。
## 15. ElyAgent
### 15.1 定位与独立性
提供独立的 Agent 配置和会话管理界面。用户选择已安装的 Agent、工作目录、模型配置和执行参数,查看输入输出、工具调用、文件改动和运行状态。
推理循环来自嵌入的 Kigi,不另写一套;不成为其他 Ely 应用的内置 AI 功能,也不自动读取邮件或办公文档。用户通过工作目录或选定文件向后端提供内容。
### 15.2 接入基线
后端以 Kigi CLI(https://git.zacharyzhang.com/ZacharyZhang-NY/Kigi-CLI,Rust)为基础,以 Rust 依赖直接嵌入,不用 ACP(用户 2026-10-05 定)。提供商登录(订阅账号或 API 密钥)、模型与端点配置沿用 Kigi 的实现,不另写一套。不解析终端装饰文本来伪造结构化事件。
会话恢复只在 Kigi 的会话存储支持并通过验证时启用。不能把本地历史回放当成后端会话已经恢复。
### 15.3 功能要求
| 编号 | 功能 | 完成要求 |
| --- | --- | --- |
| AGENT-01 | 工具管理 | 检测、添加和选择本地 Agent 路径,显示版本、启动参数与可用状态 |
| AGENT-02 | 配置管理 | 按后端支持配置提供商、端点、模型及参数;密钥引用系统凭据库 |
| AGENT-03 | 配置保护 | 修改已有工具配置前备份;只修改明确字段,保留未知字段和用户注释 |
| AGENT-04 | 会话 | 按工作目录分组,支持新建、命名、搜索、归档和删除本地记录 |
| AGENT-05 | 输入输出 | 支持流式输出、停止生成、复制、日志展开和发送失败重试 |
| AGENT-06 | 并行运行 | 支持多个本机会话并行;显示各自目录、后端、模型和状态 |
| AGENT-07 | 工具与授权 | 展示后端提供的工具调用和授权请求;用户决定允许或拒绝 |
| AGENT-08 | 文件与产物 | 展示后端声明的改动、差异和产物;可打开文件或交给 ElyTree 查看 |
| AGENT-09 | 会话恢复 | 后端支持时恢复会话;否则只能查看历史或新建,不提供虚假的恢复按钮 |
| AGENT-10 | 执行控制 | 支持取消请求、停止进程和清理已退出会话;强制终止前说明影响 |
| AGENT-11 | 历史 | 本地保存可用的消息、工具事件、时间、后端会话标识和错误;可导出文本或 JSON |
| AGENT-12 | 用量 | 仅显示后端返回的 token、费用或上下文信息;缺失时显示未知 |
| AGENT-13 | 本地模型 | 允许连接后端本身支持的本地兼容端点,显示连通性;不内置模型下载与推理引擎 |
| AGENT-14 | 托盘 | 提供菜单栏或托盘入口与运行会话数量;可关闭,不依赖托盘完成操作 |
### 15.4 会话状态与生命周期
会话状态包括未启动、启动中、空闲、运行中、等待输入、等待授权、已完成、已取消、失败和连接中断。只有收到后端证据才能进入相应状态。未提供进度百分比时不估造百分比。
界面关闭窗口时,如有运行会话,提示继续在应用后台运行或停止后退出。继续运行指 ElyAgent 进程仍在,不额外安装系统级守护服务。系统无托盘时仍需保留可重新打开的应用入口。
明确选择退出应用时,说明哪些任务会终止并等待用户决定。崩溃或系统重启后只展示持久化历史及可验证的恢复选项,不承诺进程继续运行,也不自动重放可能改写文件的请求。
同一工作目录启动多个可写会话时提示可能发生冲突,提供使用独立目录或 worktree 的入口,但不强制建设自动工作树编排系统。
切换模型或提供商只按后端支持的机制执行。不宣称所有历史会话可以无损迁移到不同后端。不内置跨协议转换网关、账户轮换或额度规避功能。
独立 Agent 的安装、授权和模型供应商条件不应影响其他七个应用的本地功能。
## 16. 应用之间的连接
应用间只提供明确、可取消的文件操作,不建设跨应用事务系统。
| 入口 | 行为 | 边界 |
| --- | --- | --- |
| 邮件附件用 Ely 打开 | 将附件复制为工作副本,再交给对应应用 | 不修改原始邮件 |
| 文档导出后查看 | 在 ElyPDF 或系统默认 PDF 应用打开输出文件 | 不把导出文件当成原编辑会话 |
| Markdown 查看版本 | 向 ElyTree 传递仓库路径和文件路径 | 不自动提交、不自动创建仓库 |
| Agent 查看产物 | 按用户选择调用已安装的 Ely 应用 | 不静默打开敏感目录或执行文件 |
| 文件作为邮件附件 | 创建草稿并附加指定文件 | 不自动发送 |
| 应用切换 | 显示已安装应用并启动 | 不要求先打开统一启动器 |
目标应用未安装时,提供系统默认应用或安装说明,不阻塞原工作。应用间不读取彼此的内部数据库。
## 17. 工程结构与接入边界
### 17.1 项目组织
使用一个 Rust workspace。各应用独立编译,共享 UI、平台适配和文件处理代码。内核依赖按应用引入,不因 workspace 共存而全部链接。
```text
ely-office/
apps/
ely-doc/
ely-table/
ely-show/
ely-pdf/
ely-mail/
ely-tree/
ely-mark/
ely-agent/
crates/
ely-ui/ # 组件库的应用级组合与共用交互
ely-platform/ # 文件对话框、打印、凭据、系统集成
ely-files/ # 文件会话、保存、恢复、监听
ely-lok/ # LOK 接入;仅 Doc/Table/Show 使用
ely-pdf-engine/ # MuPDF 与 OCR 接入
ely-mail-engine/ # 邮件协议、同步和本地存储
assets/
tests/
packaging/
```
Ely GPUI Components 保持独立仓库依赖。ElyTree 的 Git 调用与 ElyAgent 的适配器先放在各自应用内;没有重复需求时不抽出通用框架。
### 17.2 进程与状态
GPUI 主进程负责界面和用户输入。LOK 和 MuPDF 放在本地工作进程中,避免解析或原生库故障直接终止整个界面。默认按应用实例启动所需工作进程,不预建全套进程池。
工作进程使用本地管道传递控制消息和结果。每条请求包含会话标识、请求标识及所需的修订信息。只在进程边界使用通信协议;进程内使用普通 Rust 类型和函数,不把所有组件都包装成消息服务。
文档会话、编辑状态和内核对象的责任必须明确。内核持有文档的可写状态,UI 保存可展示的状态,不双向同步两份完整文档模型。调用顺序和线程规则按内核要求执行。
C/C++ 异常、原生错误与资源释放在 FFI 边界处理,不能跨 Rust 调用栈传播未处理的原生错误。原生库版本和绑定一起锁定。
邮件同步、Git 命令、OCR、缩略图与索引构建在后台任务中执行,不阻塞 UI 事件循环。后台指应用运行期间的本地任务,不表示默认安装常驻系统服务。
### 17.3 内容渲染
不用 CEF,不用 Electron(用户 2026-10-11 定)。Mark 的编辑与预览全部由 GPUI 绘制(§14.1)。Mail 的 HTML 正文和富文本撰写的渲染方式随 ElyMail 任务另定,定前不写成已有。
Ely 主题 token 用于内容区。键盘、焦点、选择、缩放、右键菜单和输入法进入统一适配规则。对话框和应用菜单由 GPUI 展示。
邮件阅读不运行正文脚本。Markdown 文档里的 HTML 不运行;数学与图只渲染保存的源码。外部文档内容不能调用任意本地命令。
### 17.4 不建设的技术设施
不引入 Kubernetes、Docker 运行依赖、远程数据库、消息中间件、事件溯源系统、通用插件市场、统一知识图谱、分布式搜索或独立账号中心。
应用间不做 RPC 服务发现。Agent 不以独立后台服务器为前提。Git 不同时接入多个 Git 内核。Markdown 只有一个解析内核(markdown-rs)。新抽象必须服务于已有的重复需求。
## 18. 数据、配置与隐私
### 18.1 存储边界
| 数据 | 存储位置与规则 |
| --- | --- |
| 办公文件、PDF、Markdown | 用户指定目录;不强制复制到应用资料库 |
| Git 数据 | 仓库自身的 Git 目录及工作区;ElyTree 只缓存视图状态 |
| 邮件与附件 | ElyMail 独立 SQLite 索引和本地内容目录;可导出和设置容量 |
| Agent 会话 | ElyAgent 独立本地记录;区分应用记录与后端自身记录 |
| 通用偏好 | 版本化的本地配置文件;仅保存主题、语言及共用交互设置 |
| 应用偏好 | 各应用独立保存窗口、最近文件、编辑和业务设置 |
| 恢复副本 | 各应用恢复目录;受时间和容量限制 |
| 密钥与令牌 | 系统凭据库;不写入普通配置、诊断日志或同步索引 |
数据库不作为通用文档格式。全文索引是可重建数据,索引损坏不能导致邮件原文、文档或会话原始记录丢失。
配置修改采用原子写入与必要的文件锁。多个应用同时修改共用偏好时合并已知字段,不互相覆盖其他字段。不为此启动常驻配置服务。
### 18.2 凭据与私密内容
Windows 使用系统凭据能力,macOS 使用 Keychain,Linux 使用可用的 Secret Service。系统凭据库不可用时,允许凭据只保留到本次退出,不能静默退回明文保存。
加密文档的密码只在需要期间保留。其恢复副本不得以明文落盘;使用成熟加密库和系统密钥保护。用户不能解锁恢复密钥时,提示无法恢复,不导出不受保护副本。
第三方模型、邮箱和 Git 服务的连接由用户配置或授权。文档编辑不向这些服务发送内容。打开文件不会自动产生 AI 请求。
诊断日志默认不写正文、附件、密钥和提示词。Agent 的本地会话历史是用户数据,不属于可自动上传的诊断日志。导出诊断包前显示内容范围,并允许移除路径和个人信息。
不内置行为广告和自动上传分析。崩溃报告由用户触发提交。更新检查与远程内容加载遵守离线及隐私设置。
### 18.3 数据管理
设置中提供缓存容量、清理、恢复副本管理、邮件导出和 Agent 历史导出。删除账号先说明会删除哪些本地缓存、草稿和凭据,不影响未授权删除的服务端数据。
数据库升级前创建备份。升级失败保留旧数据,并阻止用不兼容结构继续写入。应用降级不尝试猜测转换;允许恢复备份或只读导出。
## 19. 文件兼容与输出
### 19.1 格式范围
| 应用 | 正式编辑与保存范围 | 导入或受限范围 | 输出 |
| --- | --- | --- | --- |
| Doc | DOCX、ODT | DOC、RTF、TXT,以及可识别的模板;按兼容提示转换 | DOCX、ODT、PDF、文本 |
| Table | XLSX、ODS | XLS;CSV/TSV 按导入设置转换;宏文件只读或另存 | XLSX、ODS、CSV、TSV、PDF |
| Show | PPTX、ODP | PPT、模板及含专有对象的文件按兼容提示处理 | PPTX、ODP、PDF、PNG/JPEG |
| PDF | 本文定义的 PDF 对象、批注、表单和页面功能 | XFA、嵌入程序或无法编辑的对象只读或提示 | PDF、页面图片、文本 |
| Mail | 本地消息、草稿与服务端协议操作 | EML、MBOX、vCard | EML、MBOX、附件、vCard |
| Tree | 标准本地 Git 仓库 | 浅克隆、LFS、submodule 等显示其限制 | Git 原生提交与引用 |
| Mark | MD、Markdown 文本与本地资源 | 未支持语法保留原文;编码转换需确认 | Markdown、HTML、PDF、文本 |
| Agent | 已验证后端的配置与会话记录 | 外部会话按适配器能力读取,不作通用格式承诺 | 文本、JSON、后端支持的格式 |
支持文件格式不等于支持该格式所有功能。发行说明必须提供版本化兼容清单,注明已验证的内容、限制和保存行为。
### 19.2 往返验证
每个办公测试文件依次执行:在 Ely 打开、编辑指定内容、保存、关闭、再次打开,再由独立目标应用打开。检查结构、内容、公式、修订、批注、对象可编辑性和输出画面。
不只与同一个内核比较。DOCX/XLSX/PPTX 应包含与 Microsoft Office 的交叉检查;测试机和目标 Office 版本写入报告。用于测试的第三方软件由团队合法取得,不要求终端用户安装。
字体一致的环境与字体缺失环境分别测试。字体缺失时出现分页差异不应被隐瞒,替换规则和缺失字体列表必须可见。产品不捆绑无再分发许可的微软字体。
数值计算使用独立预期结果。精确整数、日期、错误值和文本严格比较;浮点数按测试用例声明的容差比较,不设一个掩盖所有误差的全局容差。
已知高风险文件默认不自动覆盖。无法完整识别所有未知对象时,不宣称“已经检测全部兼容风险”。首次修改此类文件应提示另存副本。
### 19.3 打印与 PDF 输出
打印预览与实际输出使用同一文档修订、纸张、页边距和缩放设置。显示页数和范围。打印取消不改变文档内容。
Doc、Table、Show 的 PDF 输出由 LOK 完成,不要求安装 ElyPDF。ElyMark 的 PDF 输出由本地渲染与打印能力完成,不要求安装 LOK。ElyPDF 用于后续编辑和查看,而不是全套应用的强制导出服务。
## 20. 质量与性能要求
### 20.1 验收环境
基准使用 16 GB 内存、NVMe 存储、至少 6 个 CPU 核心或同档 Apple Silicon,以及满足所选图形后端要求的设备。三端记录具体 CPU、GPU、驱动、系统、字体、编译版本和样本哈希。
以下指标按发行构建、每项 30 次测试的 P95 统计。首次安装初始化与日常冷启动分开记录。网络、远程模型速度及打印机出纸时间不计入本地响应指标。指标是项目目标,调整需要在发布前更新文档和理由。
| 编号 | 项目 | 目标与样本 |
| --- | --- | --- |
| PERF-01 | 启动 | 无文件冷启动至窗口可操作不超过 3 秒;不预加载未使用内核 |
| PERF-02 | 交互 | 普通点击与按键反馈不超过 100 毫秒;长任务不连续阻塞主线程 100 毫秒以上 |
| PERF-03 | 文档打开 | 100 页、20 张图片、10 MB 内 DOCX,5 秒内显示首屏并接受操作 |
| PERF-04 | 表格打开 | 10 万非空单元格、1 万个简单公式的工作簿,5 秒内接受操作;重算另记录 |
| PERF-05 | PDF 浏览 | 本地 1000 页、200 MB 内 PDF,3 秒内显示第一页;不先渲染全部页面 |
| PERF-06 | 本地搜索 | 已完成索引的 10 万封邮件或 10 万提交仓库,常见查询首屏结果不超过 1 秒 |
| PERF-07 | Markdown | 1 MB 文档 2 秒内进入可编辑模式;超大文件允许进入明确的源码模式 |
| PERF-08 | 资源释放 | 连续打开关闭 20 次后,活动文档对象与句柄回到基线,内存无持续单向增长 |
| PERF-09 | 多会话 | 4 个 Agent 会话同时流式输出时,其他会话选择和日志滚动仍满足交互目标 |
不提前宣传固定内存占用或比其他 Office 更快。发布报告列出单应用与内核子进程的总内存,不能只测 GPUI 主进程。
### 20.2 稳定性
保存失败、内核崩溃、强制结束、磁盘不足、目录只读、临时文件损坏、失去网络、OAuth 失效、外部文件替换和系统休眠必须有测试。
主要应用完成连续 8 小时编辑与切换测试。测试中不得出现源文件损坏、跨文档撤销、无提示覆盖、邮件重复发送或 Git 状态伪报。
不能取消的操作应明确说明所处步骤,并允许用户等待或安全关闭界面。不得为了满足“有取消按钮”而在破坏性写入中任意杀进程。
### 20.3 无障碍与国际化
键盘能完成打开、编辑、保存、搜索和主要工具操作。焦点框不依赖鼠标悬停。内容区和 GPUI 区域间的焦点切换可预测。正文及常规界面文字的对比度目标至少为 4.5:1。
覆盖简体中文与英文输入、混合字体、标点、组合输入和非 ASCII 路径。日期、数字与纸张设置使用显式区域配置。修改界面语言不得重解释已存储的单元格数值或文档日期。
不因内核渲染成图块就把整个文档对辅助技术隐藏。文档内容、表格单元格和编辑状态的可访问性属于正式验收。
## 21. 开源与许可证
### 21.1 自有代码默认许可
本 PRD 对新增应用代码采用 AGPL-3.0-or-later 作为发行默认,便于与 MuPDF 的开源使用路线保持一致。Ely GPUI Components 继续保留现有 MIT/Apache-2.0 许可,不因被应用使用而改写其许可证。[S1]
第三方代码保持原许可证。不得把 LibreOffice、Git 或其他依赖的版权声明统一替换成 Ely 的声明。最终发行须按实际链接、进程调用、修改和打包方式审查,进程隔离不作为免除许可义务的依据。
### 21.2 第三方发行要求
| 依赖 | 发行要求 |
| --- | --- |
| MuPDF | 按锁定版本的 AGPL 条款提供对应源码、修改、集成和构建资料;不把公开 UI 仓库当成已完成合规 [S8][S9] |
| LibreOffice/LOK | 保留 MPL 2.0 及附带依赖的相应声明;发布对受约束文件的修改与所需资料 [S5] |
| MailCore 2 | 保留其许可、版权和免责条款,并检查其传递依赖 [S24] |
| Git | 作为独立程序分发并遵守其 GPLv2 等实际适用条款;提供所发行版本的源码取得方式 [S23] |
| Markdown | 保留 markdown-rs、RaTeX(含随带字体)、mermaid-rs-renderer 及其依赖的许可;逐项检查实际发行包 [S14][S15][S16] |
| OCR、字体、词典和媒体组件 | 记录每个资源的来源、版本和再分发条件,不从系统目录随意拷贝受限资源 |
MuPDF 的 AGPL 路线允许符合条件的开源集成;“项目开源”本身不足以证明整个发行包已经符合条件。是否收费与源码义务分开处理。[S8][S9]
普通用户文档不因经过应用编辑而自动成为应用源码。不要在产品协议中要求用户公开文档或邮件。许可证审查涉及应用与依赖,不把用户内容列入发布源码包。[S9]
### 21.3 发布资料
每个发行版本必须附带自有源码、锁定依赖清单、上游补丁、构建步骤、第三方通知和许可证文本。二进制下载页提供对应版本源码入口。不得只链接到会变化的上游默认分支。
公开构建说明必须足以让贡献者重建应用,不依赖未公开私有脚本。签名密钥和用户凭据不属于公开构建资料。
本节作为发布检查要求,不替代针对最终发行包的法律审查。
## 22. 测试与验收
### 22.1 测试资料
使用可再分发、自行制作或已获得测试授权的样本。真实客户资料须先取得授权并去除不应公开的信息,不放入公共仓库。
每个功能编号至少关联一个测试用例。每项用例记录输入样本、操作、预期结果、平台、内核版本和实际结果。测试未运行、失败与不适用分开记录。不能用“接口存在”替代操作验证。
UI 自动测试、内核测试和手工交互测试互相补充。三端的输入法、打印、屏幕阅读器、外接显示器和系统凭据流程需要真实环境检查。
### 22.2 跨应用验收用例
| 用例 | 覆盖范围 | 通过条件 |
| --- | --- | --- |
| QA-01 | 安装与离线启动 | 干净测试机断网安装;八个应用均可打开;没有登录、CDN 或外部文档服务器依赖 |
| QA-02 | 独立安装 | 只安装 Mark、Tree 或 Agent 时不安装或启动 LOK;只安装 Doc 时可独立导出 PDF |
| QA-03 | 统一界面 | 菜单、属性、错误、快捷键和主题符合第 5 节;没有上游产品界面泄露 |
| QA-04 | 输入与焦点 | 三端输入中文、选择文本、打开工具栏、提交组合输入和撤销,结果正确 |
| QA-05 | 多屏 | 缩放和屏幕变化后,选区、点击、浮层及演示画面不偏移 |
| QA-06 | 保存失败 | 断开目标盘、设置只读或写满磁盘,原文件保留,未保存状态和恢复内容正确 |
| QA-07 | 外部冲突 | 外部程序在本地未保存时覆盖文件,Ely 保留两份内容并要求处理 |
| QA-08 | 并发保存 | 在旧修订保存期间继续编辑,完成后仍显示新修订未保存 |
| QA-09 | 崩溃恢复 | 终止内核或应用,重新启动能定位恢复副本;不覆盖原件,不声称恢复不存在的历史 |
| QA-10 | Doc | 合同、长报告、跨页表格、目录和修订文件完成编辑、保存及独立应用往返验证 |
| QA-11 | Table | 函数、引用、日期、透视表、图表和 CSV 样本与预期一致;未知公式不显示伪结果 |
| QA-12 | Show | 母版、图表、备注、动画和本地媒体可编辑;多屏放映不暴露备注;PPTX 对象可继续编辑 |
| QA-13 | PDF 编辑 | 修改文字、图像、表单和页序后,在独立阅读器检查;失败不破坏原对象 |
| QA-14 | PDF 删除 | 应用删除后通过对象、文本和图像检查确认输出文件中无被删内容及旧修订残留 |
| QA-15 | PDF OCR 与签名 | 扫描件可本地搜索;签名显示完整性和信任边界;离线不虚报吊销核验 |
| QA-16 | Mail 离线 | 无网络可读取已缓存内容、搜索、写草稿和入队;未缓存附件不显示成已下载 |
| QA-17 | Mail 发送 | 模拟发送前失败、接受后断连和已发送副本失败,不重复发送,不误报最终状态 |
| QA-18 | Mail 同步 | 文件夹变更、UIDVALIDITY 变化、服务端删除和令牌失效后不丢本地草稿,不发生错误关联 |
| QA-19 | Git 结果 | GUI 操作后用独立 Git 命令检查索引、提交、引用和工作区,结果一致 |
| QA-20 | Git 异常 | 测试冲突、hook 失败、凭据失败、锁竞争、变基中断和取消;状态与实际仓库一致 |
| QA-21 | Mark 原文 | 无编辑保存逐字节一致;局部编辑不重写无关段落、front matter 和未知语法 |
| QA-22 | Mark 外部变更 | 测试原子替换、删除、重命名和并行编辑;不覆盖未保存内容,不截断导出 |
| QA-23 | Agent 能力 | 测试结构化和普通命令后端;不支持恢复、费用或权限事件时显示真实限制 |
| QA-24 | Agent 生命周期 | 并行会话、窗口关闭、停止、完整退出和重启后的状态与进程事实一致 |
| QA-25 | 隐私与断网 | 抓取测试机流量,未授权时无文档、正文、日志、CDN 或自动更新请求 |
| QA-26 | 升级与卸载 | 升级失败保留原版本和数据;卸载不删除用户目录;剩余应用继续启动 |
| QA-27 | 许可证 | 安装包、源码包、通知和构建资料相互对应,所打包资源均有来源与许可记录 |
| QA-28 | 无障碍与性能 | 完成第 20 节指标和键盘、辅助技术检查,报告记录全部子进程资源 |
### 22.3 不得带入正式版的问题
以下任一问题阻止正式发布:源文件损坏;静默丢失受支持内容;覆盖未保存文件;跨标签撤销;公式伪结果;PDF 删除后仍可恢复敏感内容;无法确认的邮件被自动重发;Git 危险操作未说明;Agent 会话被伪报为可恢复;三端核心编辑不能完成;发行包缺少必要许可或对应源码。
其他问题必须写入已知问题说明,并且不破坏本文定义的必需功能。不能通过把必需功能改成隐藏、只读或占位来规避发布要求。
## 23. 必须验证的工程风险
这些检查属于实现和发布流程,不构成分阶段交付。当前文档未包含三端集成程序的实测结果。
| 检查项 | 需要提交的证据 | 不通过时的处理 |
| --- | --- | --- |
| LOK 三端嵌入 | Doc/Table/Show 的编辑、保存、中文输入与窗口操作录像及日志 | 修复适配或更新设计决策;不能只交付查看器 |
| LOK 高级功能 | 母版、放映、图表、修订、输入法、打印和无障碍的接口映射及用例 | 补齐桥接或维护必要上游补丁,不跳回上游原生 UI |
| MuPDF 编辑 | 字体、对象修改、表单、删除及保存的样本结果 | 修复对象编辑与输出;不得改为视觉覆盖冒充编辑 |
| MailCore 2 | 三端可重复构建、TLS、OAuth、同步和 MIME 测试 | 处理依赖及适配问题;替换后端必须显式修订选型 |
| Markdown 原生编辑 | 可视、源码、预览的输入、焦点、缩放、输入法和辅助技术测试 | 未通过不能以 CEF、WebView 或 Electron 代替 |
| Markdown 原文保留 | 无编辑保存、模式切换、局部修改和外部冲突的字节级差异 | 修复映射与保存;未知语法回到原文模式,不删除 |
| 邮箱服务注册 | 正式 OAuth 配置、适用审核状态与用户流程 | 不将未获许可的内置登录描述为可用 |
| Agent 后端差异 | 每个预设后端的版本、启动、控制、日志和恢复能力表 | 按真实能力显示,禁止模拟未提供的接口 |
| 打包与许可 | 三端依赖清单、源码对应关系、资源再分发检查 | 发行前解决,不以“整个项目开源”代替检查 |
选择版本时记录源码提交、编译选项和补丁。升级 LOK、MuPDF、GPUI 或 Markdown 解析与渲染库后运行相关回归测试。不要跟随默认分支自动升级正式构建。
## 24. 交付物与完成定义
### 24.1 工程交付物
交付八个应用的三端发行包、对应源码、构建与打包脚本、依赖与许可证清单、格式兼容清单、测试样本和测试报告。帮助内容包括打开保存、恢复、离线边界、账号授权、仓库操作及 Agent 后端限制。
不为满足交付清单制造独立文档系统。构建说明、已知限制和测试报告可放在仓库现有文档目录。需求以本文件为准,代码中的功能状态不得与文档相反。
### 24.2 首个正式版完成定义
八个应用可以独立安装和运行。本文功能表均有实现与测试。三端核心流程通过。文件保存、计算、发送和 Git 操作的结果可检查。所有应用遵守同一套界面规范,用户不需要学习不同的保存和错误处理方式。
离线安装不缺资源。没有强制 Ely 账号、云服务、在线 Office 服务或跨应用 Agent 依赖。发行包与公开源码匹配,已知兼容限制写明。未验证事项不能写成已完成能力。
## 25. 资料来源
核对日期:2026-10-01。来源用于确认接口、项目结构和许可条件,不证明 Ely 已完成接入。产品功能、默认设置与性能目标由本文定义。正式实现应记录所采用版本的固定提交和许可证,而不是只引用默认分支。
[S1] Ely GPUI Components,README,组件库使用方式、主题及许可证。
`https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components/blob/main/README.md`
[S2] Ely GPUI Components,TASKS,组件分类与 host/prove 等状态定义。
`https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components/blob/main/TASKS.md`
[S3] LibreOfficeKit 官方文档,C/C++ 接口、分块渲染和输入回调。
`https://docs.libreoffice.org/libreofficekit.html`
[S4] LibreOfficeKit 官方头文件,实际接口及不稳定 API 条件。
`https://raw.githubusercontent.com/LibreOffice/core/master/include/LibreOfficeKit/LibreOfficeKit.h`
[S5] LibreOffice 官方许可证说明。
`https://www.libreoffice.org/licenses/`
[S6] MuPDF 官方 C Library 文档。
`https://mupdf.readthedocs.io/en/latest/reference/c/index.html`
[S7] MuPDF 官方能力说明。
`https://mupdf.com/`
[S8] MuPDF 官方许可证文档;本次读取页面版本为 1.28.5。
`https://mupdf.readthedocs.io/en/1.28.5/license.html`
[S9] GNU Affero General Public License 第 3 版原文。
`https://www.gnu.org/licenses/agpl-3.0.en.html`
[S10] MailCore 2 官方介绍,协议、API 与平台范围。
`https://etpan.org/mailcore2.html`
[S11] Google,Gmail IMAP/POP/SMTP OAuth 2.0 与权限要求。
`https://developers.google.com/workspace/gmail/imap/xoauth2-protocol`
[S12] Microsoft,IMAP/POP/SMTP OAuth 身份验证。
`https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth`
[S13] Git 官方文档,git-status 与 porcelain 输出格式。
`https://git-scm.com/docs/git-status`
[S14] markdown-rs 官方仓库,CommonMark、GFM 与扩展语法解析,许可证。
`https://github.com/wooorm/markdown-rs`
[S15] RaTeX 官方仓库,LaTeX 公式排版与许可证。
`https://github.com/erweixin/RaTeX`
[S16] mermaid-rs-renderer 官方仓库,Mermaid 图的 Rust 渲染与许可证。
`https://github.com/1jehuang/mermaid-rs-renderer`
[S17] MarkText 官方仓库,编辑模式、格式能力与许可证。
`https://github.com/marktext/marktext`
[S18] ColaMD 官方仓库及 package.json,本地文件行为、架构参考与依赖。
`https://github.com/marswaveai/ColaMD`
`https://github.com/marswaveai/ColaMD/blob/main/package.json`
[S19] Magpie 官方仓库,独立 Agent 工具定位参考。
`https://github.com/yetone/magpie`
[S20] Agent Client Protocol,Session Setup 与能力协商。
`https://agentclientprotocol.com/protocol/v1/session-setup`
[S21] Tesseract 官方 API 示例与本地 OCR 接入。
`https://tesseract-ocr.github.io/tessdoc/APIExample.html`
[S23] Git 官方 COPYING 文件。
`https://raw.githubusercontent.com/git/git/master/COPYING`
[S24] MailCore 2 官方 LICENSE 文件。
`https://raw.githubusercontent.com/MailCore/mailcore2/master/LICENSE`
+2 -3
View File
@@ -9,7 +9,7 @@ Offline-first desktop apps in Rust and GPUI. Files stay in your folders; no acco
| ElyShow | Presentations (PPTX, ODP) | LibreOfficeKit, system media |
| ElyPDF | PDF reading and editing, OCR, signatures | MuPDF, Tesseract |
| ElyTree | Git client | Git |
| ElyMark | Markdown | Milkdown, CodeMirror in CEF |
| ElyMark | Markdown | markdown-rs, RaTeX, mermaid-rs-renderer |
| ElyMail, ElyAgent | Second release | — |
## Build (macOS)
@@ -20,8 +20,7 @@ scripts/fetch-references.sh # upstream sources at pinned commits
scripts/build-libreoffice.sh # patched LibreOffice 26.2.6.3 (about an hour)
scripts/build-mupdf.sh # patched MuPDF 1.28.5
scripts/build-git.sh && scripts/fetch-git-lfs.sh
scripts/fetch-tessdata.sh && scripts/fetch-cef.sh
scripts/build-mark-content.sh # ElyMark's page
scripts/fetch-tessdata.sh
cargo build -p ely-lok --bins -p ely-pdf-engine --bins
cargo run -p ely-doc # or ely-table, ely-show, ely-pdf, ely-tree, ely-mark
```
+288
View File
@@ -0,0 +1,288 @@
# Ely Office
单一事实来源。技术栈或产品方向变更时,同步改本文件。需求以 `PRD.md` 为准。进度以 `tasks/TASKS.md` 为准。
## 产品
八个桌面应用:ElyDoc、ElyTable、ElyShow、ElyPDF、ElyMail、ElyTree、ElyMark、ElyAgent。离线优先,文件在用户目录。
## 技术栈
| 层 | 选型 |
| --- | --- |
| 界面 | Rust + GPUI + Ely-GPUI-Components |
| Doc / Table / Show | LibreOfficeKit,独立工作进程 |
| Show 媒体 | 各平台原生:macOS AVFoundation,Windows Media Foundation,Linux GStreamer(LOK 不放媒体) |
| PDF | MuPDF(C 接口),Tesseract OCR,独立工作进程 |
| Mail | MailCore 2 + SQLite;正文渲染随其任务另定(不用 CEF 与 Electron) |
| Tree | Git CLI,参数数组启动 |
| Mark | 组件库 `documents::MarkdownDocument`(GPUI,原文即文本,无序列化);markdown-rs 解析与导出 HTML,RaTeX 公式,mermaid-rs-renderer 图;PDF 经 `ely-pdf-worker` 由 MuPDF 排版 |
| Agent | Kigi CLI 的 Rust 代码直接嵌入(不用 ACP),沿用其提供商登录与模型配置 |
## 目录
```text
apps/ 八个应用,各自一个二进制
crates/ ely-ui ely-platform ely-ipc(应用与引擎 worker 之间的帧);ely-files ely-lok ely-office(文档应用共用的外壳:标签、会话、保存、关闭询问、冲突、密码;引擎为 LOK 或 MuPDF)ely-pdf-engine(MuPDF worker 与客户端);ely-mail-engine 随其任务建
reference/ 上游源码,只读,不属于本项目,不审查
engines/ 下载的二进制与引擎构建产物,不入库,不审查;mupdf-src 是 reference/mupdf 加 patches/mupdf 的构建副本
patches/ 对上游的补丁,随源码发行(libreoffice、mupdf)
vendor/ crates.io 上游 crate 的副本加本项目的修复,经工作区 `[patch.crates-io]` 换入(gpui-pre、gpui-pre-windows);首个提交为原样,其后是修复
packaging/ 三端打包脚本,apps.tsv 是八个应用的二进制名、名字、桌面分类与发布批次;icons/ 由图标母版渲染各端格式;libreoffice/ely.xcd 是内核的默认配置层
assets/ icons/ 是八个应用的图标母版(SVG)与渲染出的 icns、png、ico,规则见其 README
dist/ 打包产物,不入库
docs/ 兼容清单、无障碍、帮助、许可、性能记录;engineering-risks.md、qa.md 等内部笔记只在本机,不入库
scripts/ fetch-references.sh 取上游 doctor.sh 查工具 review.sh 开分栏审查 build-*.sh 构建引擎
tests/ scripts 与 engines 下是脚本测试;engines 缺二进制时报 SKIP;fixtures 是测试共用的样本(media/clip.mp4:1 秒 H.264/AAC;pdf/aes128.pdf:AES-128 加密,打开密码 user、所有者密码 owner;pdf/signer.p12 与 signer.der:自签名测试证书,密码 ely)
tasks/ TASKS.md 进度真相
site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木;中英两种文案在 src/text.ts);site/video 是发布视频的分镜、配乐、录屏与合成脚本,产物在 site/video/out(不入库)
```
## 组件映射
依赖 `ely-gpui-component`(pin 8fd3e43,gpui 为 crates.io 的 `gpui-pre` =0.3.8)。名字取自 `reference/Ely-GPUI-Components/src`。gpui 用法规则见该仓库 `AGENTS.md`。`tests/scripts/agents_md.py` 核对下表每个名字都是组件库公开项。
| 需要 | 用 |
| --- | --- |
| 窗口外壳 | `shell::{TitleBar, TabBar, StatusBar, Toolbar, ToolbarGroup, WindowSwitcher, ZoomControl}` `layout::{AppShell, SplitPane, Sidebar, Dock}` |
| 命令与菜单 | `menus::{MenuBar, ContextMenu, DropdownMenu}` `navigation::{CommandPalette, QuickOpen}` |
| 导航 | `navigation::{EditorTabs, Tabs, Breadcrumb, TableOfContents}` `lists::{Tree, Outline, FileTree, SelectableList}` |
| 属性与表单 | `data_display::{PropertyGrid, DescriptionList}` `forms::{Select, FontPicker, ColorPicker, NumberInput, ScrubInput, Switch, SearchInput, TextInput}` `buttons::{Button, IconButton, ToggleButton, SegmentedControl}` |
| 对话框 | `overlays::{Dialog, ConfirmDialog, AlertDialog, PromptDialog, Popover}` |
| 反馈 | `feedback::{Toaster, ToastViewport, SaveState, SavingIndicator, SyncStatus, Alert, Banner, EmptyState, ErrorView}` `shell::{OfflineIndicator, CrashReporter, UpdateBanner, UpdateDialog}` |
| 设置 | `settings::{SettingsLayout, SettingsSection, SettingsRow, ThemeSelector, KeyboardShortcutsList, StorageSettings, PrivacySettings}` |
| Doc | `documents::{PrintPreview, TemplatePicker, WordCount, PageThumbnailList}` `editor::{FindWidget}` `collab::{CommentSidebar, Thread, Comment, Peer}` `lists::{List, ListItem}` |
| Table | `tables::{PivotTable, FilterBuilder, SortBuilder}` `misc::{CsvImporter, ExportDialog}` `editor::{CompletionMenu, SignatureHelp, FindWidget}` `forms::{CheckboxGroup, ColorPalette}` |
| Mail | `mail::{MailboxList, MailList, MailThreadView, MailReader, MailComposer, SignatureEditor, ScheduleSend}` |
| Tree | `git::{ChangesList, CommitInput, CommitList, lanes, DiffViewer, ConflictResolver, BlameView, BranchList, TagList, StashList, FileHistory}` |
| Mark | `documents::{MarkdownDocument, MarkdownRenderer, ZenMode}` `editor::{CodeEditor, FindWidget}` |
| Agent | `agent::{ToolCallCard, PermissionPrompt, ToolApprovalDialog, AgentStepList, FileChangeCard, MultiFileDiffReview, ArtifactPanel, CostBreakdown}` `chat::{MessageList, PromptInput, StreamingMarkdown, ConversationList}` |
| 动效 | `motion::{duration, Flip, Reorder, Transition, Stagger, ProgressBar, Spinner, Skeleton}` |
| 主题 | `theme::{Theme, Mode}`;Omarchy 色板由 `ely-ui` 映射成组件库的调色板 |
## 缺口
组件库没有的,在本项目内建:
| 缺口 | 位置 |
| --- | --- |
| LOK 图块画布与输入桥 | `crates/ely-lok` |
| 富剪贴板:gpui 剪贴板只有文本与图片,无 HTML(富文本、表格) | `ely_platform::clipboard`,用 arboard(三端 HTML、PNG) |
| gpui 无障碍只有 `aria_value` 等,无文本选区与插入点:LOK 文档的光标与选区到不了辅助技术 | `LokView` 只桥接当前段落文本(`Role::MultilineTextInput`)。上游待办:gpui 补文本选区 AX |
| 选中对象的框与手柄:LOK 不画进图块 | `LokView` 按图形选区画框与 8 个手柄;按住手柄拖动经 `setGraphicSelection` 交内核(缩放;裁剪模式下裁剪) |
| 虚拟化 PDF 页面栈(`DocumentViewer` 一次铺全部页,不合 PERF-05) | `crates/ely-pdf-engine`、`apps/ely-pdf` |
| 组件库的大纲项(`lists::Outline`)与提示(`feedback::Alert`)无无障碍角色:屏幕阅读器读不到 | 上游待办;ElyMark 的搜索失败改为结果列表里的行。`feedback::Banner` 已修(fac20ab:危险与警告为 alert,其余为 status,名为其文字) |
| `documents::ZenMode` 把正文夹进 Sm 宽的列,与 ElyMark 的正文宽度设置冲突 | ElyMark 不用它,专注模式原生重做(C14.12) |
| 表格公式栏与名称框(网格是 LOK 图块,`tables::Spreadsheet` 不用) | `apps/ely-table` |
| Doc/Table/Show 工具栏(`documents::FixedFormatToolbar` 绑 `TextInput`,不接 LOK) | 各 app,用 `shell::Toolbar` 组合 |
| CSV 导入的编码、分隔符、引号、列类型(`misc::CsvImporter` 只读逗号 CSV、映射到属主字段,文案写死英文) | `apps/ely-table` 的 `text.rs`,内核按选项导入 |
| 导出格式与说明(`misc::ExportDialog` 无 TSV,文案写死英文,不能附说明) | 各 app,用 `overlays::Dialog` 组合 |
| 幻灯片缩略图条(`documents::PageThumbnailList` 只收图片路径,无隐藏标记、右键菜单与重排) | `apps/ely-show` 的 `slides.rs`,内核按页绘制到内存图 |
| PDF 页面缩略图(同上,且一次铺全部页,不合 PERF-05) | `apps/ely-pdf` 的 `side.rs`,`uniform_list` 只画可见行,worker 按需渲染 |
| 演示的参考线:LOK 不把参考线画进图块,也无参考线接口 | 补丁 0027 读写参考线;`apps/ely-show` 的 `guides.rs` 画在画布上,拖动移动,拖出幻灯片即删除 |
| gpui 文本里不能嵌图片:行内公式无从排版 | 组件库的 `MarkdownRenderer` 把行内公式显示为 TeX 原文(着色),块公式才排版 |
| `A11yChecker` 的 `audit` 与对照表是私有的;Omarchy 色板要照同一套规则 | `ely-ui` 的 `omarchy/palette.rs` 抄了对照表。上游待办:公开 `audit` |
| 组件无 role/aria 语义:菜单、列表等控件在 AX 树仍不可见 | 上游已修核心控件(7380afb:Button、TextInput、Tabs、面板行、对话框等);其余上游待办。项目自绘控件已自带 |
| gpui 无 aria-disabled API(`AriaProperties` 无 disabled 字段) | 组件 7380afb 起以本地化 aria_description 表达;上游待办:gpui 补 API |
| 组件内部文案未接 i18n(面板族之外:欢迎页标题、菜单项、`SortBuilder` 与 `FilterBuilder` 等) | 面板族文案已接 i18n(7380afb,内建 en/zh-CN),`CommentSidebar` 与 `RelativeTime` 同(408f2b0);其余上游待办 |
| LOK 的 `.uno:StyleApply` 状态给界面名(`Default Paragraph Style`),样式列表给程序名(`Standard`),两者对不上 | ElyDoc 属性栏:列表外的当前样式显示为占位文字;更新样式用状态里的界面名。上游待办:状态带程序名 |
## gpui 规则
取自组件库的 `AGENTS.md`、`src/lib.rs` 与 `examples/docs/hello.rs`,应用必须遵守。
- 启动:`gpui_platform::application().with_assets(Assets).run(..)`,闭包里先调 `ely_gpui_component::init(cx)`。没接 `Assets` 会 panic。
- 首帧用 `Theme::set_mode_now`。之后 `Theme::set_mode` 渐变。
- 根元素包进 `FocusScope`,调 `.root()`,聚焦它的句柄。
- 渲染之外改了状态,调 `cx.notify()`。绘制中改的,再调 `window.request_animation_frame()`。不用 `request_animation_frame` 轮询布局。
- 颜色、控件尺寸、圆角、阴影取自 `cx.theme()`。间距用 gpui 的 rem 阶梯。边框用 `border_1`。
- 盖在页面上的东西走 `primitives::raise`。对话框走 `overlays::Dialog`。每个窗口一个 `Toaster`,一个 `ToastViewport`。
- 可按压的元素都是 Tab 停靠点:`tab_index(0)` 加 `focus_ring`。`hover()` 需要元素 id。
- 动效时长取 `motion::duration()`,它遵守减少动效。重复动画在减少动效下静止。动画只包住移动的部分:动画 id 传给子孙,换 id 会重建它们的焦点与拖动状态。
- 文本框是 `forms::TextInput` 实体。文案走 `i18n::I18n`,缺键即失败。
- 可复用组件内的 id 带上属主的 id。`uniform_list` 的行设 `w_full`。盖在内容上的层按需用 `block_mouse_except_scroll`。
## 规则
1. 界面优先用 Ely-GPUI-Components。通用交互回组件库,不在应用里复制。
2. 出错就暴露。不吞错,不写兜底,不用默认值遮挡缺失数据。
3. 修根因。不打补丁式小修。
4. 关键路径有日志(`log`,与 gpui 一致)。日志不含正文、附件、密钥、提示词。
5. 文件不超过 500 行。注释一行,十字以内,只在必要时写。
6. 无 dead code,无 v1/v2 接口,无过期代码。
7. 禁止大框套小框。禁止无用说明文字和小标题。
8. 不过度设计。不加预留扩展,不加无第二个使用者的抽象。
9. 动效只在交互时运行,每段不超过 220 毫秒,遵守系统“减少动效”。空闲时不重绘。目标设备含树莓派。
10. 未验证的能力不写成已完成。
## 命令
```text
scripts/doctor.sh 工具链版本
scripts/fetch-references.sh 取上游源码到 reference/
scripts/fetch-tessdata.sh 取 OCR 语言数据到 engines/tessdata(钉提交,核对散列)
scripts/build-git.sh 构建钉住的 Git 到 engines/git(ElyTree 随包)
scripts/fetch-git-lfs.sh 取 git-lfs 放进 engines/git(核对散列)
scripts/review.sh ID 轮次 路径 审查一条任务
scripts/apply-lo-patches.sh engines/lo-src 找出源码已到补丁序列第几个,补上其余(构建脚本调用)
tests/scripts/lo-patches.sh 补丁序列从中途接着打、再打一次为空操作
tests/scripts/media-targets.sh 媒体播放接口在 Linux、Windows 目标可编译(缺目标时 SKIP)
tests/scripts/linux-idle.sh 真窗口 ElyTree:打开仓库后、Omarchy 换主题与改设置后,空闲时不读主题、设置,不跑 git status;照样跟随换主题、改设置与改文件,经内存映射的写入也跟随
tests/scripts/linux-backends.sh Linux 构建带 Wayland 与 X11;Hyprland 上 ElyTree 在两者上都映射出窗口、画出页面与起始页文字(截窗口量色,tesseract 读字)
packaging/linux/container.sh 命令 在 Ubuntu 26.04 镜像里运行(引擎、应用、打包;目标在 target/ubuntu)
tests/scripts/linux-glibc.sh Linux 的应用、worker 与引擎要求的 glibc 不高于 2.43
packaging/linux/package.sh 应用 出该应用的 AppImage、deb、rpm 与 Arch 包到 dist/(在容器里运行)
packaging/source.sh dist 本提交的源码包与随包的 copyleft 上游源码(打过补丁的 MuPDF、Git),文件名带提交号;工作区有未提交改动即拒绝(macOS 的 release.sh 与 CI 的 Windows、Linux 调用)
tests/scripts/ci-share.sh ci.sh 只经指向 ELY_SHARE 的链接用共享引擎:自建目录、指向别处或目标带换行的链接即拒绝
tests/scripts/linux-packages.sh deb、rpm、Arch 包在干净的 Ubuntu 26.04、Fedora 44、Arch 容器里装上,每个 ELF 都找得到库
tests/scripts/linux-pins.sh Linux 打包工具(appimagetool、linuxdeploy、运行时、nfpm)的下载与钉的散列相符
tests/scripts/linux-installed.sh installed|appimage Hyprland 上装好的六个应用或 AppImage:一个窗口、随 Omarchy(Catppuccin)、读出页面文字(PDF、Markdown 经各自引擎),ElyTree 随换主题到 Latte(grim 按窗口截取)
tests/scripts/linux-distros.sh deb 在 Ubuntu 26.04、rpm 在 Fedora 44 容器里装上,各应用在 Xvfb(Mesa 软件 Vulkan)下画出窗口、随 Omarchy、读出页面文字(与 linux-installed.sh 共用 linux-common.sh)
cargo test -p ely-show --test compose 切换半程帧经 Metal 取像素(主线程,仅 macOS)
tests/scripts/*.sh *.py 脚本测试(缺工具时报 SKIP)
packaging/macos/bundle.sh ely-doc debug 出 dist/ElyDoc.app(带图标、文档类型与该应用的引擎)
packaging/macos/release.sh [批次] 该批各应用的 release 包与 dmg;ELY_SIGN_IDENTITY 加 ELY_NOTARY_PROFILE 即公证
packaging/icons/masters.py 写出 assets/icons 的全部 SVG 母版(图标设计只改这里)
packaging/icons/build.sh 由 assets/icons 的母版渲染 icns、png、ico(只在 macOS)
tests/scripts/icons.sh 八个图标齐全、未过期,macOS 为 squircle,其余无底形
tests/scripts/release-macos.sh dist 里首批六个 .app:以访达的方式打开文件(启动与运行中),引擎都取自包内,带齐许可,不含系统会登记的内嵌包,每个二进制要求的系统不高于应用所写;源码包齐全
tests/engines/*.sh 引擎探测(LOK、MuPDF、libetpan);LOK 探测本构建的内核,不挂载官方镜像
scripts/inert-lo.sh 路径.app 构建出的 LibreOffice.app 去掉文档类型、URL 类型、快速查看与聚焦插件并注销(build-libreoffice.sh 调用)
scripts/extract-official-lo.sh 用 7zz 把官方镜像的 Frameworks 与 Resources 解到 engines/lo-official(不挂载,无包,“未打补丁即拒绝”的单测用)
tests/scripts/lo-unregistered.sh “打开方式”里没有 LibreOffice,构建出的包不声明任何文件类型
tests/scripts/lokview.sh 真窗口里的 LokView:输入法、键入、点击、拖动,AX 树里的段落文本;ELY_CLIPBOARD_TEST=1 再测复制粘贴(会覆盖系统剪贴板)
tests/scripts/doc-shell.sh 真窗口 ElyDoc:空白标签进 AX 树,启动后 Cmd+N 打开新建对话框(需辅助功能权限)
tests/scripts/doc-props.sh 真窗口 ElyDoc 属性栏:段前段后连续键入、回车、失焦,读 AX 值与命令数(需辅助功能权限)
tests/scripts/doc-review.sh 真窗口 ElyDoc:无日期的批注逐条写明作者与原因(AX 可读),多条时批注与修订列表仍在窗口内(需辅助功能权限)
tests/scripts/theme.sh 真窗口 ElyTable 以深、浅两种外观启动,截窗口量网格亮度(需屏幕录制权限)
tests/scripts/lag.sh ely-doc 真窗口键入,从调试日志算输入落地、图块、按键到新图块的分位数(需辅助功能权限);ELY_LAG_LOG=路径 留下日志
tests/scripts/pdf-idle.sh 真窗口 ElyPDF 无文件启动时无 worker 子进程
tests/scripts/comments-rules.sh comments.py 对长注释与跨行注释报错
tests/scripts/bad-settings.sh 通用设置读不出时 ElyDoc 照样启动,文件不被覆盖
tests/scripts/one-instance.sh 第二份 ElyDoc 被拒,第一份的工作目录不动
tests/scripts/update.sh 装好的 ElyTree 从本机服务的新版下载、替换、重新打开(需辅助功能权限)
tests/scripts/settings.sh 真窗口:ElyPDF 设置里选深色,ElyTree 即刻变深;作者名写入、不丢外观;ElyTree 拒绝非 Git 程序;设置文件读不出时提示且不覆盖;主窗口重开回到原处(需辅助功能与屏幕录制权限)
tests/scripts/privacy.sh 真窗口 ElyDoc:键入的文字不进日志;worker 的日志进应用日志;设置里的诊断先列出所含内容,导出的文件去掉家目录与姓名、不含正文(需辅助功能权限)
tests/scripts/panels.sh 真窗口 ElyDoc:左侧栏默认不宽于 230、拖动内侧边缘变宽、宽度存入设置、重启后照旧(需辅助功能权限)
tests/scripts/storage.sh 真窗口 ElyMark:设置里的存储列出各类大小,日志当场清空,恢复副本下次启动前清掉,恢复点数写入设置(需辅助功能权限)
tests/scripts/recovery.sh 真窗口 ElyDoc:恢复点、崩溃后副本、连续两次崩溃、只杀引擎后重新打开(需辅助功能权限)
tests/scripts/pdf-recovery.sh 真窗口 ElyPDF:加锁文件的封存恢复点、空闲引擎退出后重开时再问密码(需辅助功能权限)
tests/scripts/autosave.sh 真窗口 ElyDoc:自动保存开关、停手即写、外部改动不覆盖(需辅助功能权限)
tests/scripts/print.sh 真窗口 ElyDoc:打印用文档自己的纸张,取消不动文档(需辅助功能权限)
tests/scripts/show-fullscreen.sh 真窗口 ElyShow:F5 放映的窗口盖住整块屏幕(菜单栏与程序坞之上,需辅助功能权限)
tests/scripts/types.sh packaging/types.tsv 的文件类型在 Linux 桌面项、Windows 安装程序与 macOS 文档类型里一致,从不设为默认
tests/scripts/layout.sh 真窗口:六个应用在 640、800、1100 宽下无元素越出窗口左右,留截图(需辅助功能与屏幕录制权限)
tests/scripts/ooxml.py <用例> <文件> 不经 LibreOffice 读 DOCX、XLSX、PPTX 并按自算的预期核对(往返用例调用)
cd site && pnpm deploy 官网构建并经 wrangler 发到 Cloudflare Workers(只有静态资源),地址 https://ely-office.zhangyanghaha0407.workers.dev
cd site && pnpm test && pnpm e2e 官网单测(几何、道路连通)与 Playwright 端到端(悬停、聚焦、按压、命中归属)
cd site/video/scenes && npm run verify 发布视频:群像与离场两个渲染器在切场处屏幕角点一致,离场全程各屏播自己的录屏帧(真页面渲染)
cargo check --workspace
cargo test --workspace
rm -rf target/debug/incremental 每次构建或测试后都删(用户定:增量缓存会占满磁盘)
cargo build -p ely-lok --bins LOK 单测前先建 worker;单测不会自动重建它
cargo build -p ely-pdf-engine --bins ElyPDF 的 worker(链接 engines/mupdf-build,ELY_MUPDF_DIR 可改;运行时 ELY_PDF_WORKER 可指定)
cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely-lok --bins,LOK 取 ELY_LOK_DIR 或 engines/ 下的构建);ELY_LOG 设日志级别;ELY_SMOKE=1 开窗口、画一帧即退出,ELY_SMOKE=hold 开着不抢焦点;ELY_NO_DIALOG=1 启动失败不弹对话框
```
## 决策
- 2026-10-01:上游版本以 `scripts/fetch-references.sh` 为准。LibreOffice 26.2.6(8221e31)、MuPDF 1.28.5(8ad45e9)、gpui 为 `gpui-pre` =0.3.8 与 `gpui-pre-platform` =0.3.8(crates.io,2026-10-07 起,随组件库 K01)、组件库 d5ecc5a(2026-10-11 起)。
- 2026-10-02:组件库 7380afb 起:对话框与面板的 Enter 提交改走字段的 key-down 动作(macOS IME 天然守卫,不再提前提交);面板族文案经组件 `i18n::text`(内建 en/zh-CN,应用目录可覆盖);核心控件带 role/aria;对话框带角色、名称与说明,Cancel/Close 与禁用说明本地化。同仓库 `~/Documents/GitHub/Ely-GPUI-Components` 修复并推送(2026-10-09 起推到 git.zacharyzhang.com,HTTPS,令牌在本机钥匙串)。2026-10-04 组件库 b6cb94c:`NumberInput::on_commit` 只在 Enter、失焦、步进、拖动时触发,键入不触发;改文档的数值框用它,否则每个键入数字都成一条内核命令与一步撤销。同批带入上游的 Checkbox、Switch、Select 等 role/aria 与 RenderSurface。c0f17ee:`ToggleButton::disabled`(不可按、无 Tab 停靠、说明不可用,提示可写原因)。967d1e4:`ContextMenu::manual`(按属主编号的请求在某点打开,右键不开),供行在按下之后才到的菜单,如内核给的拼写建议。
- 2026-10-06:审查者为探针复制的源码树须用自己的 `CARGO_TARGET_DIR`(`scripts/review.sh` 写明);写完结论前删掉这些副本与其构建目录(曾积到 /tmp 近 900 GB,磁盘写满、链接失败):曾编进本仓库 `target/`,ely-lok 里烘焙的源码路径指向已删的副本,单测找不到 worker。
- 2026-10-03:每条任务由 `scripts/review.sh` 在 herdr 分栏里交给 codex(gpt-6.1-sol,effort high)审查,最多三轮。此前各轮用 gpt-6-astra xhigh。2026-10-10(用户定):`--yolo`、`service_tier="fast"`、effort xhigh,每条最多五轮;herdr 直接启动 codex 程序,不经 shell 别名。2026-10-11(用户定):改回 effort high,每条最多三轮。
- 2026-10-01:macOS 的 LOK 用自建 LibreOffice 26.2.6.3 加 `patches/libreoffice/0001-lok-macos-tile-painting.patch`(已验证出图块)。2026-10-03 加 `0002-lok-dispatch-to-document-frame.patch`:`postUnoCommand` 原发往桌面的活动框架,另一文档关闭后命令落空或落到别的文档;改发本文档的框架。2026-10-04 加 `0003-lok-char-style-and-spacing-states.patch`:LOK 原不发当前字符样式与段前段后,属性栏无从同步;补发 `.uno:CharStyle`(界面名,无字符样式为空)与 `.uno:ULSpacing`(`上,下`,1/100 mm);混合选区的状态发 `{"commandName","dontcare":true}`,`ely_lok::MIXED` 表示,段落样式跨段不一致也算混合(原样报首段),字符样式带来的字体、字号、粗斜体等在选区内不一致也算混合(原样只看直接格式)。`.uno:ULSpacing` 缺的成员归零,故两值同发;属性栏记住已发未回显的两值状态(段距、页边距),连续编辑以它为底。同日加 `0004-lok-panel-states.patch`:补发页面尺寸、页面项(横向、版式、页码格式)、页边距、分栏、页眉页脚开关与版式、四种锚点与六种环绕(真假或禁用)、标题行重复;页面项缺的成员同样归零,故转向时页面项与尺寸都发。`0005-jsdialog-skip-disposed-dialogs.patch`:对话框守卫关闭对话框后,JSDialog 空闲任务仍为它生成整窗更新,读已释放窗口崩溃(实测崩溃报告),补丁让已释放的对话框不再生成消息。`0006-page-size-takes-the-page-item.patch`:`.uno:AttributePageSize` 也收页面项,转向是一次改动、一步撤销。`0007-lok-aqua-anyinput-without-peeking.patch`:worker 的请求经主 GCD 队列派发,而 Aqua 的 `AnyInput` 用 `nextEventMatchingMask` 窥探时会转动 run loop、在内核空闲任务中途执行请求(实测:表格删列与排版空闲任务交错,`SwLayIdle::DoIdleJob_` 段错误,约三成全量测试复现);补丁让 LOK 下的 `AnyInput` 改问客户端,worker 以 `registerAnyInputCallback` 报告是否有待处理请求,请求只在顶层让出时执行(补丁后连续十次全绿)。`0008-lok-toc-caption-formula-without-dialogs.patch`:目录、题注、公式在内核只有对话框入口;补丁给 `.uno:InsertMultiIndex` 加 `Title`(默认目录)、`.uno:InsertCaptionDialog` 加 `Category`(程序名)与 `Text`(照内核题注对话框的选项)、`.uno:InsertObjectStarMath` 加 `Formula`(文字转公式,选中公式时整体替换,一步撤销),并加 `getCommandValues(".uno:Formula")` 读选中公式。插入目录后光标落在目录内(内核原样)。`0009-lok-word-count-values.patch`:`getCommandValues(".uno:WordCount")` 给文档与选区的字数、字符数;内核的 `.uno:StateWordCount` 状态是英文串,只作重读的信号。构建加 `--with-myspell-dicts`,随包词典取自 LibreOffice 的 dictionaries 子模块(core 钉的 9925407,来源与许可见各目录 README 与 license),装在 `Resources/extensions`。构建后只留 `dict-en`(25 MB,英语拼写、断词、同义词):界面语言是英文与简体中文,中文无 Hunspell 词典;全部 65 个(473 MB)时 LOK 首启 5.4 秒、再启 2.3 秒,只留英文为 1.1 秒与 0.7 秒(PERF-01 要求 3 秒内可操作)。词典语种已由用户定:英文。`0010-lok-outline-values.patch`:`getCommandValues(".uno:Outline")` 给各标题的文字、级别、位置(twips)与是否当前(光标所在处按文档顺序之前最近的标题,`GetOutlinePos`;分栏时位置比不出先后),`.uno:Bookmarks` 的每项加位置(版面不显示的书签无位置);导航栏的跳转都按位置。`0011-lok-font-lists-present-when-a-member-is-used.patch`:LOK 把以 `;` 连写的字体列表整串当作字体名,列表中有字体已用上也报缺失(实测报出界面字体的回退列表);补丁让列表中任一成员用上即不算缺失,文档里写的列表全缺时照报。`0012-lok-calc-cursor-references-merge.patch`:Calc 在回调注册后补发单元格光标;`.uno:SheetReferences?sheet=N` 给引用某表的公式格数、命名区域数、第一个公式格,以及用 INDIRECT 的公式与名称数(这类引用运行时才知指向,删除说明里单独列出);合并带 `MoveContents` 时不弹对话框,并修上游“多个数据格的 API 合并什么也不做”。worker 以 `SAL_LOK_OPTIONS=sc_print_twips_msgs`(另加 `unipoll`)运行,Calc 的光标、选区与 `.uno:SheetGeometryData` 同为文档 twips;视图把可见区经 `setClientVisibleArea` 报给内核。`0013-lok-author-option.patch`:`lo_setOption("author", 名)` 设内核用户全名,新批注与修订以此署名。`0014-lok-calc-formula-entry-and-names.patch`:公式语法错误不弹询问、补全不切走编辑、提交清引用标记、名称的可撤销定义删Line truncated
- 2026-10-04:LOK 命令与对话框。界面只经 `ely_lok::commands::COMMANDS` 发 UNO 命令,worker 拒绝表外命令;内核弹出的对话框(`LOK_CALLBACK_WINDOW` created 或 JSDialog)在回调里即刻关闭并经 `LokError` 报给应用,需要的对话框由 Ely 自建。起因:Calc 的 Backspace 弹同步模态“删除内容”,曾卡死 worker。
- 2026-10-04:ElyDoc 查找替换、拼写、比较、邮件合并(DOC-12 至 14)。查找经 `.uno:ExecuteSearch`:查全部计数并高亮,下一处按全部的矩形定当前序号;替换一处后重新计数,无当前项时“替换”先选下一处。拼写建议来自右键时内核给的上下文菜单,只取拼写部分,`ContextMenu::manual` 在插入点下打开。比较把当前文档存成工作副本、开成新标签、对它发 `.uno:CompareDocuments`,两个源文件都不动。邮件合并的字段在正文里写成 `«列名»`(同 Word 的显示,已定),预览与生成都是对模板副本整体替换;生成逐行写 DOCX 或 PDF 到所选文件夹,任一目标已存在即整体拒绝,从不发邮件。
- 2026-10-04:ElyDoc 输出与导航(DOC-15、16)。导出 PDF 经 worker 的 `EXPORT_PDF` 请求,滤镜选项为 JSON(页码范围、批注印在页边、标出修订);打印先导出同一 PDF,macOS 用 PDFKit 的 `NSPrintOperation` 弹系统打印面板(`ely_platform::print`),其他平台报错,未实现。加密文档经 LOK 的 `DOCUMENT_PASSWORD` 回调询问密码,错的密码再问一次,取消即不打开。左栏导航默认打开(大纲、页面、书签),跳转前记下所在处,“返回”逐级回到原处;上一跳的输入未落到内核、或落下后光标尚未移动时,以其目标为所在处,连续跳转不丢位置;落下之后即按实际光标。当前标题取内核的标记,当前页按光标所在页。导出 PDF 与另存为的目标按文件身份(符号链接与硬链接)比对打开着的文档:导出遇之即拒绝,另存为遇本文件时只许原格式。缺字体时页面上方给出提示条。QA-10 与 Microsoft Office 互开未验证:本机无 Office。
- 2026-10-04:ElyDoc 的标签、会话、打开、保存、另存为(按文件身份比对)、关闭询问、冲突、密码与标签条移入 `crates/ely-office`:应用实现 `Host`(格式表 `Kind`、每标签附加数据、内核回报钩子),共用逻辑以扩展 trait 提供(`use ely_office::prelude::*`)。ElyTable 用同一外壳。共用文案在 `office.*`。
- 2026-10-04:ElyTable(C09.1)。新建为空白 XLSX(首个工作表名随界面语言),保存保留 xlsx/ods/xls/fods,另存为 XLSX(默认)或 ODS,xlsm/xltm 进风险模式。上方名称框与公式栏(回车分别发 `.uno:GoToCell` 与 `.uno:EnterString`,Esc 交回网格),中间行列表头按内核几何自绘(点选整行整列,拖边改尺寸),下方工作表标签(右键与“工作表”菜单:插入、重命名、复制、左右移、隐藏、显示、删除;删除前列出引用该表的公式数与命名区域数)与状态栏(保存状态、内核给的选区统计)。“单元格”菜单:插删行列与单元格、合并拆分(多个数据格时先问移入或保留隐藏)、行高列宽(厘米)与最适、隐藏显示行列、冻结。填充柄拖到目标格发 `.uno:AutoFill`。选区统计文字由内核给出,当前为英文(内核界面语言未随 Ely 切换),待处理。
- 2026-10-04:ElyTable 公式、计算、名称(C09.2,TABLE-03 至 06)。公式语法为 Excel A1(用户定):`Sheet2!A1`、逗号分隔参数,由安装层 `packaging/libreoffice/ely.xcd` 设定;载入选项 `Language=en-US,DeviceFormFactor=desktop`,内核界面与数字区域都是 en-US(小数点、月/日/年输入)。单元格内编辑由内核完成:键入时内核给出匹配的函数(`editor::CompletionMenu` 显示在单元格下,上下键选,Tab 补全,经 `completeFunction` 入有序队列),参数提示(`editor::SignatureHelp`,取内核的用法文字),点选单元格插入引用并按内核颜色画框;内核只为点选的引用与再次编辑(F2)的已有公式画框,新键入的引用不画(同桌面版)。第一下 Esc 关提示,第二下取消(同内核)。语法错误的公式照原样输入,状态栏按界面语言解释当前格的错误(内核英文错误串换成界面文案,同统计);循环引用同此提示。“公式”菜单:自动计算开关(关时状态栏显示手动计算)、重新计算、全部重新计算、定义名称(名称、引用位置默认为所选区域的绝对引用、范围为工作簿或某表;名称先经内核判定)、名称列表(改引用、转到所命名的绝对区域、删除,失效引用标出;内核给出每个名称的区域,`.uno:SelectedRange` 给所选区域)、追踪错误、追踪引用与从属单元格、移去箭头。名称框可直接输入名称跳转,跳转后显示该名称。手动计算在 ods、xlsx、xls、fods 存后重开都保持。
- 2026-10-04:ElyTable 格式、数据整理、规则(C09.3,TABLE-07 至 09)。补丁 `0016-lok-calc-formats-and-conditions.patch`(数字格式、条件格式、两处 sdi 参数)、`0017-lok-calc-filter-validation-columns.patch`(筛选、验证、分列)、`0018-lok-calc-reads-requests-search.patch`(读值、请求、查找全部):数字格式按代码设(`.uno:NumberFormat` 的 `FormatCode`),`.uno:NumberFormatCode` 读当前格代码与类别(常规按区域块首判定)或校验代码;标准筛选收 JSON 条件(`.uno:DataFilterStandardFilter` 的 `Query`,含按列取值),`.uno:AutoFilterValues` 照内核自己的筛选菜单给出条目、他列隐藏与本列勾选;数据验证收 JSON 规则(`.uno:Validation` 的 `Rule`),`.uno:Validation` 读当前格规则,`.uno:DataSelectValues` 给序列;条件格式收 JSON(单元格规则配内核单元格样式、色阶、数据条、按键删除,`cellsh.sdi` 的同名槽也加参数),`.uno:ConditionalFormats` 列出本表;分列收 `Options`(ScAsciiOptions 串);`.uno:SelectedRange` 另给所选区域内数据区的四角、首行文字与内核的标题判定。LOK 下内核不再弹自己的筛选菜单与序列下拉,改发 `LOK_CALLBACK_TOOLTIP`(`autofilter`、`datalist`,带列行),无效输入发 `validityerror`(标题、内容、样式、是否接受;接受与否取内核对话框的默认:信息接受,警告与停止拒绝),Ely 以 `ely_lok::Request` 接收后自建菜单与提示。查找全部按格报矩形(内核原把相邻命中并成一块,计数偏少)。查找替换移入 `ely-office`(`Finding`,ElyDoc 与 ElyTable 共用,`Host::search_leaves` 供 ElyDoc 记返回位置)。“粘贴为值/公式”只在系统剪贴板仍是本视图上次的复制时进行(内核自己的单元格剪贴板;Ely 复制的 HTML 末尾带一个进程内唯一的标记注释,以标记而不是文字识别,同样文字的外来复制不算);其他内容(含别的工作簿复制的单元格)明确拒绝并提示改用粘贴:内核无法从外来内容单取值或公式。排序最多三个关键字(内核限制)。右侧格式面板(数字、对齐、边框、填充)、“数据”菜单(排序、筛选、删除重复项、分列、查找替换、粘贴为值或公式、数据验证)、“格式”菜单(格式面板开关、条件格式)。组件库 `SortBuilder`、`FilterBuilder` 的文案未接 i18n,中文界面下仍为英文(上游待办)。
- 2026-10-04:右侧属性栏宽取 `theme.sheet_size()`(380),左栏仍是 `sidebar_width`。`PropertyGrid` 名称列固定 120,240 宽时编辑器只剩约 76。颜色经色块弹出 `ColorPalette`,一次点击一条命令。
- 2026-10-01:MailCore 2 的子模块 libetpan 过旧,改钉 libetpan master `4d47862`。
- 2026-10-01:每个应用的全部数据在 `<本机数据目录>/ElyOffice/<slug>/`,路径由 `ely_platform::data_dir` 给出。卸载只动这里,且先问。用户文档永不碰。
- 2026-10-04(用户定):macOS 包标识前缀 `ely.zacharyzhang.office`(`packaging/macos/bundle.sh`);发布者 ZacharyZhang,网址 zacharyzhang.com(macOS `NSHumanReadableCopyright`,Windows 卸载项 `Publisher` 与 `URLInfoAbout`;Linux 桌面文件无此字段)。界面语言英文与简体中文,以后再加。拼写词典只随英文:LibreOffice 的词典库没有中文(中文不用 Hunspell 拼写)。内核界面语言固定英文,函数名照英文;ElyTable 把内核英文的选区统计(Sum、Average 等七个固定名)换成界面语言,数值照内核。批注与修订的作者取各用户的账户全名(无全名用登录名,与内核自身次序一致),由补丁 0013 的 `setOption("author")` 在 worker 启动时设定;作者设置页随设置任务接入。邮件合并字段写法 `«列名»` 由我定(用户交我决定)。
- 2026-10-01:更新清单是 JSON:`{"version", "assets": {"<os>-<arch>": {"url", "sha256"}}}`,只走 https(本机回环除外)。检查、下载、校验在 `ely_platform::update`,从不静默安装。包管理器安装的 Linux 版本不自更新。2026-10-09 起(用户定:0.1.2 加自动更新):每个应用一份清单,`<slug>.json`,随发布上传,应用经 git.zacharyzhang.com 的 API 读最新发布(`api/v1/repos/ZacharyZhang-NY/ElyOffice/releases/latest`),在其文件里找 `<slug>.json`(`ely_platform::release::asset_url`;Gitea 没有 `releases/latest/download` 路径;`ELY_UPDATE_MANIFEST` 直接给清单地址,测试用);只在从 `.app` 运行时可用(开发构建没有);照 PRD §4.2 自动检查默认关闭:设置“通用”的“启动时检查更新”(通用设置 `check_updates`)打开后启动时查一次、查不到只记日志,应用菜单的“检查更新…”随时手动查(已是最新或查不到都以提示写明);有新版即在窗口上方显示组件库的 `UpdateBanner`(文案进内建中英目录,组件库 5d6efd4):横幅在标题栏下方(`Shell` 的页眉里,`Updates` 自己是一个视图);下载(进度)、校验散列、“重新启动以更新”只走正常的退出流程(有未保存的照常询问;取消即什么也不装,按钮仍在);应用真正退出时(`on_app_quit`)以本程序的安装模式(`--ely-install-update dmg 包 进程号`,`ely_ui::run` 最先识别)起一个独立进程:等本进程结束,`ely_platform::install` 挂载 dmg、把同名 .app 拷到旁边再换掉原包(换上前失败即放回原包),无论成败都 `open` 该包;失败的原因写在数据目录 `updates/failed.txt`,重开的应用以提示写明。下载完成只归属于它下载的那个版本;下载中或已下载时再查不覆盖。`packaging/macos/release.sh` 为每个应用写出清单(版本、本机架构的 dmg 地址与 SHA-256),dmg 里另有指向“应用程序”的替身,可直接拖入。新包换上即为装好,旧副本删不掉只记警告。`tests/scripts/update.sh`:装好的 ElyMark 在默认设置下启动不发任何请求;按“检查更新…”后从本机服务的 9.9.9 下载;有未保存的文字时“重新启动以更新”先问,取消则原包不动,“不保存”后装上新版并重新打开(新版本身的进程在跑)。
- 2026-10-05(用户定):ElyAgent 的后端与内置模型的登录配置以 Kigi CLI(https://git.zacharyzhang.com/ZacharyZhang-NY/Kigi-CLI,Rust)为基础,以 Rust 依赖直接嵌入,不用 ACP;沿用其提供商登录(订阅账号或 API 密钥)与模型配置。
- 2026-10-05(用户定):八个应用都不许卡顿:键入、选择、滚动、文档渲染都要即时(GPUI 的 120 FPS 手感),树莓派 3 上也要流畅。输入延迟与帧时间是验收项,要实测;界面线程不等内核、不持 worker 锁做同步请求,空闲不做事。
- 2026-10-05:LOK 的输入与事件路径(实测后定)。原样:视图的事件监听长轮询 worker(持 worker 锁最多 50 毫秒,worker 读线程也随之阻塞最多 250 毫秒),按键排在锁后,键入锁等待中位 290 毫秒、最长 9 秒;输入队列在界面执行器上逐条跳回界面线程,每键约一帧,连续键入积压到 1 至 2 秒。现在:worker 有新事件即主动推送(`tag::EVENTS`,推送线程在输出锁下取批,批次即发送时状态),客户端读线程把回复与事件分开,事件存入 `client::Feed`,视图以异步方式等它(`Feed::next`,有新批次时唤醒,不占线程、不碰 worker 锁;原先每个打开的文档阻塞一个后台线程最多一秒,文档多于核数时会饿死输入与图块;监听任务归视图所有,视图释放即停);每个视图一条后台通道(`view/lane.rs`),输入优先、图块其次,连续执行,界面线程只收落地结果;文档尺寸与页框只在内核报尺寸变化时在后台读;批次弄脏的已显示图块即刻重绘,不等下一帧;视图的 `Reported` 事件只在内核回报或输入落地时发出,滚动与图块到达不触发应用的观察钩子;编辑与光标移动后应用要读的内核列表(ElyDoc 导航、字数、审阅,ElyTable 工作表与几何、单元格规则、格式面板)经 `ely_lok::read_off` 在后台读,一次一个,结果按发起者核对。实测(macOS,`tests/scripts/lag.sh`):按打字速度键入时输入落地 0 至 1 毫秒,图块 1 至 3 毫秒,按键到新图块 ElyDoc 中位 19 毫秒、ElyTable 3 毫秒;连续快速键入(脚本一次送出近百字)时,各次运行在 3 至 225 毫秒的中位之间摆动:新文档首次键入时内核有一次 150 至 230 毫秒的冷启动(首块图块与拼写等初始化),同一批字排在它之后;原为中位 1 秒、p95 1.9 秒。树莓派 3 未实测(本机无设备)。界面线程不碰 worker:应用对内核的读取、保存与另存为的导出、导出 PDF、比较、邮件合并都经 `Tabs::read_in`(或 `ely_lok::read_off`)在后台执行,回到窗口时按发起的标签与读取时的状态核对;保存导出期间标签状态为“正在保存”,同一标签的后续保存排队到它结束;另存为在写入前按文件身份再查一次目标是否已被别的标签持有;导出期间又有编辑时,“保存后关闭”不关闭,回到关闭询问;关闭标签时的撤销历史也在后台读;邮件合并预览与比较在发起时就保留自己的标签号与工作目录;读取失败不立即重试,等下一次状态变化;载入时一并在后台读版面与撤销历史(`ely_lok::layout`,`LokView::open` 接收版面)。窗口关闭回调先不放行,在后台逐个读标签的撤销历史并关闭(有改动的照常询问),最后一个标签关闭后移除窗口;内核文档的关闭也在后台(2026-10-06 起,原先同步读历史、在界面线程持 worker 锁关闭)。
- 2026-10-04(用户定):八个应用的内容区一律随系统深浅色:Doc 页面、Table 网格、Show 幻灯片、PDF 页面、邮件正文、Mark 编辑区、Agent 输出。自动颜色随之翻转,文件里写明的颜色照原样;打印与导出 PDF 仍为浅色。各引擎(LOK、MuPDF)接入应用主题,不留引擎默认色。LOK:`ely.xcd` 定义 `COLOR_SCHEME_ELY_DARK` 与 `COLOR_SCHEME_ELY_LIGHT`(文档底色、自动文字色、网格、链接等取 Ely 默认色板;内核自带的深色方案颜色留空、按应用外观取默认值,传方案名不改外观,所以不用它),`LokView` 在主题深浅变化时经有序队列发 `.uno:ChangeTheme`(不算编辑),页面底色深色取 `surface`、浅色取 `paper`;Omarchy 色板与这两套固定色可能略有差别。导出 PDF 实测仍为白底。图块作废时旧图仍显示,新图到达后替换,编辑与主题切换都不闪。
- 2026-10-01:主题。`Appearance` 为系统、浅色、深色。系统档在有 Omarchy 时跟随 Omarchy(`ThemeSettings::omarchy_sync`)。读 Omarchy 的 `$HOME/.local/state/omarchy/current/theme/colors.toml`(Omarchy 写死此路径,不随 `XDG_STATE_HOME`),目录由 `ELY_OMARCHY_DIR` 可改。读法与回退链照 `reference/omarchy/bin/omarchy-theme-color`:松散引号、`colorN`、`purple`、`selection_background`、`theme_type`、`light.mode`、亮度和 382 比。缺的色相用 Ely 自己的色并警告。色板照组件库 `A11yChecker` 的规则收口:正文、状态色、代码色在各自的表面上 4.5:1,焦点与图表色在页面上 3:1;先拉近前景再拉到纯黑白,最后逐项核对,核对不过的主题整套忽略。切换时 Omarchy 先暂存 `next-theme` 再整体替换 `theme`,用文件监视跟随,不用它的钩子。新主题无 `colors.toml` 时退回 Ely 色板。`ELY_APPEARANCE`、`ELY_OMARCHY_SYNC`、`ELY_REDUCE_MOTION` 是覆盖开关,设置页接入前先用环境变量。
- 2026-10-05:组件库 8658999(组件库重写历史后的 main,加本项目三个提交):属主不经 `Close` 移除的浮层(一起移除的嵌套浮层同样,回到最外层打开前之处)(对话框动作只清自身状态、异步校验通过后才关)照样把焦点交回打开前之处;原先焦点落在根上,键入无处可去,须先点一下网格。同批带入上游的 ColumnShell、输入法组字时光标重新闪烁、`Icon::from_path`、MIT 许可。旧 pin 2f02c82 已不在 main 上,只能按完整哈希取得。`Select::label` 与 `TextInput::label` 给控件无障碍名称而不绘制;对话框里描述列表旁的控件以此命名,开关用自身的 `label`。7186179 加 `NumberInput::label`(经内部文本框的 `TextInput::set_label`),同批带入上游的 rustfmt 修正。80c46b1:`TemplatePicker` 的列表是一个 Tab 停靠点,上下、Home、End 移动,回车使用,行带列表项语义,搜索提示、全部、使用、无匹配的文案进内建中英目录。7df3045:`SelectableList` 在 AX 树里是列表(`label` 命名),`ListItem` 是列表项(标题为名,带说明、选中、在可选列表中的序号与总数);原先行无角色,不在 AX 树里。af54877:`NumberInput::blank` 显示占位文字而非数字(混合选区),空着回车或失焦不提交,键入后照常。408f2b0:`CommentSidebar` 与 `RelativeTime` 的文案进内建中英目录;属主不处理“解决”时(无 `on_resolve`)不分未解决与已解决,一列显示全部。bcf0378:`CommentSidebar` 的批注列是列表,每条是 Tab 停靠点(列表项语义,名为作者与正文,带选中、序号与总数),回车或空格选中。71bfc93:`init_for_tests`(`test-support` 下)装主题与键位、不要资源,应用单测可渲染 Ely 组件。617a8d6:`FindWidget` 在查询框里按 Esc 即关闭(调 `on_close`)。2ce3368:`ContextMenu` 可定宿主大小。3d08744:`DescriptionList` 的值占满所在列,其中的文本框随之全宽;同批带入上游的图表、分页改动。a6b1bb0:`SignaturePad` 的“在此签名”与“清除”进内建中英目录;同批带入上游 P01 至 P15(各组件的越界与失效状态记日志而不 panic)。490b022:`ChangesList` 分列冲突、已暂存、改动、未跟踪、已忽略(各有其动作:冲突与已忽略只可打开),标题与提示进内建中英目录;同批带入上游 P16 至 P20。7bb53f4(ElyTree 用):`ChangesList` 行数可缺、按区报按下、当前行;`DiffViewer` 外算的段、块按钮、可选的变动行、逐行读给辅助技术;`CommitList`、`FileHistory`、`BlameView`、`ThreeWayMerge` 只画可见行,`CommitList` 近尾加载;`DescriptionList::text`;`SegmentedControl` 各段为单选;`BranchList`、`TagList` 可选中、只画属主处理的行内动作;`marked`、`conflicts`、`resolve` 按标记宽度(`MARKER` 为默认 7),a284c71 起只按 LF 断行;c5799cc:`StashList` 文案进内建中英目录,行可选(`selected`、`on_pick`),只画属主处理的行内动作;7fab072 至 3741d5d:分支、标签、储藏的行分两行(上行名称与行内动作,下行说明与行尾文字),窄时名称与说明先让;行内动作一律显示(原先只在悬停时显示,键盘停在其上时看不见),改动列表同;各 git 行带透明的 1 像素边框,焦点框才画得出(原先 `focus_ring` 无边框宽度,键盘焦点不可见);同批带入上游 P21 至 P29。4f8f3bd:`Changed::lines` 可缺(二进制文件、文件夹不计行数,不显示);37c5187 缩短其注释。19b0b29:`DiffViewer::from_stretches`(别处算好的差异,如 Git 自己的)、`hunk_action`(每块标题上一个按钮)、`selected` 与 `on_line`(改动行可按,带 Shift),`diff::mark_words` 给外来的行标出改动的词,“行内”“并排”“N 行未改动”进内建中英目录;30fea9e:有块按钮时各行同高,底色相接。edd2c12:`ChangesList` 每次按下带分区(`ChangeSection`),行是 Tab 停靠点(列表项语义),`selected` 标出当前文件。2e080b8:`SegmentedControl` 每段是单选按钮(名为其文字,带选中状态),原先不在 AX 树里。e226d79:差异查看器的行模型与配色移入 `git/rows.rs`(文件不超过 500 行);7b807a9 缩短其注释。51d2aea:`CommitList`、`FileHistory`、`BlameView` 只画视口内的行(`uniform_list`,填满所在的框;`CommitList` 收 `Rc` 的提交与 `lanes` 算好的图,`on_end` 在接近末尾时要下一页),各行是 Tab 停靠点(列表项语义,选中状态;Blame 每段首行可按,名为作者、时间与摘要),`FileHistory` 的行数可缺;`CommitInput` 的“提交到”“修改最近提交”进内建中英目录。e6220ad 与 3fc5a46:提交行窄时作者、提交号与时Line truncated
- 2026-10-05:ElyTable 透视表与图表(C09.4,TABLE-10、11)。补丁 `0019-lok-calc-pivot-tables.patch` 与 `0020-lok-calc-charts.patch`:`.uno:DataPilotExec` 收 `Pivot`(JSON:源区域、行、列、筛选、值字段与函数 sum/count/average/max/min),光标在透视表上即原地修改,否则从源区域在新工作表创建;行、列、筛选里的空名表示内核的数据布局字段(多个值字段横排或竖排的位置),读出与写回都保留,已在表中的行、列、筛选字段保留各自的小计函数,读出的布局原样写回不改表;照内核布局对话框的步骤(`ConvertOrientation`,保留各字段的成员、排序与版式),新表的筛选按钮、钻取、展开收起都关(同对话框初值);放到新工作表时插表与透视表是两步撤销(同桌面版)。刷新 `.uno:RecalcPivotTable` 在内核里本就不进撤销(它改缓存)。`.uno:InsertObjectChart` 收 `Chart`(JSON:类型 column/bar/line/scatter/pie、标题、图例、X/Y 轴标题;`other` 保持原类型),LOK 下不开图表向导、插入为一步撤销、插入后图表选中;给出的区域每段都要解析得了、所指工作表都要存在,否则插入与修改都拒绝、文档不动(内核原会改用示例数据,修改时 `ChartModel::setArguments` 抛出未捕获的异常使 worker 退出);`.uno:ChangeChartData` 也收 `Chart`,区域与外观合成一步撤销(撤销保存整个图表的图示与主标题的副本,堆积等外观以外的设置一并恢复)(列表动作带当前视图号,否则 LOK 拒绝撤销别的视图的动作)。读取:`.uno:Chart`(选中图表的对象名、区域、表头、外观)、`.uno:PivotTable`(光标处透视表的名称、位置、源区域、字段与布局),`.uno:SelectedRange` 加 `data`(选区内数据的绝对区域)。界面在“插入”菜单:数据透视表、刷新数据透视表、图表;对话框在光标处有透视表、或有选中图表时为修改,否则以选中的数据(单格扩到数据区)新建,源与图表区域取 `data`,不含空行。为对话框发起的读取(`Tabs::ask_in`)回来时,发起的工作簿已不是当前标签即丢弃,不为隐藏的工作簿开对话框(ElyTable 全部对话框、合并询问)。没有数据时恢复原光标(`select.data` 会把单格标成区域,回车随之只在格内打转)。公式语法是 Excel A1,区域写 `Sheet1!$A$1:$C$5`。
- 2026-10-05:ElyTable 文本文件、导出、打印、保护、大表(C09.5,TABLE-12 至 16,§9.3)。补丁 `0021-lok-calc-page-setup-protection.patch`:`.uno:PageFormatDialog` 收 `Page`(JSON:横向,百分比、宽高页数或总页数之一),一步撤销;`.uno:Protect` 收 `Protection`(JSON:带密码与八项许可保护,或凭密码取消,密码错不改动);`.uno:CellProtection` 带值时按值设锁定(原样只切换);读 `.uno:PrintLayout`(打印区域、重复行列、方向、缩放、共用页面样式的工作表、光标处手动分页)与 `.uno:SheetProtection`;修上游:缩放改动后 `InvalidateTextWidth` 按整表列数遍历未分配的列,越界崩溃。`0022-lok-calc-text-files-and-links.patch`:LOK 下 CSV 带选项载入不再弹导入对话框(原样照弹,模态挂死 worker);`saveAs` 的过滤器选项原样交给过滤器,只去掉 LOK 自己的关键字(原样丢空项、插空格,CSV 选项错位);读 `.uno:ExternalLinks`(外部引用、区域链接、表链接、DDE、WEBSERVICE 的来源)。LOK 载入选项可带 `FilterName`(原样只按扩展名猜,`.txt` 落进 Writer)。worker 的 CSV 导入与导出只收类型化字段(`IMPORT`、`EXPORT_TEXT`),选项串与 Calc 文本过滤器名在 worker 内生成,载入后不是表格即关闭并报错。CSV 规则由我定(用户未定):打开 csv、tsv、tab、txt 先出导入对话框,选编码(UTF-8、UTF-16、GB18030、Big5、Shift_JIS、EUC-KR、Windows-1252、Windows-1251、ISO-8859-1;有 BOM 按 BOM,合法 UTF-8 即 UTF-8,否则中文界面 GB18030、英文界面 Windows-1252)、分隔符(tsv、tab 为制表符,否则取首行最多的逗号、分号、制表符、竖线,都没有为逗号)、引号、逐列类型(常规、文本、三种日期顺序、跳过),预览与类型推断按 Calc 文本导入的规则拆字段(`impex.cxx`:引号前的空格跳过、引号后的文字并入、双写引号转义、空行算一行),后台读整个文件,严格解码(有字节读不通即提示并禁用打开,不以替换字符静默改写),预览前 8 行、每格 200 字,每列都有类型选择;打开时导入的就是检查过的那份字节(对话框期间源文件再变也不读);整个文件里出现前导零、加号开头、超过 15 位数字(可带负号)的列默认为文本;数字按 en-US 读,日期只在标为日期的列按所选顺序读,不识别特殊数字,`=` 开头的文字不作公式。导入得到未命名的新工作簿(标签为原文件名),保存走另存为 XLSX,原文件不动。导出(Cmd+E):PDF(全部工作表,按页面设置)、CSV、TSV;文本导出只写当前工作表的显示值(UTF-8,无 BOM),对话框写明导出哪张表、公式格式与其他表不保留;内核“只存当前表”的警告框由 `ely.xcd` 关掉。打印(Cmd+P)先导出同一 PDF 再交系统打印面板。“页面”菜单:页面设置(打印区域写作 A1:D20,多个用逗号;重复行 1:2;重复列 A:B;方向;缩放),插入、删除行列分页符(光标处没有即提示);页面样式由多张表共用时对话框写明作用于哪些表。“工作表保护…”在工作表菜单,“锁定单元格”“解锁单元格”在格式菜单。§9.3:`ely.xcd` 设 XLSX 与非 LibreOffice 生成的 ODF 载入时总是重算(原样信任文件缓存,实测 Excel 留下的 `=1+1` 过期缓存 7 与未知函数缓存 42 都当结果显示);不支持的函数显示 `#NAME?`,原公式保留(函数名存成小写),状态栏解释错误;其缓存值不保留,另存写成错误值(已知限制)。外部链接照内核“按需更新”不刷新,显示保存时的值,工作簿有链接时上方提示条列出来源(每次编辑后在后台重读)。NOW、TODAY 用系统时区;1904 日期系统随文件,未专门验证。视图只请求屏上的图块,离屏超过一屏的图块丢弃(原样只增不减,滚动长表内存随之涨);行列表头只画可见的。
- 2026-10-05:ElyShow 应用壳、幻灯片管理、模板与母版(C10.1,SHOW-01、02)。补丁 `0023-lok-impress-slide-design.patch`:`.uno:PageSize` 改走页面对话框的 `SetPageSizeAndBorder`(原样走 `SetupPage`,不进撤销);`.uno:PresentationLayout` 收 `Master`(母版名,套到当前幻灯片,一步撤销;不存在即拒绝);`.uno:ThemeDialog` 收 `ColorSet`(内核配色名,套到当前母版,一步撤销);读 `.uno:SlideDesign`(母版列表、当前母版、是否在母版视图、当前版式、配色列表与当前配色)。内核参数类型:`SfxUInt32Item` 取 `long`(传 `unsigned long` 时参数被丢弃,命令仍跑出一步错误的撤销)。缩略图经 worker 的 `PAINT_PART`(`paintPartTile`,第二参是编辑模式:0 幻灯片,1 母版),按名缓存,编辑时只重画当前页,结构、尺寸、配色变动时全部重画,内核换深浅色方案后全部重画(首帧先于方案落地)。母版视图的进出内核不发重绘,按设计读出的编辑模式判定,进出后整屏作废重画(`LokView::invalidate`)。演示的画布按视口缩放整页并居中(`LokView::fit_page`)。新建:内核自己的空白演示(`private:factory/simpress`,16:9,标题版式)每次运行写一次到工作目录,再按副本打开;或内核随带的 Impress 模板(`template/common/presnt`)。版式取内核版式面板的 12 种;尺寸预设宽屏 16:9、16:9、4:3、A4、Letter,另可输入厘米。章节(用户定为外部阻塞):内核无幻灯片章节,PPTX 导入丢弃 `p14:sectionLst`,ODF 无此结构。补丁 `0024-lok-math-tiles-keep-the-painted-tile.patch`:图块收尾时公式对象助手以默认擦除重设设备尺寸,文字编辑中的 Impress 图块整块被擦(macOS 上为黑);改为不擦,同图表助手。离开文字编辑(内核收起插入点,`LokView::caret`)时重画当前页缩略图,编辑中画的缩略图带编辑框。`0025-lok-impress-paints-the-current-view.patch`:Impress 的 `GetViewShell` 原取文档外壳所连的视图,外壳栈重排(`ViewShellManager::MoveToTop`)时内核先断开再接回,其间到达的绘制得到空白图块(macOS 上为黑),按键丢失;LOK 下改取当前视图的主外壳。`0026-lok-impress-settles-panes-before-commands.patch`:Impress 的窗格重配排在用户事件里,先到的命令找不到幻灯片排序外壳,隐藏、显示等槽被丢弃;`ITiledRenderable::settleViews` 在 `postUnoCommand` 派发前执行,Impress 在此同步处理排队的配置。两者在八路并行下实测(各 96 次全绿,修前约四十次一败)。缩略图失效按撤销与重做列表的条目判定(`ely_lok::history::steps`):撤销列表有条目消失即撤销,重做列表的条目进了撤销列表即重做,撤销后立即编辑也能识别。
- 2026-10-05:ElyShow 文字、图形、图片、对象操作(C10.2,SHOW-03 至 06)。补丁 `0027-lok-impress-objects-guides.patch`:读 `.uno:SlideObjects`(当前页各对象的种类、外框、占位符角色、锁定、是否选中,选区的种类、填充、轮廓、透明度、阴影、自动适配、外框、旋转与中心、锁定、能否组合与取消组合,网格与参考线吸附,参考线);`.uno:SelectObject` 收 `Index`、`Add`(按层次选中);`.uno:CapturePoint` 收 `Kind`、`X`、`Y` 添加参考线,收 `Index` 加坐标或 `Delete` 移动或删除(LOK 无参考线对话框);`.uno:TransformDialog` 收 `ProtectPos`、`ProtectSize`;`.uno:TextAutoFitToSize` 收 `Fit`(off、shrink、grow,对所有选中对象一步撤销,横向增长一律关,撤销连同文字恢复;原样是只对单个对象的开关);`.uno:ChangePicture` 收 `FileName` 原位替换;LOK 下插入图片放在幻灯片中央(原用窗口可见区中心,LOK 下无意义)。LOK 把 `TransformDialog` 的位置、尺寸与旋转中心当 twips 读入,读出是 1/100 mm,面板发送前换算;线宽与参考线不换算。插入菜单:文本框、矩形、椭圆、三角形、直线、箭头、连接线(内核直接在幻灯片中央创建)、图片;视图菜单:吸附到网格、吸附到参考线(勾选随内核)、添加水平或垂直参考线。右侧面板选中对象时为对象属性(位置、大小、旋转、锁定;填充、透明度、阴影、轮廓与线宽;文字的字体、字号、样式、颜色、对齐、列表、自动适配;图片的替换与裁剪;层次、对齐、分布、组合),否则为设计;两者下方是对象列表(可多选,按层次选中)。数值框在回车与随后的失焦各提交一次时只发一次:按字段记住已发值与发出时读到的值,到下一次读取(按标签与读取次数)为止;混合值的数值框为空并显示“混合”(`NumberInput::blank`),空着回车或失焦不发送,不作去重的依据。色块的清除按钮发各自的命令(无填充、无轮廓)。直线、箭头、连接线与图片只显示轮廓与线宽。对象列表取消全部选中即清除内核选区(`.uno:SelectObject` 只带 `Add` 时不选任何对象)。补丁按序列打:`scripts/apply-lo-patches.sh` 找出源码已等于“原版加前 N 个补丁”的 N,再打其余;原先逐个反向检测,补丁重叠后重跑构建失败。面板与菜单的命令不经视图输入,缩略图改按撤销列表的变化判定当前页作废。ElyDoc 属性栏的内核控件(开关、按钮、色块、字体、字号)移入 `ely_office::panel`,ElyShow 共用。连接线接到图形靠在画布上拖动端点,未单独验证粘附。
- 2026-10-05(用户定):ElyShow 的音视频由各平台原生播放:macOS AVFoundation,Windows Media Foundation,Linux GStreamer(同 LibreOffice 自己的 avmedia 后端),H.264/AAC 解码与许可随系统;Ely 一个接口,三份实现。LOK 不放媒体。
- 2026-10-05:ElyShow 动画、切换、媒体(C10.4,SHOW-09、10)。补丁 `0033-lok-impress-slide-layers-on-macos.patch`:放映图层渲染(`SlideshowLayerRenderer`)把内核画进客户缓冲区,Aqua 设备画不进,macOS 上每层都空;改为画进自带透明度的设备再拷出(同图块)。`0034-lok-impress-transitions-and-animations.patch`:`.uno:SetSlideTransition`(JSON:预设、时长、单击或自动及秒数、全部幻灯片)、`.uno:AddAnimation`(给选中对象加预设)、`.uno:ChangeAnimation`(开始方式、时长、延迟、前后移、删除),各一步撤销;类型不合或内核无此预设即不改;读 `.uno:SlideTransition` 与 `.uno:SlideAnimations`(每个效果的目标按 `.uno:SlideObjects` 的序号,并给放映图层的散列,同 `anim::convertTarget`)。`0035-lok-impress-media.patch`:`.uno:InsertAVMedia` 收 `Width`、`Height`(1/100 mm)时不弹媒体对话框、不经内核播放器量尺寸,放在幻灯片中央;读 `.uno:SlideMedia`(序号、URL、是否链接、实际播放的文件:链接即原文件,嵌入即内核的临时副本、文件是否缺失)。Ely 只给基础一组(切换:无、淡化、经黑色淡化、向左推入、向上推入、向左覆盖、向左揭开、向右擦除、向外分割、溶解;动画:出现、淡入、飞入、擦入、放大进入、浮入、放大缩小、闪烁及对应的退出),文案由 Ely 给出;文件自带的其他效果显示内核名称。预览不阻塞编辑:worker 的 `SLIDE_LAYERS` 在后台取图层(静止层、动画对象各一层、母版字段占位),Ely 按效果排时(单击接在前面全部之后,与上一同时,在上一之后)在画布上合成:淡入淡出改透明度,飞入飞出与浮入浮出移位,擦除裁剪,放大缩小与强调缩放,闪烁隐显;预览里不画日期、页脚、页码字段(内核留给客户端画)。切换预览用两页的图层:新页按初始可见,旧页对象全显示(只读当前页的效果,旧页的退出不知道);首页从黑开始;无填充的页铺页面底色(`ely_lok::paper`,同编辑区)。媒体(用户定各平台原生):`ely_platform::media::Player`,macOS 以 AVFoundation(`AVPlayer` 加 `AVPlayerItemVideoOutput`,帧为 420f 的 `CVPixelBuffer`,经 GPUI 的 `surface` 画在对象上),状态有载入、可播(时长、画面尺寸)、失败(系统给的原因);Windows 与 Linux 尚未构建,打开即报“此平台未构建”。插入:选文件后先由系统播放器打开,可播才插入(视频按 96 dpi 换算并缩进幻灯片八成之内,声音 2×2 厘米),不能播即提示原因、不插入;默认嵌入,随文档保存。选中媒体对象时属性栏给播放、暂停与时间;链接文件缺失或系统不能播放时写明。正在播放的媒体按标签、幻灯片名与媒体 URL 认定,不符即停;播完再播从头开始。预览按标签、幻灯片名与撤销列表认定,读取回来、每帧绘制时不符即弃;停止令在途读取作废。向左揭开时旧页叠在新页上。面板的秒数字段经 `object::commit` 去重(回车与随后的失焦只发一次);切换的各字段以已发未读回的切换为底(`Extra::transition_asked`,带命令的输入序号;命令落地后开始的读取回来或换页即清,撤销也随之生效);秒数去重的键带幻灯片,动画的还带效果序号与图层散列(`motion::effect_key`)。预览另按视图的已落地与待落地编辑认定。播放实例在文档每次通知时核对(`observe_self`),标签关闭即停。
- 2026-10-05:ElyShow 表格与图表、备注与审阅(C10.3,SHOW-07、08)。补丁 `0028-svx-chart-look-helpers.patch` 与 `0029-svx-chart-data-helpers.patch`:图表的外观与数据读写移入 svx 的 `ChartHelper`(Calc 与 Impress 共用;JSON 经 boost.json 保留类型,类型不对即拒绝,改动之前判定);`0030-sc-chart-looks-from-svx.patch`:Calc 改用它,原代码删去;`0031-lok-impress-charts-notes-comments.patch`:`.uno:InsertObjectChart` 收 `Chart`(外观与数据的 JSON),不开图表界面,落在幻灯片中央,选中一个图表时就地修改(复制后替换,一步撤销),JSON 不合即拒绝;先设外观再设数据(数据按图表类型写);读 `.uno:Chart`。缺失的点读写为 null(图表自己的非数标记),对话框里是空格;散点图的类别即 X 值:须为数字,按数字写入(原样写成文字,PPTX 导出为文字引用,再打开 X 变成 1、2、3);PPTX 打开的散点图按系列角色读出,各系列共用一组 X 时以 X 为类别,各系列 X 不同时不是一张表,读出不带 `data`,对话框只改外观并说明。`0032-oox-scatter-x-values-as-numbers.patch`:散点图的 X 全是数字时导出 PPTX 为数字引用。只在图表变成散点图时设为只画点,原是散点图的保留连接线;应用对散点图先查 X 是否数字,指明哪一格。原有的 `attachData` 去掉:各写入已自行生效,它反把数字类别写回文字;新命令 `.uno:SlideNotes` 以纯文字设 `Part` 页的备注,一步撤销(备注随所属页提交,切页排在失焦提交之前也不错页);读出当前页序号、文字与是否“带格式”。PowerPoint 给每段文字都写全字符格式,所以“带格式”指段落格式或字符格式在文中不一致:一致时新文字沿用它,不一致时拒绝(纯文字会丢格式,界面只读并说明)。批注带所在幻灯片序号;视图无作者时新批注取用户名(原样留空,补丁 0013 设的用户名到不了 Impress)。0027 另加:`.uno:SlideObjects` 不列批注(26.2 的批注是绘图对象),`.uno:SelectObject` 的序号同样不数批注。共用:`ely_office::chart`(图表对话框的外观字段,ElyTable 与 ElyShow),`ely_office::panel`(内核状态驱动的面板控件,ElyDoc 属性栏与 ElyShow 对象面板)。界面:“插入”菜单的表格(列 1–20、行 1–50)、图表(外观与图表自带的数据表,类别乘系列,可增删;非数字的格拒绝并指明哪一格;选中图表时为修改)、批注;表格对话框的行列数键入即生效(不等失焦);读选中图表时在同一次 worker 调用里读出当时选中的对象(页、在列表中的序号、外框),与发起时不符即丢弃,建对话框前再按界面所见核对一次;对象面板的表格组(行列插删、合并、删除表格,可否由内核状态定,光标进单元格后可用);画布下方备注栏(失焦提交);“视图”菜单的批注栏(`collab::CommentSidebar`:选中即转到该页并点亮画布上的标记,编辑、删除;只在所在页显示为选中,离开后可再点回)。不做回复:Impress 无回复结构,内核的回复把原批注改写成英文引语加回答;PRD 未要求。备注不上观众屏随放映(C10.5)。
- 2026-10-06:ElyShow 放映与演讲者视图(C10.5,SHOW-11、12)。补丁 `0036-lok-impress-slide-show.patch`:读 `.uno:SlideShow`(每张幻灯片的隐藏、备注文字、切换与动画,同 `.uno:SlideTransition`、`.uno:SlideAnimations` 的形状,媒体带外框);`.uno:SlideMedia` 每项加外框(1/100 mm)。放映用内核的放映图层(`SLIDE_LAYERS`),不是截图轮播:母版的日期、页脚、页码由内核另画成文字层,`ely_lok::show::layers` 把它放到母版占位处的层次上(该页不显示的字段无文字层,即不画)。“放映”菜单:从头开始(F5,从第一张未隐藏的)、从当前幻灯片开始(Shift+F5,隐藏的也从它开始);开始时等该标签已发的输入落地,在同一次 worker 调用里读当前页、把文档另存为工作目录里的 ODP 副本(每次放映一个文件名)、载入副本并按应用深浅色设方案、读全部幻灯片;放映的图层、动画与嵌入媒体都来自副本,编辑不进入本次放映,放映结束时关闭并删除副本。启动中与放映中再按开始不响应。单击组从此前全部已触发动画与入页切换都结束后才开始。单击、右、下、空格、PageDown、N 下一步(先放本页的单击动画组,再到下一张未隐藏的幻灯片,最后是黑色结束屏,再一下退出);左、上、PageUp、Backspace、P 回到上一张并显示为全部动画已完成;数字加回车跳到第几张(含隐藏的);Home、End;B 或句号黑屏;L 激光笔;Esc 退出(有已键入的数字时先清掉)。与“上一个”同时、在其后开始的动画并入前一单击组,第一组在进入本页(切换结束)时开始;自动换片在本页动画全部完成后再等所设秒数。切换按进入方式:下一步有切换,跳转(含跳到本页)与后退无;离开的页按离开那一刻的动画状态画在切换里。媒体载入或播放失败即停,演讲者视图(单屏时观众屏)写明原因,放映继续。后退显示该页全部动画已完成。本页图层到达后才开始入页切换与动画计时;切换结束即释放离开页的图层。`Player::tick` 判定循环重播与失败,`Player::ended` 判定播完(人按的暂停不算播完;编辑区与放映共用);定位为精确定位(容差为零)。单击媒体对象播放、暂停或播完重播,离开本页即停;视频帧画在对象上。多屏:屏幕多于一块时先问观众屏与是否开演讲者视图(演讲者视图开在另一块屏上),两窗口都全屏;演讲者视图有当前页(鼠标在其上即为观众屏的激光点)、下一页(初始状态)、备注、计时(自开始起)、时钟、页码、上一步、下一步、黑屏、激光笔、结束放映。备注只在演讲者视图,观众屏从不画备注。图层宽度取观众屏上幻灯片的两倍像素,不超过 1920;只缓存当前页与下一页。GPUI 无隐藏光标的样式:单屏放映开激光笔时系统光标仍在。应用单测以组件库 `init_for_tests` 装主题,可渲染放映窗口。
- 2026-10-06:ElyShow 输出与演示检查(C10.6,SHOW-13、14,QA-12)。补丁 `0037-pdf-impress-handouts.patch`:PDF 导出的 `ExportHandouts`(每页张数 1、2、3、4、6、9)照打印的讲义版式(`SdPage::CalculateHandoutAreas`,3 张带笔记线)把 `PageRange` 内未隐藏的幻灯片排到讲义页上;其他张数不出页,导出失败。讲义版式会改文档的讲义页,所以讲义与图片都在另存的 ODP 副本上做(`output::on_copy`,做完关闭并删除)。`Pdf::handouts` 经 worker 的 `EXPORT_PDF` 传入。导出对话框:内容 PDF、图片、讲义,范围全部、当前、从–到,讲义每页张数(默认 3);打印对话框:幻灯片或讲义,生成同一 PDF 后交系统打印面板。图片每张未隐藏的幻灯片一个 PNG,宽 1920,副本设浅色方案,文件名 `名称-序号.png`,所选文件夹里任一目标已存在即整体不写。PPTX/ODP 由保存与另存为负责。补丁 `0038-lok-impress-slide-check.patch`:读 `.uno:SlideCheck`(每张幻灯片与母版:文字所用字体族、链接图片与 OLE 链接及是否缺失、内核不能显示的对象:读不出的图片、无数据的 OLE;缺失的链接图片只作为链接报一次)。演示检查以内核报的缺失字体(`FONTS_MISSING`)为准,按所用字体定位到幻灯片或母版;媒体缺失与媒体链接取自 `.uno:SlideShow`;点一行转到所在第一张幻灯片。系统打印面板只经 `ely_office::print_later` 打开:它在前台任务里、GPUI 放开应用之后运行(面板的模态循环会回调 GPUI,在实体更新里打开即借用冲突崩溃),三个应用共用。图片输出先以 `create_new` 占下全部目标(不跟随符号链接),任一冲突或失败都删去本次占下的文件。输出与检查的对话框由后台读取打开,回来时须仍是最近一次请求(`Deck::ask_dialog`、`dialog_due`)且没有别的对话框;对话框种类或当前标签一有变化,此前的请求即作废(`watch_dialogs`,随文档通知)。图片的张数与隐藏取自写出的副本本身;PNG 写完显式 flush。读不出的图片按载入后的状态判定(`NONE` 或解码失败的 `Default` 占位);OLE 链接检查源文件是否在。
- 2026-10-06(用户定):ElyPDF 签名与权限。验证签名时证书信任取操作系统的信任库(macOS 钥匙串的信任设置;Windows、Linux 随其构建)。可视签名三种来源:手写、键入姓名、图片文件;可视签名不是数字签名。只凭用户密码打开、带所有者限制的文件照限制办(打印、复制、编辑等被拦并说明原因),输入所有者密码可解除;改保护须所有者权限。
- 2026-10-06(用户定):OCR 随包语言数据用 tessdata_fast,英文、简体中文、繁体中文(eng、chi_sim、chi_tra)。
- 2026-10-06(用户定):ElyMail 在八个应用里最后做:任务表中 C12 移到 C15 之后、C16 之前;邮件编号依次前移。
- 2026-10-06(用户定):`ely-office` 的外壳不再只服务 LOK:`Host` 带关联类型 `Engine`(`ely_office::Engine`:后台载入、视图、撤销历史的标记、写出、关闭,视图的落定、编辑计数、待落地、焦点与事件),`Tab` 与 `Core` 按引擎取文档、视图与 worker 类型。LOK 的实现在 `ely_office::lok`(`Lok`、`start`、只对 LOK 的 `Commands` 即 `run_active`/`run_in`;查找替换、面板、图表仍只限 LOK);MuPDF 的在 `apps/ely-pdf/src/engine.rs`(`Pdf`;worker 首次载入才起,`Mupdf::get`)。`Kind::macros` 为 `None` 表示该格式无宏,不扫文件。ElyPDF 每个标签一个 `PdfView` 实体(页面、图块、文字、选择、找到的词都在里面,自己绘制),`Viewer` 只管标签条、书签栏、查找框与状态栏;打开、密码、关闭询问与保存走外壳。外壳的密码框:对话框开着时后到的加锁文件排队(`Core::locked`),对话框关闭(含取消、提交)后下一个再问;每次询问一个身份(按输入框实体),连续询问的 Esc 各归各的;提交时对话框先关、再载入。ElyDoc 的加锁测试原先清的是应用自己的对话框字段,改为清外壳的。
- 2026-10-07:ElyTree 恢复与清理、外部工具、操作规则(C13.6,TREE-13、14,§13.3,QA-19、20)。仓库页第二行:在终端中打开(macOS `open -a Terminal`,其他平台报未构建)、在访达中显示(`reveal_path`)、清理未跟踪的文件…;页底“引用日志”列出 HEAD 最近 200 次移动(`reflog show -z`,位置、Git 写的原因、时间),选中即中间显示该提交(同提交详情,`Whose::Reflog`),上方“在此新建分支…”(找回删掉的分支)与“把当前分支移到此…”:有已暂存的改动即拒绝(`reset --keep` 会丢掉它们,请先提交或取消暂存);否则在后台算出离开该分支的提交,对话框写明分支、从哪到哪、离开的提交(引用日志仍可找回)与“工作区改动保留”,确认后 `reset --keep`(Git 在改动冲突时拒绝;没有任何按钮做硬重置),执行前在写队列里核对 HEAD 仍在预览时的分支(完整引用名,或仍游离)与提交、仍无暂存,任一变了即什么也不做。引用日志的时间是该次移动的时间(`--date=unix` 下的 `%gd`),不是提交时间;选中按事件本身(提交、原因、时间与它在全部引用日志中自最早起的序号,后者新移动不改,同一秒同原因的两次也分得开),同一提交的多条不会一起选中;之后有新的移动时该事件的位置后移,选中与详情跟着它,事件已不在引用日志里即不显示。清理:对话框先由 `git clean -n -d`(勾“包括被忽略的文件”另加 `-x`)得出 Git 会删除的路径(`core.quotePath=false`,其余 C 引号照 Git 规则还原),其中的文件夹当场展开为其中的文件(`ls-files --others`,同一忽略规则,另一仓库不入),对话框逐个列出文件与文件夹;确认后先逐段查每个所列路径:仍在仓库里、途中没有符号链接、文件仍是文件、文件夹仍是文件夹(任一变了即整体拒绝,什么也不删),再以 `git clean -f [-x] -- 所列文件` 删去(只给文件,Git 不递归;此后被跟踪了的文件 Git 留下,也不穿过链接),最后自深而浅删去所列文件夹中已空的(预览后在其中新出现的文件不动,文件夹随之留下),每次都问。文件标题栏多出“在编辑器中打开”(用户在“仓库›选择编辑器…”选的程序,存在数据目录 `tools.json`,字段类型不对与读不出同样报错且不覆盖;macOS 的 .app 经 `open -a`,在后台等它结束,失败即显示其原话;未选时先问)与 Markdown 的“在 ElyMark 中打开”(`ely_platform::apps::open`:按安装布局找同级的应用,macOS 的 `名称.app/Contents/MacOS`,Windows 的 `Ely Office\名称`,开发构建的同一目录;找不到即报错)。锁(§13.3):任何写操作因 Git 的锁文件失败(`….lock': File exists`,或配置文件的 `could not lock config file …: File exists`)时,对话框说明另一个 Git 正在工作、Ely 从不删除锁文件,“重试”把同一写操作重新排队。QA-19:测试都以独立的 Git 命令核对索引、提交、引用与工作区。QA-20:冲突、钩子失败、凭据被拒(本机 401 服务器,显示 Git 的 “terminal prompts disabled” 原话)、锁、变基中断后中止、取消(停止取回)都有窗口测试,结束时仓库状态以 Git 读回核对。切换到系统 Git 目前经 `ELY_GIT`(设置页随 C18.1)。真窗口(macOS):仓库页工具行、清理预览对话框、文件标题栏的编辑器与 ElyMark 按钮,按下后同级的 ely-mark 以该文件启动。未验证:Windows 与 Linux 的终端;ElyMark 打开文件的内容(C14)。
- 2026-10-07:ElyTree 储藏、远程、工作树、子模块、LFS(C13.5,TREE-10 至 12,§13.3)。左栏第四页“仓库”:上方取回(`fetch --progress --all`)、拉取(`pull --progress`,照用户的 pull 设置)、推送、强制推送…;储藏(`stash list -z`,新的在前;“储藏改动…”问说明与是否包括未跟踪的文件,`stash push`),远程(名称与地址,`remote` 加 `config --get-regexp` 读出取回与推送地址),工作树(`worktree list --porcelain -z`,行名为文件夹名,有失效的可“清理”即 `worktree prune`),子模块(索引里的 gitlink 与文件夹自身 `.git` 的 HEAD:未检出、在记录的提交上、在别的提交上、冲突),LFS 文件(`git lfs ls-files --long --json`:工作树里是内容、对象在本机而工作树是指针、只有指针而对象不在本机);没有子模块或 LFS 文件时不列。各列表分别读出,一个读不出只在该处显示 Git 原话;切到此页时读,此后每次状态重读时重读,只取最新一次。选中一项,中间显示:储藏为提交详情的同一套(`hist::Picked`,历史与储藏共用读取与面板):改动的文件(对第一个父提交)与储藏的未跟踪文件(第三个父提交,各自对空树),选文件看差异,上方应用、弹出、删除…;远程为地址与推送地址,取回、修改…(改名 `remote rename`、改地址 `remote set-url`)、移除…(先问);工作树为路径与分支或提交、锁定与失效原因,打开(作为标签)、移除…(先问,主工作树与当前的不给);子模块为记录的与检出的提交,“检出记录的提交”(`submodule update --init`,经远程运行)与打开。用户输入的名称、地址、路径前一律加 `--`(推送的远程名同样)。表单与选择对话框(新建分支、标签、改名、身份、储藏、远程、工作树、选远程与上游)记下打开时的仓库,提交时写进那个仓库,不随当前标签变;储藏列表按每条三个字段读(说明可为空)。储藏的应用、弹出、删除按 `stash@{n}` 执行,执行前在写队列里核对它仍是所见的那个储藏(另有储藏插入即拒绝,什么也不做);弹出遇冲突时 Git 保留储藏,对话框显示 Git 原话。删除储藏、移除远程与工作树每次都问。分支页的本地分支多出“设置上游…”(列出远程分支,`branch --set-upstream-to=refs/remotes/名`)与“取消上游”。推送:有上游即 `push --progress`;没有上游时后台读出远程,问推到哪个远程(`push --set-upstream -- 远程 refs/heads/分支:refs/heads/分支`),没有远程或 HEAD 游离即说明。强制推送(§13.3):后台读出当前分支、上游的远程与远端分支、上次取回时远端的提交(租约)、此刻 HEAD 的提交(源)与远端有而源没有的提交,对话框写明远程、分支与将被覆盖的提交(或写明没有),确认后推送的就是所示的源提交(`push --force-with-lease=远端分支:租约提交 -- 远程 源提交:远端分支`),确认前本地分支再动也不改变推送内容;远端自上次取回后变了即被拒绝;没有上游或游离即说明不能。远程命令(取回、拉取、推送、子模块更新)是写队列里的一种(`Write::Remote`),经 `running::Running` 运行:Git 在自己的会话里(`setsid`,SSH 找不到终端,不会挂在终端提示上;凭据照用 credential helper 与 ssh-agent),进度逐行显示在中间上方的提示条,可停止(向整个进程组发 SIGTERM,Git 自行清理;已结束的不再发);用户停止时提示“已停止”、清掉其后的排队写,不当作失败;失败时对话框显示 Git 的 stdout 与 stderr(以回车结尾的进度行被下一行改写,只留最后一次;以换行结尾的行全部保留,钩子的每一行都在);拉取遇冲突照合并类命令判定为停下。关窗与退出时停下所有远程命令与克隆。克隆改用同一运行器。真窗口(macOS):仓库页五类列表、储藏详情与文件、工作树详情、分支行。未验证:Windows 与 Linux(停止远程命令在非 Unix 上报未构建);真实网络与 HTTPS、SSH 凭据;LFS 对象的下载(只显示状态)。
- 2026-10-07(用户定):跟随组件库最新的 main。组件库 K01 把 gpui 从 zed 仓库的提交换成 crates.io 的 `gpui-pre` =0.3.8(`gpui-pre-platform` 同版本),K02 让 Tab 绑在 FocusScope 的 ElyFocus 上下文下、更深的上下文先得;本项目工作区的 `gpui`、`gpui_platform` 随之改为这两个包(以 `package =` 保留原名,源码不变),组件库 pin 到 dabc1eb(其上加本项目一个提交:树与树行的无障碍角色)。升级后全工作区编译无改动。
- 2026-10-08:首版打包(R1.1)。图标(用户定:logo-design 技能出方案,选 C“实心几何”;macOS 标准 squircle,Linux 与 Windows 无底形,八个同一套设计):母版在 `assets/icons`(我按圆角矩形、圆与 30°/45° 斜线在 256 网格上画;每个颜色对白与 `#1E1E1E` 都不低于 3:1,ElyMark 原定的石板灰在深色上 1.75:1,改为琥珀 `#B87A00`;ElyTable 在 48 像素以下用简化的一版),`packaging/icons/build.sh` 以 AppKit 渲染出 icns(macOS)、512 像素 png(Linux 的 AppImage 与桌面文件)、16 至 256 像素 ico(Windows 安装程序、开始菜单快捷方式与卸载项);生成物入库,Linux 与 Windows 构建不需要 macOS。系统交给应用的文件:`ely_ui::run` 收下启动参数与 `on_open_urls`(访达的打开请求,启动前到达的也在内),应用先取 `launch_files`(有则不开空白文档;ElyMark 有则不恢复上次的标签),再以 `on_open` 处理运行中的请求;六个应用都走这一条(ElyDoc、ElyTable、ElyShow 原先不收启动参数)。macOS 包:LOK 应用只带内核,按 LibreOffice 自己的布局并入本应用:内核的库进 `Contents/Frameworks`(合并库构建留下的“invalid - merged lib”占位文件不拷,其余非代码文件即报错),内核的资料进 `Contents/Resources`,另有 `MacOS/ely-lok-worker`;`install_dir` 先找包内(`Frameworks/libmergedlo.dylib`)。不带 `LibreOffice.app` 本身:原先整份嵌入时,其快速查看与聚焦插件、`soffice` 等启动程序与文档类型被系统登记,系统设置里出现“LibreOffice 扩展”(用户发现;已注销本机七处登记);测试也不再运行 LibreOffice 应用(样本改为 `tests/fixtures/office` 的固定文件,运行开发构建的 `soffice` 会让系统重新登记它)。ElyPDF 带 `MacOS/ely-pdf-worker` 与 `Resources/tessdata`(三种语言),OCR 数据先找应用旁再找包内;ElyTree 带 `Resources/git`,其中每个 Mach-O 先签(Git 以 `INSTALL_SYMLINKS` 构建,内建命令是指向同一程序的相对链接;原先是硬链接,拷进包后变成约 150 份副本、689 MB;以 `MACOSX_DEPLOYMENT_TARGET=14.0` 构建,原先随本机 SDK 要求 macOS 27);ElyMark 用同级 ElyTree 包里的 Git。文档类型(`packaging/macos/documents.sh`)一律 `Alternate`,不抢系统默认:Doc 为 docx、odt、doc、rtf;Table 为 xlsx、ods、xls、csv、tsv;Show 为 pptx、odp、ppt;PDF;Markdown;Tree 为文件夹(查看者)。worker、内核、OCR 数据的所在路径都写日志,`tests/scripts/release-macos.sh` 据此核对都在包内。许可(§21.2、§21.3):每个包的 `Resources/Licenses` 有 Ely 的 AGPL 全文(仓库根 `LICENSE`)、`rust-crates.txt`(`packaging/crates.py`:本机目标下链进各二进制的全部 crate 与其许可文件,无许可文件的取 `packaging/licenses` 里该表达式的标准文本并写明作者,都没有即失败)与所带引擎的许可:LibreOffice 的 LICENSE、NOTICE、CREDITS;MuPDF 的 COPYING 与其编进去的每个第三方及每组内嵌字体(按构建实际嵌入的)的许可、OpenSSL、tessdata(Apache-2.0,按提交与散列取)、Great Vibes 的 OFL;Git 的 COPYING 与 LGPL-2.1、git-lfs 的 LICENSE.md(发布包里没有,按标签与散列取);ElyMark 带 RaTeX 嵌入的 KaTeX 字体的 OFL 与说明;缺任何一份即打包失败(`packaging/licenses.sh`)。`release.sh` 另出 `ElyOffice-<版本>-source.tar.gz`:自有源码、锁文件、上游补丁与构建脚本(上游各自钉在提交,取得方式即其中的 fetch 脚本)。ElyTable 同时收到多个文本文件(访达一次交来多份 CSV)时,读完的导入排队,没有对话框开着才逐个询问(原先后到的一份替换前一份,前一份静默丢失)。`packaging/macos/release.sh` 按 `apps.tsv` 的批次出各应用的 dmg;本机只有 Apple Development 证书,无 Developer ID,未公证。未验证:Intel 包(只建了 arm64)、公证、Linux 与 Windows 的包(本机不能建)。
- 2026-10-08(用户定):深色主题不用纯黑或近黑,底色取柔和的灰 `#252525`。组件库 fd0efc7:深色色板 bg `#252525`、surface `#2c2c2c`、sunken `#202020`、overlay `#323232`、hover `#383838`、active 与 border `#3f3f3f`、border_strong `#555555`;为让说明文字在较亮的 hover 与 active 上仍有 4.5:1,muted 提到 `#c4c2c0`、subtle 与代码注释提到 `#acaaa8`;色盲友好图表的紫与橙改为 `#9d8dec`、`#ffd93d`(3:1 且各色两两分得开)。`ely.xcd` 的深色方案随之:页面 `#2c2c2c`、页外 `#252525`、边界与网格 `#555555`、域底纹 `#3f3f3f`。跨应用同步外观、设置里的浅色/深色/跟随系统、Omarchy 下随 Omarchy 主题,本来已有(共用 `settings.json` 加文件监视;`settings.sh` 核对 ElyPDF 选深色后 ElyTree 即刻变深);Omarchy 的读法与 OmaPhoto 相同(`~/.local/state/omarchy/current/theme/colors.toml`、`mode` 与具名颜色)。
- 2026-10-08:图像的显存与内存(C20.2 长时测试查出)。GPUI 把每张画过的 `RenderImage` 留在窗口的图集里,直到调 `window.drop_image`;LOK 的图块、PDF 的图块与缩略图、演示的缩略图、动画预览与放映图层原先都不释放,ElyDoc 编辑保存 60 轮后应用进程涨到约 2 GB(内核不涨)。组件库 `primitives::Retired`:不再画的图先交给它,下一次绘制时释放(`release` 只放别处已不持有的,`release_all` 给自知不再画的属主,`append` 接过关闭者的);LokView、PdfView 在替换、挤出、清空图块时交出旧图,绘制前释放;ElyShow 每个标签一份,关闭的标签与结束的预览交给 Deck;放映按窗口各一份(观众屏、演讲者视图),离场中的幻灯片还在画时其图层等它结束。修后同样 100 轮在 340 至 440 MB 之间起落。
- 2026-10-08:性能基准(C20.1,§20.1)。`tests/perf/bench.py` 在发行构建上测 PERF-01 至 08(每项 30 次,P50、P95,含全部子进程的内存),样本由 `tests/perf/samples.py` 按固定种子生成到 `engines/bench`,记录写到 `docs/perf/<日期>-<机型>.md`(格式见其 README);`ELY_STALLS=1` 开主线程忙碌记录(只供测量,空闲的应用照样不做事)。实测改动:Doc、Table、Show 原先要等内核启动完(约 2.5 至 3.9 秒)才开窗口,ElyDoc 日常启动 P95 3.9 秒不达标;现 `Worker::launch` 只起进程,内核的版本与图块核对在第一个请求前做(`Worker::ready`,任何请求都先过它,未核对的 worker 收不到请求),`ely_office::start` 在后台核对并设作者名,失败即 `startup::fail`;窗口约 0.15 秒可操作,空白文档在内核首次载入后出现(中位约 2.7 秒,其中约 2.2 秒是内核首次载入文档)。启动参数改为按当前目录取绝对路径(原相对路径成了 `file://相对` 而打不开)。补丁 `0039-skia-macos-no-unused-font-manager.patch`:macOS Skia 首次画字时以全部系统字体再建一个无人使用的 `SkFontMgr`(约 370 个字体枚举加排序,主线程约 175 毫秒),新文档的第一个字与打开文档的首块带字图块因此迟到;去掉后 PERF-03 的 10 次对比 P95 由 2.6 秒降到 1.5 秒。PERF-02 的计时原以输入落地时的代际为基准,内核的作废事件先于落地回报到达时,该次编辑的图块不计,钟一直走到下一次按键(记下的是按键间隔约 250 毫秒);现按编辑的修订认定(`view/clock.rs`:修订不低于该编辑的批次所升的代际,其图块即为所求),实测中位 2 毫秒、P95 5 毫秒。PERF-08:关闭的标签原把其图块留在 GPUI 的图集里(每个文档约 20 MB 显存,40 次新建关闭后应用涨约 250 MB);`Engine::retire` 交出关闭视图的全部图像(LOK 图块、PDF 图块与缩略图、ElyMark 页面的画面),外壳在窗口的下一帧释放,修后 40 次在 454 至 462 MB 之间。历史首屏改为从按下“历史”计(原从进程启动计,含打开仓库)。R1 后基准更严:PERF-03、04 到视图收下的第一块图块(`lok: tile (x, y) shown`),其间有图块画失败即整次作废;DOCX 样本原以单独一段的分页符分页,内核只排出 60 余页(`Heading1` 段上的 `pageBreakBefore` 同样丢页),改为分页符放在标题段内,PERF-03 之前先核对恰为 100 页(视图记录页数);PERF-02 每键须有自己的计时,缺即失败;PERF-08 循环前记基线,记内核中的文档数(worker 每次载入关闭写 `N documents open`),文档数须回到基线,文件数自第一次之后不变(第一次打开模板对话框时进程一次性载入两个系统字体与两个 AppKit 本地化表)。worker 的启动核对失败后保持失败,此后一切请求被拒(原核对失败的状态与成功相同,第二次即放行)。空白文档就绪由约 2.5 秒降到约 1.0 秒,发生在本次重建内核之后,与 0039 无关(对比实测),原因未查明。
- 2026-10-08:打印输出(C19.2,§19.3)。`ely_platform::print::print_pdf` 每次取系统打印设置的副本,纸张取 PDF 中最大的一页的尺寸与方向(一次打印只有一种纸张,较小的页按原大印在其上),四边距为零(文档的页边距已在 PDF 里),缩放为 1;`ELY_PRINT_TO=路径` 让作业写成文件、不弹面板(测试用);原先用共用设置,打印机默认纸张与边距使 A4 以外的文档被裁切或偏移。Doc、Table、Show、PDF、Mark 的打印都先等输入落地、在后台写出同一份 PDF(同一修订、页面设置与缩放),对话框给出页数与范围,再交系统面板(`ely_office::print_later`);取消不动文档,副本随即删去。`tests/scripts/print.sh` 真窗口核对。
- 2026-10-08:兼容清单与往返验证(C19.1,§19.1、§19.2)。`docs/compatibility.md` 是随版本发布的兼容清单(版本、内核与补丁、验证机,各应用的编辑保存、导入受限与输出,已验证与限制),版本或格式行为变了即同步改它。往返用例在 `crates/ely-lok/src/client/tests/independent.rs`:Ely 编辑、保存、关闭、再打开、再编辑、再保存,再由 `tests/scripts/ooxml.py` 检查;它不用 LibreOffice,自己解 OOXML,预期值自己算(整数、日期序号、错误值、文本严格相等,浮点按用例声明的容差),DOCX 另交 macOS 的 `textutil`;没有 python3 时报 SKIP。与 Microsoft Office 的交叉检查未做(本机没有 Office)。
- 2026-10-08:恢复副本、引擎停止与自动保存(C06.5,§6.2、§6.3)。Doc、Table、Show、PDF 共用 `ely_office::Recovering`:每 10 秒为有改动、已落定、不在导出的标签经 `Engine::snapshot`(写副本,文档不动)写一个恢复点(`ely_files::Recovery`,限额取存储设置;加密文档封存),索引在数据目录 `recovery/index.json`;崩溃标记照 ElyMark 的 `Crash`,下次启动把崩溃留下的最新点开成未命名副本并标为未保存,提示写明只恢复了内容,光标与撤销历史没有。引擎进程退出(`Engine::ended`,客户端 `try_wait`)时 `ely_office::Stopping` 在上方显示提示条(原因、最近一次恢复点的时间)与“重新打开文档”:`Engine::restart` 换上新 worker(LOK 照原配置与作者名重起,PDF 下次打开再起),有改动的标签从最新点开成副本,无改动的从文件重开,有改动却没有恢复点的列出;被放下的标签迟到的失败不再提示,指向它们的关闭询问一并撤下。重开的副本接过原恢复点的文件夹(封存随之),旧点留到新标签写下新点为止;载入或解封失败的条目留在索引里,下次崩溃后的启动再给。需要密码的副本(加密 PDF 的恢复点照原加密写出)照样询问,答后仍是未命名副本(`Source::Locked`)。引擎是否退出在出错时与每次恢复计时时查,空闲的引擎退出也会显示。恢复索引经 `ely_files::save_atomic` 暂存后替换,写不进时上一份照旧可读;写不进即提示一次、每次计时重试。干净结束后的下次启动只删已接过或正常的条目,没能重开的留着再试。自动保存(设置“文档”页,每个应用一个开关,存在应用的 `settings.json` 的 `autosave`,默认关):只对本应用自有格式(`Kind::save_as`)、无风险(宏、签名、修复)、状态为“已编辑”的标签,编辑计数停 2 秒不变即走普通保存;保存失败、外部改动后状态不再是“已编辑”,不再自动写,等用户处理。Cmd+W 关闭标签是共用命令(`ely_ui::commands::CloseTab`,原来只有 ElyPDF 绑了;文档应用里 Cmd+W 落进 LOK,内核按自己的 Ctrl+W 关掉框架,worker 下一次绘制或读取即在已释放的文档上段错误);worker 另拒绝内核关闭框架与退出的快捷键(Mod1 加 W、F4、Q)。真窗口(macOS):`recovery.sh`(崩溃后副本回来、关闭询问;只杀引擎后提示条、重新打开、文字回来、关闭询问),`autosave.sh`(关时不写,开关存下,开后停手即写,外部改动后不覆盖)。未验证:Table、Show、PDF 的真窗口恢复(同一外壳);Windows 与 Linux。
- 2026-10-08(用户定):首版发布六个应用(ElyDoc、ElyTable、ElyShow、ElyPDF、ElyTree、ElyMark);ElyMail 与 ElyAgent 第二批发布。ElyMark(C14)做完即开始首版的完整测试与打包,C12、C15 留到第二批。
- 2026-10-08:数据管理(C18.3,§18.3)。设置的“存储”页(每个应用):本应用数据目录里的日志(`ely.log`)、恢复副本(`recovery`)各自的大小与清除(`ely_platform::storage`);日志当场清空(日志器以追加打开,截短不坏句柄),恢复副本在用着,写下记号(`.clear-recovery`),下次启动在设置与窗口之前删去(删不掉即启动失败、写明原因,记号留着下次再试,不打开没删掉的数据);每次清除先问,写明删什么、何时删;每个文档保留的恢复点(10、20、50)与空间(32、64、256 MB)存在应用设置的 `recovery`(严格读,不合即报错),ElyMark 写恢复点时照用(C06.5 的文档应用同)。日志超过 8 MB 时,启动时只留后 4 MB、从整行起。数据库的升级前备份与失败只读只属 ElyMail(第二批),首版的六个应用没有数据库;邮件与 Agent 历史导出同属第二批。组件库 ea4ed4c:`SettingsRow` 是以标题命名、以说明描述的组(原先行标题不进 AX 树,两个同名按钮分不出);待清除的状态以禁用的按钮写明。
- 2026-10-08(用户定:图标照 Papirus 重画):每个应用是 Papirus 192 网格上的一个大的扁平物件(页、讲台、信封、终端),主体一种饱和色,折角为其浅色,内容为白色、至多一个点缀色,主体与内容带 Papirus 的柔和投影(下移 4 或 2、模糊、20% 黑)。macOS 为浅色 squircle 上的同一物件(占主体 86%),Linux 与 Windows 只有物件。48 像素以下用无投影、无小细节的简化版。母版由 `packaging/icons/masters.py` 写出,配色见 `assets/icons/README.md`。Papirus 图标包(`assets/papirus_icons-master.zip`,用户放入)只作参照,不随包。
- 2026-10-08(用户要求核对各系统的文件关联):文件类型只写在 `packaging/types.tsv`(应用、扩展名、MIME;`/` 为文件夹)。Linux 的桌面项由它写 `MimeType=`(原先没有,桌面环境从不提供 Ely 打开文件);Windows 安装程序由 `packaging/windows/types.sh` 生成的 NSIS 宏注册(原先没有):HKCU 下每个扩展名的 `OpenWithProgids` 与 `Applications\<程序>\SupportedTypes`,ProgID `ElyOffice.<名称>` 带图标与打开命令,ElyTree 在文件夹右键菜单加“Open in ElyTree”;只进“打开方式”,从不改默认(同 macOS 的 Alternate),卸载时删去。makensis 在本机编过 ElyDoc 与 ElyTree 的安装程序;Windows 与 Linux 上实际打开文件未验证。
- 2026-10-08(用户定:窗口缩窄时控件挤成一团、越出边框,不可接受):窄窗口的版面。外壳一次排两侧栏(`shell/panel.rs` 的 `room`,有单测):内容区至少留 360,放不下时两侧先缩到各自最窄,再先藏右栏、后藏左栏,窗口变宽即回来,用户拖的宽度不变。组件库 6d95b24 至 69265b4:`ScrollArea::vertical` 只纵向滚动、内容不得宽于区域(侧栏一律用它,原先横向不受限,整栏按自然宽度排后被裁);`PropertyGrid` 每行可折行,放不下时控件到标签下方,标签列 104;全部图标的 `SegmentedControl` 与 `Tabs` 放不下全部文字时只显示图标,文字作提示与无障碍名(宽度取上一帧量得的条宽与文字排版宽度);`StatusBar` 两侧与各项可缩,文字以省略号结尾;`Banner` 文字折行;`Button` 不再拒绝收缩,只在无法折行时缩并以省略号结尾;未聚焦的单行文本框显示文字开头(原插入点在末尾,看到的是尾部);`Switch::name` 只给无障碍名(行已有标签时不再重复画字);`CommitInput` 的勾选与按钮一行可折。各应用固定宽度的控件框(`w_32` 等)改为占满、以原宽为上限。`tests/scripts/layout.sh`:六个应用各开样本,640、800、1100 宽下读 AX 树,任何元素越出窗口左右即失败,并留截图供目视(栏内裁切 AX 看不出)。缩放(用户问为何 Doc、Table、Show 不能缩放;视图早有 `set_zoom`,界面从未接上):`ely_office::Zooming`,Cmd+= / Cmd+- / Cmd+0,“视图”菜单三项,状态栏右侧 `ZoomControl` 的属主模式(组件 69265b4:显示属主给的百分比、按下交属主;原只缩放整个界面);Cmd(Windows、Linux 为 Ctrl)加滚轮与触控板捏合以指针处为中心缩放,滚轮一格一档(`ely_ui::wheel`,2026-10-12 起;原认 Windows 键、一格三档),ElyPDF 与 ElyMark 同(`view/zoom.rs`,档位 25% 至 400%);ElyShow 的 Cmd+0 回到适合幻灯片,手动缩放即停止适合。缩放只在内存里随标签,不跨重启。
- 2026-10-08(用户定:侧栏太占地方,要可调):侧栏宽度(C05.6)。外壳的左右侧栏(`shell/panel.rs`,八个应用共用)默认左 220、右 320(原取 `sidebar_width` 240 与 `sheet_size` 380;右 320 是 ElyDoc 属性栏不截断控件的最窄宽度,实测 280 截断数值框);内侧边缘是组件库的 `layout::resize_edge`,拖动改宽,左至少 160、右至少 280(原 220,属性栏放不下)、至多窗口的 45%;拖动停 0.5 秒后存入应用设置的 `panels`(`settings::panels`),退出时还没存的当场写入(窗口位置同),启动时严格读回(不是正数即在设置窗口写明并用默认),写入失败也在设置窗口写明;开合动画期间不显示拖动边。
- 2026-10-08:凭据与私密内容(C18.2,§18.2)。恢复密钥(加密文档的恢复副本用)存在系统凭据库(keyring:钥匙串、Windows 凭据、Secret Service);凭据库不可用或拒绝时改用本次运行的随机密钥,只在内存(`RecoveryKey { key, lasting }`,`lasting` 为假即提示恢复不跨重启),从不写成普通文件;用旧密钥封存的恢复点读不开即报“无法恢复”,不导出明文。PDF 打开密码与证书密码只在 worker 内存里、文档关闭即弃。日志:worker 的 stderr 经 `ely_ipc::relay` 按其级别并入应用日志(原先从访达启动时 worker 的日志丢失),两个 worker 与应用一样读 `ELY_LOG`、默认 info(LOK worker 原读 `RUST_LOG`,平时只出错误);日志调用不打印正文、密码、密钥;引擎(worker、内核、MuPDF)给的错误串可能引用文档,日志只记请求号与错误类别,全文只经回复交给界面;worker 的 stderr 只放行其结构化日志记录与崩溃栈帧,引擎自己打印的文字不进日志(只计数)。worker 启动失败也写成结构化记录,最后一条错误由 `Relay::last_error` 带进客户端的报错。诊断导出时姓名来源(账户名、设置)读不出即不导出并写明原因;日志尾部从整行开始。诊断(设置里的“诊断”,每个应用):先列出要导出的内容(本应用日志的最新 4 MB、macOS 上 7 天内本应用与其 worker 的崩溃报告,列表在 AX 树里),开关“去掉路径与个人姓名”默认开(凡路径与 URL 整段换成 `<path>`:有引号到引号为止,否则到行末;分段标题只写文件名;账户全名、登录名与作者名按从长到短换成 `<user>`),导出为一个文本文件(`ely_platform::diagnostics`),不上传。Git 服务的凭据照用用户自己的 credential helper 与 ssh-agent;打开文件不发任何请求。未验证:Windows 与 Linux 的凭据库与崩溃报告位置(那两端诊断只含日志)。
- 2026-10-08:配置(C18.1,§18.1)。通用偏好在 `<本机数据目录>/ElyOffice/settings.json`,各应用的在其数据目录的 `settings.json`;都经 `ely_platform::settings::Store`:带 `version`(本构建为 1;更新的版本或读不通即报错,不覆盖),写入在同目录的 `.settings.lock`(`ely_platform::lock::FileLock`,flock 或 LockFileEx,由 ely-files 移来共用)下先重读、只改所写的键、未知字段原样留下,临时文件 fsync 后原子替换;多个应用同时写不丢字段(单测八路并发)。通用字段(`settings::Shared`,类型不对即报错):外观(跟随系统、浅色、深色)、语言(跟随系统、English、简体中文;下次启动生效,设置里写明)、动效(跟随系统、减少、完整)、跟随 Omarchy(只在有 Omarchy 时显示)、作者名(空即账户名;`user::name` 先取它,ElyPDF 的批注、LOK 的批注与修订都用它)。环境变量 `ELY_APPEARANCE`、`ELY_OMARCHY_SYNC`、`ELY_REDUCE_MOTION`、`ELY_LOCALE` 仍优先。`ely_ui::settings` 启动时读入(读不出即用默认值、记下原因),以 `notify` 监视文件夹,别的应用写入后即刻重读:外观、Omarchy、动效当场换,作者名经 `observe_global` 在后台交给 LOK worker(`set_author`)。设置窗口(应用菜单“设置…”,Cmd+,,每个应用一个)用组件库 `SettingsLayout`、`SettingsSection`、`SettingsRow`、`ThemeSelector`(组件 489ef31 起三项文字进内建中英目录)、`SegmentedControl`、`Switch`、`forms::Input`:通用;Doc、Table、Show、PDF 另有“文档”(作者名,失焦或回车写入);ElyTree 另有“Git”(Git 程序,空即随带的;写入前在后台核对 2.40 或更新,不是即在框下以 Banner 说明、不写;`ely_platform::git::locate` 次序为 `ELY_GIT`、此设置、随带的,版本核对移入 `ely_platform::git::system_version`,下次启动 ElyTree 生效)。文件读不出时窗口上方以 Banner 写明原因,改动不保存。作者名与 Git 程序每次提交都编号,写入经 `Store::set_if` 在文件锁下核对仍是最新的提交才落盘(等锁期间改了主意,旧写入作废);写入失败另以 Banner 写明,键入的值留着可重试;作者框在未编辑时跟随别的应用写入的名字。版本核对先看 `--version` 的退出状态(`git::version_of`)。各应用主窗口的位置、大小与最大化、全屏存在应用的设置里(`window`,停止移动 0.5 秒后写),启动时所记之处的中心仍在某块显示器上才用。未验证:Windows 与 Linux 的锁与窗口位置。
- 2026-10-08:进程与状态核对(C17.1,§17.2、§17.4)。帧(`ely-ipc`)由长度、标签、请求号与内容组成:客户端每个请求一个递增的请求号,worker 的回复带回同一个,不符即报错(`check_reply`;原先只靠一问一答的次序配对,丢一个回复即整串错位而不自知);LOK 主动推送的事件请求号为 0(`UNASKED`);PDF worker 的版本戳包含 `ely-ipc` 的源码。帧超过上限(64 MB)发送前即报错,什么也不写;worker 的结果超限时回一个错误,worker 不退出。LOK 的输出读不下去(坏帧、提前结束)时,原因经回复通道交给等待的请求,客户端停下并回收 worker 再报出(原先请求一直等进程结束)。会话:LOK 每次载入一个会话号(递增,原先用内核文档指针,释放后的地址被下一份文档复用,旧句柄读到新文档、事件被当作已关的丢掉);PDF 的 `Doc` 带打开它的 worker 会话,客户端发送前核对,别的 worker 的旧句柄被拒;worker 的文档号带进程前缀(进程号与启动时刻),别的进程的号在此不存在。修订(PDF):`Doc` 记着读到时的历史标记(标记相同即文档相同,撤销回到原标记),指向页、对象、位置的请求带上它,worker 在动内核之前核对,不符即以 `stale:` 拒绝;整份文档的请求(页数、保存、撤销、元数据、附件列表与添加、保护等)带“任意”;按键读取或移除附件带标记。ElyPDF 的编辑在排队时记下当时的句柄,落地后按新标记换句柄,过期的读取照旧按版本丢弃(画不出的块不因过期记为失败),过期的编辑提示“文档已改动,未执行”:排在别的编辑后面的编辑因此被拒(它对着的是前一个编辑落地之前的页面),再按一次即可。LOK 的修订(R2):worker 给每份文档记一个修订,会改文档的请求(键、鼠标按下与松开、拖动、文字、命令、粘贴、剪切、函数补全;不按键的鼠标移动不算)在执行前加一;按编号指向工作表、幻灯片、对象、参考线、制表位、行列的命令(参数里有 `Index`、`nTabNumber`、`TabNo`、`Part`、`Row`、`Col`、`Column` 或区域的四角,值不是 -1;-1 是新建)先发 `EXPECT` 带读到时的修订,不符即以 `stale:` 拒绝,内核不动;同一次读取得出的一组命令(对象列表的多选)只核对一次,在一次 worker 调用里依次执行(`Worker::run_all`、`LokView::run_all_at`、`Commands::run_all_in`)。`LokView::run` 拒绝带编号的命令,须经 `run_at`;外壳的 `run_in` 对带编号的命令用 `Host::read_at`(ElyTable、ElyShow 为其读取时同一次 worker 调用里取的 `Worker::now`,ElyDoc 为视图所见:推送的事件批次带修订,每个输入落地后视图另取一次修订);`Tabs::read_in` 也给读取闭包这个句柄(ElyPDF 为视图当前的句柄)。ElyTable、ElyShow 在视图所见修订新于其读取时重读,所以键入之后按编号的命令不会一直过期;记着编号的对话框(删除与重命名工作表、删除重复项、幻灯片备注栏键入期间)在读取时记下修订,确认按它执行(`Commands::run_on`),其间文档结构变了即拒绝;ElyDoc 属性栏按视图所见的修订发(删除所示的制表位)。输入失败时视图照样取回修订(请求在执行前已计数),之后的编号命令不会一直过期。过期的拒绝提示“文档在此间已改动,请重试”(`office.error.changed`)。MuPDF 打开中途(加锁检查、日志、修复)失败即释放已打开的文档与修复副本(原先每次失败留下整个文档;MuPDF 的存储另有 256 MB 上限的缓存,满后不再增长)。FFI:MuPDF 每个调用在垫片的 `fz_try`/`fz_catch` 里,返回码加错误信息;LibreOffice 只经 LOK 的 C 接口;worker 崩溃时客户端报出退出状态。工作进程:LOK 随应用窗口启动(启动即开空白文档),MuPDF 在打开第一个 PDF 时启动;不建进程池。后台:Git、OCR、缩略图、文件夹搜索、导出与系统打开都在后台任务里。不建设 §17.4 所列设施(无 RPC 服务发现、无常驻服务、Git 只有一个内核、Markdown 只有 markdown-rs)。
- 2026-10-08:应用之间的连接(C16.1,§16;首版只做六个应用之间的三项,邮件与 Agent 的三项随第二批)。启动别的 Ely 应用(`ely_platform::apps`):先找与本应用同一安装或构建的同级(`.app` 旁、Windows 的 `Ely Office\名称`、开发构建同一目录),再找 macOS 登记过的(按包标识 `AppId::bundle_id`,`NSWorkspace`);`.app` 一律经 `open -a` 交给 Launch Services,正在运行的应用经其打开请求收下文件(不另起一个),其余直接启动。应用切换:每个应用的应用菜单有“打开 Ely 应用”子菜单,只列已安装的其他 Ely 应用(`commands::switch_to`,八个无参数动作),启动失败即提示原因。导出后查看:导出、提取、拆分、删除标记内容、清理、保护、签名与附件另存成功后,提示带“打开”:PDF 在已安装的 ElyPDF 打开(作为新标签,不当作原编辑会话),否则与其他文件、文件夹一样交系统默认程序(gpui `open_with_system`);ElyPDF 打开失败即提示原因,不改交系统。Markdown 查看版本:ElyMark 状态栏的 Git 项把文件本身交给 ElyTree,ElyTree 收到文件即打开其所在仓库并显示该文件的历史(`Tree::open_path` 按文件所在文件夹开仓库,再以仓库内路径 `file_log`);不自动提交,不建仓库;未安装 ElyTree 时提示从发行包安装。真窗口(macOS):`release-macos.sh` 从 ElyDoc 的应用菜单启动 ElyMark;ElyMark 的 Git 项打开 ElyTree 的真窗口测试随 C14.16 重写。AppKit 自己的窗口恢复关掉(`startup::no_window_restore`,`ApplePersistenceIgnoreState`):Ely 自己恢复会话;原先应用被强退后再启动,AppKit 先弹模态“是否重新打开窗口”,访达交来的文件被挡在后面(打包测试实测)。窗口测试的 `seen` 不看系统菜单(苹果菜单的“最近使用的项目”会列出别处打开过的同名文件)。未验证:Windows 与 Linux 的同级查找与系统打开;系统默认程序打开(会在本机弹出别的应用,未自动测)。
- 2026-10-07:ElyTree 分支、标签、合并、变基、冲突(C13.4,TREE-07 至 09,§13.3)。Git 一律以 `GIT_EDITOR=true`、`GIT_SEQUENCE_EDITOR=true` 运行(没有终端可开编辑器,合并与撤销照 Git 自己拟的信息);失败时 stderr 为空即报 stdout(合并的冲突写在 stdout)。左栏第三页“分支”:本地分支(当前在前)、远程分支、标签(新的在前),`for-each-ref` 读出(附注标签取所指提交与标签自身的说明,`origin/HEAD` 不算分支,上游已删即不显示领先落后),上方“新建分支…”“新建标签…”;选中一个,中间列出提交、日期、上游与说明,以及适用的动作:切换(远程分支为 `switch --track refs/remotes/名`,不被同名标签劫走)、合并到当前分支、把当前分支变基到此、在此新建分支、重命名、删除(`branch -d`,Git 拒绝时报原话)、强制删除(`-D`)、删除标签。历史页提交详情多出拣选、撤销提交(合并提交以 `-m 1`)、在此新建分支、在此新建标签。新建分支可选“并切换过去”(`switch -c`);标签有说明即附注标签(`tag -a -m`,签名照用户的 `tag.gpgSign`)。变基、删除、强制删除、删除标签、中止、跳过每次都先问(每个确认只对这一次,§13.3),合并、拣选、撤销、继续不问。进行中的操作按 Git 目录里的 `rebase-merge`/`rebase-apply`、`MERGE_HEAD`、`CHERRY_PICK_HEAD`、`REVERT_HEAD` 判定(每次状态重读时读,只取最新一次),中间上方的提示写明正在把谁并到谁(变基时“当前侧”是新基底、“另一侧”是正在重放的提交),给继续(`<操作> --continue`)、跳过(合并无此项)、中止。命令失败时,只有合并、变基、拣选、撤销,且 Git 的输出有以 `CONFLICT (` 起头的行、失败后索引仍有未合并条目(`ls-files --unmerged`),才提示“因冲突停下”;其余(如钩子拒绝、冲突中 `checkout --ours` 的钩子失败)对话框显示 Git 的原话。失败的报错带上 stdout 与 stderr 两者(拣选的 CONFLICT 行在 stdout,钩子多写 stderr)。分支与标签的名称取自完整引用名(`refs/heads/`、`refs/remotes/`、`refs/tags/` 之后),同名的分支与标签各归各的;合并与变基用完整引用。分支行内的切换与删除接到同样的入口(删除照样先问)。冲突文件:中间上方是三侧与 `ThreeWayMerge` 的块级选择,各块照工作树文件里 Git 写下的标记分组(不另算一套合并;标记宽度取 `check-attr conflict-marker-size`,照 Git 的 `strtol_i` 读,不是正整数即 7;行须恰为该宽度,长短不同的同形行照正文;只按 LF 断行,同 Git,正文里的独立 CR 不算行界),基底取自 diff3 标记,没有时取 Git 自己 `merge-file --zdiff3 --marker-size` 对三个索引段的结果(块数相同时,只填没有基底的块,diff3 里空的基底照空);下方是可编辑的结果(`CodeEditor`,初为工作树里的带标记文件);选一侧即在结果里解决对应的那一块标记(其余手改照留),结果里的标记数与未选的冲突数对不上时(已手改过)不再按块选,提示在结果里改;“保存”只写工作树文件(经同一仓库的写队列,与 Git 的写操作不重叠;落地时按仓库与路径记下;路径已成符号链接即拒绝,不顺着链接写),“标为已解决”只做 `git add`(须先保存;结果里还有标记时先问),两者分开(§13.3);另有“用当前侧的文件”“用传入侧的文件”(`checkout --ours/--theirs`),一侧没有该文件时还有“以删除解决…”(`git rm`,先问)。符号链接、子模块、任一侧含 NUL(Git 视为二进制)、不是 UTF-8 或缺少的冲突整份解决:写明情形,给“用当前侧的文件”“用传入侧的文件”(只给有该文件的一侧)、“标为已解决”与“以删除解决…”。重读时结果有未保存的手改即保留并提示。真窗口(macOS):合并冲突的提示条、三侧与按块取另一侧后结果更新。未验证:Windows 与 Linux;交互式变基(不提供)。
- 2026-10-07:ElyTree 提交、历史、Blame(C13.3,TREE-05、06)。左栏分“改动”与“历史”两页(`navigation::Tabs`)。提交:改动页下方是 `git::CommitInput`(摘要与说明两个框,摘要满 50 变黄、72 变红),上面一行是 Git 设置里的身份(每次状态重读与窗口回到前台时重读,只取最新一次;`user.name <user.email>`,未设即写明,点它开身份对话框:姓名、邮箱(两个输入框以其标签为无障碍名),可勾“用于所有仓库”写 `--global`,否则写本仓库;空着即提示不写)与签名(`commit.gpgsign` 为真时写明以 `gpg.format` 签名,默认 openpgp),签名照用户自己的 Git 与工具,Ely 不另设。提交框按仓库各存一份(换标签时存下当前的、换上另一个的:摘要、说明、修改最近提交与其草稿),提交为 `commit --cleanup=whitespace -F -`(信息经标准输入,`#` 开头的行照留,用户的 `commit.cleanup` 不删它们,不经 shell),“修改最近一次提交”勾上即显示为勾选、读出上次的信息填入(读回前不能提交;先存下已键入的,取消勾选时放回;每次勾选或取消、换仓库都使在途的读取作废,读回时须仍是发起的仓库与那一次),提交成功后只在提交框仍是所提交的内容时清空(提交期间又键入的留着,别的仓库的提交框不动);钩子或签名失败时以对话框显示 Git 的全部原话,不只说失败。提交同其他写操作排队。历史:`log --topo-order --exclude=refs/stash --all`,每页 2000 个,滚到距末尾 20 行内读下一页;每次状态重读后比对 `show-ref --head`,引用变了(含外部提交、切分支、取回)即从头重读;读取期间来的比对记下,读完再比;引用读不出时历史栏显示 Git 的原话,之后读通即重读并清掉;提交图由 `git::lanes` 按已读的全部提交算出;时间按本机时区写到分钟(不用相对时间,长列表不必随时刷新)。搜索:在“提交信息”(`--grep`)、“作者”(`--author`)、“改动的文字”(`-S`)中按字面、不分大小写找,或按提交号(`rev-parse --verify`,前缀可);搜索时不画图。选中一个提交:中间上方是摘要、说明、作者与提交者(不同时才列)、完整提交号、父提交(可点,转到父提交)、签名状态(`%G?` 按字母译出,带签名者)、引用;下方左列是相对第一个父提交改动的文件(`diff --name-status -M` 与 `--numstat`,根提交对空树,空树由 `hash-object -t tree` 算,不写死 SHA-1),右边是所选文件的差异(与改动区同一读法:Git 自己的差异,图片、二进制、非 UTF-8 同样处理,无块的纯重命名显示为无改动)。改动区差异与历史页所选文件的标题栏都有“文件历史”与“逐行追溯”(改动区限已跟踪的文件):文件历史为 `log --follow --numstat -z`,跨重命名,每个提交带当时的路径与行数,选中即显示该提交里这个文件的差异(状态按两侧是否存在判定);逐行追溯为工作树文件的 `blame --porcelain`,未提交的行写“尚未提交”,每段首行可按,转到历史页的该提交;条色按提交时间新旧。读取都在后台,按发起时的仓库、提交与文件核对;文件历史与逐行追溯每次打开有窗口内唯一的序号,迟到的结果即弃。提交详情的作者、提交者、提交号、签名、引用、摘要与说明在 AX 树里有文字。真窗口(macOS)经 AX:历史页、提交图、引用、提交详情、文件列表与差异。未验证:Windows 与 Linux;GPG 与 SSH 实际签名(只读设置,签名由用户的 Git 完成)。
- 2026-10-07:ElyTree 差异与暂存(C13.2,TREE-03、04)。差异一律取 Git 自己的输出(`-c diff.suppressBlankEmpty=false diff --no-color --no-ext-diff --no-textconv -U3`,前缀写明 `a/`、`b/`,覆盖用户的 noprefix 等设置;空白的上下文行照样带前导空格):已暂存为 `--cached -M`(HEAD 对索引,即提交前的实际索引内容),改动为索引对工作树,未跟踪为 `--no-index` 对 `/dev/null`(退出码 1 即有差异);Git 的清理过滤(换行转换等)由它自己做,按块、按行暂存的补丁对得上索引。块之间未改动的行从旧侧的 blob 取(`cat-file blob`,已暂存为 `HEAD:旧路径`,改动为 `:路径`),可展开;行号按块头推算,旧文件比差异短即报错。改动的词按每对删去与加入的行标出(Unicode 词)。空白:精确、忽略空白变化(`-b`)、忽略全部空白(`-w`);行内与并排。按块或按行:只在精确比较、且是普通内容改动时(无新建、删除、重命名、复制、权限变化的头),已暂存区为取消暂存(`apply --cached --reverse`),改动区为暂存(`apply --cached`);未跟踪、重命名等整份暂存,界面写明。补丁由所选行切出:暂存时未选的删去行留作上下文、未选的加入行不写;取消暂存时反之;块头按“范围前的行数”换算,空范围照 Git 的写法;“无换行结尾”按切出后两侧的实际行尾:无换行的行在哪一侧后面还有行,那一侧就带上换行(所选的删去或加入行同样),两侧不同的上下文行拆成一删一加;`apply` 带 `--whitespace=nowarn`,用户的 `apply.whitespace` 不改所选的字节。写入前在后台重读同一条差异,与所选时读到的字节不同即拒绝(“文件在显示后改了,未暂存”),索引不动;同一仓库的写仍排队。所选行按住 Shift 取同一块内的范围;差异一变(任何重读得到不同的输出)所选即清空;每次读取有窗口内唯一的序号,换文件后旧文件迟到的读取即弃。图片(png、jpg、gif、webp、bmp、tiff、ico)以 `BeforeAfter` 左右拖动对比,一侧缺失时只显示另一侧;其他二进制文件,以及两侧任一侧不全是 UTF-8 的文件(差异以外的字节也算),显示两侧大小,整份暂存;文件与符号链接互换时 Git 为同一路径打出两段(删去与新增),两段都显示(不折叠:两段没有共同的旧文),整份暂存;工作树侧照 Git 读:符号链接读其自身的路径,不跟随;未跟踪的文件夹与冲突文件(冲突在 C13.4 处理)只说明。单击文件即在中间显示其差异(已忽略的文件以系统程序打开),标题栏另有“打开文件”。外部改动后状态重读,正显示的差异随之重读;文件不再列在原分区即收起。真窗口(macOS):改动区选两行暂存,索引恰为所选,已暂存区显示实际索引内容,并排视图;AX 树中各行、块按钮与分段可按。
- 2026-10-07(用户定):ElyTree 的 Git。随包自建钉住的 Git v2.56.0(a018953)加 git-lfs v3.8.0(各平台散列钉在 `scripts/fetch-git-lfs.sh`),`scripts/build-git.sh` 以 RUNTIME_PREFIX 构建到 `engines/git`,LFS 放在 `libexec/git-core`。查找次序:`ELY_GIT`(系统 Git,低于 2.40 即拒绝),否则应用旁或 `Resources/git`,否则本构建的 `engines/git`;没有即报错,不退回别的 Git。每次运行都是参数数组,带 `GIT_OPTIONAL_LOCKS=0`(读取不抢索引锁)、`GIT_TERMINAL_PROMPT=0`(从不在终端问凭据)、`GIT_LITERAL_PATHSPECS=1`(路径不当通配)。
- 2026-10-07:ElyTree 仓库与工作区(C13.1,TREE-01、02)。窗口经 `ely_ui::tool_root`:“文件”只有新建(选文件夹 `git init`)与打开(可多选文件夹),`commands::menus` 改收各应用自己的文件命令(文档应用照旧六项),保存、另存、导出、打印的动作在工具窗口里不挂;“仓库”菜单:克隆…、关闭仓库(Cmd+W)。每个仓库一个标签,同一工作树(按 `--show-toplevel` 的真实路径)只开一个,再开即切过去;裸仓库拒绝。最近列表在数据目录的 `recent.json`(新的在前,留 20 个),读不出即报错且不覆盖;最近项打不开即移出并说明。克隆:先问地址,再在最近仓库旁问新文件夹(名取地址末段去 `.git`),文件夹非空即拒绝;Git 的进度逐行显示在正中(回车与换行都断行,读到即显示),可停止(SIGTERM,Git 自己删去半成品;已结束的进程不再发信号,免误伤重用的进程号);关窗或退出时停下进行中的克隆。状态读 `status --porcelain=v2 -z --branch --untracked-files=normal --ignored`,未知字母即报错;行数取 `diff --numstat -z`(已暂存与未暂存各一次,二进制不计),未跟踪、冲突、忽略的不计行数。左栏 `git::ChangesList` 分冲突、已暂存、改动、未跟踪、已忽略;单击文件以系统程序打开;暂存(`add --all`)、取消暂存(有提交时 `restore --staged`,首个提交前 `rm --cached -f`:索引与文件不同也照取消,文件不动)、全部暂存(`add --update`)、全部取消(`reset -q`);取消暂存重命名连旧路径,暂存只用新路径(已暂存的重命名再改时旧路径已不在);放弃改动每次都问(已跟踪的 `restore --worktree`,未跟踪的 `clean -f -d` 并写明会删除)。同一仓库的写操作排队、一次一条,失败即清掉其后的排队并报出 Git 原话,关闭标签要等排队写完。外部改动:`notify` 监视工作树、Git 目录与共用目录(`--git-common-dir`;链接的 worktree 的分支与引用在主仓库里),250 毫秒内的一串改动合成一次重读,读取中再来的合成一次;空闲不读。状态栏:分支(游离显示短提交号,无提交写明)、上游与领先落后(上游引用已删即写明不存在,不显示为 0)、真实路径(所用 Git 的版本与路径只记日志,2026-10-11 用户定从状态栏去掉)。中间区在 C13.2 前只显示改动数或“没有要提交的改动”。组件库 4f8f3bd 与 37c5187:`Changed::lines` 可缺。真窗口(macOS):五个分区、行数、状态栏。未验证:Windows 与 Linux(停止克隆在非 Unix 上报未构建);LFS 仓库的克隆。
- 2026-10-07:ElyPDF 输出与文件恢复(C11.8,PDF-13、14,QA-13)。“文件›导出…”(Cmd+E)一个对话框三种:页面图片(PNG,72、150、300 dpi,全部或所选页,总是浅色,选文件夹,`名称-序号.png` 按页数补零,先以 `ely_files::claim_all` 占下全部目标,任一失败即删去本次全部);文字(UTF-8,各页以换页符分开,MuPDF 的 stext);优化的 PDF(可看可改的设置:压缩数据流、字体与图片;图片高于某 dpi 降到某 dpi,双三次重采样到确切目标,重压缩:无损的仍无损、有损的保持原格式,默认 300 降到 150;降到的 dpi 须低于阈值,否则不可导出;完成提示写明原文件与新文件大小)。导出图片与文字要“复制”许可,优化副本要“修改”许可。“打印…”(Cmd+P)把文档此刻写成副本(要“打印”许可,worker 的 `PRINT_COPY`)再交系统打印面板(`ely_office::print_later`)。文件恢复:MuPDF 打开时修复了文件结构(`pdf_was_repaired`),worker 即把修复后的文档整份写成临时副本、改开这份副本(日志与快照从此正常;原先修复过的文件不能保存),关闭文档时删去;打开时 MuPDF 的警告记下作修复报告(`ely_open` 期间接管警告回调,照样转给原回调)。外壳的 `Engine::signed` 改为 `Engine::risk`(宏由外壳判,签名、修复由引擎答),加 `Risk::Repaired`:提示条说明打开的是修复的副本、“另存为”写新文件、原件不动,带“报告…”按钮显示 MuPDF 的原话;保存一律另存,另存到原件被拒;签名或修复的文件另存成功后标签记下“待重开”,到内存与文件一致(已保存、无待落地编辑、不在导出)时才按新文件重开;另存期间又有编辑则留着编辑,下一次保存后再重开。MuPDF 有时读到坏对象才修复(尾部完好、某对象偏移坏):打开与解锁后先把每个对象读一遍,并解码图片以外的每个数据流(`ely_scan`,读不出的对象记入报告);MuPDF 修复了结构或读的过程中有任何警告(如内容流解压失败),都当作损坏文件,改开整份写出的副本、报告列出警告、保存一律另存。图片(用户定)在画出时检查:画图块或缩略图时 MuPDF 有警告即为损坏,报告补上警告,标签转为修复风险(保存一律另存);没画过的图片在原位保存(覆盖文件)之前统一解码检查(外壳的 `Engine::unsound`,有问题即转为另存为)。全部解码放在打开时的代价实测:100 张 2000×2000 的 JPEG(281 MB)打开到首块 2.6 秒(release,本机)。导出图片与文字期间 MuPDF 有警告即报错不写(读不全的内容不当作导出)。打印副本不带加密(已核过打印许可;否则系统打印面板打不开加了打开密码的副本),`print_later` 在面板关闭后删去它。导出对话框的降采样数值随键入生效(不等回车或失焦),直接点导出用的是所见的数。补丁 `0005` 见上(保存打不开输出时的保存中标志)。QA-13:worker 改字、加并填文本字段、移页后保存,以 PDFKit(另一阅读器)读回:页序、新字取代旧字、字段值都对(`tests/independent.rs`,只在 macOS);图片编辑在 PDFKit 里未单独核对(PDFKit 无图片清单接口)。组件库 a6b1bb0 的 `init` 改为返回错误(缺资源时),启动时出错即以 `startup::fail` 报出。真窗口(macOS):损坏文件的提示条与“修复的副本”状态、导出对话框。未验证:Windows 与 Linux 的打印(打印面板只在 macOS 实现);真窗口里实际打印。
- 2026-10-06:ElyPDF 密码与签名(C11.7,PDF-11、12,QA-15,§11.4)。权限:worker 记下每个文档的访问(MuPDF 的认证结果,4 为所有者;未加密即全权),`PERMISSIONS` 按 Ely 的位报出(打印、复制、修改、批注、填表、页面与书签、所有者),每个改动请求先按表核对(`secure::needs`:页面增删移转与书签要“页面与书签”或“修改”,内容、链接、属性、附件、OCR 层、删除与清理要“修改”,批注要“批注”,建改字段要修改加批注,填表要填表或批注,提取要复制,改保护要所有者),不许即以 `restricted:名` 报错,客户端转成 `PermissionDenied`,界面按名说明并提示输入所有者密码;复制文字在应用里按位拦(读文字本身不拦)。“文档”菜单“输入所有者密码…”:对时解除限制;错时 MuPDF 已丢了原密钥(RC4 与 AES-128 实测文字读不出),worker 以原密码重新认证。“密码与权限…”只在所有者权限下:写新文件(AES-256,`ely_protect` 经 `ely_save_copy` 带写出选项),打开密码可空,所有者密码必填且须与打开密码不同,六项许可勾选;或不保护(去掉加密)。只凭用户密码打开的文件改保护即拒绝。签名:MuPDF 以 `HAVE_LIBCRYPTO` 构建,worker 另链 `libmupdf-pkcs7.a` 与 OpenSSL 的静态 `libcrypto.a`(pkg-config 给目录)。`SIGNATURES` 按表单字段树列出全部签名字段(不画在页面上的也列,页与框可无;控件无 /P 时从各页 /Annots 找页):已签的给出摘要核对(`pdf_check_widget_digest`:未改、已改、读不出)、其后有无更新与更新是否越出签名所许(`pdf_signature_incremental_change_since_signing`、`pdf_validate_signature`)、签名者(MuPDF 的识别名与通用名)、签名者自称的时间、原因、地点,以及签名里的证书(签名者在前,DER);单个签名读不出只报该项的原因。证书信任由应用交系统判定(`ely_platform::trust`:macOS 用 `SecTrust`、`SecPolicy::create_x509`、禁网,故从不核验吊销;Windows 与 Linux 未构建,报“此平台未构建”)。界面“文档›签名…”对话框逐个列出完整性、证书、吊销(一律“未核验吊销状态”)、时间(带时区)、原因、地点与所在页,不给笼统的“有效”。新签:“以证书签名…”工具,拖出框或点空的签名字段,对话框选 .p12/.pfx、填证书密码、原因、地点,另存:快照副本上建字段(名 `SignatureN` 避开表单已有名)并签,以增量更新写出(旧签名覆盖的字节不动),写完重开核对新签名的摘要,不过即报错删文件;原文档不动;发起时绑定视图版本与所选位置。补丁 `patches/mupdf/0004-signature-reason-and-location.patch`:原因与地点写进签名字典(MuPDF 原只画进外观;签后再改会落到第二个更新段,字节范围算成负数)。外观只画标签、名字与时间(不画空的识别名各栏,不画 MuPDF 标志)。已签文件(`ely_signed` 数表单里有值的签名字段)打开为 `Risk::Signed`:上方提示条说明修改会使签名失效、“另存为”写出未签名副本,带“签名…”按钮;保存不覆盖原文件。外壳的 `risk` 由布尔改为 `Option<Risk>`(宏或签名),`Engine::signed` 由引擎回答。Ely 一切整份写出(保存、另存、提取、删除、清理、保护)在 `ely_save_copy` 里去掉签名值与其外观(字段留下、空着,去掉 AppendOnly 标志):整份重写本就使签名失效,不留假签名。签名核对的是文件,不是编辑中的文档:`SIGNATURES` 带路径,worker 以文档的密码另开该文件读出再关闭;签名列表按标签当前的文件路径读、按路径缓存,保存后重读。已签文件另存为成功后,标签按新文件重新打开(外壳的 `reload`,同冲突时;撤销历史随之重新开始),打开着的文档、签名列表与以后的签名都对应这份未签名的副本;另存失败时标签与签名都仍是原文件。签名对话框每次打开都重读文件,磁盘上被改动即显示已改。去掉签名值时一并去掉该签名字段自身的只读位(签名时由它的锁设下),以便再签;它锁住的其他字段的只读位留着(分不出原本是否只读,已知限制)。补丁 `patches/mupdf/0005-failed-save-clears-save-in-progress.patch`:MuPDF 的 `prepare_for_save` 置起保存中标志、只有写出过程清除它;打不开输出文件时标志留着,此后一切改动绕过日志,编辑没有撤销步(实测:存到不存在的目录后旋转,历史标记仍为 0);补丁让打开输出或预排签名失败时清除它。可视签名(用户定三种来源):“添加可视签名…”对话框,手写(组件库 `SiLine truncated
- 2026-10-06:ElyPDF 内容删除与文档清理(C11.6,PDF-09、10,§11.4,QA-14)。标记只在视图里(“标记文字以删除”按所选文字每行一框,“标记区域以删除”拖框,单击已有标记取消;预览为页面上的深色框;任何编辑落地即清空,页码可能已变),文档本身不动。“删除标记的内容…”与“清理文档…”都写新文件:先把文档快照写出、重开副本(`ely_save_copy`,提取与保存同用),在副本上改,再以 `garbage=2` 整份写出(不增量,旧修订不留),写到工作目录、核对通过后经 `ely_files::export` 交到所选位置,工作副本普通删除(对话框写明:原件、旧备份、同步历史与已发出的副本仍可能含有被删内容;不是磁盘级擦除)。删除:先删去碰到区域的批注与字段(MuPDF 的删除只管链接与文本框批注,其他批注的内容、字段的值会留下),字段失去最后一个控件即连同空了的上级一起从表单去掉(否则值留在字段树里);全部区域的删除做完后才建 Redact 批注(否则后一区域会删掉前一区域的标记)并 `pdf_redact_page`(黑框;图片在区域内的像素由 MuPDF 改成白色;碰到区域的线条整条删去,只删被盖满的会留下可复原的路径;碰到的文字删去,含隐藏文字)。有区域的页再过一遍内容(含表单):标记内容的 /ActualText、/Alt、/E 一律去掉,结构树里属于该页的元素(自身或继承的 `Pg`,或子项 MCR、OBJR 的 `Pg` 指向该页,连同其上级)去掉这三项与 /T;整页去掉而不只区域内的,宁多勿漏。页面缩略图 /Thumb 删去(它可引用原图),核对时区域所在页仍有缩略图即失败。写出后重开核对:区域内的字(与删除同一判法:字框各边缩一成后相交)为零;区域内画出来不是黑色的像素为零;逐页以设备重放,落进区域的路径、渐变、图像蒙版除我们的黑框外为零,图片单独画出在区域内只剩白或黑;文件只有一个版本;任一不过即报错并删去输出。确认时绑定标记与文档版本,选完路径后标记或版本变了即不写。清理:先扫出数量(属性与元数据条数、批注、附件含批注里的文件、有隐藏文字的页、脚本入口),勾选后写出:删 Info;其余按一遍遍历全部对象及其中的直接字典与数组:/Metadata 与 /PieceInfo,文件说明的 /EF 与 /RF 及关联文件 /AF,脚本;删批注(链接与字段不算批注,保留),删附件(名称树与文件批注;附件按带 /EF 的文件说明计数,每个一次),隐藏内容为:内容过滤(包住 MuPDF 的 sanitize 过滤器,用途设为 View 才认关着的图层)删去关着的图层里的一切、3 与 7 渲染模式及零透明度的文字,脚本(名称树 JavaScript;任何对象的 /A、/OpenAction、/Next 与 /AA 各项里 S 为 JavaScript 或带 /JS 的动作,从链中摘除,其后的 /Next 链接回原处,非脚本动作保留),带隐藏或不可见标志的批注(含链接与字段控件:链接不在 MuPDF 的批注表里,按页面 /Annots 删;控件删去时字段连同值从表单去掉);有隐藏文字被删的页,标记内容的替代文字照删除的做法去掉。扫描与写出后的核对都在快照副本上跑同一遍清理来计数,所以计数与执行一致;所选类别核对不为零即报错。扫描回来时须仍是当前标签且版本未变,对话框写明文件名。未验证:Windows 与 Linux。
- 2026-10-06:ElyPDF 文档结构与 OCR(C11.5,PDF-07、08)。MuPDF 以 `tesseract=yes` 构建(自带的 Tesseract 与 Leptonica,C++,worker 另链 libc++/libstdc++);构建脚本记下所用参数,参数一变即整个重建(make 不跟踪参数,曾留下未开 OCR 的旧目标文件)。语言数据(用户定 tessdata_fast:eng、chi_sim、chi_tra)由 `scripts/fetch-tessdata.sh` 按钉住的提交取到 `engines/tessdata` 并核对散列;运行时取 `ELY_TESSDATA`、应用旁的 `tessdata`、或本构建的目录,缺 eng 即报错。OCR:worker 把页面按 300 dpi 画成灰度图,只让这张图经 MuPDF 的 OCR 设备进 stext(不把页面原有内容交给它),按行回报页面坐标的外框与文字,不改文档;应用逐页在后台读,可取消,每页回来时核对视图版本,读完以一步写入隐藏文字层:无字形的 Identity-H 字体(ToUnicode 恒等映射),每行一个 `3 Tr` 的文字对象按外框横向拉伸,写在单独的内容流里(自带 `q … Q`,3 Tr 不漏到后来写的字)并以 `/ElyOCR` 标记;“重新识别”先删去这些流再写,原有文字不动;“跳过”不读已有文字的页。BMP 以外的字拒绝整层(已知限制)。补丁 `0003-ocr-needs-every-language.patch`:Tesseract 只载入能载入的语言、静默丢掉其余,补丁让载入数少于所请求时报错。每次识别有自己的编号,取消后的页回来即弃(不再按版本与页码认);页码范围随键入更新。补丁 `patches/mupdf/0002-embedded-file-and-link-uri.patch`:`pdf_add_embedded_file` 成功时未结束它开的操作,之后撤销与状态读取都抛错;`pdf_set_link_uri` 写了 `/A` 却留着旧 `/Dest`(读取先看 `/Dest`),改链接目标无效。书签:添加(在选中项之后,否则末尾)、改名、改目标、删除(连子项),按自顶的路径定位并核对标题,经 MuPDF 的大纲迭代器在一步内完成。链接:读取每页的外框、地址与内部目标;阅读时单击即跳转(内部)或打开(只 http、https、mailto,其他拒绝并告知);“添加链接”工具拖出外框,目标为页码或网址;单击已有链接改目标或删除,按序号并核对地址。文档属性:标题、作者、主题、关键词可改(只写改过的,一步),创建与生成程序及时间只读,文件没有的显示“无”。附件:EmbeddedFiles 名称树按名列出,再列各页 FileAttachment 批注的文件(标出页码),以键区分(`n:名称`、`a:页:对象号`,重名不混);大小取 `/Params /Size`,缺即按实际字节(不把缺失显示成 0);类型、说明,另存为(经 `ely_files::export`)、添加(文件名为名,重名拒绝)、移除;改动时名称树重写为一层有序的 `/Names`。左栏分段为页面、书签、批注、附件,书签与附件为空时显示空状态;“文档”菜单:文档属性…、识别文字…、添加书签、添加链接、添加附件…。真窗口:扫描页识别后可查到文字、页面观感不变。移除批注里的文件即删去该批注。未验证:XMP 元数据不随属性修改;Windows 与 Linux 上的 OCR。
- 2026-10-06:ElyPDF 批注与表单(C11.4,PDF-05、06)。垫片 `ely_annot.c`、`ely_form.c`:读一页的批注与字段(JSON:对象号、种类、外框、颜色、内容、作者、印章名;字段的全名、值、开状态、是否选中、只读、选项与显示文字);批注与字段按对象号认定,撤销后不变,页上没有即拒绝。添加批注收 JSON 列表(可跨页,一步):高亮、下划线、删除线(按所选文字每行一框)、便签(内容)、矩形、椭圆、直线、手绘、印章(MuPDF 的 14 种标准印章);全部检查在开步之前,不合即不改、重做不丢。作者取账户全名(`ely_platform::user::name`)。改批注的颜色与内容、删除批注各一步。字段(MuPDF 无建字段接口,垫片自建):文本、复选、单选、选择框(组合框);名字不可含点、不可重名;同名单选并入同一组(各按钮的开状态为 ChoiceN,开状态先画外观再设回 Off);改名、改选项(原值不在新选项里即清空)、删除(组里最后一个按钮删去即删组)各一步。填写:文本与选择框经 `pdf_set_field_value`(不跑脚本),复选与单选设组头 /V 与各按钮 /AS;只读字段拒绝。外观在同一步里生成。界面:“批注”菜单(高亮、下划线、删除线、便签、印章、矩形、椭圆、直线、手绘)与“表单”菜单(文本字段、复选框、单选按钮、选择框)各为工具,Esc 退出。阅读时单击复选与单选即填写,单击文本与选择框在右栏填写,单击批注在右栏改颜色、内容或删除;表单工具下单击已有字段改名、选项或删除,拖出新字段(单击给默认大小)后在右栏取名再添加;新单选默认取本页最后一组的名字。按下即改的操作须视图无排队编辑,右栏的改动按发起的标签与版本核对。右栏的开合在拖动与绘制期间保持手势开始时的样子,手势按开始时该页在窗口中的位置与比例换算(之后页面因右栏开合或滚动移动也不影响);单击按窗口像素判定(移动不足 3 像素)。文字未读到时拖出的标记等文字到后再加。新字段的默认名避开表单里的全部名称(worker 读整棵 `/AcroForm/Fields`,含没有页面控件的节点与父名),右栏等它读完再给;改名只改名字末段,按父路径下的全名查重。左栏“批注”列出全文批注(每批 20 页在后台读),点一行转到所在页。组件库 3d08744:`DescriptionList` 的值占满所在列。未验证:已有文件里的签名字段与按钮只读不改;多选列表框只当单选填;Windows 与 Linux。
- 2026-10-06:ElyPDF 文字、图片与对象编辑(C11.3,PDF-03、04,§11.3)。字体(用户定):文档自带字体(能写新字时)或 MuPDF 内置的 Helvetica、Times、Courier(不嵌入,WinAnsi)与 Noto 中日韩(不嵌入,UTF-16 的 Adobe CJK 字体),不扫描系统字体;自带字体以 CID 字体(Identity-H,按字形号写)写入。对象(用户定):图片与表单 XObject。文字块以 MuPDF 的 stext 为准(同一读法,块序号稳定);可编辑的块须横排,且每个字形都在页面自身的内容流里(以不落盘的过滤试跑核对,表单里的字不可改)。改字是一步:用内容过滤的 `text_filter` 去掉该块的字形(按字形原点在用户空间 0.5 点内且字符相符,不靠白块遮盖),再在原首行基线处按框宽排版写新字(空格处折行,太长的词断开,行距沿用原块);所有检查(块文字未变、横排、字形都在页面内容里、字体画得出每个字、排版)都在开步之前,拒绝时文档与重做都不动;画不出某字即告知是哪个字,界面请用户换字体;换了字体、字号改变或新文字越出原块的框时先出预览(在快照副本上改并画出该区域,文档与撤销历史都不动),确认后才写。行距取首行与下一条不同基线的距离(同一基线的几段不算行),没有即字号的 1.2 倍。新文字在点击处起框。新增内容第一次写入时把页面原内容包进 `q … Q`(以 `%Ely` 标记,只包一次)。图片与表单:清点处理器按 `q`、`Q`、`cm` 跟踪,列出页面顶层的每次 `Do` 与其页面框;移动、缩放(右下角柄,保持比例、左上不动)以 `X = C·T·C⁻¹` 包住该次绘制重写内容流,删除即不写该次 `Do`,替换图片是新图片资源、按比例放进原框中央;添加图片落在当前页中央(像素按点、缩到页面一半以内)。MuPDF 的字体与图片资源缓存不随撤销与放弃回退(实测:预览后再写,字体引用指向已不存在的对象),每次放弃、撤销、重做后清空。编辑工具在“编辑”菜单(编辑文字和图片、添加文字、添加图片…),Esc 退出(键位上下文在窗口根上,页面与面板里都管用);替换、添加与删除图片和表单、拖动移动与缩放、写字与预览的确认都绑定发起的标签与视图版本,执行前视图须未变且无排队的编辑,否则提示不执行;任何编辑落地即关掉此前的预览;图层读取在回来时总清掉进行中的标记,编辑之前发出的读取作废后重读;右侧编辑面板:文字、字体、字号、颜色、框宽,应用、删除文字;对象:替换图片…、删除。真窗口(macOS)改过一次字:同字体同位置替换,标签为已编辑。
- 2026-10-06:ElyPDF 页面组织(C11.2,PDF-02)。worker 打开即开 MuPDF journal;插入空白页、从文件插入(整份 PDF 嫁接)、删除、移动、旋转(90 度倍数,写页面的 `/Rotate`)、裁剪(按所见的上右下左边距设 `/CropBox`,全零即去掉)、撤销与重做都是一步,回复带历史标记(worker 按 MuPDF 的撤销位置记每步是编辑还是保存,标记是最后一个已做的编辑;撤销重做跳过保存步)。删除时指向已删页的书签保留但失去跳转,链接移除,表单字段去掉已删页上的控件;页码标签由 MuPDF 的删除与插入随之平移。坏的编辑(删光、页码乱序、非 90 度、边距超出页面)拒绝并回滚整步。保存(Save、Save As 走外壳)先 `pdf_save_snapshot` 写出(不动内存中的文档与撤销历史,靠补丁 0001),再打开该副本以 `garbage=2` 紧缩写到工作目录(已删页的对象不留在文件里),加密文件以用户输入的密码重开副本、照原加密写出(密码只在 worker 内存);外壳读回字节、原子写入。提取(选中页写新文件,经 `ely_files::export`,不覆盖打开着的文件,被别的标签持有即拒绝)在快照副本上删去其余页再紧缩写出,原文档的撤销与重做都不动;拆分按每份页数写到所选文件夹(`名称-序号.pdf`,先以 `ely_files::claim_all` 占下全部目标,任一已存在即一个不写;原 ElyShow 的同名函数移入 ely-files 共用)。合并即从文件插入到末尾再另存;插入的页带着自己的批注、链接与表单字段(指向源文件自身页的链接改指插入后的页,字段并入表单,重名的加序号,如 `field0 2`(名称按需分配,长名也终止),源表单的默认外观 `/DA` 与默认对齐 `/Q` 给到没有自己值的字段树根,源表单的字体资源并入目标 `/DR`,同名而不同的字体改名为 `名称_2` 并改写字段外观里的引用(按 PDF 名称语法解 `#xx` 转义,写出时照样转义);源文件的书签不并入)。删除时表单控件按被删页的 `/Annots` 认定,不只看可省略的 `/P`。裁剪边距以所见为准:先按视图旋转、再按页面 `/Rotate` 换到页面自身方向,并按 `/UserUnit` 换算。编辑失败也通知外壳队列已动,等着的保存、关闭不会挂住。结构编辑后区间点选的起点作废。应用:每个视图一条编辑队列,一次一条、按序执行,落地后重读页数、尺寸与书签,清掉图块、文字、缩略图、找到的词与选择,视图版本加一,此前发出的读取回来即丢;查找在编辑后重查。修改标记是当前历史与载入或上次保存时的历史不同,撤销回到保存点即为已保存(外壳新增 `Engine::saved` 与 `Session::reverted`:引擎回报未修改时 Dirty 回到 Saved,保存中、冲突、失败不变)。左栏是页面缩略图(`side.rs`;有书签时上方分段切换到书签):点选、Shift 连选、Cmd 增减,右键菜单,拖到某页之前即移动(移动后仍选中),Delete 删除;无选中时作用于当前页。“页面”菜单与“编辑”菜单的撤销(Cmd+Z)、重做(Shift+Cmd+Z)。裁剪对话框以毫米输入。MuPDF 的构建改为在 `engines/mupdf-src`(reference 只读)打 `patches/mupdf` 后编译。
- 2026-10-06:ElyPDF 阅读(C11.1,PDF-01)。MuPDF 只链进 `ely-pdf-worker`(`crates/ely-pdf-engine`,build.rs 把 C 垫片 `shim/ely_mupdf.c` 与 `libmupdf.a`、`libmupdf-third.a` 只链给这个二进制),应用不链 MuPDF。build.rs 以协议、垫片与 worker 源码的散列为版本戳,应用启动 worker 先握手(`HELLO`),戳不同即拒绝(单测不重建 worker,旧二进制曾被误用)。worker 在打开第一个文件时才启动(PERF-01),`tests/scripts/pdf-idle.sh` 查空白窗口无子进程。垫片每个调用都在 `fz_try`/`fz_catch` 里,返回码加最后一条错误信息,MuPDF 的跳转从不越过 Rust。worker 与应用之间是 `ely-ipc` 的帧(从 ely-lok 抽出,两者共用):打开(锁定即报)、解锁、页数、页框、渲染一块(设备像素、缩放、旋转、深色)、按阅读顺序的字符与外框、页内搜索(每处命中及其全部四边形,跨行短语算一处,数量不设上限)、书签(JSON)、关闭;文件读不出只报错,worker 照常工作。查看器:打开时只读页数、前 100 页尺寸与书签,其余尺寸后台每批 100 页补齐;视图只排屏上的页(连续、单页、双页;适合宽度、适合页面、实际大小与缩放档;视图旋转 90°),按 512 设备像素一块请求,一次一块、由近及远,只留屏上的块(横纵两向),后台回来的块不在屏上即弃;画不出的块记下不再请求,换文档或重开前不重试;深色外观下页面按亮度反相(MuPDF `fz_invert_pixmap_luminance`,色相保留)。文字选择按字符、可跨页,复制按行换行,未读的页先读;查找每批 20 页在后台进行,命中逐个跳转并标出,Esc 关闭(组件库 617a8d6);书签栏用 `lists::Outline`,点即跳到页与位置。切换视图、缩放、旋转后仍停在正在读的那页。加密文件打开时问密码(外壳的密码框,见上一条),错了再问,取消即不打开。没有页的文件报错并关闭,不开标签。双页时同一行在视口内的两页都可为当前页;当前页只在横纵两向都可见的页里定,跳转在目标页左缘不在视口时横向移过去。图块按视口半开区间取。首次按下时文字未读,读到后照原按下处起选;查找结果按查找实例与所属标签核对,只在该标签为当前时跳转。书签为空时不留左栏。启动参数里的文件直接打开。
- 2026-10-09(用户定:“打开方式”里不得出现 LibreOffice):系统会登记磁盘上能找到的 .app、挂载镜像里的 .app 与运行过的包,构建目录里的 `LibreOffice.app` 与测试挂载的官方镜像曾因此成为 xlsx 等的默认程序。现构建后由 `scripts/inert-lo.sh` 去掉其全部文件与 URL 声明、快速查看与聚焦插件,注销它,并在 `engines/` 放 `.metadata_never_index`;Ely 只取其 Frameworks 与 Resources。任何脚本与测试都不挂载 LibreOffice 镜像:LOK 探测用本构建的内核,单测所需的官方内核由 `scripts/extract-official-lo.sh` 以 7zz 解成无包的文件夹。`tests/scripts/lo-unregistered.sh` 以 NSWorkspace 核对 docx、xlsx、pptx、odt、csv 的“打开方式”里没有 LibreOffice。
- 2026-10-09(用户报:ElyTable 点格跳到别处、拖不出选区):Calc 在 `sc_print_twips_msgs` 下以打印 twips(行高原值)报光标、选区与 `.uno:SheetGeometryData`,图块与鼠标却在图块 twips(每行按 `ScViewData::ToPixel` 截成整像素),两者随行号漂移(5000 行处约 140 行)。视图对外一律打印 twips;`LokView::set_geometry` 收下工作表几何后,`sheet::Axis::tile`、`print` 照内核的截断换算(每像素 twips 取图块的像素与 twips 之比),绘制、显示、跳转与冻结都先换成图块 twips,鼠标以图块 twips 发出(`tile_point`),拖动句柄仍为打印 twips;`.uno:` 读回与应用(表头、填充柄)照旧用打印 twips,表头的起点取 `LokView::scrolled_start`。内核读鼠标用的比例原取最后一块图块,worker 现于每次 `setClientVisibleArea` 前以 `setClientZoom` 告知图块比例,点击不再依赖先画过图块。
- 2026-10-09(用户报:ElyPDF 改字报“这种字体画不出 V”):文档自带的子集字体常无可用的 Unicode 表(LibreOffice 导出的子集表为占位,0x00 起逐一对应字形号),按 Unicode 找字形一律失败。现以“文档自带字体”写字时直接用页面上的那个字体对象(`shim/ely_pen.c`;`pdf_load_font` 按对象缓存,与文字层的字体同一对象),以它自己的编码写:按文字提取的读法(ToUnicode,缺项或坏值退回 cid_to_ucs,同 pdf-op-run.c)倒出 Unicode 到编码的表,排版取它的宽度(`pdf_lookup_hmtx`),原有的 ToUnicode 与宽度不动,两个字共用一个字形时各写各的编码(原先按字形号写、重建 ToUnicode,共用字形的字被改写,且 CMap 漏写条目数,PDFKit 读不出字)。删去原文字块后页面资源里可能已无该字体,故按对象号以新名字重新引用。只支持编码即 CID 的字体(简单字体与 Identity-H),其余拒绝并说明;页面上没有的字仍报缺字。排版按同一套宽度量出的框,比较时留 0.01 点的余量(原块宽度即这些宽度之和,浮点误差曾使末字折行)。
- 2026-10-09:ElyTable 的自动筛选(R2.9)。内核会重新推算未命名的自动筛选区:每次打开筛选列表时向下扩到紧邻的数据行(`ExtendDataArea`、`ExtendBackColorArea`,为接上后来追加的行),命令从单格找到它时按连续数据区重算(`ScDocShell::GetDBData`:应用条件、关闭筛选及其撤销);选区下方紧邻的合计行因此进了列表、随数据一起被隐藏,存后重开的筛选区同样会扩。补丁 `0040-lok-calc-autofilter-keeps-its-rows.patch`:LOK 下已有的自动筛选区保持设定时的行(同 Excel),不再隐式扩展(光标在区内时;光标在同表另一块数据里即按那一块);单格开筛选时仍取整个数据区。代价:紧贴在筛选区下方追加的行不自动并入,需重新选定后再开筛选。
- 2026-10-09:ElyTree 的差异按文件类型着色(R2.12)。组件库 7f2ac3e 至 b0dbfd4:`DiffViewer`(建立时按路径定下读法) 按路径的扩展名判定散文与纯文本(md、markdown、txt、rst、adoc、org、csv、tsv、log 等,无扩展名同,不着色),代码按语言着色(组件库 b0dbfd4 的 `forms::syntax`:Rust、JavaScript/TypeScript、类 C、Python、Ruby、Shell、YAML、INI、SQL、Lua、Haskell、Lisp、Erlang、TeX 与其他 `#` 语言各有行注释记号、关键字与常量;Shell 与 YAML 的 `#` 只在词首起注释;Makefile、Dockerfile、CMakeLists.txt 先按文件名认),不是完整的语法分析器(字符串插值、块注释、多行字符串照通用词法)(原先一律套 `//` 的通用词法,Markdown 的 `#`、Python 的注释都被当作代码上色)。同批带入上游的块编辑器跨块选择、浮层滚轮、输入法候选框位置、插入点按设备像素对齐与格式修正。
- 2026-10-09:ElyTable 新插图表的位置(R2.10)。内核按“定位区域”把图表放在其旁、并挤回当前可见区;带参数的请求(Ely 经 `FN_PARAM_5` 给区域)原把定位区域取为光标格,图表压在数据上或旁边的另一块数据上。补丁 `0041-lok-calc-chart-beside-its-data.patch`:LOK 下有请求区域时,图表放进数据区右侧(隔一列)第一块够大、无合并格的空单元格(逐列向右找,跨过旁边的数据块),右侧没有依次找下方、左侧、上方,再按行带找全表第一块够大的空处(尺寸从候选格起累加,表尾也快),不受可见区限制;空处按图表所在的工作表查,数据在别的表时从光标起找;整张表没有空处即插入失败、文档不动;`LokView` 在新选中对象(原先无选中)时把它滚入视口,拖动中的对象不滚动。
- 2026-10-09:ElyPDF 编辑与缩放不闪白(R2.6)。旧图块不再随编辑或比例变化即刻丢弃:编辑落地时全部当前图块、比例变化时离开视图的同页图块,连同当时的页面尺寸进入“底图”(`apps/ely-pdf/src/backdrop.rs`),按其在页面中的比例位置画在该页新图块之下,直到该页在视口里的新图块到齐、或页面尺寸与旋转变了才释放;每页画什么由 `PdfView::pieces` 决定,绘制与测试同用它。只有改内容的编辑保留底图并沿用已测的页面尺寸(前 100 页以外不再被占位尺寸覆盖);增删、移动、旋转、裁剪页面与撤销重做(`PdfView::edit_pages`)清空底图,因同一序号可能已是另一页。关闭标签时底图随图块交给窗口释放。原先比较整份尺寸表(超过 100 页的混合纸张文档尺寸表后段为占位,比较失败即全部清空)且只认同比例的图块(编辑后右栏收起、比例变化即清空),都会闪白。
- 2026-10-09:文档的缩放锚点(R2.7)。窄于视口的文档居中,缩放原按视口坐标求锚点、漏了居中的偏移,锚点随缩放漂移;现锚点先减去缩放前的偏移,缩放后按新的偏移(`LokView::slack`,绘制同用)换回。按键与菜单的缩放(无指针)另保持首页左缘在视口内;指针与捏合的缩放保持指针下的点不动(文档边界以外即止)。
- 2026-10-09:退出(缺陷清单 X-01)。Cmd+Q 与应用菜单的“退出”由应用级动作接收:主窗口的宿主在 `Closing::own_window` 里登记自己的退出流程(`ely_ui::commands::route_quit`),任何窗口(含设置窗口)发出的退出都经它逐个关闭标签、询问未保存的;宿主的窗口已不在时才直接退出;没有宿主的应用(ElyTree)直接退出。`Host::quitting` 在退出开始时运行(ElyMark 先记下打开的标签)。原先只有主窗口根上截退出,设置窗口在前时 Cmd+Q 不问即退,并把这次运行记为干净结束、删掉恢复点。
- 2026-10-09:作者名与读不出的设置(缺陷清单 X-02)。签名用的名字取启动时读入的通用设置(`ely_ui::settings::current`;文件读不出时为默认值,设置窗口另以 Banner 写明),`ely_platform::user::named(作者名)` 为空即用账户名;不再在引擎启动时重读文件(原先 `settings.json` 读不出时 Doc、Table、Show 以致命错误退出)。LOK worker 记下设定的作者名,引擎重启照用它。`tests/scripts/bad-settings.sh` 核对读不出的设置下 ElyDoc 照样启动、文件不被覆盖。
- 2026-10-09:每个应用只跑一份(缺陷清单 X-03)。`ely_ui::run` 在写日志之前取应用数据目录的 `instance.lock`(`ely_platform::instance::hold`;Unix 为 fcntl 记录锁,进程结束即放,被拒时以 `F_GETLK` 向系统问出持锁的进程号,不另存进程号文件,同时启动的几份也报得准;Windows 为 LockFileEx,不报进程号);已有一份在跑时第二份不启动,报“已在运行(进程号)”。原先第二份启动即清空第一份的工作目录、把它的恢复点当作干净结束删掉、改写崩溃锁。macOS 经访达打开文件本就交给运行中的那份;`open -n` 与 Linux 每文件一个进程时第二份被拒,文件须在已开的窗口里打开(未做转交)。`tests/scripts/one-instance.sh` 核对。
- 2026-10-10:ElyMark 的链接(M-05,我定,同常见编辑器):可视模式单击链接是编辑,Cmd 单击(Windows、Linux 为 Ctrl)交应用;预览单击即交应用;应用照 ElyPDF:http、https、mailto 交系统打开,其余提示拒绝。
- 2026-10-10:Windows 与 Linux 的构建环境(E-01)。Windows:MSVC(VS 2022,v143);MuPDF 由 `build-mupdf.sh` 以 MSBuild 编上游 `platform/win32` 的 `libmupdf`(静态运行库 /MT,同 Rust 的 `crt-static`;`HAVE_LIBCRYPTO`,`libmupdf.lib` 内含第三方、Tesseract 与 PKCS#7);静态 OpenSSL 取自钉在 2026.07.29 的 vcpkg(`engines/vcpkg`,`openssl:x64-windows-static`);Git 为 Git for Windows 的 MinGit 2.56.0(钉散列,`engines/git/cmd/git.exe`,LFS 在 `ucrt64/libexec/git-core`);`%TEMP%` 下的程序被系统的应用控制拦下,构建与探针都不放那里;cmake 与 ninja 须在 PATH;LibreOffice 的 configure 只认 VS 自带的 CMake(组件 `VC.CMake.Project`)。仓库与 `reference/` 一律 LF(`core.autocrlf false`、`core.eol lf`;`fetch-references.sh` 对每个上游照设)。Linux:WSL 的 Ubuntu 26.04;静态 libcrypto 另需其 pkg-config 列出的库(jitterentropy、zstd、z)。LibreOffice 在 Linux 照 Collabora 的 LOKit 配置构建(无图形后端、cairo RGBA、自带各库)。
- 2026-10-10:Windows 与 Linux 的内核与 worker(W-01、K-01,用户定:0.1.3 先三端发布)。内核只认合并库(`ffi::KERNEL`:`libmergedlo.dylib`、`libmergedlo.so`、`mergedlo.dll`;`sofficeapp` 在合并构建里是文本占位);Windows 载入时一并搜索该 DLL 所在的 `program/`。查找次序照旧,Windows 与 Linux 的包内位置是应用旁的 `libreoffice/program`,开发构建为 `engines/lo-src/instdir/program`。worker 的主循环:macOS 照旧以 GCD;Linux 以 unipoll,`runLoop` 的轮询回调里每次答一个请求,内核的唤醒回调往同一队列放信号(`worker/unipoll.rs`;回调的数据指针须非空,VCL 见空即不调用);Windows 也以 unipoll:LOK 不另起主线程,worker 的主线程跑 `runLoop`(Windows 的消息循环),请求经只收消息的窗口投到这条线程上执行(`worker/messages.rs`),同 macOS 的 GCD;原先 LOK 自起主线程、worker 在另一线程作答,载入表格即死锁。Windows 的循环不调轮询回调,但须传非空的回调,`isUnipoll` 只看它。补丁 `0043-lok-callbacks-flush-inline-everywhere.patch`:回调队列在各平台都即时冲出(原只 macOS),否则请求里等的命令结果与状态要等下一轮主循环才到。`0042-svx-boost-json-needs-no-library.patch`:svxcore 与 sd 定义 `BOOST_ALL_NO_LIB`,MSVC 不再按 Boost 的自动链接索要 `libboost_json`、`libboost_container`。Linux 的内核以 cairo 画图块(worker 设 `SAL_DISABLESKIA`;构建去掉 `--enable-cairo-rgba`,自带的 cairo 要清掉重建),字节为 BGRA,同 macOS 与 Windows;`getTileMode` 只看 cairo 开关,不可信。worker 启动核对在三端都画一段红字,按 BGRA 找不到红色即拒绝。`scripts/apply-lo-patches.sh` 的临时库设 `core.autocrlf false`(Windows 默认转换使任何前缀都对不上)。Windows 的 LibreOffice 在 Cygwin 下构建(`build-libreoffice.sh` 的 CYGWIN 分支:去掉 Unix 专用开关,加 VS 2022,用 `/opt/lo/bin` 的 make,清掉 Git Bash 带进来的 `MSYSTEM`);构建目录与 Cygwin 排除在 Defender 扫描之外(否则并行 configure 删不掉 `conftest.dir`)。英文词典扩展里的 Lightproof(Python 写的语法检查)在构建后删去:macOS 与 Linux 的内核不带 Python,它本不生效;Windows 必带内置 Python(Cygwin 下 configure 不许去掉),第二次用同一配置启动时初始化它,worker 挂起。worker 以 `SAL_NOLOCK_PROFILE` 运行,`ely.xcd` 关掉 LibreOffice 的文档锁(`Common/Misc/UseLocking`):被强杀的 worker 留下的锁会让下次启动或载入弹出交互而挂起;每个应用只有一个 worker 用自己的配置,冲突由 Ely 自己核对。三端发布(CI-01):`.gitea/workflows/release.yml` 在打标签时于三台运行器上执行 `scripts/ci.sh`:macOS 在 Mac mini 的常驻克隆里借用开发克隆 `~/Documents/GitHub/ElyOfficeSuite` 的引擎(`ELY_SHARE`;2026-10-11 起:从零构建 LibreOffice 超过 act_runner 默认的 3 小时,v0.1.3 的 Mac 任务被杀、无 dmg),Windows 在本机运行器上把 `engines` 与 `reference` 接到开发克隆,Linux 由同一台 Windows 运行器经 WSL 构建(服务器上的 Linux 运行器是一次性容器,建不了内核);各自打包后上传到该标签的发布。Windows 为 NSIS 安装程序,Linux 为 AppImage、deb、rpm 与 Arch 包(引擎构建与打包都经 `packaging/linux/container.sh` 进 Ubuntu 26.04 容器,WSL 里须有 docker;`ELY_SHARE` 的目录一并挂进容器),都由 `packaging/stage.sh` 分装引擎;每个平台都上传本提交的源码包(`packaging/source.sh`);更新清单只含 macOS(Windows 与 Linux 的自动安装未做)。
- 2026-10-11:gpui 的 Windows 窗口关闭(W-03 冒烟查出)。`gpui-pre-windows` 0.3.8 在窗口关闭时报三条错:系统已销毁窗口(ElyTree 的关窗交给系统)后 Drop 仍对旧句柄 `RevokeDragDrop` 与 `DestroyWindow`(拖放处理器因此不释放,连带窗口状态);销毁时的失活回报排在异步任务里,执行时窗口已移除(“window not found”);退出后后台任务向已销毁的平台窗口投递(`Invalid window handle`)。修复在 `vendor/gpui-pre-windows`:`WM_DESTROY` 时记下已销毁、在句柄仍有效时撤销拖放、去掉失活回调,Drop 见已销毁即不再动句柄;平台窗口已不在时投递失败只记 debug(应用已退出),其余投递失败照报。三个应用关窗与退出的日志无错。
- 2026-10-11(用户报:Windows 启动带黑色命令行窗口、“打开方式”里无图标、点 X 不退出、表格全黑无网格线):四处根因。命令行窗口:应用是控制台子系统;发行构建改为 `windows_subsystem = "windows"`(开发构建照旧有控制台看日志),子进程(两个 worker、Git、`cmd start`)一律经 `ely_ipc::windowless` 以 `CREATE_NO_WINDOW` 启动。图标:exe 里只有 gpui 的清单资源;各应用共用构建脚本 `packaging/windows/icon.rs`,把 `assets/icons/<应用>.ico` 编成资源 1(gpui 的窗口图标也取资源 1)。点 X 不退出:窗口其实弹了“保存更改”询问。`ely.xcd` 深色方案的 `CalcNotesBackground` 与浅色不同,Calc 在配色变化时改写批注样式并记一步撤销(`ScDetectiveFunc::UpdateAllComments`),未动的表格切到深色即算已修改;两套方案的批注底色改为同一值(LibreOffice 默认的浅黄),切换主题不再改文档,三端同此(`sheet.rs` 的 `a_theme_switch_is_no_edit`)。全黑:Win32 的虚拟设备没有 `SetSizeUsingBuffer`(基类默认返回假,断言在发行构建里不在),给客户缓冲区的设备停在 1×1 像素,Calc 图块只画了一个像素,放映图层同空;补丁 `0044-lok-win32-paints-then-copies.patch` 让 Windows 走 macOS 的路:按全尺寸画进设备,再拷进客户缓冲区(`doc_paintTile` 与 `SlideshowLayerRenderer`)。Writer 自设设备尺寸,原本不受影响。`tests/scripts/lo-patches.sh` 的临时库也设 `core.autocrlf false`(原先在 Windows 上恒失败)。
- 2026-10-11(用户报:Windows 上标题栏的最小化、最大化、关闭三个按钮都按不动):gpui 回答系统的命中测试(`WM_NCHITTEST`)时按绘制次序取第一个含指针的窗口控制区,标题栏的拖动区先于其中的按钮画出,按钮上一律答“标题栏”,系统把按下当作拖动窗口的开始,点击到不了按钮。修复在 `vendor/gpui-pre`(原样一个提交,其后是修复):按命中测试的次序取最上层的控制区,且只看未被遮挡的部分(同 `is_hovered`,盖在标题栏上的对话框挡住其下的按钮)。实测(发行构建,DPI 感知的探针移动真实指针):自右向左依次答关闭、最大化、最小化、拖动;按下最小化即最小化,最大化即最大化,关闭即退出、worker 随之结束;v0.1.3 的安装版同一探针处处答拖动。该回调只有 Windows 平台调用。
- 2026-10-11:Linux 标题栏按钮(核查 Windows 按钮问题时查出)。macOS 的红绿灯由系统画,不受影响;Linux 为客户端装饰,按钮由组件库画在标题栏的拖动区里,拖动区对任何按下都准备移动窗口,按住按钮时指针动一像素即开始移动窗口,合成器接管指针,松开到不了按钮,点击落空。组件库 3eb4d34:`drag_region` 只在按下未被里面的按钮认领(按钮以 `prevent_default` 认领)时才准备移动;`shell/title_tests.rs` 核对晃动的按下点得动 Linux 的关闭、标题栏空处照样移动窗口(去掉修复时前者失败)。未在真 Linux 桌面上点过。
- 2026-10-11(用户报:ElyTree 改动列表里文件名长时盖住行尾的打开按钮):组件库 bd01470:`ChangesList` 行里的文件名原为不缩的盒子,长名越过行宽压在按钮下;现可缩、以省略号结尾,先于按钮让位;`git/tests/narrow.rs` 核对 260 宽时文件名止于按钮之前(原布局时失败)。
- 2026-10-11(用户定:ElyTree 状态栏去掉 Git 的版本与路径那一项):所用 Git 的版本与路径只记日志。同时查出 Windows 上 `std::fs::canonicalize` 给出 `\\?\C:\…` 形式,仓库根、Git 目录、工作树与文件的真实路径一律改用 `dunce::canonicalize`(能写成普通路径即普通路径,同一文件仍是同一真实路径),状态栏的仓库路径随之为普通形式;ElyTree 的单测在 Windows 上由 44 个失败降到 8 个,余下 8 个改前同样失败(如写入 Windows 不许的文件名),未处理。
- 2026-10-09(用户定):代码与发布从 GitHub 搬到自建的 Gitea(git.zacharyzhang.com;GitHub 账号被封,推送与发布都被拒)。公开仓库 `ZacharyZhang-NY/ElyOffice`(原样推上 main、缺陷清单分支与 v0.1.0、v0.1.1 标签),组件库为同站的 `ZacharyZhang-NY/Ely-GPUI-Components`(Cargo 的 git 依赖与 `fetch-references.sh` 随之改址,钉的提交不变)。v0.1.1 的六个 dmg 与两个源码包重新发在 Gitea 的 v0.1.1 发布里。官网的下载与源码链接指向 Gitea(`releases/latest` 会跳到最新发布页)。`.github/workflows` 删去(它们靠 GitHub 的 macOS 机器与 `gh`,发布改在本机以 `packaging/macos/release.sh` 构建、经 Gitea API 上传)。发布视频里写的地址已在源文件改为 Gitea,已发布的成片仍是旧地址,重渲染后才更新。上游项目(LibreOffice、MuPDF、git-lfs、tessdata 等)照旧从它们自己的 GitHub 地址取。
- 2026-10-11(用户报:Omarchy 上六个 AppImage 都打不开):`gpui-pre-platform` 对 `gpui-pre-linux` 关了默认特性,Wayland 与 X11 只经它自己的 `wayland`、`x11` 特性打开;工作区原只开 `font-kit`,Linux 上 gpui 退成无头平台,日志照写“window open”,合成器里从无窗口。工作区的 `gpui_platform` 加开两者;`tests/scripts/linux-backends.sh` 核对特性,在 Hyprland 上核对 Wayland 与 X11 两路都映射出窗口、截图里有均匀的深色页面,tesseract 读出起始页的 “Open a repository”(不交缓冲的窗口 Hyprland 不映射;不画字形时读不出)。
- 2026-10-11:Linux 的 inotify 把打开文件报成事件(notify 的 `Access`),macOS 的 FSEvents 不报;四处文件监视原把任何事件当作改动,重读本身又触发下一次:Omarchy 主题与通用设置在第一次改动后、ElyTree 打开仓库起(`git status` 读仓库),空闲时每 250 毫秒重读。四处都经 `ely_ui::watching::only_read` 略去读取(打开、读、只读关闭),写后关闭照算改动(经内存映射的写入只报 CLOSE_WRITE,不报 MODIFY);`tests/scripts/linux-idle.sh` 以 inotifywait 从外面数空闲时的读取与 git status 次数。
- 2026-10-11(用户定):Linux 的引擎、应用与包都在 `ubuntu:26.04` 容器里构建(`packaging/linux/Dockerfile`,钉摘要;glibc 2.43),不在本机(Arch 的 glibc 更新,建出的程序在 Ubuntu 上起不来)。`packaging/linux/container.sh` 以本人身份运行,Cargo 的目标与家目录另放 `target/ubuntu*`;`stage.sh` 取 `CARGO_TARGET_DIR`;Git 2.56 用自己的 `target/` 链接其 Rust 部分,`build-git.sh` 为它去掉该变量。`tests/scripts/linux-glibc.sh` 核对每个动态链接的 ELF(静态的 git-lfs 不算)。
- 2026-10-11(用户要求:Linux 不只 AppImage):`packaging/linux/package.sh` 由同一个 AppDir 出四种包:AppImage,以及经 nfpm v2.47.0(钉散列)的 deb、rpm、Arch 包。包把 AppDir 的 `usr` 装到 `/opt/ely-office/<应用>`,`/usr/bin/<应用>` 为链接,桌面项与 512 像素图标在 `/usr/share`。依赖由 AppDir 里每个 ELF 所需而未随包的库,加 gpui 按名载入的 Vulkan 与 Wayland 客户端库得出:deb 与 Arch 按 `packaging/linux/depends.tsv` 换成包名(表里没有即打包失败),rpm 直接依赖库名(`libxcb.so.1()(64bit)`)。同级应用多找一处 `/opt/ely-office/<应用>/bin`(ElyMark 打开 ElyTree)。LibreOffice 的 cairo 由 meson 构建,内核直接拷其构建目录的产物,RUNPATH 仍指向构建树里的 pixman 与 fontconfig(别的机器上找不到);`build-libreoffice.sh` 在 Linux 上把指向源码树的 RUNPATH 改为 `$ORIGIN`(patchelf)。
- 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-12(用户报:Windows 上 ElyMark 的最近列表显示 `\\?\C:\…`):凡取真实路径一律 `dunce::canonicalize`(ely-files 的会话登记、ely-lok 的内核目录与配置目录、ElyMark 的文件夹、最近列表、会话与标签比对;ElyTree 原已如此):Windows 上能写成普通路径即普通路径,他处同 `std::fs::canonicalize`。只供读写的路径照旧保留 `\\?\`(`ely_files::save` 的落盘与同文件判定:在 250 字的文件夹里,普通写法的暂存文件名越过 260 而写不进),保存后交给会话与 `Saved` 的路径以 `dunce::simplified` 整体判定后为普通写法;ely-office 另存的目标先以完整路径求出,再以 `dunce::simplified` 整体判定能否写成普通路径(先简化文件夹再接文件名时,结尾带空格的名字会被 Windows 削成另一个已打开的文件)。`ely_files::recent` 读入时以 `dunce::simplified` 去掉旧版写下的 `\\?\` 并去重,下次写入即为普通路径。
- 2026-10-12(用户报:ElyTable 的网格上一直是文字指针):`LokView` 原把指针写死为文字指针。现不按键的鼠标移动也交内核(悬停,不算编辑、不加修订),内核以 `LOK_CALLBACK_MOUSE_POINTER` 回报 CSS 指针名(`vcl` 的 `gaLOKPointerMap`),随事件批次到视图,换成 gpui 的指针:单元格上为箭头,编辑单元格或文字时为文字指针,链接为手;未知的名字记警告并用箭头。
+4
View File
@@ -5,6 +5,10 @@ edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
build = "../../packaging/windows/icon.rs"
[build-dependencies]
embed-resource.workspace = true
[dependencies]
ely-platform.workspace = true
+3
View File
@@ -1,3 +1,6 @@
// Release builds open no console on Windows.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use ely_platform::AppId;
fn main() {
+5
View File
@@ -5,6 +5,10 @@ edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
build = "../../packaging/windows/icon.rs"
[build-dependencies]
embed-resource.workspace = true
[dependencies]
csv.workspace = true
@@ -22,6 +26,7 @@ ely-ui.workspace = true
[dev-dependencies]
ely-ui = { workspace = true, features = ["test-support"] }
ely-gpui-component = { workspace = true, features = ["test-support"] }
env_logger.workspace = true
image.workspace = true
tempfile.workspace = true
+11 -9
View File
@@ -24,17 +24,18 @@ impl Docs {
let Some(counts) = self.active_tab().and_then(|tab| tab.extra.counts) else {
return status.when_some(zoom, |status, zoom| status.right(zoom));
};
let (words, characters) = (counts.words.to_string(), counts.characters.to_string());
let count = |key, n: u64| ely_ui::i18n::counted(cx, key, n, &[("n", &n.to_string())]);
let label = match counts.selected_words {
0 => Self::text(
cx,
"doc.count.words",
&[("words", &words), ("characters", &characters)],
),
selected => Self::text(
0 => {
let words = count("doc.count.word", counts.words);
let characters = count("doc.count.character", counts.characters);
Self::text(cx, "doc.count.words", &[("words", &words), ("characters", &characters)])
}
selected => ely_ui::i18n::counted(
cx,
"doc.count.selected",
&[("selected", &selected.to_string()), ("words", &words)],
counts.words,
&[("selected", &selected.to_string()), ("words", &counts.words.to_string())],
),
};
status.right(StatusBarItem::new("doc-count").label(label)).when_some(zoom, |status, zoom| status.right(zoom))
@@ -79,7 +80,8 @@ impl Docs {
let shown = self
.active_tab()
.map(|tab| (tab.view.clone(), tab.doc.clone()));
let tab = shown.as_ref().map(|(view, doc)| (view, doc));
// Hidden, it follows nothing: no reads on every move.
let tab = shown.as_ref().filter(|_| self.navigating).map(|(view, doc)| (view, doc));
self.navigator
.update(cx, |navigator, cx| navigator.show(tab, cx));
shown.filter(|_| self.navigating)?;
+28 -2
View File
@@ -46,8 +46,14 @@ pub struct Docs {
pub back: Vec<(u64, ely_lok::Rect)>,
/// Last jump: tab, input, prior cursor moves, target.
pub going: Option<(u64, u64, u64, ely_lok::Rect)>,
/// Dialog reads asked; a dialog or tab change voids older.
pub dialog_asked: u64,
dialog_seen: Seen,
}
/// Open dialog kinds, Ely's and the shell's, and the tab.
type Seen = (Option<std::mem::Discriminant<Dialog>>, Option<std::mem::Discriminant<ely_office::Dialog>>, Option<u64>);
/// The right column's panels.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Side {
@@ -85,6 +91,14 @@ impl Host for Docs {
self.offer_spelling(id, view, cx);
}
/// A dialog about a closed tab goes with it.
fn tab_closed(&mut self, tab: &ely_office::Tab<Extra, ely_office::Lok>, cx: &mut Context<Self>) {
if matches!(self.dialog, Some(Dialog::Output { tab: of, .. }) if of == tab.id) {
self.dialog = None;
cx.notify();
}
}
fn search_leaves(&mut self, cx: &mut Context<Self>) {
self.mark_place(cx);
}
@@ -126,7 +140,7 @@ pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
let root = ely_ui::document_root(app, docs.clone().into(), &ely_ui::commands::FILE, menus, window, cx);
docs.update(cx, |docs, cx| {
docs.core.root = root.downgrade();
docs.core.window = Some(window.window_handle());
docs.own_window(window, cx);
window.focus(&docs.core.focus, cx);
});
let launched = ely_ui::launch_files(cx);
@@ -163,6 +177,7 @@ impl Docs {
failed(docs, error, cx)
})
.detach();
cx.observe_self(|docs, _| docs.watch_dialogs()).detach();
Docs {
core: Core::new(worker, work, cx),
dialog: None,
@@ -175,6 +190,18 @@ impl Docs {
navigating: true,
back: Vec::new(),
going: None,
dialog_asked: 0,
dialog_seen: (None, None, None),
}
}
/// A dialog or tab change ends earlier dialog reads.
fn watch_dialogs(&mut self) {
let ely = self.dialog.as_ref().map(std::mem::discriminant);
let now = (ely, self.core.dialog.as_ref().map(std::mem::discriminant), self.core.active);
if self.dialog_seen != now {
self.dialog_seen = now;
self.dialog_asked += 1;
}
}
@@ -209,7 +236,6 @@ impl Render for Docs {
.on_action(cx.listener(Self::on_open))
.on_action(cx.listener(Self::on_save))
.on_action(cx.listener(Self::on_save_as))
.on_action(cx.listener(Self::on_quit))
.on_action(cx.listener(Self::on_close_tab))
.child(shell)
.children(self.core_dialog(cx))
+22 -23
View File
@@ -128,29 +128,28 @@ impl Docs {
let text = Docs::text(cx, "command.blocked.no_document", &[]);
return self.toast(text, Severity::Warning, cx);
};
let listed = self
.core
.worker
.lock()
.expect("the worker lock")
.values(&tab.doc, ".uno:StyleApply")
.and_then(|json| ely_lok::commands::styles(&json, "8"));
let names = match listed {
Ok(names) => names,
Err(error) => {
let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]);
return self.toast(text, Severity::Danger, cx);
}
};
let name = (2..)
.map(|n| Docs::text(cx, "doc.section.name", &[("n", &n.to_string())]).to_string())
.find(|name| !names.contains(name))
.expect("a free section name");
log::info!("doc: a new section");
self.run_active("style.new", &[&name, "8"], cx);
self.run_active("page.section", &["3", &name], cx);
self.props.update(cx, |props, _| props.refresh_lists());
cx.notify();
let id = tab.id;
// Page style names are read off the window thread.
self.ask_in(
id,
|worker, doc| ely_lok::commands::styles(&worker.values(doc, ".uno:StyleApply")?, "8"),
move |docs, listed, _, cx| {
let names = match listed {
Ok(names) => names,
Err(error) => return docs.engine_failed(&error.to_string(), cx),
};
let name = (2..)
.map(|n| Docs::text(cx, "doc.section.name", &[("n", &n.to_string())]).to_string())
.find(|name| !names.contains(name))
.expect("a free section name");
log::info!("doc: a new section");
docs.run_in(Some(id), "style.new", &[&name, "8"], cx);
docs.run_in(Some(id), "page.section", &["3", &name], cx);
docs.props.update(cx, |props, _| props.refresh_lists());
cx.notify();
},
cx,
);
}
pub fn on_page_number(&mut self, _: &InsertPageNumber, _: &mut Window, cx: &mut Context<Self>) {
+3
View File
@@ -1,3 +1,6 @@
// Release builds open no console on Windows.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod actions;
mod chrome;
mod compare;
+29 -9
View File
@@ -36,6 +36,10 @@ pub fn menu(cx: &gpui::App) -> gpui::Menu {
pub struct Merge {
docs: WeakEntity<Docs>,
pub table: Option<Table>,
/// The template's tab, active when the data was chosen.
pub source: Option<u64>,
/// Tabs opened as previews; none is a template.
pub previews: std::collections::HashSet<u64>,
/// The record to preview, from 1.
record: usize,
pdf: bool,
@@ -46,6 +50,8 @@ impl Merge {
Merge {
docs,
table: None,
source: None,
previews: std::collections::HashSet::new(),
record: 1,
pdf: false,
}
@@ -62,11 +68,20 @@ impl Docs {
}
pub(crate) fn choose_csv(&mut self, cx: &mut Context<Self>) {
self.pick(false, cx, |docs, path, cx| match read_csv(&path) {
let Some(source) = self.core.active else {
let text = Docs::text(cx, "command.blocked.no_document", &[]);
return self.toast(text, Severity::Warning, cx);
};
if self.merge.read(cx).previews.contains(&source) {
let text = Docs::text(cx, "doc.merge.from_preview", &[]);
return self.toast(text, Severity::Warning, cx);
}
self.pick(false, cx, move |docs, path, cx| match read_csv(&path) {
Ok(table) => {
log::info!("doc: merge data has {} rows", table.rows.len());
docs.merge.update(cx, |merge, cx| {
merge.table = Some(table);
merge.source = Some(source);
merge.record = 1;
cx.notify();
});
@@ -84,20 +99,20 @@ impl Docs {
});
}
/// The tab a merge starts from: the active one.
/// The template's tab, while it is open.
fn source(&self, cx: &mut Context<Self>) -> Option<u64> {
if self.core.active.is_none() {
let text = Docs::text(cx, "command.blocked.no_document", &[]);
let source = self.merge.read(cx).source.filter(|source| self.tab(*source).is_some());
if source.is_none() {
let text = Docs::text(cx, "doc.merge.gone", &[]);
self.toast(text, Severity::Warning, cx);
}
self.core.active
source
}
/// Types a field's marker at the cursor.
/// Types a field's marker at the template's cursor.
pub(crate) fn insert_field(&mut self, header: &str, cx: &mut Context<Self>) {
let Some(tab) = self.active_tab() else {
let text = Docs::text(cx, "command.blocked.no_document", &[]);
return self.toast(text, Severity::Warning, cx);
let Some(tab) = self.source(cx).and_then(|source| self.tab(source)) else {
return;
};
tab.view.update(cx, |view, _| view.insert(&marker(header)));
}
@@ -194,7 +209,12 @@ impl Render for Merge {
})
};
let row = || div().flex().flex_wrap().items_center().gap_1();
let template = self.source.and_then(|source| {
let docs = self.docs.upgrade()?;
Some(docs.read(cx).tab(source)?.name.clone())
});
let group = PropertyGroup::new(table.name.clone())
.row(text("doc.merge.template"), div().child(template.unwrap_or_else(|| text("doc.merge.gone"))))
.row(text("doc.merge.data"), choose)
.row(text("doc.merge.fields"), row().children(fields))
.row(
+8 -6
View File
@@ -164,6 +164,7 @@ impl Docs {
cx: &mut Context<Self>,
) {
log::info!("doc: previewing merge record {}", at + 1);
self.merge.update(cx, |merge, _| merge.previews.insert(into));
self.load_into(into, Source::Copy(copy), cx);
self.core.waiting.push((
into,
@@ -246,9 +247,11 @@ impl Docs {
let mut worker = worker.lock().expect("the worker lock");
let staged = template.with_file_name(format!("record.{extension}"));
let made = worker.load(&ely_lok::file_url(&template)).and_then(|doc| {
fill(&mut worker, &doc, &table.headers, row)?;
doc.save_as(&mut worker, &ely_lok::file_url(&staged), extension)?;
worker.close_doc(doc)?;
let saved = fill(&mut worker, &doc, &table.headers, row)
.and_then(|()| doc.save_as(&mut worker, &ely_lok::file_url(&staged), extension));
// Closed even when this record failed.
let closed = worker.close_doc(doc);
saved.and(closed)?;
publish(&staged, target)
});
made.map_err(|error| (n + 1, error))?;
@@ -260,9 +263,8 @@ impl Docs {
let folder = folder.display().to_string();
let text = match written {
Ok(count) => {
let count = count.to_string();
let text =
Docs::text(cx, "doc.merge.done", &[("n", &count), ("folder", &folder)]);
let n = count.to_string();
let text = ely_ui::i18n::counted(cx, "doc.merge.done", count as u64, &[("n", &n), ("folder", &folder)]);
return docs.toast(text, Severity::Success, cx);
}
Err((n, error)) => Docs::text(
+49 -4
View File
@@ -22,9 +22,9 @@ fn records_fill_previews_and_files() {
let csv = dir.path().join("people.csv");
std::fs::write(&csv, "Name,City\nAda,London\n«City»,上海\n").expect("the csv writes");
let table = read_csv(&csv).expect("the csv reads");
cx.update(|app| docs.update(app, |docs, cx| docs.insert_field("Name", cx)));
cx.update(|app| docs.update(app, |docs, cx| docs.field_here("Name", cx)));
type_into(&mut cx, &docs, " of ");
cx.update(|app| docs.update(app, |docs, cx| docs.insert_field("City", cx)));
cx.update(|app| docs.update(app, |docs, cx| docs.field_here("City", cx)));
let source = cx.update(|app| docs.read(app).core.tabs[0].name.to_string());
cx.update(|app| {
docs.update(app, |docs, cx| {
@@ -171,7 +171,7 @@ fn a_merge_keeps_its_source_when_the_tab_changes() {
let table = read_csv(&csv).expect("the csv reads");
cx.update(|app| {
docs.update(app, |docs, cx| {
docs.insert_field("Name", cx);
docs.field_here("Name", cx);
docs.preview_record(source, &table, 0, cx);
docs.core.active = Some(other);
})
@@ -191,6 +191,14 @@ fn a_merge_keeps_its_source_when_the_tab_changes() {
});
let preview = dir.path().join("preview.odt");
let id = cx.update(|app| docs.read(app).core.tabs[2].id);
// The preview is active now; the template stays.
cx.update(|app| {
docs.update(app, |docs, cx| {
docs.core.active = Some(id);
assert_eq!(docs.source(cx), Some(source), "the template stays after a preview");
assert!(docs.merge.read(cx).previews.contains(&id), "the preview is known");
})
});
with_window(&mut cx, &docs, |docs, window, cx| {
docs.write_as(id, preview.clone(), None, window, cx)
});
@@ -227,7 +235,7 @@ fn two_generations_keep_their_own_templates() {
for text in ["SourceA ", "SourceB "] {
sources.push(blank(&mut cx, &docs));
type_into(&mut cx, &docs, text);
cx.update(|app| docs.update(app, |docs, cx| docs.insert_field("Name", cx)));
cx.update(|app| docs.update(app, |docs, cx| docs.field_here("Name", cx)));
settled(&cx, &docs);
}
let csv = dir.path().join("one.csv");
@@ -265,3 +273,40 @@ fn two_generations_keep_their_own_templates() {
);
}
}
#[test]
fn the_template_chosen_with_the_data_stays_through_a_preview() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let template = blank(&mut cx, &docs);
type_into(&mut cx, &docs, "Dear ");
let csv = dir.path().join("two.csv");
std::fs::write(&csv, "Name\nAda\nGrace\n").expect("the csv writes");
cx.update(|app| docs.update(app, |docs, cx| docs.choose_csv(cx)));
cx.simulate_path_prompt_response(move |_| Some(vec![csv]));
settle(&cx, |app| docs.read(app).merge.read(app).table.is_some());
cx.update(|app| docs.update(app, |docs, cx| docs.insert_field("Name", cx)));
let table = cx.update(|app| docs.read(app).merge.read(app).table.clone().expect("the data"));
// As the panel's buttons do: preview, then generate.
cx.update(|app| docs.update(app, |docs, cx| {
let source = docs.source(cx).expect("the template");
docs.preview_record(source, &table, 0, cx);
}));
settle(&cx, |app| tabs(app, &docs) == 2 && docs.read(app).core.active != Some(template));
let out = dir.path().join("out");
std::fs::create_dir(&out).expect("the folder");
cx.update(|app| docs.update(app, |docs, cx| {
let source = docs.source(cx).expect("the template, not the preview");
assert_eq!(source, template);
docs.generate(source, table.clone(), false, out.clone(), cx);
}));
let stem = cx.update(|app| docs.read(app).tab(template).expect("a tab").name.to_string());
let target = |n: usize| out.join(format!("{stem} {n}.docx"));
settle(&cx, |_| target(2).exists());
let (first, second) = (text_of(&mut cx, &docs, &target(1)), text_of(&mut cx, &docs, &target(2)));
assert!(first.contains("Dear Ada") && second.contains("Dear Grace"), "{first:?} {second:?}");
}
+4 -6
View File
@@ -272,15 +272,13 @@ impl Render for Navigator {
if let Some(at) = self.outline.iter().position(|heading| heading.current) {
outline = outline.current(at.to_string());
}
let this = this.clone();
// The places shown: a reread may change the list.
let places: Vec<_> = self.outline.iter().map(|heading| heading.rect).collect();
let docs = self.docs.clone();
outline
.on_select(move |key, _, cx| {
let at: usize = key.parse().expect("an outline index");
let (to, docs) = {
let navigator = this.read(cx);
(navigator.outline[at].rect, navigator.docs.clone())
};
Navigator::jump(&docs, to, cx);
Navigator::jump(&docs, places[at], cx);
})
.into_any_element()
}
+84 -4
View File
@@ -181,7 +181,6 @@ fn the_current_heading_follows_the_cursor_across_columns() {
settle(&cx, |app| current(app) == Some(0));
}
#[cfg(unix)]
#[test]
fn pdfs_export_a_range_and_locked_files_ask() {
let dir = tempfile::tempdir().expect("a temp dir");
@@ -205,11 +204,17 @@ fn pdfs_export_a_range_and_locked_files_ask() {
});
written(&cx, &pdf, None);
let source = dir.path().join("long.fodt");
let alias = dir.path().join("alias.pdf");
std::os::unix::fs::symlink(&source, &alias).expect("the alias");
let link = dir.path().join("link.pdf");
std::fs::hard_link(&source, &link).expect("the hard link");
for target in [&source, &alias, &link] {
#[cfg(unix)]
let aliases = {
let alias = dir.path().join("alias.pdf");
std::os::unix::fs::symlink(&source, &alias).expect("the alias");
vec![alias]
};
#[cfg(not(unix))]
let aliases: Vec<std::path::PathBuf> = Vec::new();
for target in [source.clone(), link].iter().chain(&aliases) {
cx.update(|app| {
docs.update(app, |docs, cx| {
docs.export_to(id, target.clone(), ely_lok::Pdf::default(), cx)
@@ -252,3 +257,78 @@ fn pdfs_export_a_range_and_locked_files_ask() {
"the locked text opens"
);
}
#[test]
fn a_hidden_navigator_reads_nothing() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
crate::tests::blank(&mut cx, &docs);
let follows = |cx: &mut TestAppContext| cx.update(|app| docs.read(app).navigator.read(app).shown.is_some());
cx.update(|app| docs.update(app, |docs, cx| docs.left_view(cx)));
assert!(follows(&mut cx), "shown: it follows the tab");
cx.update(|app| docs.update(app, |docs, cx| {
docs.navigating = false;
docs.left_view(cx)
}));
assert!(!follows(&mut cx), "hidden: no tab to reread");
cx.update(|app| docs.update(app, |docs, cx| {
docs.navigating = true;
docs.left_view(cx)
}));
assert!(follows(&mut cx), "shown again: it reads once more");
}
/// The navigator alone as a window's view.
struct Host(Entity<Navigator>);
impl gpui::Render for Host {
fn render(&mut self, _: &mut gpui::Window, _: &mut gpui::Context<Self>) -> impl gpui::IntoElement {
gpui::div().size_full().child(self.0.clone())
}
}
#[test]
fn an_outline_click_goes_where_its_row_was_drawn() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
cx.update(ely_gpui_component::init_for_tests);
crate::tests::blank(&mut cx, &docs);
let navigator = cx.update(|app| docs.read(app).navigator.clone());
let heading = |n: i32| ely_lok::review::Heading { text: format!("Part {n}"), level: 0, rect: (0, n * 100, 10, 10), current: false };
cx.update(|app| navigator.update(app, |navigator, cx| {
navigator.outline = vec![heading(1), heading(2), heading(3)];
cx.notify();
}));
let (_, vcx) = cx.add_window_view(|_, _| Host(navigator.clone()));
let target = |vcx: &mut gpui::VisualTestContext| vcx.update(|_, app| docs.read(app).going.map(|going| going.3));
// Find where the drawn third row sits.
let third = (0..120)
.map(|step| gpui::point(gpui::px(40.), gpui::px(step as f32 * 4.)))
.find(|at| {
vcx.simulate_click(*at, gpui::Modifiers::none());
target(vcx) == Some((0, 300, 10, 10))
})
.expect("the third row is drawn");
vcx.update(|_, app| docs.update(app, |docs, _| docs.going = None));
// The list shrinks; the click lands on the old frame.
vcx.update(|window, app| {
navigator.update(app, |navigator, cx| {
navigator.outline.truncate(1);
cx.notify();
});
let (position, modifiers, button) = (third, gpui::Modifiers::none(), gpui::MouseButton::Left);
let down = gpui::MouseDownEvent { position, modifiers, button, click_count: 1, first_mouse: false };
window.dispatch_event(gpui::PlatformInput::MouseDown(down), app);
let up = gpui::MouseUpEvent { position, modifiers, button, click_count: 1 };
window.dispatch_event(gpui::PlatformInput::MouseUp(up), app);
});
assert_eq!(target(vcx), Some((0, 300, 10, 10)), "the click goes where its row was");
}
+3 -5
View File
@@ -29,7 +29,7 @@ impl Docs {
self.ask_output(true, cx);
}
fn ask_output(&mut self, print: bool, cx: &mut Context<Self>) {
pub(crate) fn ask_output(&mut self, print: bool, cx: &mut Context<Self>) {
let Some(tab) = self.active_tab() else {
let text = Docs::text(cx, "command.blocked.no_document", &[]);
return self.toast(text, Severity::Warning, cx);
@@ -81,10 +81,8 @@ impl Docs {
let path = source.work.join("print.pdf");
let printing = path.clone();
return self.write_pdf(tab, path, pdf, cx, move |docs, written, cx| match written {
Ok(()) => ely_office::print_later(printing, cx, |docs, error, cx| {
docs.output_failed(&error, cx)
}),
Err(error) => docs.output_failed(&error.to_string(), cx),
Ok(()) => ely_office::print_later(printing, cx),
Err(error) => docs.print_failed(&error.to_string(), cx),
});
}
let Some(dir) = ely_office::folder(source) else {
+38 -26
View File
@@ -113,43 +113,55 @@ impl Props {
self.read_styles(cx);
}
/// The installed fonts, read off the window thread.
fn read_fonts(&mut self, doc: &Doc, cx: &mut Context<Self>) {
let read = self
.worker
.lock()
.expect("the worker lock")
.values(doc, ".uno:CharFontName")
.and_then(|json| ely_lok::commands::fonts(&json));
match read {
Ok(fonts) => self.fonts = fonts.into_iter().map(Into::into).collect(),
Err(error) => cx.emit(LokError(format!("the font list is unreadable: {error}"))),
}
ely_lok::read_off(
&self.worker,
doc,
|worker, doc| ely_lok::commands::fonts(&worker.values(doc, ".uno:CharFontName")?),
|props: &mut Props, read, cx| match read {
Ok(fonts) => {
props.fonts = fonts.into_iter().map(Into::into).collect();
cx.notify();
}
Err(error) => cx.emit(LokError(format!("the font list is unreadable: {error}"))),
},
cx,
);
}
/// The shown document's style lists, read off the window thread.
fn read_styles(&mut self, cx: &mut Context<Self>) {
let Some(shown) = self.shown.as_mut() else {
let Some(shown) = self.shown.as_ref() else {
return;
};
let read = self
.worker
.lock()
.expect("the worker lock")
.values(&shown.doc, ".uno:StyleApply")
.and_then(|json| {
let view = shown.view.entity_id();
ely_lok::read_off(
&self.worker,
&shown.doc,
|worker, doc| {
let json = worker.values(doc, ".uno:StyleApply")?;
let list = |family| -> std::io::Result<Vec<SharedString>> {
let names = ely_lok::commands::styles(&json, family)?;
Ok(names.into_iter().map(Into::into).collect())
};
Ok((list("2")?, list("1")?, list("8")?))
});
match read {
Ok((paragraph, character, pages)) => {
shown.paragraph = paragraph;
shown.character = character;
shown.pages = pages;
}
Err(error) => cx.emit(LokError(format!("the style list is unreadable: {error}"))),
}
},
move |props: &mut Props, read, cx| {
// Another tab shown since: its own read follows.
let Some(shown) = props.shown.as_mut().filter(|shown| shown.view.entity_id() == view) else {
return;
};
match read {
Ok((paragraph, character, pages)) => {
(shown.paragraph, shown.character, shown.pages) = (paragraph, character, pages);
cx.notify();
}
Err(error) => cx.emit(LokError(format!("the style list is unreadable: {error}"))),
}
},
cx,
);
}
/// Re-reads the style lists once queued input lands.
+8 -2
View File
@@ -11,13 +11,19 @@ use ely_office::prelude::*;
/// The panel, shown on the active tab.
pub(super) fn panel(cx: &mut TestAppContext, docs: &Entity<Docs>) -> Entity<Props> {
cx.update(|app| {
let props = cx.update(|app| {
let tab = docs.read(app).active_tab().expect("a tab");
let (view, doc) = (tab.view.clone(), tab.doc.clone());
let props = docs.read(app).props.clone();
props.update(app, |props, cx| props.show(Some((&view, &doc)), cx));
props
})
});
// The lists come from a background read.
settle(cx, |app| {
let props = props.read(app);
!props.fonts.is_empty() && props.shown.as_ref().is_some_and(|shown| !shown.paragraph.is_empty())
});
props
}
fn run(cx: &mut TestAppContext, props: &Entity<Props>, id: &str, values: &[&str]) {
+63 -53
View File
@@ -71,35 +71,51 @@ pub fn items(cx: &App) -> Vec<MenuItem> {
}
impl Docs {
/// A kernel list for the active document; failures toast.
fn read(&self, command: &str, cx: &mut Context<Self>) -> Option<String> {
/// Reads a kernel list off the window thread, then `then`.
fn read_then(
&mut self,
command: &'static str,
then: impl FnOnce(&mut Self, String, &mut Window, &mut Context<Self>) + 'static,
cx: &mut Context<Self>,
) {
let Some(tab) = self.active_tab() else {
let text = Docs::text(cx, "command.blocked.no_document", &[]);
self.toast(text, Severity::Warning, cx);
return None;
return self.toast(text, Severity::Warning, cx);
};
let read = self
.core
.worker
.lock()
.expect("the worker lock")
.values(&tab.doc, command);
read.map_err(|error| {
let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]);
self.toast(text, Severity::Danger, cx);
})
.ok()
let id = tab.id;
self.dialog_asked += 1;
let asked = self.dialog_asked;
self.ask_in(
id,
move |worker, doc| worker.values(doc, command),
move |docs, read, window, cx| {
// A dialog opened since, or a later ask, wins.
if docs.dialog_asked != asked || docs.dialog.is_some() || docs.core.dialog.is_some() {
return log::info!("doc: a dialog read for tab {id} lapsed");
}
match read {
Ok(json) => then(docs, json, window, cx),
Err(error) => docs.engine_failed(&error.to_string(), cx),
}
},
cx,
);
}
fn bookmarks(&self, cx: &mut Context<Self>) -> Option<Vec<String>> {
let listed = ely_lok::review::bookmarks(&self.read(".uno:Bookmarks", cx)?);
listed
.map(|marks| marks.into_iter().map(|mark| mark.name).collect())
.map_err(|error| {
let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]);
self.toast(text, Severity::Danger, cx);
})
.ok()
/// The bookmarks' names, then `then`.
fn with_bookmarks(
&mut self,
then: impl FnOnce(&mut Self, Vec<String>, &mut Window, &mut Context<Self>) + 'static,
cx: &mut Context<Self>,
) {
self.read_then(
".uno:Bookmarks",
move |docs, json, window, cx| match ely_lok::review::bookmarks(&json) {
Ok(marks) => then(docs, marks.into_iter().map(|mark| mark.name).collect(), window, cx),
Err(error) => docs.engine_failed(&error.to_string(), cx),
},
cx,
);
}
fn ask(&mut self, ask: Ask, text: &str, window: &mut Window, cx: &mut Context<Self>) {
@@ -126,10 +142,8 @@ impl Docs {
self.run_active("note.endnote", &[], cx);
}
pub fn on_bookmark(&mut self, _: &InsertBookmark, window: &mut Window, cx: &mut Context<Self>) {
if let Some(taken) = self.bookmarks(cx) {
self.ask(Ask::Bookmark(taken), "", window, cx);
}
pub fn on_bookmark(&mut self, _: &InsertBookmark, _: &mut Window, cx: &mut Context<Self>) {
self.with_bookmarks(|docs, taken, window, cx| docs.ask(Ask::Bookmark(taken), "", window, cx), cx);
}
pub fn on_link(&mut self, _: &InsertLink, window: &mut Window, cx: &mut Context<Self>) {
@@ -141,19 +155,17 @@ impl Docs {
}
pub fn on_reference(&mut self, _: &InsertReference, _: &mut Window, cx: &mut Context<Self>) {
let Some(marks) = self.bookmarks(cx) else {
return;
};
let Some(mark) = marks.first().cloned() else {
let text = Docs::text(cx, "doc.reference.none", &[]);
return self.toast(text, Severity::Warning, cx);
};
self.dialog = Some(Dialog::Reference {
marks,
mark,
show: "2",
});
cx.notify();
self.with_bookmarks(
|docs, marks, _, cx| {
let Some(mark) = marks.first().cloned() else {
let text = Docs::text(cx, "doc.reference.none", &[]);
return docs.toast(text, Severity::Warning, cx);
};
docs.dialog = Some(Dialog::Reference { marks, mark, show: "2" });
cx.notify();
},
cx,
);
}
pub fn on_caption(&mut self, _: &InsertCaption, window: &mut Window, cx: &mut Context<Self>) {
@@ -173,18 +185,16 @@ impl Docs {
self.ask(Ask::Caption(category), "", window, cx);
}
pub fn on_formula(&mut self, _: &InsertFormula, window: &mut Window, cx: &mut Context<Self>) {
let Some(json) = self.read(".uno:Formula", cx) else {
return;
};
match ely_lok::review::formula(&json) {
Ok(Some(formula)) => self.ask(Ask::Formula(true), &formula, window, cx),
Ok(None) => self.ask(Ask::Formula(false), "", window, cx),
Err(error) => {
let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]);
self.toast(text, Severity::Danger, cx);
}
}
pub fn on_formula(&mut self, _: &InsertFormula, _: &mut Window, cx: &mut Context<Self>) {
self.read_then(
".uno:Formula",
|docs, json, window, cx| match ely_lok::review::formula(&json) {
Ok(Some(formula)) => docs.ask(Ask::Formula(true), &formula, window, cx),
Ok(None) => docs.ask(Ask::Formula(false), "", window, cx),
Err(error) => docs.engine_failed(&error.to_string(), cx),
},
cx,
);
}
pub fn on_comment(&mut self, _: &InsertComment, window: &mut Window, cx: &mut Context<Self>) {
+50
View File
@@ -5,6 +5,8 @@ use crate::tests::{blank, docs, settle, settled, text_of, type_into, with_window
/// The open prompt's request and field.
fn asked(cx: &TestAppContext, docs: &Entity<Docs>) -> (Ask, Entity<TextInput>) {
// Prompts open once their kernel read comes back.
cx.run_until_parked();
cx.update(|app| match &docs.read(app).dialog {
Some(Dialog::Ask { ask, field }) => (ask.clone(), field.clone()),
_ => panic!("a prompt is open"),
@@ -35,6 +37,7 @@ fn references_go_in_through_their_dialogs() {
with_window(&mut cx, &docs, |docs, window, cx| {
docs.on_reference(&InsertReference, window, cx)
});
cx.run_until_parked();
assert!(
cx.update(|app| docs.read(app).dialog.is_none()),
"no bookmark, no cross-reference dialog"
@@ -55,6 +58,7 @@ fn references_go_in_through_their_dialogs() {
with_window(&mut cx, &docs, |docs, window, cx| {
docs.on_reference(&InsertReference, window, cx)
});
cx.run_until_parked();
cx.update(|app| docs.update(app, |docs, cx| docs.insert_reference(cx)));
with_window(&mut cx, &docs, |docs, window, cx| {
docs.on_link(&InsertLink, window, cx)
@@ -188,3 +192,49 @@ fn a_long_formula_prefills_whole() {
let (_, field) = asked(&cx, &docs);
assert_eq!(cx.update(|app| field.read(app).text().to_string()), long);
}
#[test]
fn a_late_dialog_read_never_replaces_a_newer_dialog() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
blank(&mut cx, &docs);
// The link dialog opens before the bookmarks come back.
with_window(&mut cx, &docs, |docs, window, cx| {
docs.on_bookmark(&InsertBookmark, window, cx);
docs.on_link(&InsertLink, window, cx);
});
cx.run_until_parked();
assert!(cx.update(|app| matches!(docs.read(app).dialog, Some(Dialog::Link { .. }))), "the link dialog stays");
// Opened and closed meanwhile: the old ask lapses too.
with_window(&mut cx, &docs, |docs, window, cx| {
docs.dialog = None;
cx.notify();
docs.on_bookmark(&InsertBookmark, window, cx);
docs.on_link(&InsertLink, window, cx);
});
cx.update(|app| docs.update(app, |docs, cx| {
docs.dialog = None;
cx.notify();
}));
cx.run_until_parked();
assert!(cx.update(|app| docs.read(app).dialog.is_none()), "nothing pops up later");
// A close prompt shown and cancelled meanwhile counts too.
let id = cx.update(|app| docs.read(app).core.active.expect("a tab"));
type_into(&mut cx, &docs, "edited");
settled(&cx, &docs);
with_window(&mut cx, &docs, |docs, window, cx| {
docs.on_bookmark(&InsertBookmark, window, cx);
docs.request_close(id, ely_office::After::Tab, window, cx);
});
assert!(cx.update(|app| docs.read(app).core.dialog.is_some()), "the close asks");
cx.update(|app| docs.update(app, |docs, cx| {
docs.core.dialog = None;
cx.notify();
}));
cx.run_until_parked();
assert!(cx.update(|app| docs.read(app).dialog.is_none()), "no bookmark prompt after the cancel");
}
+69 -26
View File
@@ -8,11 +8,11 @@ use std::{
use ely_gpui_component::{
buttons::{Button, ButtonVariant, IconButton, SegmentedControl},
collab::{Comment, CommentSidebar, Peer, Thread},
feedback::EmptyState,
feedback::{EmptyState, InlineMessage},
forms::{Switch, TextInput},
layout::ScrollArea,
lists::{List, ListItem},
primitives::IconName,
primitives::{IconName, Severity},
theme::ControlSize,
};
use ely_lok::{
@@ -20,7 +20,7 @@ use ely_lok::{
review::{Change, Note},
};
use gpui::{
App, Context, Entity, EventEmitter, IntoElement, Menu, MenuItem, ParentElement as _, Render,
App, Context, Entity, EventEmitter, InteractiveElement as _, IntoElement, Menu, StatefulInteractiveElement as _, MenuItem, ParentElement as _, Render,
SharedString, Styled as _, Subscription, Window, actions, div,
};
@@ -86,6 +86,8 @@ pub struct Review {
shown: Option<Shown>,
notes: Vec<Note>,
threads: Vec<Thread>,
/// Notes whose time does not read: time and why.
unread: Vec<Unread>,
pub(crate) changes: Vec<Change>,
active: Option<SharedString>,
pane: Pane,
@@ -100,6 +102,7 @@ impl Review {
shown: None,
notes: Vec::new(),
threads: Vec::new(),
unread: Vec::new(),
changes: Vec::new(),
active: None,
pane: Pane::Comments,
@@ -117,6 +120,7 @@ impl Review {
self.active = None;
self.notes.clear();
self.threads.clear();
self.unread.clear();
self.changes.clear();
cx.notify();
let Some((view, doc)) = tab else {
@@ -161,21 +165,8 @@ impl Review {
return;
}
shown.reading = false;
let read = read.and_then(|(notes, changes)| {
let threads = threads(&notes).map_err(std::io::Error::other)?;
Ok((notes, threads, changes))
});
match read {
Ok((notes, threads, changes)) => {
log::info!(
"doc: review lists {} notes, {} changes",
notes.len(),
changes.len()
);
review.notes = notes;
review.threads = threads;
review.changes = changes;
}
Ok((notes, changes)) => review.take_lists(notes, changes),
Err(error) => cx.emit(LokError(format!(
"the review lists are unreadable: {error}"
))),
@@ -366,12 +357,61 @@ impl Render for Review {
.p_2()
.child(head)
.child(pane)
// Many bad notes scroll; the lists stay in reach.
.children(self.unread_alert(cx).map(|alert| {
ScrollArea::new("doc-review-unread-scroll").vertical().w_full().max_h(gpui::rems(6.)).flex_none().child(alert)
}))
.child(div().flex_1().min_h_0().child(body))
}
}
/// The notes as threads under their first comment.
fn threads(notes: &[Note]) -> Result<Vec<Thread>, String> {
/// A note whose time does not read, and why.
#[derive(Clone, Debug, PartialEq)]
pub struct Unread {
pub author: String,
pub at: String,
pub error: String,
}
impl Review {
/// The kernel's lists; one bad time spares the rest.
fn take_lists(&mut self, notes: Vec<Note>, changes: Vec<Change>) {
let (threads, unread) = threads(&notes);
for bad in &unread {
log::error!("doc: a bad comment time {:?}: {}", bad.at, bad.error);
}
log::info!("doc: review lists {} notes, {} changes", notes.len(), changes.len());
(self.notes, self.threads, self.unread, self.changes) = (notes, threads, unread, changes);
}
/// Each unreadable note, by its author and the reason.
fn unread_lines(&self, cx: &App) -> Vec<SharedString> {
let line = |bad: &Unread| {
Docs::text(cx, "doc.review.unread", &[("author", &bad.author), ("at", &bad.at), ("error", &bad.error)])
};
self.unread.iter().map(line).collect()
}
/// The lines as one alert assistive tech reads whole.
fn unread_alert(&self, cx: &App) -> Option<gpui::AnyElement> {
let lines = self.unread_lines(cx);
let said: SharedString = lines.join("\n").into();
(!lines.is_empty()).then(|| {
div()
.id("doc-review-unread")
.role(gpui::Role::Alert)
.aria_label(said)
.flex()
.flex_col()
.gap_1()
.children(lines.into_iter().map(|line| InlineMessage::new(Severity::Danger, line)))
.into_any_element()
})
}
}
/// Notes threaded under their first comment; bad times apart.
fn threads(notes: &[Note]) -> (Vec<Thread>, Vec<Unread>) {
let parents: HashMap<&str, &str> = notes
.iter()
.map(|note| (note.id.as_str(), note.parent.as_str()))
@@ -384,13 +424,16 @@ fn threads(notes: &[Note]) -> Result<Vec<Thread>, String> {
id.to_string()
};
let authors: Vec<&str> = notes.iter().map(|note| note.author.as_str()).collect();
let mut threads: Vec<Thread> = Vec::new();
let (mut threads, mut unread): (Vec<Thread>, Vec<Unread>) = (Vec::new(), Vec::new());
for note in notes {
let at = note
.at
.parse::<jiff::civil::DateTime>()
.and_then(|at| at.to_zoned(jiff::tz::TimeZone::system()))
.map_err(|error| format!("a bad comment time {}: {error}", note.at))?;
let read = note.at.parse::<jiff::civil::DateTime>().and_then(|at| at.to_zoned(jiff::tz::TimeZone::system()));
let at = match read {
Ok(at) => at,
Err(error) => {
unread.push(Unread { author: note.author.clone(), at: note.at.clone(), error: error.to_string() });
continue;
}
};
let hue = authors
.iter()
.position(|name| *name == note.author)
@@ -412,7 +455,7 @@ fn threads(notes: &[Note]) -> Result<Vec<Thread>, String> {
}),
}
}
Ok(threads)
(threads, unread)
}
#[cfg(test)]
+37
View File
@@ -82,3 +82,40 @@ fn comments_thread_and_changes_accept_or_reject() {
}
assert!(!text.contains("gone"), "the rejected words go");
}
#[test]
fn one_unreadable_time_spares_the_other_notes() {
let note = |id: &str, parent: &str, at: &str| Note {
id: id.into(),
parent: parent.into(),
author: "A".into(),
text: "t".into(),
resolved: false,
at: at.into(),
anchor: (0, 0, 0, 0),
};
// The kernel writes an empty date as 0-00-00.
let mut undated = note("2", "0", "0-00-00");
undated.author = "Undated".into();
let notes = vec![note("1", "0", "2025-03-01T10:00:00"), undated, note("3", "1", "2025-03-02T09:00:00")];
let (threads, unread) = threads(&notes);
assert_eq!(threads.len(), 1);
assert_eq!(threads[0].comments.len(), 2, "the reply stays in its thread");
assert_eq!((unread.len(), unread[0].author.as_str(), unread[0].at.as_str()), (1, "Undated", "0-00-00"));
// Through the refresh path: changes stay, the line names both.
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = gpui::TestAppContext::single();
let Some(docs) = crate::tests::docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let review = cx.update(|app| docs.read(app).review.clone());
let change = Change { index: 7, author: "B".into(), kind: "Insert".into(), description: String::new(), at: "2025-03-01T10:00:00".into(), anchor: None };
let lines = cx.update(|cx| review.update(cx, |review, cx| {
review.take_lists(notes, vec![change]);
assert_eq!(review.changes.len(), 1, "the changes stay");
review.unread_lines(cx)
}));
assert_eq!(lines.len(), 1);
assert!(lines[0].contains("Undated") && lines[0].contains(&unread[0].error), "{}", lines[0]);
}
+10 -1
View File
@@ -365,7 +365,7 @@ fn a_macro_file_opens_in_risk_mode_and_saves_only_as_a_copy() {
);
cx.update(|app| {
assert!(
!docs.read(app).active_tab().expect("a tab").risk.is_some(),
docs.read(app).active_tab().expect("a tab").risk.is_none(),
"the DOCX copy holds no macros"
)
});
@@ -463,3 +463,12 @@ fn an_outside_change_asks_and_keep_mine_overwrites() {
mod find;
mod more;
mod overlap;
impl Docs {
/// Chooses the active tab as template, then types a field.
pub(crate) fn field_here(&mut self, header: &str, cx: &mut gpui::Context<Self>) {
let active = self.core.active;
self.merge.update(cx, |merge, _| merge.source = active);
self.insert_field(header, cx);
}
}
+86
View File
@@ -215,3 +215,89 @@ fn an_edit_that_just_landed_still_asks_and_selection_does_not() {
});
settle(&cx, |app| docs.read(app).tab(id).is_none());
}
#[test]
fn a_quit_from_either_window_asks_first() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let id = blank(&mut cx, &docs);
commit(&mut cx, &docs, "Unsaved");
let view = cx.update(|app| docs.read(app).active_tab().expect("a tab").view.clone());
settle(&cx, |app| view.read(app).settled());
cx.update(ely_ui::commands::bind_quit);
let home = cx.add_window(|_, _| Blank);
cx.update_window(home.into(), |_, window, app| docs.update(app, |docs, cx| docs.own_window(window, cx)))
.expect("the window lives");
// The settings window has no quit of its own.
let settings = cx.add_window(|_, _| Blank);
for from in [home.into(), settings.into()] {
cx.dispatch_action(from, ely_ui::commands::Quit);
settle(&cx, |app| matches!(docs.read(app).core.dialog, Some(Dialog::Unsaved { .. })));
assert!(cx.update(|app| docs.read(app).tab(id).is_some()), "the tab waits for an answer");
assert_eq!(cx.update(|app| app.active_window()), Some(home.into()), "the question's window comes forward");
// Cancel, as the dialog's button does.
cx.update(|app| docs.update(app, |docs, cx| {
docs.close_cancelled(cx);
docs.core.dialog = None;
}));
}
}
#[test]
fn closing_a_tab_takes_its_export_dialog() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let id = blank(&mut cx, &docs);
cx.update(|app| docs.update(app, |docs, cx| docs.ask_output(false, cx)));
assert!(cx.update(|app| matches!(docs.read(app).dialog, Some(crate::actions::Dialog::Output { .. }))));
with_window(&mut cx, &docs, |docs, window, cx| docs.request_close(id, After::Tab, window, cx));
settle(&cx, |app| docs.read(app).tab(id).is_none());
// Drawn now, a dialog for the gone tab would crash.
assert!(cx.update(|app| docs.read(app).dialog.is_none()), "the dialog went with its tab");
}
#[test]
fn a_tab_on_a_vanished_folder_takes_no_other_file() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let id = blank(&mut cx, &docs);
let gone = dir.path().join("volume");
std::fs::create_dir(&gone).expect("a folder");
let first = gone.join("first.docx");
with_window(&mut cx, &docs, |docs, window, cx| docs.write_as(id, first.clone(), None, window, cx));
written(&cx, &first, None);
// Its volume goes away; another file still saves.
std::fs::remove_dir_all(&gone).expect("the folder goes");
let other = dir.path().join("other.docx");
let holder = cx.update(|app| docs.read(app).holder(&other).map_err(|error| error.to_string()));
assert_eq!(holder, Ok(None));
}
#[test]
fn an_edit_after_a_save_raced_it_counts_again() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let id = blank(&mut cx, &docs);
type_into(&mut cx, &docs, "a");
settle(&cx, |app| docs.read(app).tab(id).is_some_and(|tab| tab.modified));
// A save's ticket landed after this edit: saved, still modified.
cx.update(|app| docs.update(app, |docs, _| docs.tab_mut(id).expect("a tab").session.reverted()));
type_into(&mut cx, &docs, "b");
settle(&cx, |app| docs.read(app).tab(id).is_some_and(|tab| tab.session.state() == ely_files::SaveState::Dirty));
}
+2 -2
View File
@@ -86,10 +86,10 @@ fn interleaved_previews_keep_their_sources() {
};
let alpha = blank(&mut cx, &docs);
type_into(&mut cx, &docs, "SourceAlpha ");
update_doc(&mut cx, &docs, |docs, cx| docs.insert_field("Name", cx));
update_doc(&mut cx, &docs, |docs, cx| docs.field_here("Name", cx));
let beta = blank(&mut cx, &docs);
type_into(&mut cx, &docs, "SourceBeta ");
update_doc(&mut cx, &docs, |docs, cx| docs.insert_field("Name", cx));
update_doc(&mut cx, &docs, |docs, cx| docs.field_here("Name", cx));
settled(&cx, &docs);
let csv = dir.path().join("two.csv");
std::fs::write(&csv, "Name\nAda\nGrace\n").expect("the csv writes");
+4
View File
@@ -5,6 +5,10 @@ edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
build = "../../packaging/windows/icon.rs"
[build-dependencies]
embed-resource.workspace = true
[dependencies]
ely-platform.workspace = true
+3
View File
@@ -1,3 +1,6 @@
// Release builds open no console on Windows.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use ely_platform::AppId;
fn main() {
+11 -2
View File
@@ -5,19 +5,25 @@ edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
build = "../../packaging/windows/icon.rs"
[build-dependencies]
embed-resource.workspace = true
[dependencies]
dunce.workspace = true
anyhow.workspace = true
base64.workspace = true
ely-files.workspace = true
ely-gpui-component.workspace = true
ely-office.workspace = true
ely-pdf-engine.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
markdown = "=1.0.0"
jiff.workspace = true
notify.workspace = true
serde.workspace = true
@@ -27,4 +33,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
-433
View File
@@ -1,433 +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,
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);
}
+317 -117
View File
@@ -1,163 +1,363 @@
//! Images dropped or pasted, copied beside the file (MARK-07).
//! Pictures 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_gpui_component::documents::Attachment;
use ely_ui::i18n::text;
use gpui::{App, Context, WeakEntity, Window};
use serde_json::json;
use gpui::{App, Context, ImageFormat};
use crate::view::{Failed, MarkView};
use crate::view::{Failed, MarkView, Mode};
/// The folder beside the file that takes its images.
pub const FOLDER: &str = "assets";
const PICTURES: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"];
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())
/// What goes into `assets/`: a file, or pasted bytes.
enum Source {
File(PathBuf),
Bytes(String, Vec<u8>),
}
/// 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>),
fn said(key: &str, cx: &App) -> Failed {
Failed(text(cx, key, &[]).to_string())
}
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()));
/// 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 folder = self.folder.clone();
let dir = folder.join(FOLDER);
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));
}
// A link names the file; its name must be text.
let (pictures, unnamed): (Vec<&PathBuf>, Vec<&PathBuf>) = pictures
.into_iter()
.partition(|path| path.file_name().is_some_and(|name| name.to_str().is_some()));
if !unnamed.is_empty() {
cx.emit(said("mark.attach.name", 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 {
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]),
}
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))
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: &Path,
folder: PathBuf,
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()));
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()));
}
};
// 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(),
));
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));
}
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));
}
}
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_str().expect("named in text")))
.collect();
let stem = copy
.file_stem()
.unwrap_or_default()
.to_str()
.expect("named in text");
// A line break would end the image.
let alt = stem
.replace(|ch: char| ch.is_control(), " ")
.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_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");
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)"
);
}
#[test]
fn a_name_with_line_breaks_links_one_image() {
let folder = Path::new("/notes");
let written = link(
folder,
&folder.join("assets").join(
"a
b.png",
),
);
let tree =
markdown::to_mdast(&written, &markdown::ParseOptions::default()).expect("parses");
let blocks = tree.children().expect("a root");
let [markdown::mdast::Node::Paragraph(line)] = blocks.as_slice() else {
panic!("one paragraph, not {blocks:?}")
};
assert!(
matches!(line.children.as_slice(), [markdown::mdast::Node::Image(_)]),
"{written}"
);
}
/// A file name that is no text.
#[cfg(unix)]
fn odd_name() -> std::ffi::OsString {
use std::os::unix::ffi::OsStrExt as _;
std::ffi::OsStr::from_bytes(b"pic-\xFF.png").to_owned()
}
#[cfg(windows)]
fn odd_name() -> std::ffi::OsString {
use std::os::windows::ffi::OsStringExt as _;
std::ffi::OsString::from_wide(&[0x70, 0xD800, 0x2E, 0x70, 0x6E, 0x67])
}
#[gpui::test]
fn a_name_that_is_no_text_is_refused(cx: &mut TestAppContext) {
let (view, cx, [_, files], said) = saved(cx);
let odd = files.path().join(odd_name());
std::fs::write(&odd, "png").expect("an oddly named picture");
attach(&view, Attachment::Files(vec![odd]), cx);
assert!(!files.path().join("assets").exists());
assert_eq!(said.borrow().len(), 1);
assert_eq!(
current(&view, cx),
"a
"
);
}
}
+10 -2
View File
@@ -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,11 +24,19 @@ 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;
}
tab.extra.heard = changed;
self.save_after_pause(id, cx);
}
/// Counts unsaved text as an edit; saves after the pause.
pub fn save_after_pause(&mut self, id: u64, cx: &mut Context<Self>) {
let Some(tab) = self.tab_mut(id) else {
return;
};
tab.extra.policy.edit(Instant::now());
cx.spawn(async move |desk, cx| {
let wait = DEBOUNCE + Duration::from_millis(50);
+38 -18
View File
@@ -2,7 +2,7 @@
use std::rc::Rc;
use ely_files::{Fingerprint, Policy, PolicyEngine};
use ely_files::Fingerprint;
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
git::{self as merging, Region, Take, ThreeWayMerge},
@@ -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;
};
@@ -102,7 +98,7 @@ impl Desk {
Choice::Mine => {
if let Some(tab) = self.tab_mut(id) {
tab.session.rebase(fingerprint);
tab.extra.policy = PolicyEngine::new(Policy::MarkAutoSave, true);
tab.extra.restart_autosave();
}
self.set_outside(id, Outside::Clear, cx);
self.save(id, None, window, cx);
@@ -112,19 +108,10 @@ impl Desk {
if open > 0 {
return log::error!("mark: a merge with {open} open conflicts");
}
let Some(tab) = self.tab_mut(id) else {
return;
};
if let Err(error) = tab.session.reload_at(fingerprint) {
let why = error.to_string();
return self.set_outside(id, Outside::Unread(why), cx);
// Disk text is saved; the merge edits it.
if let Some(view) = self.adopt_disk(id, &disk, fingerprint, cx) {
view.update(cx, |view, cx| view.adopt(merged, cx));
}
tab.extra.base = disk.text.clone();
tab.extra.policy = PolicyEngine::new(Policy::MarkAutoSave, true);
tab.doc.lock().expect("the text lock").source = disk;
let view = tab.view.clone();
view.update(cx, |view, cx| view.adopt(merged, cx));
self.set_outside(id, Outside::Clear, cx);
}
}
}
@@ -203,3 +190,36 @@ enum Choice {
Mine,
Merged,
}
#[cfg(test)]
mod tests {
use gpui::TestAppContext;
use super::Choice;
use crate::outside::tests::{look, opened, shown};
#[gpui::test]
fn a_merge_lands_as_one_undo_step(cx: &mut TestAppContext) {
let (desk, cx, id, path, _dirs) = opened("a\n\nb\n", cx);
let view = desk.read_with(cx, |desk, _| desk.active_view().expect("a tab"));
view.update(cx, |view, cx| view.adopt("a\n\nb\n\nmine\n".into(), cx));
cx.run_until_parked();
std::fs::write(&path, "theirs\n\na\n\nb\n").expect("an outside write");
look(&desk, id, cx);
desk.update(cx, |desk, cx| desk.compare(id, cx));
desk.update_in(cx, |desk, window, cx| {
desk.settle(Choice::Merged, window, cx)
});
cx.run_until_parked();
assert_eq!(shown(&desk, cx), "theirs\n\na\n\nb\n\nmine\n");
let focus = view.read_with(cx, |view, _| view.focus.clone());
cx.update(|window, cx| window.focus(&focus, cx));
cx.run_until_parked();
cx.simulate_keystrokes("secondary-z");
assert_eq!(
shown(&desk, cx),
"a\n\nb\n\nmine\n",
"one undo takes the merge back"
);
}
}
+106 -82
View File
@@ -22,38 +22,32 @@ 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>,
/// The tab being renamed and its name field.
pub naming: Option<(u64, Entity<TextInput>)>,
/// The export dialog's tab and format.
pub exporting: Option<(u64, crate::output::Format)>,
pub folder: Option<crate::folder::Folder>,
/// Folder listings so far; searches redo on a new one.
pub listings: u64,
/// Files and folders opened lately, newest first.
pub recent: Vec<PathBuf>,
pub recent_file: PathBuf,
/// A file asked to open at a place.
pub going: Option<(PathBuf, serde_json::Value)>,
/// A file asked to open at a line and column.
pub going: Option<(PathBuf, (usize, 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(crate) side: Entity<crate::side::Side>,
pub watcher: Option<notify::RecommendedWatcher>,
/// The folders the watcher covers.
pub watched: Vec<PathBuf>,
pub comparing: Option<crate::compare::Comparing>,
pub keeping: crate::restore::Keeping,
/// The export dialog's choice, while it is open.
pub output: Option<crate::export::Output>,
pub printing: Option<crate::export::Printing>,
pub look: crate::look::Look,
pub look_file: PathBuf,
/// Git's word on the shown file, and which tab asked.
@@ -61,6 +55,8 @@ pub struct Desk {
asked: Option<u64>,
/// The shown file's fingerprint when Git was last asked.
asked_print: Option<ely_files::Fingerprint>,
/// The find box, while open.
pub(crate) finding: Option<crate::finding::Find>,
}
impl Host for Desk {
@@ -81,6 +77,11 @@ impl Host for Desk {
&mut self.core
}
/// A quit records the open tabs first.
fn quitting(&mut self, cx: &mut Context<Self>) {
self.leave(cx);
}
/// A quit or close stopped: the kept list follows again.
fn close_cancelled(&mut self, cx: &mut Context<Self>) {
self.keeping.leaving = false;
@@ -106,9 +107,8 @@ 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.look_once(id, cx);
let changed = view.read(cx).changed();
self.autosave(id, changed, cx);
let recheck = self.tab_mut(id).is_some_and(|tab| {
if !tab.dirty(cx) {
@@ -119,8 +119,9 @@ 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);
if self.finding.is_some() {
self.count(cx);
}
// A save changes what Git sees.
let print = self.active_tab().and_then(|tab| tab.session.fingerprint());
@@ -132,19 +133,18 @@ 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));
}
self.track_recent(id, &path, cx);
let real = std::fs::canonicalize(&path).ok();
let real = dunce::canonicalize(&path).ok();
if self
.going
.as_ref()
.is_some_and(|(file, _)| Some(file) == real.as_ref())
&& let Some((_, place)) = self.going.take()
&& let Some((_, at)) = self.going.take()
{
view.update(cx, |view, cx| view.reveal(place, cx));
self.reveal_later(view.clone(), at, cx);
}
}
cx.notify();
@@ -153,15 +153,21 @@ impl Host for Desk {
/// The window root; files given at launch open.
pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
let data = ely_platform::data_dir(app)
.unwrap_or_else(|error| startup::fail(app, &format!("{error:#}")));
open_in(app, data, window, cx).0
}
/// The window root and its desk, kept in `data`.
pub(crate) fn open_in(
app: AppId,
data: PathBuf,
window: &mut Window,
cx: &mut App,
) -> (Entity<Root>, Entity<Desk>) {
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.
@@ -180,8 +186,9 @@ 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,
exporting: None,
folder: None,
listings: 0,
recent,
@@ -189,18 +196,16 @@ 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: None,
look: look.clone(),
look_file,
tracked: None,
asked: None,
asked_print: None,
finding: None,
}
});
let menus = crate::keys::menus(&look, cx);
@@ -215,7 +220,7 @@ pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
let asked = ely_ui::launch_files(cx);
desk.update(cx, |desk, cx| {
desk.core.root = root.downgrade();
desk.core.window = Some(window.window_handle());
desk.own_window(window, cx);
window.focus(&desk.core.focus, cx);
// Launch files stand alone; unsaved text still returns.
if asked.is_empty() {
@@ -223,8 +228,13 @@ pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
desk.open_path(path, cx);
}
}
for name in desk.keeping.untitled() {
for (at, name) in desk.keeping.untitled() {
// Tab numbers come from `next` as the load starts.
let id = desk.core.next;
desk.new_from_text(&name, "", cx);
if desk.core.next > id {
desk.keeping.made(at, id);
}
}
for path in asked {
desk.open_path(path, cx);
@@ -241,8 +251,13 @@ pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
}
})
.detach();
cx.on_app_quit(|desk, _| {
desk.keeping.close();
cx.on_app_quit(|desk, cx| {
// A system end skips asking: unsaved tabs keep points.
if desk.core.tabs.iter().any(|tab| tab.dirty(cx)) {
desk.keeping.hold();
} else {
desk.keeping.close();
}
async {}
})
.detach();
@@ -280,7 +295,7 @@ pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
desk.should_close(window, cx)
})
});
root
(root, desk)
}
impl Desk {
@@ -319,16 +334,28 @@ 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));
}
/// Shows line and column `at` from outside an update.
pub fn reveal_later(
&mut self,
view: Entity<MarkView>,
at: (usize, 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(at, window, cx))
});
if let Err(error) = shown {
log::warn!("mark: line {} was not shown: {error:#}", at.0);
}
})
.detach();
}
fn status(&self, cx: &mut Context<Self>) -> StatusBar {
@@ -349,9 +376,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);
@@ -390,41 +417,35 @@ impl Render for Desk {
.status(self.status(cx)),
}
.child(body);
let root = div()
.size_full()
.key_context(commands::CONTEXT)
.track_focus(&self.core.focus)
.on_action(cx.listener(Self::open_picked))
.on_action(cx.listener(Self::on_new))
.on_action(cx.listener(Self::on_save))
.on_action(cx.listener(Self::on_save_as))
.on_action(cx.listener(Self::on_rename))
.on_action(cx.listener(Self::on_find))
.on_action(cx.listener(Self::on_export))
.on_action(cx.listener(Self::on_print))
.on_action(cx.listener(Self::on_close_tab))
.on_action(cx.listener(|desk, quit, window, cx| {
desk.leave(cx);
desk.on_quit(quit, window, cx)
}))
.on_action(cx.listener(|desk, _: &InsertTable, _, cx| desk.table("insert", cx)))
.on_action(cx.listener(|desk, _: &RowAbove, _, cx| desk.table("row-above", cx)))
.on_action(cx.listener(|desk, _: &RowBelow, _, cx| desk.table("row-below", cx)))
.on_action(cx.listener(|desk, _: &ColumnLeft, _, cx| desk.table("column-left", cx)))
.on_action(cx.listener(|desk, _: &ColumnRight, _, cx| desk.table("column-right", cx)))
.on_action(cx.listener(|desk, _: &DeleteRow, _, cx| desk.table("delete-row", cx)))
.on_action(cx.listener(|desk, _: &DeleteColumn, _, cx| desk.table("delete-column", cx)))
.on_action(cx.listener(|desk, _: &CheckDisk, _, cx| desk.look_all(cx)))
.on_action(cx.listener(|desk, _: &VisualMode, _, cx| desk.switch(Mode::Visual, cx)))
.on_action(cx.listener(|desk, _: &SourceMode, _, cx| desk.switch(Mode::Source, cx)))
.on_action(cx.listener(|desk, _: &PreviewMode, _, cx| desk.switch(Mode::Preview, cx)))
.child(shell)
.children(self.core_dialog(cx))
.children(self.naming_element(cx))
.children(self.find_bar(cx))
.children(self.compare_dialog(cx))
.children(self.export_dialog(cx));
Self::look_actions(root, cx)
let root =
div()
.size_full()
.key_context(commands::CONTEXT)
.track_focus(&self.core.focus)
.on_action(cx.listener(Self::open_picked))
.on_action(cx.listener(Self::on_new))
.on_action(cx.listener(Self::on_save))
.on_action(cx.listener(Self::on_save_as))
.on_action(cx.listener(Self::on_rename))
.on_action(cx.listener(Self::on_close_tab))
.on_action(cx.listener(Self::on_find))
.on_action(cx.listener(|desk, _: &CheckDisk, _, cx| desk.look_all(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.compare_dialog(cx))
.children(self.find_box(cx))
.children(self.export_dialog(cx));
Self::output_actions(Self::table_actions(Self::look_actions(root, cx), cx), cx)
}
}
@@ -433,7 +454,7 @@ fn large_banner(
view: &Entity<MarkView>,
cx: &mut Context<Desk>,
) -> Option<ely_gpui_component::feedback::Banner> {
if !view.read(cx).large {
if !view.read(cx).large() {
return None;
}
let size = view.read(cx).bytes() as f64 / (1024.0 * 1024.0);
@@ -444,3 +465,6 @@ fn large_banner(
said,
))
}
#[cfg(test)]
pub(crate) mod tests;
+148
View File
@@ -0,0 +1,148 @@
use std::{cell::RefCell, path::Path, rc::Rc};
use ely_platform::AppId;
use gpui::{Entity, TestAppContext, VisualTestContext};
use super::{Desk, open_in};
use crate::view::Mode;
/// ElyMark over a fresh data folder, as `main` opens it.
pub(crate) fn start(
cx: &mut TestAppContext,
data: &Path,
) -> (Entity<Desk>, &'static mut VisualTestContext) {
// The file watcher runs on a real thread.
cx.executor().allow_parking();
cx.update(ely_gpui_component::init_for_tests);
cx.update(|cx| {
ely_ui::settings::install_defaults(cx);
ely_ui::i18n::install(cx);
ely_ui::install_no_launch_files(cx);
});
let held = Rc::new(RefCell::new(None));
let kept = held.clone();
let data = data.to_path_buf();
let window = cx.update(|cx| {
cx.open_window(Default::default(), |window, cx| {
let (root, desk) = open_in(AppId::Mark, data, window, cx);
kept.replace(Some(desk));
root
})
.expect("a window")
});
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
cx.update(|window, _| window.activate_window());
cx.run_until_parked();
let desk = held.take().expect("the desk");
(desk, cx)
}
pub(crate) fn open(desk: &Entity<Desk>, path: &Path, cx: &mut VisualTestContext) {
desk.update(cx, |desk, cx| desk.open_path(path.to_path_buf(), cx));
cx.run_until_parked();
}
fn outline(desk: &Entity<Desk>, cx: &mut VisualTestContext) -> Vec<(usize, String, usize)> {
desk.read_with(cx, |desk, cx| {
let view = desk.active_view().expect("a tab");
let outline = view.read(cx).outline(cx);
outline
.into_iter()
.map(|heading| (heading.level, heading.title, heading.line))
.collect()
})
}
#[gpui::test]
fn a_file_opens_with_its_outline_and_joins_the_recent_list(cx: &mut TestAppContext) {
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
let path = files.path().join("a.md");
std::fs::write(&path, "# One\n\nText.\n\n## Two\n\nMore.\n").expect("a file");
let (desk, cx) = start(cx, data.path());
open(&desk, &path, cx);
assert_eq!(
outline(&desk, cx),
[(1, "One".into(), 0), (2, "Two".into(), 4)]
);
let view = desk.read_with(cx, |desk, _| desk.active_view().expect("a tab"));
view.update_in(cx, |view, window, cx| view.reveal((4, 0), window, cx));
cx.run_until_parked();
assert_eq!(
view.read_with(cx, |view, cx| view.place(cx).line),
4,
"the jump lands"
);
let real = dunce::canonicalize(&path).expect("real");
assert!(desk.read_with(cx, |desk, _| desk.recent.contains(&real)));
let kept = std::fs::read_to_string(data.path().join("recent.json")).expect("the list");
assert!(kept.contains("a.md"), "{kept}");
assert!(!kept.contains(r"\\\\?\\"), "plain paths only: {kept}");
}
#[gpui::test]
fn the_outline_follows_typing(cx: &mut TestAppContext) {
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
let path = files.path().join("a.md");
std::fs::write(&path, "Text.\n").expect("a file");
let (desk, cx) = start(cx, data.path());
open(&desk, &path, cx);
let view = desk.read_with(cx, |desk, _| desk.active_view().expect("a tab"));
view.update_in(cx, |view, window, cx| view.switch(Mode::Source, window, cx));
view.update(cx, |view, cx| view.adopt("# New\n\nText.\n".into(), cx));
cx.executor()
.advance_clock(std::time::Duration::from_secs(1));
cx.run_until_parked();
assert_eq!(outline(&desk, cx), [(1, "New".into(), 0)]);
}
#[gpui::test]
fn two_files_are_two_tabs_each_with_its_outline(cx: &mut TestAppContext) {
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
let (a, b) = (files.path().join("a.md"), files.path().join("b.md"));
std::fs::write(&a, "# A\n").expect("a");
std::fs::write(&b, "# B\n").expect("b");
let (desk, cx) = start(cx, data.path());
open(&desk, &a, cx);
open(&desk, &b, cx);
assert_eq!(desk.read_with(cx, |desk, _| desk.core.tabs.len()), 2);
assert_eq!(outline(&desk, cx), [(1, "B".into(), 0)]);
let first = desk.read_with(cx, |desk, _| desk.core.tabs[0].id);
desk.update(cx, |desk, cx| {
desk.core.active = Some(first);
cx.notify();
});
assert_eq!(outline(&desk, cx), [(1, "A".into(), 0)]);
}
#[gpui::test]
fn a_folder_lists_its_markdown_files(cx: &mut TestAppContext) {
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
std::fs::create_dir(files.path().join("sub")).expect("sub");
std::fs::write(files.path().join("a.md"), "a").expect("a");
std::fs::write(files.path().join("sub/b.markdown"), "b").expect("b");
std::fs::write(files.path().join("c.txt"), "c").expect("c");
let (desk, cx) = start(cx, data.path());
open(&desk, files.path(), cx);
let mut listed = desk.read_with(cx, |desk, _| {
let folder = desk.folder.as_ref().expect("a folder");
folder
.files
.iter()
.map(|file| file.replace('\\', "/"))
.collect::<Vec<_>>()
});
listed.sort();
assert_eq!(listed, ["a.md", "sub/b.markdown"]);
}
+37 -65
View File
@@ -4,22 +4,16 @@ use std::{
convert::Infallible,
hash::{DefaultHasher, Hash as _, Hasher as _},
io,
path::{Path, PathBuf},
path::Path,
sync::{Arc, Mutex},
};
use ely_office::{Engine, Host, Load};
use ely_web_content::WebView;
use gpui::{App, AppContext as _, Context, Entity, FocusHandle, Window};
use crate::text::Source;
use crate::view::{Failed, MarkView, Reported};
/// The bundled page every tab shows.
pub struct Pages {
pub dir: PathBuf,
}
/// A file as read, and its text now.
pub struct Text {
pub source: Source,
@@ -28,14 +22,20 @@ pub struct Text {
written: Option<String>,
}
pub type Doc = Arc<Mutex<Text>>;
/// A load: the file as read, and where it is.
pub struct Opened {
pub source: Source,
pub path: PathBuf,
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>>;
/// The history mark: this text's length and hash.
pub fn mark(text: &str) -> String {
let mut hasher = DefaultHasher::new();
@@ -46,13 +46,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 Loaded = Source;
type Import = Infallible;
fn load(_: &mut Pages, 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 {},
@@ -62,50 +63,29 @@ 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(
worker: &Arc<Mutex<Pages>>,
loaded: Opened,
_: &Arc<Mutex<()>>,
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 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, 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 +98,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 {
@@ -132,11 +112,11 @@ impl Engine for Mark {
}
fn edited(view: &MarkView) -> u64 {
view.changed
view.changed()
}
fn edit_pending(view: &MarkView) -> bool {
!view.settled()
fn edit_pending(_: &MarkView) -> bool {
false
}
fn focus(view: &MarkView) -> FocusHandle {
@@ -171,30 +151,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")
);
}
-474
View File
@@ -1,474 +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, Window,
};
use serde_json::Value;
use crate::desk::Desk;
use crate::paper;
/// What an export writes.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Output {
Page,
Linked,
Pdf,
Text,
}
impl Output {
const ALL: [Output; 4] = [Output::Page, Output::Linked, Output::Pdf, Output::Text];
fn key(self) -> &'static str {
match self {
Output::Page => "page",
Output::Linked => "linked",
Output::Pdf => "pdf",
Output::Text => "text",
}
}
fn extension(self) -> &'static str {
match self {
Output::Page | Output::Linked => "html",
Output::Pdf => "pdf",
Output::Text => "txt",
}
}
}
/// What an export lacks: images, diagrams, formulas.
#[derive(Clone, Debug, Default)]
pub struct Lost {
images: Vec<String>,
diagrams: Vec<u64>,
/// Each broken formula's line and KaTeX's message.
formulas: Vec<(u64, String)>,
}
impl Lost {
fn of(images: Vec<String>, rendered: &Value) -> Lost {
let diagrams = rendered["failed"].as_array().into_iter().flatten();
let formulas = rendered["formulas"].as_array().into_iter().flatten();
Lost {
images,
diagrams: diagrams.filter_map(Value::as_u64).collect(),
formulas: formulas
.filter_map(|f| Some((f["line"].as_u64()?, f["error"].as_str()?.to_owned())))
.collect(),
}
}
fn whole(&self) -> bool {
self.images.is_empty() && self.diagrams.is_empty() && self.formulas.is_empty()
}
}
/// No export lands on a file a tab holds (FILE-06).
fn not_open(desk: &Desk, path: &Path) -> io::Result<()> {
match desk.holder(path)? {
Some(_) => Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("{} is open in ElyMark", file_name(path)),
)),
None => Ok(()),
}
}
/// A page printing to PDF in a view nobody sees.
pub struct Printing {
_view: Entity<WebView>,
_heard: Subscription,
}
impl Desk {
pub fn on_export(&mut self, _: &commands::Export, _: &mut Window, cx: &mut Context<Self>) {
if self.active_tab().is_some() {
self.output = Some(Output::Page);
cx.notify();
}
}
pub fn on_print(&mut self, _: &commands::Print, _: &mut Window, cx: &mut Context<Self>) {
let Some(id) = self.active_tab().map(|tab| tab.id) else {
return;
};
let pdf = self.core.work.join(format!("print-{id}.pdf"));
self.render_pdf(id, pdf, None, cx);
}
/// Asks where, renders the whole text, then writes it.
fn export_to(&mut self, output: Output, cx: &mut Context<Self>) {
let Some(tab) = self.active_tab() else {
return;
};
let id = tab.id;
let stem = tab
.name
.rsplit_once('.')
.map_or(tab.name.as_ref(), |(stem, _)| stem)
.to_owned();
let dir = ely_office::folder(tab).unwrap_or_else(std::env::temp_dir);
let chosen = cx.prompt_for_new_path(&dir, Some(&format!("{stem}.{}", output.extension())));
cx.spawn(async move |desk, cx| {
let Ok(Ok(Some(path))) = chosen.await else {
return log::info!("mark: the export panel closed without a path");
};
desk.update(cx, |desk, cx| {
if let Err(error) = not_open(desk, &path) {
return desk.exported(Err(error), cx);
}
match output {
Output::Pdf => desk.render_pdf(
id,
desk.core.work.join(format!("export-{id}.pdf")),
Some(path),
cx,
),
_ => desk.write_output(id, output, path, cx),
}
})
.ok();
})
.detach();
}
/// HTML or text, assembled and written off the main thread.
fn write_output(&mut self, id: u64, output: Output, path: PathBuf, cx: &mut Context<Self>) {
let Some(tab) = self.tab(id) else {
return;
};
let (view, folder) = (tab.view.clone(), tab.view.read(cx).folder.clone());
let open = tab.session.path().map(Path::to_path_buf);
let style = self.pages_dir().join("export.css");
let kind = if output == Output::Text {
"text"
} else {
"html"
};
let desk = cx.weak_entity();
view.update(cx, |view, cx| {
view.render(
kind,
move |rendered, cx: &mut App| {
// Checked again: tabs may have opened meanwhile.
let free = desk.update(cx, |desk, _| not_open(desk, &path));
let free = free.map_err(io::Error::other).and_then(|free| free);
let job = cx.background_spawn(async move {
free?;
let rendered = rendered.map_err(io::Error::other)?;
write(output, &rendered, &folder, &style, &path, open.as_deref())
});
cx.spawn(async move |cx| {
let written = job.await;
desk.update(cx, |desk, cx| desk.exported(written, cx)).ok();
})
.detach();
},
cx,
)
});
}
fn exported(&mut self, written: io::Result<(PathBuf, Lost)>, cx: &mut Context<Self>) {
match written {
Ok((path, lost)) if lost.whole() => {
log::info!("mark: exported {}", path.display());
let said = text(cx, "mark.export.done", &[("name", &file_name(&path))]);
self.toast_output(said, path, cx);
}
Ok((path, lost)) => {
// Counts only: image names come from the text.
log::warn!(
"mark: exported {} without {} images and {} diagrams; formulas failed at lines {:?}",
path.display(),
lost.images.len(),
lost.diagrams.len(),
lost.formulas.iter().map(|(line, _)| line).collect::<Vec<_>>()
);
self.warn_lost(&lost, Some(path), cx);
}
Err(error) => {
log::error!("mark: an export failed: {error}");
let said = text(cx, "mark.export.failed", &[("error", &error.to_string())]);
self.toast(said, Severity::Danger, cx);
}
}
}
/// Says which images, diagrams and formulas an output lacks.
fn warn_lost(&mut self, lost: &Lost, output: Option<PathBuf>, cx: &mut Context<Self>) {
let mut said = Vec::new();
if !lost.images.is_empty() {
said.push(text(
cx,
"mark.export.missing",
&[("list", &lost.images.join(", "))],
));
}
if !lost.diagrams.is_empty() {
said.push(text(
cx,
"mark.export.diagrams",
&[(
"list",
&lost
.diagrams
.iter()
.map(u64::to_string)
.collect::<Vec<_>>()
.join(", "),
)],
));
}
for (line, error) in &lost.formulas {
let line = line.to_string();
said.push(text(
cx,
"mark.export.formula",
&[("line", &line), ("error", error)],
));
}
// Names and messages quote the text: no log.
let said: gpui::SharedString = said.join(" ").into();
match (output, self.core.root.upgrade()) {
(Some(path), Some(root)) => root.update(cx, |root, cx| root.toast_output(said, Severity::Warning, path, cx)),
_ => self.toast_unlogged(said, Severity::Warning, cx),
}
}
fn pages_dir(&self) -> PathBuf {
self.core.worker.lock().expect("the pages lock").dir.clone()
}
/// The whole text as PDF; then to `to`, else printed.
fn render_pdf(&mut self, id: u64, pdf: PathBuf, to: Option<PathBuf>, cx: &mut Context<Self>) {
let Some(tab) = self.tab(id) else {
return;
};
let (view, folder, name) = (
tab.view.clone(),
tab.view.read(cx).folder.clone(),
tab.name.to_string(),
);
let open = tab.session.path().map(Path::to_path_buf);
let (style, work) = (self.pages_dir().join("export.css"), self.core.work.clone());
let desk = cx.weak_entity();
view.update(cx, |view, cx| {
view.render(
"html",
move |rendered, cx: &mut App| {
let staged = work.join(format!("print-{id}.html"));
let page = rendered.map_err(io::Error::other).and_then(|rendered| {
let (html, images) = paper::Marked::read(&rendered)?.inline(&folder);
let style = format!("<style>{}</style>", std::fs::read_to_string(&style)?);
std::fs::write(&staged, paper::page(&name, &style, &html))?;
Ok(Lost::of(images, &rendered))
});
cx.spawn(async move |cx| {
in_window(&desk, cx, |desk, window, cx| match page {
Ok(lost) => {
let job = Job {
pdf,
to,
open,
lost,
};
desk.print_page(&staged, job, window, cx)
}
Err(error) => desk.exported(Err(error), cx),
})
.ok();
})
.detach();
},
cx,
)
});
}
/// Chromium prints the staged page; the PDF goes where asked.
fn print_page(&mut self, page: &Path, job: Job, window: &mut Window, cx: &mut Context<Self>) {
let root = page
.parent()
.expect("a staged page sits in a folder")
.to_path_buf();
let view = match WebView::open(page, &root, window, cx) {
Ok(view) => view,
Err(error) => return self.exported(Err(io::Error::other(error)), cx),
};
let heard = cx.subscribe(&view, move |desk, view, event: &Event, cx| match event {
Event::Loaded => view.read(cx).print_pdf(&job.pdf),
Event::Printed(path, ok) => {
desk.printing = None;
let printed = ok
.then(|| path.clone())
.ok_or_else(|| io::Error::other("Chromium did not print the page"));
desk.printed(printed, job.clone(), cx);
}
Event::Failed(why) => {
desk.printing = None;
desk.exported(Err(io::Error::other(why.clone())), cx);
}
Event::Query(_, reply) => reply.ok(""),
Event::Leave(_) => {}
});
self.printing = Some(Printing {
_view: view,
_heard: heard,
});
}
fn printed(&mut self, pdf: io::Result<PathBuf>, job: Job, cx: &mut Context<Self>) {
let pdf = match pdf {
Ok(pdf) => pdf,
Err(error) => return self.exported(Err(error), cx),
};
let Some(to) = job.to else {
if !job.lost.whole() {
self.warn_lost(&job.lost, None, cx);
}
return ely_office::print_later(pdf, cx, |desk, why, cx| {
desk.exported(Err(io::Error::other(why)), cx)
});
};
// Never over the open file, not even through a link.
let moved = not_open(self, &to)
.and_then(|()| std::fs::read(&pdf))
.and_then(|bytes| ely_files::export(&to, job.open.as_deref(), &bytes, 0).map(|_| ()));
let _ = std::fs::remove_file(&pdf);
self.exported(moved.map(|()| (to, job.lost)), cx);
}
pub fn export_dialog(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let chosen = self.output?;
let this = cx.entity();
let kinds = Output::ALL.into_iter().fold(
SegmentedControl::new("mark-export-kind", chosen.key()),
|control, output| {
control.segment(
output.key(),
text(cx, &format!("mark.export.{}", output.key()), &[]),
None,
)
},
);
let kinds = kinds.on_change({
let this = this.clone();
move |key, _, cx| {
let picked = Output::ALL
.into_iter()
.find(|output| key.as_ref() == output.key());
this.update(cx, |desk, cx| {
desk.output = picked;
cx.notify();
})
}
});
let close = {
let this = this.clone();
move |_: &mut Window, cx: &mut App| {
this.update(cx, |desk, cx| {
desk.output = None;
cx.notify();
})
}
};
let label = text(cx, "mark.export.go", &[]);
let dialog = Dialog::new("mark-export", text(cx, "mark.export", &[]), close)
.child(kinds)
.action(cancel(cx))
.action(move |close| {
Button::new("mark-export-go", label)
.variant(ButtonVariant::Primary)
.on_click(move |_, window, cx| {
close(window, cx);
this.update(cx, |desk, cx| desk.export_to(chosen, cx));
})
});
Some(dialog.into_any_element())
}
}
/// A PDF in flight: where printed, where bound, what lacks.
#[derive(Clone)]
struct Job {
pdf: PathBuf,
to: Option<PathBuf>,
/// The tab's own file, never overwritten.
open: Option<PathBuf>,
lost: Lost,
}
/// Writes `output` at `path`; and what it left out.
fn write(
output: Output,
rendered: &Value,
folder: &Path,
style: &Path,
path: &Path,
open: Option<&Path>,
) -> io::Result<(PathBuf, Lost)> {
let title = path
.file_stem()
.map(|stem| stem.to_string_lossy().into_owned())
.unwrap_or_default();
let (bytes, missing) = match output {
Output::Text => (
rendered["text"]
.as_str()
.unwrap_or_default()
.as_bytes()
.to_vec(),
Vec::new(),
),
Output::Page => {
let (html, missing) = paper::Marked::read(rendered)?.inline(folder);
let style = format!("<style>{}</style>", std::fs::read_to_string(style)?);
(paper::page(&title, &style, &html).into_bytes(), missing)
}
Output::Linked => {
let assets = format!("{title}_files");
let place = path.with_file_name(&assets);
let (html, copies, missing) = paper::Marked::read(rendered)?.linked(folder, &assets);
// A folder already there is never written into.
std::fs::create_dir(&place)?;
let copied = copies
.iter()
.try_for_each(|(from, name)| std::fs::copy(from, place.join(name)).map(|_| ()))
.and_then(|()| std::fs::copy(style, place.join("export.css")).map(|_| ()));
if let Err(error) = copied {
let _ = std::fs::remove_dir_all(&place);
return Err(error);
}
let style = format!(
"<link rel=\"stylesheet\" href=\"{}/export.css\">",
paper::segment(&assets)
);
(paper::page(&title, &style, &html).into_bytes(), missing)
}
Output::Pdf => unreachable!("PDF prints in a page"),
};
if let Err(error) = ely_files::export(path, open, &bytes, 0) {
if output == Output::Linked {
let _ = std::fs::remove_dir_all(path.with_file_name(format!("{title}_files")));
}
return Err(error);
}
Ok((path.to_path_buf(), Lost::of(missing, rendered)))
}
+321
View File
@@ -0,0 +1,321 @@
//! Export, print and rich copy (MARK-11).
use std::path::PathBuf;
use ely_gpui_component::{
buttons::{Button, ButtonVariant, SegmentedControl},
overlays::Dialog,
primitives::Severity,
theme::ControlSize,
};
use ely_office::prelude::*;
use ely_platform::clipboard::{self, Clip};
use ely_ui::{
commands::{Export, Print},
i18n::text,
};
use gpui::{
AnyElement, Context, Div, InteractiveElement, IntoElement, ParentElement as _, TaskExt as _,
actions,
};
use crate::desk::Desk;
use crate::output::{self, Format, Page, Pictures, Target};
actions!(mark, [CopyRich]);
const FORMATS: [(Format, &str, &str); 4] = [
(Format::Page, "page", "html"),
(Format::Linked, "linked", "html"),
(Format::Pdf, "pdf", "pdf"),
(Format::Text, "text", "txt"),
];
fn key(format: Format) -> &'static str {
FORMATS
.iter()
.find(|(each, ..)| *each == format)
.expect("listed")
.1
}
impl Desk {
pub fn output_actions(root: Div, cx: &mut Context<Self>) -> Div {
root.on_action(cx.listener(|desk, _: &Export, _, cx| desk.ask_export(cx)))
.on_action(cx.listener(|desk, _: &Print, _, cx| desk.print(cx)))
.on_action(cx.listener(|desk, _: &CopyRich, _, cx| desk.copy_rich(cx)))
}
fn no_document(&self, cx: &mut Context<Self>) {
let said = text(cx, "command.blocked.no_document", &[]);
self.toast(said, Severity::Warning, cx);
}
fn ask_export(&mut self, cx: &mut Context<Self>) {
let Some(tab) = self.core.active else {
return self.no_document(cx);
};
self.exporting = Some((tab, Format::Page));
cx.notify();
}
pub fn export_dialog(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let (_, format) = self.exporting?;
let (picking, going, closing) = (cx.entity(), cx.entity(), cx.entity());
let formats = FORMATS
.iter()
.fold(
SegmentedControl::new("mark-export-format", key(format)),
|control, (_, value, _)| {
control.segment(*value, text(cx, &format!("mark.export.{value}"), &[]), None)
},
)
.size(ControlSize::Sm)
.on_change(move |value, _, cx| {
let Some((chosen, ..)) =
FORMATS.iter().find(|(_, each, _)| *each == value.as_ref())
else {
return;
};
let chosen = *chosen;
picking.update(cx, |desk, cx| {
if let Some((_, format)) = &mut desk.exporting {
*format = chosen;
}
cx.notify();
})
});
let go = text(cx, "mark.export.go", &[]);
let card = Dialog::new("mark-export", text(cx, "mark.export", &[]), move |_, cx| {
closing.update(cx, |desk, cx| {
desk.exporting = None;
cx.notify();
})
})
.child(formats)
.action(ely_office::cancel(cx))
.action(move |_| {
Button::new("mark-export-go", go)
.variant(ButtonVariant::Primary)
.on_click(move |_, _, cx| going.update(cx, |desk, cx| desk.export_asked(cx)))
});
Some(card.into_any_element())
}
/// Asks where, then writes the chosen format.
fn export_asked(&mut self, cx: &mut Context<Self>) {
let Some((tab, format)) = self.exporting.take() else {
return;
};
cx.notify();
let Some(source) = self.tab(tab) else {
return;
};
let Some(dir) = ely_office::folder(source) else {
return self.export_failed("no documents folder", cx);
};
let stem = source
.name
.rsplit_once('.')
.map_or(source.name.as_ref(), |(stem, _)| stem);
let extension = FORMATS
.iter()
.find(|(each, ..)| *each == format)
.expect("listed")
.2;
let chosen = cx.prompt_for_new_path(&dir, Some(&format!("{stem}.{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()),
};
desk.update(cx, |desk, cx| match chosen {
Ok(Some(path)) => desk.export_to(tab, path, format, cx),
Ok(None) => log::info!("mark: the export panel was cancelled"),
Err(error) => desk.export_failed(&error, cx),
})
})
.detach_and_log_err(cx);
}
pub fn export_to(&mut self, tab: u64, path: PathBuf, format: Format, cx: &mut Context<Self>) {
if !self.settled(tab, cx) {
return self.when_settled(tab, move |desk, _, cx| {
desk.export_to(tab, path, format, cx)
});
}
let (Some(source), Some(markdown)) = (self.tab(tab), self.text_of(tab)) else {
return;
};
match self.holder(&path) {
Ok(None) => {}
Ok(Some(_)) => return self.export_failed("an open document is there", cx),
Err(error) => return self.export_failed(&error.to_string(), cx),
}
let session = source.session.path().map(PathBuf::from);
let beside = session
.as_deref()
.and_then(|path| path.parent())
.map(PathBuf::from);
let (revision, work) = (source.session.revision(), source.work.clone());
let opened = path.clone();
cx.spawn(async move |desk, cx| {
let written = cx
.background_executor()
.spawn(async move {
let target = Target {
path: &path,
session: session.as_deref(),
revision,
work: &work,
};
output::write(format, &markdown, beside.as_deref(), &target)
})
.await;
desk.update(cx, |desk, cx| match written {
Ok(page) => {
let name = ely_office::files::file_name(&opened);
desk.toast_output(text(cx, "mark.export.done", &[("name", &name)]), opened, cx);
desk.left_out(&page, cx);
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
let said = text(
cx,
"mark.export.folder_taken",
&[("name", &error.to_string())],
);
desk.toast(said, Severity::Danger, cx);
}
Err(error) => desk.export_failed(&error.to_string(), cx),
})
})
.detach_and_log_err(cx);
}
/// Says what an export could not carry.
fn left_out(&self, page: &Page, cx: &mut Context<Self>) {
// Names and errors quote the document: not logged.
if !page.missing.is_empty() {
let said = text(
cx,
"mark.export.missing",
&[("list", &page.missing.join(", "))],
);
self.toast_unlogged(said, Severity::Warning, cx);
}
if !page.diagrams.is_empty() {
let lines: Vec<String> = page.diagrams.iter().map(usize::to_string).collect();
let said = text(cx, "mark.export.diagrams", &[("list", &lines.join(", "))]);
self.toast(said, Severity::Warning, cx);
}
if !page.formulas.is_empty() {
let each = page.formulas.iter().map(|(line, error)| {
text(
cx,
"mark.export.formula",
&[("line", &line.to_string()), ("error", error)],
)
.to_string()
});
let said = text(
cx,
"mark.export.formulas",
&[("list", &each.collect::<Vec<_>>().join("; "))],
);
self.toast_unlogged(said, Severity::Warning, cx);
}
}
fn export_failed(&self, error: &str, cx: &mut Context<Self>) {
let said = text(cx, "mark.export.failed", &[("error", error)]);
self.toast(said, Severity::Danger, cx);
}
/// Prints the PDF export through the system panel.
fn print(&mut self, cx: &mut Context<Self>) {
let Some(tab) = self.core.active else {
return self.no_document(cx);
};
if !self.settled(tab, cx) {
return self.when_settled(tab, |desk, _, cx| desk.print(cx));
}
let (Some(source), Some(markdown)) = (self.tab(tab), self.text_of(tab)) else {
return;
};
let beside = source
.session
.path()
.and_then(|path| path.parent())
.map(PathBuf::from);
let (revision, work) = (source.session.revision(), source.work.clone());
let pdf = print_copy(&work);
log::info!("mark: prints tab {tab}");
cx.spawn(async move |desk, cx| {
let written = cx
.background_executor()
.spawn(async move {
let target = Target {
path: &pdf,
session: None,
revision,
work: &work,
};
output::write(Format::Pdf, &markdown, beside.as_deref(), &target)
.map(|page| (page, pdf))
})
.await;
desk.update(cx, |desk, cx| match written {
Ok((page, pdf)) => {
desk.left_out(&page, cx);
ely_office::print_later(pdf, cx);
}
Err(error) => desk.print_failed(&error.to_string(), cx),
})
})
.detach_and_log_err(cx);
}
/// The selection, or the whole text, as HTML.
fn copy_rich(&mut self, cx: &mut Context<Self>) {
let Some(view) = self.active_view() else {
return self.no_document(cx);
};
let document = view.read(cx).document().clone();
let selected = document.read(cx).selected(cx);
let markdown = match selected.is_empty() {
true => document.read(cx).text(cx),
false => selected,
};
let beside = self
.active_tab()
.and_then(|tab| tab.session.path())
.and_then(|path| path.parent());
let copied = output::body(&markdown, beside, &Pictures::Inline).and_then(|mut page| {
let clip = Clip::Html {
html: std::mem::take(&mut page.html),
text: markdown,
};
clipboard::write(&clip)
.map(|()| page)
.map_err(|error| std::io::Error::other(format!("{error:#}")))
});
match copied {
Ok(page) => self.left_out(&page, cx),
Err(error) => {
let said = text(cx, "mark.copy.failed", &[("error", &error.to_string())]);
self.toast(said, Severity::Danger, cx);
}
}
}
}
/// A file of its own for each print job.
fn print_copy(work: &std::path::Path) -> PathBuf {
static JOBS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let job = JOBS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
work.join(format!("print-{job}.pdf"))
}
#[cfg(test)]
mod tests;
+94
View File
@@ -0,0 +1,94 @@
use gpui::TestAppContext;
use crate::desk::tests::{open, start};
use crate::output::Format;
#[gpui::test]
fn export_asks_for_a_format_then_writes_it(cx: &mut TestAppContext) {
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
let path = files.path().join("note.md");
std::fs::write(&path, "# Note\n\nText $x$.\n").expect("a file");
let (desk, cx) = start(cx, data.path());
open(&desk, &path, cx);
cx.dispatch_action(ely_ui::commands::Export);
cx.run_until_parked();
let (tab, format) = desk.read_with(cx, |desk, _| desk.exporting.expect("the dialog"));
assert_eq!(format, Format::Page);
let out = files.path().join("note.html");
desk.update(cx, |desk, cx| {
desk.exporting = None;
desk.export_to(tab, out.clone(), Format::Page, cx)
});
cx.run_until_parked();
let html = std::fs::read_to_string(&out).expect("written");
assert!(
html.contains("<h1>Note</h1>") && html.contains("<svg"),
"{html}"
);
}
#[gpui::test]
fn export_never_writes_over_the_open_file(cx: &mut TestAppContext) {
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
let path = files.path().join("note.md");
std::fs::write(&path, "# Note\n").expect("a file");
let (desk, cx) = start(cx, data.path());
open(&desk, &path, cx);
let tab = desk.read_with(cx, |desk, _| desk.core.active.expect("a tab"));
desk.update(cx, |desk, cx| {
desk.export_to(tab, path.clone(), Format::Text, cx)
});
cx.run_until_parked();
assert_eq!(std::fs::read_to_string(&path).expect("reads"), "# Note\n");
}
#[gpui::test]
fn rich_copy_takes_the_selection_as_html(cx: &mut TestAppContext) {
if std::env::var_os("ELY_CLIPBOARD_TEST").is_none() {
eprintln!("SKIP: set ELY_CLIPBOARD_TEST=1; this replaces the clipboard");
return;
}
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
let path = files.path().join("note.md");
std::fs::write(&path, "# Note\n\n**Bold** words.\n").expect("a file");
let (desk, cx) = start(cx, data.path());
open(&desk, &path, cx);
let view = desk.read_with(cx, |desk, _| desk.active_view().expect("a tab"));
view.update_in(cx, |view, window, cx| {
view.switch(crate::view::Mode::Source, window, cx)
});
cx.run_until_parked();
let document = view.read_with(cx, |view, _| view.document().clone());
document.update(cx, |document, cx| {
document
.editor()
.update(cx, |editor, cx| editor.select(std::iter::once(8..22), cx))
});
cx.dispatch_action(crate::export::CopyRich);
let clip = ely_platform::clipboard::read()
.expect("reads")
.expect("a clip");
let ely_platform::clipboard::Clip::Html { html, text } = clip else {
panic!("not HTML: {clip:?}");
};
assert_eq!(text, "**Bold** words");
assert!(
html.contains("<strong>Bold</strong>") && !html.contains("Note"),
"{html}"
);
}
#[test]
fn each_print_job_writes_its_own_file() {
let work = std::path::Path::new("work");
assert_ne!(super::print_copy(work), super::print_copy(work));
}
+185 -112
View File
@@ -1,179 +1,229 @@
//! Find and replace over the shown editor (MARK-06).
//! Find and replace in the shown file (MARK-06).
use std::ops::Range;
use ely_gpui_component::primitives::raise;
use ely_gpui_component::{
editor::{FindOptions, FindWidget},
editor::{FindOptions, FindWidget, find_all},
forms::{InputEvent, TextInput},
primitives::{Severity, raise},
theme::ActiveTheme as _,
};
use ely_office::prelude::*;
use ely_ui::i18n::text;
use gpui::{
AnyElement, AppContext as _, Context, Entity, Focusable as _, InteractiveElement as _,
IntoElement as _, ParentElement as _, Styled as _, Subscription, Window, actions, div,
IntoElement, ParentElement as _, SharedString, Styled as _, Subscription, Window, div,
};
use serde_json::json;
use crate::desk::Desk;
use crate::view::MarkView;
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 {
/// The find box over one tab, and what it found.
pub struct Find {
tab: u64,
query: Entity<TextInput>,
with: Entity<TextInput>,
replace: Option<Entity<TextInput>>,
options: FindOptions,
replacing: bool,
/// The tab last searched; another one is searched anew.
tab: Option<u64>,
_follow: Subscription,
hits: Vec<Range<usize>>,
current: Option<usize>,
/// The pattern did not compile; why.
error: Option<SharedString>,
_query: 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);
pub fn on_find(
&mut self,
_: &ely_office::FindReplace,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(tab) = self.core.active else {
let said = text(cx, "command.blocked.no_document", &[]);
return self.toast(said, Severity::Warning, cx);
};
if let Some(find) = self.finding.as_ref().filter(|find| find.tab == tab) {
return window.focus(&find.query.focus_handle(cx), cx);
}
self.close_find(window, 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 {
let subscription =
cx.subscribe_in(&query, window, |desk, _, event, window, cx| match event {
InputEvent::Changed => desk.count(cx),
InputEvent::Submit => desk.step(true, window, cx),
_ => {}
});
window.focus(&query.focus_handle(cx), cx);
log::info!("mark: find opens on tab {tab}");
self.finding = Some(Find {
tab,
query,
with,
replace: None,
options: FindOptions::default(),
replacing: false,
tab: None,
_follow: follow,
hits: Vec::new(),
current: None,
error: None,
_query: subscription,
});
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 found_view(&self) -> Option<(Entity<MarkView>, &Find)> {
let find = self.finding.as_ref()?;
Some((self.tab(find.tab)?.view.clone(), find))
}
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));
}
/// Matches again in the tab's text, marked in source.
pub fn count(&mut self, cx: &mut Context<Self>) {
let Some((view, find)) = self.found_view() else {
return;
};
let query = find.query.read(cx).text().to_string();
let options = find.options;
let document = view.read(cx).document().clone();
let found = find_all(&document.read(cx).text(cx), &query, options);
let Some(find) = self.finding.as_mut() else {
return;
};
(find.hits, find.error) = match found {
Ok(hits) => (hits, None),
Err(why) => (Vec::new(), Some(why.into())),
};
find.current = find.current.filter(|current| *current < find.hits.len());
let (hits, current) = (find.hits.clone(), find.current);
mark(&document, &hits, current, cx);
cx.notify();
}
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 step(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
let Some((view, find)) = self.found_view() else {
return;
};
let count = find.hits.len();
if count == 0 {
return;
}
let current = match (find.current, forward) {
(None, true) => 0,
(None, false) => count - 1,
(Some(at), true) => (at + 1) % count,
(Some(at), false) => (at + count - 1) % count,
};
let range = find.hits[current].clone();
let hits = find.hits.clone();
if let Some(find) = self.finding.as_mut() {
find.current = Some(current);
}
let document = view.read(cx).document().clone();
mark(&document, &hits, Some(current), cx);
document.update(cx, |document, cx| document.show_match(range, window, cx));
cx.notify();
}
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 replace(&mut self, every: bool, window: &mut Window, cx: &mut Context<Self>) {
let Some((view, find)) = self.found_view() else {
return;
};
let with = find
.replace
.as_ref()
.map_or(String::new(), |field| field.read(cx).text().to_string());
let edits: Vec<(Range<usize>, String)> = match (every, find.current) {
(true, _) => find
.hits
.iter()
.map(|hit| (hit.clone(), with.clone()))
.collect(),
(false, Some(at)) => vec![(find.hits[at].clone(), with)],
(false, None) => return self.step(true, window, cx),
};
if edits.is_empty() {
return;
}
// The next match starts past the replaced text.
let after = edits[0].0.start + edits[0].1.len();
let document = view.read(cx).document().clone();
if !document.update(cx, |document, cx| document.replace(edits, cx)) {
let said = text(cx, "mark.preview.read_only", &[]);
return self.toast(said, Severity::Info, cx);
}
self.count(cx);
if every {
return;
}
let Some(find) = self.finding.as_mut() else {
return;
};
let next = find.hits.iter().position(|hit| hit.start >= after);
find.current = next.or((!find.hits.is_empty()).then_some(0));
let Some(range) = find.current.map(|at| find.hits[at].clone()) else {
return;
};
let hits = find.hits.clone();
mark(&document, &hits, find.current, cx);
document.update(cx, |document, cx| document.show_match(range, window, cx));
}
fn close_find(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.finder = None;
if let Some(view) = self.active_view() {
view.update(cx, |view, cx| view.unfind(cx));
let focus = view.read(cx).focus.clone();
window.focus(&focus, cx);
let Some(find) = self.finding.take() else {
return;
};
if let Some(tab) = self.tab(find.tab) {
let document = tab.view.read(cx).document().clone();
mark(&document, &[], None, cx);
window.focus(&tab.view.read(cx).focus.clone(), 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
/// The find box over the file, while open.
pub fn find_box(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let find = self
.finding
.as_ref()
.map_or((None, 0), |found| (found.current, found.total));
.filter(|find| Some(find.tab) == self.core.active)?;
let this = cx.entity();
let widget = FindWidget::new("mark-find", &finder.query, current, total)
.options(finder.options)
let mut widget = FindWidget::new("mark-find", &find.query, find.current, find.hits.len())
.options(find.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;
if let Some(find) = desk.finding.as_mut() {
find.options = options;
}
desk.seek(cx);
desk.count(cx);
})
}
})
.on_step({
let this = this.clone();
move |forward, _, cx| this.update(cx, |desk, cx| desk.step(forward, cx))
move |forward, window, cx| {
this.update(cx, |desk, cx| desk.step(forward, window, cx))
}
})
.on_replace({
let this = this.clone();
move |all, _, cx| this.update(cx, |desk, cx| desk.replace(all, cx))
move |every, window, cx| this.update(cx, |desk, cx| desk.replace(every, window, cx))
})
.on_toggle_replace({
let this = this.clone();
move |open, _, cx| {
move |show, window, cx| {
this.update(cx, |desk, cx| {
if let Some(finder) = desk.finder.as_mut() {
finder.replacing = open;
if let Some(find) = desk.finding.as_mut() {
find.replace = show.then(|| cx.new(|cx| TextInput::new(window, cx)));
}
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.
if let Some(field) = &find.replace {
widget = widget.replace(field);
}
if let Some(error) = &find.error {
widget = widget.error(error.clone());
}
let place = div()
.absolute()
.top_2()
@@ -183,3 +233,26 @@ impl Desk {
Some(raise("mark-find-layer", place).into_any_element())
}
}
/// The matches marked in the source; the current one stronger.
fn mark(
document: &Entity<ely_gpui_component::documents::MarkdownDocument>,
hits: &[Range<usize>],
current: Option<usize>,
cx: &mut Context<Desk>,
) {
let tone = cx.theme().colors.warning;
let marks = hits
.iter()
.enumerate()
.map(|(at, hit)| {
let strength = if Some(at) == current { 0.45 } else { 0.2 };
(hit.clone(), tone.opacity(strength))
})
.collect();
let editor = document.read(cx).editor().clone();
editor.update(cx, |editor, cx| editor.set_backgrounds(marks, cx));
}
#[cfg(test)]
mod tests;
+177
View File
@@ -0,0 +1,177 @@
use ely_gpui_component::forms::TextInput;
use gpui::{AppContext as _, Entity, TestAppContext, VisualTestContext};
use crate::desk::{
Desk,
tests::{open, start},
};
fn opened(
text: &str,
cx: &mut TestAppContext,
) -> (
Entity<Desk>,
&'static mut VisualTestContext,
[tempfile::TempDir; 2],
) {
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
let path = files.path().join("a.md");
std::fs::write(&path, text).expect("a file");
let (desk, cx) = start(cx, data.path());
open(&desk, &path, cx);
(desk, cx, [data, files])
}
fn query(desk: &Entity<Desk>, words: &str, cx: &mut VisualTestContext) {
desk.update_in(cx, |desk, window, cx| {
desk.on_find(&ely_office::FindReplace, window, cx)
});
cx.simulate_input(words);
cx.run_until_parked();
}
fn current_text(desk: &Entity<Desk>, cx: &mut VisualTestContext) -> String {
desk.read_with(cx, |desk, cx| {
let view = desk.active_view().expect("a tab");
view.read(cx).document().read(cx).text(cx)
})
}
#[gpui::test]
fn every_match_is_counted_and_replaced_in_one_step(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("a b a\n", cx);
query(&desk, "a", cx);
assert_eq!(
desk.read_with(cx, |desk, _| desk
.finding
.as_ref()
.map(|find| find.hits.len())),
Some(2)
);
desk.update_in(cx, |desk, window, cx| {
let field = cx.new(|cx| TextInput::new(window, cx));
field.update(cx, |field, cx| field.set_text("x", cx));
desk.finding.as_mut().expect("open").replace = Some(field);
desk.replace(true, window, cx);
});
cx.run_until_parked();
assert_eq!(current_text(&desk, cx), "x b x\n");
let focus = desk.read_with(cx, |desk, cx| {
desk.active_view().expect("a tab").read(cx).focus.clone()
});
cx.update(|window, cx| window.focus(&focus, cx));
cx.run_until_parked();
cx.simulate_keystrokes("secondary-z");
assert_eq!(
current_text(&desk, cx),
"a b a\n",
"one undo takes all back"
);
}
#[gpui::test]
fn a_bad_pattern_says_why(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("a\n", cx);
desk.update_in(cx, |desk, window, cx| {
desk.on_find(&ely_office::FindReplace, window, cx)
});
desk.update(cx, |desk, cx| {
desk.finding.as_mut().expect("open").options.regex = true;
desk.count(cx);
});
cx.simulate_input("(");
cx.run_until_parked();
let error = desk.read_with(cx, |desk, _| {
desk.finding.as_ref().and_then(|find| find.error.clone())
});
assert!(error.is_some());
}
#[gpui::test]
fn a_step_shows_the_next_match(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("one\n\ntwo one\n", cx);
query(&desk, "one", cx);
desk.update_in(cx, |desk, window, cx| {
desk.step(true, window, cx);
desk.step(true, window, cx);
});
let current = desk.read_with(cx, |desk, _| {
desk.finding.as_ref().and_then(|find| find.current)
});
assert_eq!(current, Some(1));
}
#[gpui::test]
fn the_table_menu_inserts_and_edits_a_table(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("Intro\n", cx);
let focus = desk.read_with(cx, |desk, _| desk.core.focus.clone());
cx.update(|window, cx| window.focus(&focus, cx));
cx.dispatch_action(crate::tools::InsertTable);
assert!(current_text(&desk, cx).contains("| --- | --- | --- |"));
cx.dispatch_action(crate::tools::DeleteColumn);
assert!(
current_text(&desk, cx).contains("| --- | --- |\n"),
"{}",
current_text(&desk, cx)
);
}
fn replace_one(desk: &Entity<Desk>, with: &str, cx: &mut VisualTestContext) {
desk.update_in(cx, |desk, window, cx| {
let field = cx.new(|cx| TextInput::new(window, cx));
field.update(cx, |field, cx| field.set_text(with, cx));
desk.finding.as_mut().expect("open").replace = Some(field);
desk.replace(false, window, cx);
});
cx.run_until_parked();
}
fn current_hit(desk: &Entity<Desk>, cx: &mut VisualTestContext) -> Option<std::ops::Range<usize>> {
desk.read_with(cx, |desk, _| {
let find = desk.finding.as_ref()?;
find.current.map(|at| find.hits[at].clone())
})
}
#[gpui::test]
fn a_replacement_that_still_matches_moves_on(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("a a\n", cx);
query(&desk, "a", cx);
desk.update_in(cx, |desk, window, cx| desk.step(true, window, cx));
replace_one(&desk, "aa", cx);
assert_eq!(current_text(&desk, cx), "aa a\n");
assert_eq!(
current_hit(&desk, cx),
Some(3..4),
"the next original match"
);
}
#[gpui::test]
fn replacing_the_last_match_wraps_to_the_first(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("a b a\n", cx);
query(&desk, "a", cx);
desk.update_in(cx, |desk, window, cx| desk.step(false, window, cx));
replace_one(&desk, "x", cx);
assert_eq!(current_text(&desk, cx), "a b x\n");
assert_eq!(current_hit(&desk, cx), Some(0..1));
}
#[gpui::test]
fn preview_keeps_its_text_from_tables_and_replacement(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("a\n", cx);
let view = desk.read_with(cx, |desk, _| desk.active_view().expect("a tab"));
view.update_in(cx, |view, window, cx| {
view.switch(crate::view::Mode::Preview, window, cx)
});
let focus = desk.read_with(cx, |desk, _| desk.core.focus.clone());
cx.update(|window, cx| window.focus(&focus, cx));
cx.dispatch_action(crate::tools::InsertTable);
query(&desk, "a", cx);
desk.update_in(cx, |desk, window, cx| desk.step(true, window, cx));
replace_one(&desk, "x", cx);
assert_eq!(current_text(&desk, cx), "a\n");
}
+45 -4
View File
@@ -49,21 +49,43 @@ pub struct Hit {
pub file: PathBuf,
/// Counted from one.
pub line: usize,
/// The first match, in characters from zero.
pub column: usize,
pub text: String,
}
/// `ch` without case; final sigma folds as Unicode does.
fn fold(ch: char) -> impl Iterator<Item = char> {
ch.to_lowercase()
.map(|low| if low == 'ς' { 'σ' } else { low })
}
/// Where folded `query` starts in `line`, in characters.
fn column_of(line: &str, query: &str) -> Option<usize> {
// Each folded byte keeps its character's place.
let (mut folded, mut places) = (String::new(), Vec::new());
for (place, ch) in line.chars().enumerate() {
for low in fold(ch) {
folded.push(low);
places.extend(std::iter::repeat_n(place, low.len_utf8()));
}
}
folded.find(query).map(|at| places[at])
}
/// Lines of `text` holding `query`, any case.
pub fn lines_with(file: &Path, text: &str, query: &str, hits: &mut Vec<Hit>) {
let query = query.to_lowercase();
let query: String = query.chars().flat_map(fold).collect();
for (at, line) in text.lines().enumerate() {
if hits.len() == MOST_HITS {
return;
}
if line.to_lowercase().contains(&query) {
if let Some(column) = column_of(line, &query) {
let shown: String = line.trim().chars().take(160).collect();
hits.push(Hit {
file: file.to_path_buf(),
line: at + 1,
column,
text: shown,
});
}
@@ -108,8 +130,27 @@ mod tests {
);
let lines: Vec<_> = hits
.iter()
.map(|hit| (hit.line, hit.text.as_str()))
.map(|hit| (hit.line, hit.column, hit.text.as_str()))
.collect();
assert_eq!(lines, [(2, "some Words here"), (4, "WORDS")]);
assert_eq!(lines, [(2, 5, "some Words here"), (4, 0, "WORDS")]);
}
#[test]
fn the_column_counts_characters_through_case_changes() {
// İ lowers to two characters.
assert_eq!(super::column_of("İİ née Word", "word"), Some(7));
assert_eq!(super::column_of("中文 搜索", "搜索"), Some(3));
assert_eq!(super::column_of("none", "word"), None);
}
#[test]
fn sigma_matches_in_every_form() {
for line in ["ΟΣ", "οσ", "ος"] {
for query in ["ΟΣ", "οσ", "ος"] {
let mut hits = Vec::new();
super::lines_with(Path::new("a.md"), line, query, &mut hits);
assert_eq!(hits.len(), 1, "{query} in {line}");
}
}
}
}
+21 -30
View File
@@ -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,11 +13,11 @@ 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),
KeyBinding::new("secondary-0", crate::look::ActualSize, at),
KeyBinding::new("secondary-f", ely_office::FindReplace, at),
]);
}
@@ -51,19 +36,25 @@ 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)),
]);
use crate::tools::{
ColumnLeft, ColumnRight, DeleteColumn, DeleteRow, InsertTable, RowAbove, RowBelow,
};
vec![
table,
Menu::new(text(cx, "menu.edit", &[])).items([
item("office.find.open", Box::new(ely_office::FindReplace)),
item("mark.copy.rich", Box::new(crate::export::CopyRich)),
]),
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)),
]),
Menu::new(text(cx, "menu.view", &[])).items([
MenuItem::action(label(Mode::Visual, cx), VisualMode),
MenuItem::action(label(Mode::Source, cx), SourceMode),
+160 -9
View File
@@ -2,7 +2,9 @@
use std::{io, path::Path};
use gpui::{Context, actions};
use ely_gpui_component::{documents::DocumentLook, primitives::Severity, theme::ActiveTheme as _};
use ely_office::prelude::*;
use gpui::{App, Context, actions, rems};
use serde::{Deserialize, Serialize};
use crate::desk::Desk;
@@ -53,6 +55,36 @@ impl Default for Look {
}
}
/// A serif face each system carries.
const SERIF: &str = if cfg!(target_os = "linux") {
"DejaVu Serif"
} else {
"Georgia"
};
impl Look {
/// How the document shows text by this look.
pub fn document(&self, cx: &App) -> DocumentLook {
let measure = match self.width {
Width::Narrow => 36.0,
Width::Medium => 48.0,
Width::Wide => 64.0,
};
let font = match self.font {
Font::Sans => None,
Font::Serif => Some(SERIF.into()),
Font::Mono => Some(cx.theme().mono_family.clone()),
};
DocumentLook {
focus: self.focus,
typewriter: self.typewriter,
measure: Some(rems(measure)),
font,
scale: self.size as f32 / 100.0,
}
}
}
/// The kept look; none kept is the default.
pub fn load(file: &Path) -> io::Result<Look> {
match std::fs::read(file) {
@@ -66,16 +98,19 @@ 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}");
let said =
ely_ui::i18n::text(cx, "office.error.engine", &[("error", &error.to_string())]);
self.toast(said, Severity::Danger, cx);
}
self.apply_look(cx);
let menus = crate::keys::menus(&self.look, cx);
ely_ui::set_document_menus(
ely_platform::AppId::Mark,
@@ -86,15 +121,31 @@ 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));
/// Every tab's document takes the look.
fn apply_look(&mut self, cx: &mut Context<Self>) {
let look = self.look.document(cx);
let documents: Vec<_> = self
.core
.tabs
.iter()
.map(|tab| tab.view.read(cx).document().clone())
.collect();
for document in documents {
document.update(cx, |document, cx| document.set_look(look.clone(), cx));
}
}
/// A new tab's document takes the look once.
pub fn look_once(&mut self, id: u64, cx: &mut Context<Self>) {
let look = self.look.document(cx);
let Some(tab) = self.tab_mut(id).filter(|tab| !tab.extra.looked) else {
return;
};
tab.extra.looked = true;
let document = tab.view.read(cx).document().clone();
document.update(cx, |document, cx| document.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;
@@ -143,6 +194,70 @@ impl Desk {
#[cfg(test)]
mod tests {
use ely_gpui_component::documents::DocumentLook;
use gpui::{
Entity, Modifiers, ScrollDelta, ScrollWheelEvent, TestAppContext, VisualTestContext,
point, rems,
};
use crate::desk::{
Desk,
tests::{open, start},
};
fn document_look(desk: &Entity<Desk>, cx: &mut VisualTestContext) -> DocumentLook {
desk.read_with(cx, |desk, cx| {
let view = desk.active_view().expect("a tab");
view.read(cx).document().read(cx).look().clone()
})
}
#[gpui::test]
fn a_new_tab_takes_the_kept_look(cx: &mut TestAppContext) {
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
let kept = r#"{"focus":true,"typewriter":true,"width":"narrow","font":"serif","size":120}"#;
std::fs::write(data.path().join("look.json"), kept).expect("a look");
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 look = document_look(&desk, cx);
assert!(look.focus && look.typewriter);
assert_eq!((look.measure, look.scale), (Some(rems(36.0)), 1.2));
assert_eq!(look.font.as_deref(), Some(super::SERIF));
}
#[gpui::test]
fn a_menu_change_reaches_open_tabs_and_is_kept(cx: &mut TestAppContext) {
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 focus = desk.read_with(cx, |desk, _| desk.core.focus.clone());
cx.update(|window, cx| window.focus(&focus, cx));
cx.dispatch_action(super::Wide);
cx.dispatch_action(super::Larger);
let look = document_look(&desk, cx);
assert_eq!((look.measure, look.scale), (Some(rems(64.0)), 1.1));
let kept = std::fs::read_to_string(data.path().join("look.json")).expect("kept");
assert!(kept.contains("\"wide\"") && kept.contains("110"), "{kept}");
}
#[test]
fn a_kept_look_reads_back_and_none_is_default() {
let dir = tempfile::tempdir().expect("a temp dir");
@@ -163,4 +278,40 @@ mod tests {
(super::Width::Wide, super::Font::Serif, 120)
);
}
#[gpui::test]
fn ctrl_and_the_wheel_size_the_text(cx: &mut TestAppContext) {
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 focus = desk.read_with(cx, |desk, _| desk.core.focus.clone());
cx.update(|window, cx| window.focus(&focus, cx));
let at = cx.update(|window, _| window.viewport_size()).center();
let wheel = |modifiers, y| ScrollWheelEvent {
position: at,
delta: ScrollDelta::Lines(point(0.0, y)),
modifiers,
..Default::default()
};
let other = match cfg!(target_os = "macos") {
true => Modifiers { control: true, ..Default::default() },
false => Modifiers { platform: true, ..Default::default() },
};
cx.simulate_event(wheel(other, 3.0));
cx.run_until_parked();
assert_eq!(document_look(&desk, cx).scale, 1.0, "only Ctrl, or Cmd on macOS");
cx.simulate_event(wheel(Modifiers::secondary_key(), 3.0));
cx.run_until_parked();
assert_eq!(document_look(&desk, cx).scale, 1.1, "a notch is one step");
cx.simulate_event(wheel(Modifiers::secondary_key(), -1.0));
cx.simulate_event(wheel(Modifiers::secondary_key(), -1.0));
cx.run_until_parked();
assert_eq!(document_look(&desk, cx).scale, 0.9);
}
}
+5 -6
View File
@@ -1,3 +1,6 @@
// Release builds open no console on Windows.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod attach;
mod autosave;
mod compare;
@@ -8,23 +11,19 @@ mod finding;
mod folder;
mod keys;
mod look;
mod output;
mod outside;
mod page;
mod paper;
mod places;
mod rename;
mod restore;
mod side;
mod text;
mod tools;
mod vcs;
mod view;
use ely_platform::AppId;
fn main() {
// CEF runs its helper processes from this same binary.
if let Some(code) = ely_web_content::subprocess() {
std::process::exit(code);
}
ely_ui::run(AppId::Mark, desk::open);
}
+100
View File
@@ -0,0 +1,100 @@
//! An export written to disk.
use std::{
io,
path::Path,
sync::atomic::{AtomicU64, Ordering},
};
use super::{Page, Pictures, body, document, page_of, pictures::Placed, plain};
/// What an export writes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Format {
/// One HTML file, pictures inside.
Page,
/// HTML and a folder of its pictures.
Linked,
Pdf,
Text,
}
/// Where one export goes, and what it leaves alone.
pub struct Target<'a> {
pub path: &'a Path,
/// The tab's own file, never written over.
pub session: Option<&'a Path>,
pub revision: u64,
/// A scratch folder of the tab's.
pub work: &'a Path,
}
/// `text` written as `format`; what it left out.
pub fn write(
format: Format,
text: &str,
beside: Option<&Path>,
target: &Target,
) -> io::Result<Page> {
let title = target
.path
.file_stem()
.map_or_else(String::new, |stem| stem.to_string_lossy().into_owned());
let page = match format {
Format::Page => {
let page = body(text, beside, &Pictures::Inline)?;
export(target, document(&title, &page.html).as_bytes())?;
page
}
Format::Linked => linked(text, beside, target, &title)?,
Format::Pdf => pdf(text, beside, target, &title)?,
Format::Text => {
export(target, plain(text)?.as_bytes())?;
Page::default()
}
};
log::info!("mark: exported {format:?}, revision {}", target.revision);
Ok(page)
}
fn export(target: &Target, bytes: &[u8]) -> io::Result<()> {
ely_files::export(target.path, target.session, bytes, target.revision).map(|_| ())
}
/// The page beside a new folder of its pictures.
fn linked(text: &str, beside: Option<&Path>, target: &Target, title: &str) -> io::Result<Page> {
let folder = target.path.with_file_name(format!("{title}_files"));
let pictures = Pictures::Folder(folder.clone());
let mut placed = Placed::new(beside, &pictures);
let written = page_of(text, &mut placed)
.and_then(|page| export(target, document(title, &page.html).as_bytes()).map(|()| page));
// A failure takes only the folder it made.
if written.is_err()
&& placed.made
&& let Err(left) = std::fs::remove_dir_all(&folder)
{
log::error!("mark: the half-made picture folder stays: {left}");
}
written
}
/// The page set by MuPDF, in its own scratch.
fn pdf(text: &str, beside: Option<&Path>, target: &Target, title: &str) -> io::Result<Page> {
static JOBS: AtomicU64 = AtomicU64::new(0);
let dir = target
.work
.join(format!("pdf-{}", JOBS.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir_all(&dir)?;
let set = (|| -> io::Result<Page> {
let page = body(text, beside, &Pictures::Pdf(dir.join("files")))?;
std::fs::write(dir.join("page.html"), document(title, &page.html))?;
let out = dir.join("out.pdf");
ely_pdf_engine::Worker::spawn()?.html_pdf(&dir, "page.html", &out)?;
export(target, &std::fs::read(&out)?)?;
Ok(page)
})();
let cleaned = std::fs::remove_dir_all(&dir);
let page = set?;
cleaned?;
Ok(page)
}
+284
View File
@@ -0,0 +1,284 @@
//! A syntax tree written as HTML, node by node.
use std::{collections::HashMap, io};
use ely_gpui_component::{documents::diagram_svg, typography::formula_svg};
use markdown::mdast::{AlignKind, Node};
use super::{Page, attribute, escape, pictures::Placed};
/// Writes `tree` into `page`; pictures go through `placed`.
pub(super) fn write(tree: &Node, placed: &mut Placed, page: &mut Page) -> io::Result<()> {
let mut writer = Writer {
placed,
page,
links: HashMap::new(),
notes: HashMap::new(),
called: Vec::new(),
};
writer.gather(tree);
writer.children(tree, false)?;
writer.footnotes()
}
struct Writer<'a, 'b, 'c> {
placed: &'b mut Placed<'a>,
page: &'c mut Page,
/// Link definitions by identifier: address and title.
links: HashMap<String, (String, Option<String>)>,
notes: HashMap<String, Node>,
/// Footnotes in the order first called.
called: Vec<String>,
}
impl Writer<'_, '_, '_> {
fn gather(&mut self, node: &Node) {
match node {
Node::Definition(definition) => {
let target = (definition.url.clone(), definition.title.clone());
self.links
.entry(definition.identifier.clone())
.or_insert(target);
}
Node::FootnoteDefinition(note) => {
self.notes
.entry(note.identifier.clone())
.or_insert_with(|| node.clone());
}
_ => {}
}
for child in node.children().into_iter().flatten() {
self.gather(child);
}
}
fn push(&mut self, text: &str) {
self.page.html.push_str(text);
}
fn children(&mut self, node: &Node, tight: bool) -> io::Result<()> {
for child in node.children().into_iter().flatten() {
self.node(child, tight)?;
}
Ok(())
}
fn wrapped(&mut self, tag: &str, node: &Node) -> io::Result<()> {
self.push(&format!("<{tag}>"));
self.children(node, false)?;
self.push(&format!("</{tag}>"));
Ok(())
}
fn node(&mut self, node: &Node, tight: bool) -> io::Result<()> {
match node {
Node::Paragraph(_) if tight => self.children(node, false)?,
Node::Paragraph(_) => self.wrapped("p", node)?,
Node::Heading(heading) => self.wrapped(&format!("h{}", heading.depth), node)?,
Node::Blockquote(_) => self.wrapped("blockquote", node)?,
Node::Emphasis(_) => self.wrapped("em", node)?,
Node::Strong(_) => self.wrapped("strong", node)?,
Node::Delete(_) => self.wrapped("del", node)?,
Node::List(list) => {
let tag = if list.ordered { "ol" } else { "ul" };
match list.start.filter(|start| list.ordered && *start != 1) {
Some(start) => self.push(&format!("<ol start=\"{start}\">")),
None => self.push(&format!("<{tag}>")),
}
for item in &list.children {
let Node::ListItem(each) = item else { continue };
self.push("<li>");
if let Some(checked) = each.checked {
let mark = if checked { " checked" } else { "" };
self.push(&format!("<input type=\"checkbox\" disabled{mark}> "));
}
self.children(item, !list.spread && !each.spread)?;
self.push("</li>");
}
self.push(&format!("</{tag}>"));
}
Node::Table(table) => self.table(node, &table.align)?,
Node::Code(code) if code.lang.as_deref() == Some("mermaid") => {
let line = line_of(node);
match diagram_svg(&code.value) {
Ok(svg) => self.push(&format!("<div class=\"diagram\">{}</div>", bare(&svg))),
Err(_) => {
self.page.diagrams.push(line);
self.code(Some("mermaid"), &code.value);
}
}
}
Node::Code(code) => self.code(code.lang.as_deref(), &code.value),
Node::Math(math) => self.math(node, &math.value, true),
Node::InlineMath(math) => self.math(node, &math.value, false),
Node::InlineCode(code) => self.push(&format!("<code>{}</code>", escape(&code.value))),
Node::Text(text) => self.push(&escape(&text.value)),
// HTML in the document shows as text, never runs.
Node::Html(html) => self.push(&escape(&html.value)),
Node::Break(_) => self.push("<br>"),
Node::ThematicBreak(_) => self.push("<hr>"),
Node::Link(link) => self.link(node, &link.url, link.title.as_deref())?,
Node::LinkReference(link) => match self.links.get(&link.identifier).cloned() {
Some((url, title)) => self.link(node, &url, title.as_deref())?,
None => self.children(node, false)?,
},
Node::Image(image) => self.picture(&image.url, &image.alt, image.title.as_deref())?,
Node::ImageReference(image) => match self.links.get(&image.identifier).cloned() {
Some((url, title)) => self.picture(&url, &image.alt, title.as_deref())?,
None => self.push(&escape(&image.alt)),
},
Node::FootnoteReference(call) => {
let number = match self.called.iter().position(|id| *id == call.identifier) {
Some(at) => at + 1,
None => {
self.called.push(call.identifier.clone());
self.called.len()
}
};
self.push(&format!("<sup><a href=\"#fn-{number}\">{number}</a></sup>"));
}
// Written elsewhere, or never.
Node::Yaml(_) | Node::Toml(_) | Node::Definition(_) | Node::FootnoteDefinition(_) => {}
_ => self.children(node, false)?,
}
Ok(())
}
fn table(&mut self, node: &Node, align: &[AlignKind]) -> io::Result<()> {
self.push("<table>");
for (at, row) in node.children().into_iter().flatten().enumerate() {
let cell = if at == 0 { "th" } else { "td" };
self.push("<tr>");
for (column, each) in row.children().into_iter().flatten().enumerate() {
let side = match align.get(column) {
Some(AlignKind::Left) => " style=\"text-align:left\"",
Some(AlignKind::Right) => " style=\"text-align:right\"",
Some(AlignKind::Center) => " style=\"text-align:center\"",
_ => "",
};
self.push(&format!("<{cell}{side}>"));
self.children(each, false)?;
self.push(&format!("</{cell}>"));
}
self.push("</tr>");
}
self.push("</table>");
Ok(())
}
fn code(&mut self, lang: Option<&str>, value: &str) {
let class = lang.map_or_else(String::new, |lang| {
format!(" class=\"language-{}\"", attribute(lang))
});
self.push(&format!("<pre><code{class}>{}</code></pre>", escape(value)));
}
fn math(&mut self, node: &Node, value: &str, display: bool) {
match (formula_svg(value, display), display) {
(Ok(svg), true) => self.push(&format!("<div class=\"math\">{}</div>", bare(&svg))),
(Ok(svg), false) => self.push(&format!("<span class=\"math\">{}</span>", bare(&svg))),
(Err(error), _) => {
self.page.formulas.push((line_of(node), error.to_string()));
match display {
true => self.code(Some("math"), value),
false => self.push(&format!("<code>{}</code>", escape(value))),
}
}
}
}
fn link(&mut self, node: &Node, url: &str, title: Option<&str>) -> io::Result<()> {
let title = title.map_or_else(String::new, |title| {
format!(" title=\"{}\"", attribute(title))
});
// Only web, mail and in-page addresses follow.
match safe(url) {
true => self.push(&format!("<a href=\"{}\"{title}>", attribute(url))),
false => self.push("<a>"),
}
self.children(node, false)?;
self.push("</a>");
Ok(())
}
fn picture(&mut self, url: &str, alt: &str, title: Option<&str>) -> io::Result<()> {
let src = self.placed.place(url)?;
if src.is_none() {
self.page.missing.push(if url.is_empty() {
alt.to_owned()
} else {
url.to_owned()
});
}
// A PDF shows what it left out as words.
let Some(src) = src.or_else(|| (!self.placed.pdf()).then(|| url.to_owned())) else {
self.push(&format!("[{}]", escape(alt)));
return Ok(());
};
let title = title.map_or_else(String::new, |title| {
format!(" title=\"{}\"", attribute(title))
});
self.push(&format!(
"<img src=\"{}\" alt=\"{}\"{title}>",
attribute(&src),
attribute(alt)
));
Ok(())
}
fn footnotes(&mut self) -> io::Result<()> {
if self.called.is_empty() {
return Ok(());
}
self.push("<section class=\"footnotes\"><hr><ol>");
// A note may call another note.
let mut at = 0;
while at < self.called.len() {
let note = self.notes.get(&self.called[at]).cloned();
at += 1;
self.push(&format!("<li id=\"fn-{at}\">"));
match note {
Some(note) => self.children(&note, false)?,
None => log::error!("mark: footnote {at} has no definition"),
}
self.push("</li>");
}
self.push("</ol></section>");
Ok(())
}
}
/// The line a node starts on, from one.
fn line_of(node: &Node) -> usize {
node.position().map_or(0, |at| at.start.line)
}
/// An SVG without its XML declaration.
fn bare(svg: &str) -> &str {
svg.find("<svg").map_or(svg, |at| &svg[at..])
}
/// A plain, web or mail address, read as browsers do.
fn safe(url: &str) -> bool {
// Browsers trim spaces and controls, then drop tabs and newlines.
let read: String = url
.trim_matches(|ch: char| ch <= ' ')
.chars()
.filter(|ch| !matches!(ch, '\t' | '\n' | '\r'))
.collect();
match scheme(&read) {
None => true,
Some(scheme) => ["http", "https", "mailto"].contains(&scheme.to_lowercase().as_str()),
}
}
/// An address's scheme, when it has one.
pub(super) fn scheme(url: &str) -> Option<&str> {
let end = url.find(':')?;
let name = &url[..end];
let plain = name
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || "+-.".contains(ch));
// A drive letter is a path, not a scheme.
(plain && name.len() > 1).then_some(name)
}
+78
View File
@@ -0,0 +1,78 @@
//! Markdown as an HTML page or plain text (MARK-11).
mod files;
mod html;
mod pictures;
mod plain;
#[cfg(test)]
mod tests;
use std::{io, path::Path};
use ely_gpui_component::documents::markdown_options;
pub use files::{Format, Target, write};
pub use pictures::Pictures;
pub use plain::plain;
/// A page and what it could not carry.
#[derive(Debug, Default)]
pub struct Page {
pub html: String,
/// Pictures left out, as the document wrote them.
pub missing: Vec<String>,
/// Lines of diagrams kept as code.
pub diagrams: Vec<usize>,
/// Lines of formulas kept as code, and why.
pub formulas: Vec<(usize, String)>,
}
/// The page's look: light, as printed.
const STYLE: &str = "body{font-family:sans-serif;font-size:11pt;line-height:1.5;color:#1f2328;max-width:48rem;margin:2rem auto;padding:0 1rem}\
pre{background:#f6f8fa;padding:.75em;white-space:pre-wrap}\
code{font-family:monospace}\
table{border-collapse:collapse}th,td{border:1px solid #d0d7de;padding:.3em .6em}\
blockquote{margin-left:0;padding-left:1em;border-left:3px solid #d0d7de;color:#59636e}\
img,svg{max-width:100%}div.math{text-align:center}span.math svg{vertical-align:middle}";
/// `text`'s body as HTML; pictures go as `pictures` says.
pub fn body(text: &str, beside: Option<&Path>, pictures: &Pictures) -> io::Result<Page> {
page_of(text, &mut pictures::Placed::new(beside, pictures))
}
/// `text`'s body; `placed` keeps what it copied.
fn page_of(text: &str, placed: &mut pictures::Placed) -> io::Result<Page> {
let tree = markdown::to_mdast(text, &markdown_options())
.map_err(|error| io::Error::other(error.to_string()))?;
let mut page = Page::default();
html::write(&tree, placed, &mut page)?;
log::info!(
"mark: a page of {} bytes, {} pictures copied, {} missing, {} diagrams and {} formulas as code",
page.html.len(),
placed.copied.len(),
page.missing.len(),
page.diagrams.len(),
page.formulas.len()
);
Ok(page)
}
/// A whole HTML document around `body`.
pub fn document(title: &str, body: &str) -> String {
format!(
"<!doctype html>\n<html><head><meta charset=\"utf-8\"><title>{}</title><style>{STYLE}</style></head>\n<body>\n{body}\n</body></html>\n",
escape(title)
)
}
/// Text safe between tags.
fn escape(text: &str) -> String {
text.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
/// Text safe inside a quoted attribute.
fn attribute(text: &str) -> String {
escape(text).replace('"', "&quot;")
}
+215
View File
@@ -0,0 +1,215 @@
//! Where an exported page's pictures go.
use std::{
fs::File,
io,
path::{Path, PathBuf},
};
use base64::Engine as _;
use super::html::scheme;
/// Where pictures go.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Pictures {
/// Inside the page, as data.
Inline,
/// Copied into this new folder beside the page.
Folder(PathBuf),
/// Copied into this folder, in formats MuPDF reads.
Pdf(PathBuf),
}
/// Picture formats MuPDF reads from files.
const READABLE: [&str; 8] = ["png", "jpg", "jpeg", "gif", "bmp", "tif", "tiff", "svg"];
/// Inline pictures MuPDF reads.
const READABLE_DATA: [&str; 3] = [
"data:image/png;base64,",
"data:image/jpeg;base64,",
"data:image/gif;base64,",
];
pub(super) struct Placed<'a> {
beside: Option<&'a Path>,
pictures: &'a Pictures,
/// Pictures copied so far, and their names.
pub copied: Vec<(PathBuf, String)>,
/// This export made the picture folder.
pub made: bool,
}
impl<'a> Placed<'a> {
pub fn new(beside: Option<&'a Path>, pictures: &'a Pictures) -> Self {
Self {
beside,
pictures,
copied: Vec::new(),
made: false,
}
}
pub fn pdf(&self) -> bool {
matches!(self.pictures, Pictures::Pdf(_))
}
/// The `src` for `url`; none when it is not carried.
pub fn place(&mut self, url: &str) -> io::Result<Option<String>> {
let lower = url.to_lowercase();
if lower.starts_with("http://") || lower.starts_with("https://") {
return Ok((!self.pdf()).then(|| url.to_owned()));
}
if lower.starts_with("data:") {
let readable = match self.pdf() {
true => READABLE_DATA.iter().any(|kind| lower.starts_with(kind)),
false => lower.starts_with("data:image/"),
};
return Ok(readable.then(|| url.to_owned()));
}
let path = match (lower.starts_with("file://"), scheme(url)) {
(true, _) => file_path(&url["file://".len()..]),
// Other schemes are no picture of the document's.
(false, Some(_)) => return Ok(None),
(false, None) => {
let path = PathBuf::from(decoded(url));
match (path.is_absolute(), self.beside) {
(true, _) => path,
(false, Some(beside)) => beside.join(path),
(false, None) => return Ok(None),
}
}
};
self.carried(&path)
}
fn carried(&mut self, path: &Path) -> io::Result<Option<String>> {
let extension = path
.extension()
.map(|extension| extension.to_string_lossy().to_lowercase())
.unwrap_or_default();
let folder = match self.pictures {
Pictures::Inline => {
let Ok(bytes) = std::fs::read(path) else {
return Ok(None);
};
let data = base64::engine::general_purpose::STANDARD.encode(bytes);
return Ok(Some(format!("data:{};base64,{data}", mime(&extension))));
}
Pictures::Pdf(_) if !READABLE.contains(&extension.as_str()) => return Ok(None),
Pictures::Folder(folder) | Pictures::Pdf(folder) => folder.clone(),
};
let named = match self.copied.iter().find(|(from, _)| from == path) {
Some((_, name)) => name.clone(),
None => {
let Ok(mut source) = File::open(path) else {
return Ok(None);
};
if !source.metadata()?.is_file() {
return Ok(None);
}
let name = self.copy(&mut source, path, &folder)?;
self.copied.push((path.to_path_buf(), name.clone()));
name
}
};
let shelf = folder
.file_name()
.map_or_else(String::new, |name| name.to_string_lossy().into_owned());
Ok(Some(format!("{}/{}", encoded(&shelf), encoded(&named))))
}
/// Copies `source` under a name still free.
fn copy(&mut self, source: &mut File, path: &Path, folder: &Path) -> io::Result<String> {
if !self.made {
// A folder already there is someone's: refused.
std::fs::create_dir(folder).map_err(|error| match error.kind() {
io::ErrorKind::AlreadyExists => {
io::Error::new(error.kind(), folder.display().to_string())
}
_ => error,
})?;
self.made = true;
}
let file = path
.file_name()
.map_or_else(String::new, |name| name.to_string_lossy().into_owned());
let (stem, extension) = file.rsplit_once('.').unwrap_or((file.as_str(), ""));
for count in 1.. {
let name = match (count, extension) {
(1, _) => file.clone(),
(_, "") => format!("{stem}-{count}"),
_ => format!("{stem}-{count}.{extension}"),
};
// The file system judges which names are the same.
match File::create_new(folder.join(&name)) {
Ok(mut kept) => {
io::copy(source, &mut kept)?;
return Ok(name);
}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error),
}
}
unreachable!("names never run out")
}
}
fn mime(extension: &str) -> &'static str {
match extension {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"svg" => "image/svg+xml",
"webp" => "image/webp",
"bmp" => "image/bmp",
"tif" | "tiff" => "image/tiff",
"avif" => "image/avif",
_ => "application/octet-stream",
}
}
/// A `file:` address's path; a drive keeps no leading slash.
fn file_path(rest: &str) -> PathBuf {
let path = decoded(rest.strip_prefix("localhost").unwrap_or(rest));
let drive = path.len() > 2 && path.as_bytes()[2] == b':' && path.starts_with('/');
PathBuf::from(if drive { &path[1..] } else { &path })
}
/// `%XX` undone; bytes that are not UTF-8 stay as written.
fn decoded(url: &str) -> String {
let bytes = url.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut at = 0;
while at < bytes.len() {
let hex = bytes
.get(at + 1..at + 3)
.and_then(|pair| std::str::from_utf8(pair).ok());
match (
bytes[at],
hex.and_then(|pair| u8::from_str_radix(pair, 16).ok()),
) {
(b'%', Some(byte)) => {
out.push(byte);
at += 3;
}
(byte, _) => {
out.push(byte);
at += 1;
}
}
}
String::from_utf8(out).unwrap_or_else(|_| url.to_owned())
}
/// A name as one path segment of a URL.
fn encoded(name: &str) -> String {
name.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()
}
_ => format!("%{byte:02X}"),
})
.collect()
}
+63
View File
@@ -0,0 +1,63 @@
//! Markdown as plain text: words, code and TeX, no marks.
use std::io;
use ely_gpui_component::documents::markdown_options;
use markdown::mdast::Node;
/// `text` as plain text, a blank line between blocks.
pub fn plain(text: &str) -> io::Result<String> {
let tree = markdown::to_mdast(text, &markdown_options())
.map_err(|error| io::Error::other(error.to_string()))?;
let mut blocks = Vec::new();
for child in tree.children().into_iter().flatten() {
block(child, &mut blocks);
}
Ok(blocks.join("\n\n") + "\n")
}
fn block(node: &Node, blocks: &mut Vec<String>) {
match node {
Node::Code(code) => blocks.push(code.value.clone()),
Node::Math(math) => blocks.push(math.value.clone()),
Node::List(list) => {
let items = list.children.iter().map(|item| {
let mut inner = Vec::new();
for child in item.children().into_iter().flatten() {
block(child, &mut inner);
}
inner.join("\n")
});
blocks.push(items.collect::<Vec<_>>().join("\n"));
}
Node::Table(table) => {
let rows = table.children.iter().map(|row| {
let cells = row.children().into_iter().flatten().map(words);
cells.collect::<Vec<_>>().join("\t")
});
blocks.push(rows.collect::<Vec<_>>().join("\n"));
}
Node::Blockquote(_) | Node::FootnoteDefinition(_) => {
for child in node.children().into_iter().flatten() {
block(child, blocks);
}
}
Node::Paragraph(_) | Node::Heading(_) => blocks.push(words(node)),
// Front matter, HTML, rules and definitions are not words.
_ => {}
}
}
/// The words of an inline run.
fn words(node: &Node) -> String {
match node {
Node::Text(text) => text.value.clone(),
Node::InlineCode(code) => code.value.clone(),
Node::InlineMath(math) => math.value.clone(),
Node::Break(_) => "\n".into(),
Node::Image(image) => image.alt.clone(),
Node::ImageReference(image) => image.alt.clone(),
Node::Html(_) => String::new(),
_ => node.children().into_iter().flatten().map(words).collect(),
}
}
+466
View File
@@ -0,0 +1,466 @@
use std::path::Path;
use super::{Pictures, body, document, plain};
fn page(text: &str, beside: Option<&Path>, pictures: &Pictures) -> super::Page {
body(text, beside, pictures).expect("a page")
}
/// A 1×1 PNG, as base64.
const DOT: &str =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2wAAAAAElFTkSuQmCC";
#[test]
fn formulas_and_diagrams_draw_as_svg() {
let text =
"Inline $x^2$ here.\n\n$$\n\\sqrt{y}\n$$\n\n```mermaid\nflowchart LR\n A --> B\n```\n";
let page = page(text, None, &Pictures::Inline);
assert_eq!(page.html.matches("<svg").count(), 3, "{}", page.html);
assert!(
page.html.contains("<span class=\"math\"><svg")
&& page.html.contains("<div class=\"diagram\"><svg")
);
assert!(page.formulas.is_empty() && page.diagrams.is_empty());
}
#[test]
fn what_does_not_draw_stays_as_code_at_its_own_line() {
let text = "$$\n\\frac{\n$$\n\n$$\n\\frac{\n$$\n\nText $\\sqrt{$ more.\n\n```mermaid\nflowchart LR\n A -->\n```\n\n```mermaid\nflowchart LR\n A[\u{F0000}] --> B\n```\n";
let page = page(text, None, &Pictures::Inline);
let lines: Vec<usize> = page.formulas.iter().map(|(line, _)| *line).collect();
assert_eq!(lines, [1, 5, 9]);
assert!(page.formulas.iter().all(|(_, why)| !why.is_empty()));
assert_eq!(page.diagrams, [11, 16]);
assert!(
page.html
.contains("<pre><code class=\"language-math\">\\frac{"),
"{}",
page.html
);
assert!(page.html.contains("<code>\\sqrt{</code>"), "{}", page.html);
assert!(
page.html
.contains("<pre><code class=\"language-mermaid\">flowchart LR"),
"{}",
page.html
);
}
#[test]
fn markdown_writes_as_html() {
let text = "# T\n\n- [x] done\n- b\n\n3. c\n\n| l | r |\n|:--|--:|\n| 1 | 2 |\n\n~~gone~~ [a](https://x.org \"t\") [r][ref] [bad](javascript:alert(1))\n\nNote[^1].\n\n[^1]: The note.\n\n[ref]: /docs\n";
let html = page(text, None, &Pictures::Inline).html;
for part in [
"<h1>T</h1>",
"<li><input type=\"checkbox\" disabled checked> done</li>",
"<ol start=\"3\">",
"<th style=\"text-align:left\">l</th>",
"<td style=\"text-align:right\">2</td>",
"<del>gone</del>",
"<a href=\"https://x.org\" title=\"t\">a</a>",
"<a href=\"/docs\">r</a>",
"<a>bad</a>",
"<sup><a href=\"#fn-1\">1</a></sup>",
"<li id=\"fn-1\"><p>The note.</p></li>",
] {
assert!(html.contains(part), "{part} in {html}");
}
}
#[test]
fn front_matter_and_raw_html_do_not_go_live() {
let page = page(
"---\ntitle: Secret\n---\n\n<script>alert(1)</script>\n\nText.\n",
None,
&Pictures::Inline,
);
assert!(!page.html.contains("Secret"), "{}", page.html);
assert!(!page.html.contains("<script>"), "{}", page.html);
assert!(page.html.contains("<p>Text.</p>"));
}
#[test]
fn inline_pictures_are_data_and_addresses_stay_as_written() {
let dir = tempfile::tempdir().expect("a dir");
std::fs::write(dir.path().join("dot.png"), b"png bytes").expect("writes");
let text = format!(
"![a](dot.png) ![b](gone.png) ![c](https://example.com/a%2Fb.png?q=a%26b&x=1) ![d](data:image/png;base64,{DOT})\n"
);
let page = page(&text, Some(dir.path()), &Pictures::Inline);
assert!(
page.html
.contains("src=\"data:image/png;base64,cG5nIGJ5dGVz\""),
"{}",
page.html
);
assert!(
page.html
.contains("src=\"https://example.com/a%2Fb.png?q=a%26b&amp;x=1\""),
"{}",
page.html
);
assert!(
page.html
.contains(&format!("src=\"data:image/png;base64,{DOT}\"")),
"{}",
page.html
);
assert!(
page.html.contains("<img src=\"gone.png\" alt=\"b\">"),
"{}",
page.html
);
assert_eq!(page.missing, ["gone.png"]);
}
#[test]
fn folder_pictures_copy_once_under_names_of_their_own() {
let dir = tempfile::tempdir().expect("a dir");
for (folder, name) in [("one", "image.png"), ("two", "IMAGE.PNG")] {
std::fs::create_dir(dir.path().join(folder)).expect("makes");
std::fs::write(dir.path().join(folder).join(name), folder).expect("writes");
}
let shelf = dir.path().join("note#1_files");
let text = "![a](one/image.png) ![b](two/IMAGE.PNG) ![c](one/image.png)\n";
let page = page(text, Some(dir.path()), &Pictures::Folder(shelf.clone()));
let mut held: Vec<Vec<u8>> = std::fs::read_dir(&shelf)
.expect("the folder")
.map(|entry| std::fs::read(entry.expect("an entry").path()).expect("reads"))
.collect();
held.sort();
assert_eq!(
held,
[b"one".to_vec(), b"two".to_vec()],
"no picture overwrote another"
);
assert_eq!(
page.html
.matches("src=\"note%231_files/image.png\"")
.count(),
2,
"{}",
page.html
);
}
#[test]
fn a_folder_already_there_is_refused() {
let dir = tempfile::tempdir().expect("a dir");
std::fs::write(dir.path().join("dot.png"), b"p").expect("writes");
let shelf = dir.path().join("taken");
std::fs::create_dir(&shelf).expect("makes");
let error = body(
"![a](dot.png)\n",
Some(dir.path()),
&Pictures::Folder(shelf),
)
.expect_err("refused");
assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists);
}
#[test]
fn a_pdf_leaves_out_what_mupdf_cannot_read_as_words() {
let dir = tempfile::tempdir().expect("a dir");
std::fs::write(dir.path().join("a.webp"), b"w").expect("writes");
let text = "![first](a.webp) ![second](https://example.com/c.png) ![third](gone.png)\n";
let page = page(
text,
Some(dir.path()),
&Pictures::Pdf(dir.path().join("files")),
);
assert_eq!(
page.missing,
["a.webp", "https://example.com/c.png", "gone.png"]
);
assert!(!page.html.contains("<img"), "{}", page.html);
assert!(
page.html.contains("[first]") && page.html.contains("[third]"),
"{}",
page.html
);
}
#[test]
fn other_schemes_and_untitled_pictures_are_missing() {
let page = page("![a](a.png) ![b](javascript:x)\n", None, &Pictures::Inline);
assert_eq!(page.missing, ["a.png", "javascript:x"]);
}
#[test]
fn a_file_address_reads_its_path() {
let dir = tempfile::tempdir().expect("a dir");
let picture = dir.path().join("d o t.png");
std::fs::write(&picture, b"png").expect("writes");
let path = picture
.to_string_lossy()
.replace('\\', "/")
.replace(' ', "%20");
let url = if path.starts_with('/') {
format!("file://{path}")
} else {
format!("file:///{path}")
};
let page = page(&format!("![a](<{url}>)\n"), None, &Pictures::Inline);
assert!(page.missing.is_empty(), "{:?}", page.missing);
assert!(
page.html.contains("data:image/png;base64,cG5n"),
"{}",
page.html
);
}
#[test]
fn a_document_wraps_the_body_and_escapes_its_title() {
let html = document("a<b & c", "<p>x</p>");
assert!(html.starts_with("<!doctype html>"));
assert!(html.contains("<title>a&lt;b &amp; c</title>") && html.contains("<p>x</p>"));
}
#[test]
fn plain_text_keeps_words_code_and_tex() {
let text = "---\ntitle: x\n---\n\n# Head *one*\n\n- a\n- b `c`\n\n| h | i |\n|---|---|\n| 1 | 2 |\n\n$$\n\\sqrt{x}\n$$\n\n> quoted $y$ ![a cat][cat]\n\n[cat]: pic.png\n";
assert_eq!(
plain(text).expect("text"),
"Head one\n\na\nb c\n\nh\ti\n1\t2\n\n\\sqrt{x}\n\nquoted y a cat\n"
);
}
mod files {
use std::path::Path;
use super::super::{Format, Target, write};
use super::DOT;
fn target<'a>(path: &'a Path, work: &'a Path) -> Target<'a> {
Target {
path,
session: None,
revision: 1,
work,
}
}
#[test]
fn a_page_carries_its_pictures_inside() {
let dir = tempfile::tempdir().expect("a dir");
std::fs::write(dir.path().join("dot.png"), b"png").expect("writes");
let out = dir.path().join("out.html");
write(
Format::Page,
"# Hi\n\n![d](dot.png)\n",
Some(dir.path()),
&target(&out, dir.path()),
)
.expect("writes");
let html = std::fs::read_to_string(&out).expect("reads");
assert!(html.starts_with("<!doctype html>") && html.contains("<title>out</title>"));
assert!(html.contains("data:image/png;base64,"), "{html}");
}
#[test]
fn a_linked_page_copies_into_a_new_folder_only() {
let dir = tempfile::tempdir().expect("a dir");
std::fs::write(dir.path().join("dot.png"), b"png").expect("writes");
let out = dir.path().join("out.html");
write(
Format::Linked,
"![d](dot.png)\n",
Some(dir.path()),
&target(&out, dir.path()),
)
.expect("writes");
assert_eq!(
std::fs::read(dir.path().join("out_files/dot.png")).expect("copied"),
b"png"
);
assert!(
std::fs::read_to_string(&out)
.expect("reads")
.contains("src=\"out_files/dot.png\"")
);
std::fs::remove_file(&out).expect("removes");
let error = write(
Format::Linked,
"![d](dot.png)\n",
Some(dir.path()),
&target(&out, dir.path()),
)
.expect_err("the folder is there");
assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists);
assert!(error.to_string().contains("out_files"), "{error}");
assert!(!out.exists(), "nothing written");
assert!(
dir.path().join("out_files/dot.png").exists(),
"the folder found stays"
);
}
#[test]
fn a_linked_page_without_pictures_makes_no_folder() {
let dir = tempfile::tempdir().expect("a dir");
let out = dir.path().join("out.html");
write(
Format::Linked,
"Text.\n",
Some(dir.path()),
&target(&out, dir.path()),
)
.expect("writes");
assert!(!dir.path().join("out_files").exists());
}
#[test]
fn text_is_plain_and_the_open_file_is_never_written() {
let dir = tempfile::tempdir().expect("a dir");
let out = dir.path().join("doc.md");
std::fs::write(&out, "# Kept\n").expect("writes");
let mut aimed = target(&out, dir.path());
aimed.session = Some(&out);
write(Format::Text, "# Other\n", None, &aimed).expect_err("refused");
assert_eq!(std::fs::read_to_string(&out).expect("reads"), "# Kept\n");
let txt = dir.path().join("doc.txt");
write(
Format::Text,
"# Title *here*\n",
None,
&target(&txt, dir.path()),
)
.expect("writes");
assert_eq!(
std::fs::read_to_string(&txt).expect("reads"),
"Title here\n"
);
}
/// The text of a PDF's first page.
fn words(pdf: &Path) -> String {
let mut worker = ely_pdf_engine::Worker::spawn().expect("the worker");
let doc = worker.open(pdf).expect("opens").doc;
worker
.text(doc, 0)
.expect("text")
.iter()
.map(|ch| ch.c)
.collect()
}
#[test]
fn a_pdf_sets_the_page_and_leaves_no_scratch() {
let dir = tempfile::tempdir().expect("a dir");
let work = tempfile::tempdir().expect("work");
let out = dir.path().join("out.pdf");
let text = format!(
"# Heading 标题\n\nInline $x^2$.\n\n![dot](data:image/png;base64,{DOT}) ![lost](gone.png)\n\n```mermaid\nflowchart LR\n A --> B\n```\n"
);
let page = write(
Format::Pdf,
&text,
Some(dir.path()),
&target(&out, work.path()),
)
.expect("writes");
assert_eq!(page.missing, ["gone.png"]);
assert!(page.formulas.is_empty() && page.diagrams.is_empty());
assert_eq!(
std::fs::read_dir(work.path()).expect("work").count(),
0,
"the scratch is gone"
);
let words = words(&out);
assert!(
words.contains("Heading") && words.contains("标题") && words.contains("[lost]"),
"{words:?}"
);
}
#[test]
fn pdfs_at_once_keep_to_their_own_pages() {
let (dir, work) = (
tempfile::tempdir().expect("a dir"),
tempfile::tempdir().expect("work"),
);
let jobs: Vec<_> = (0..4)
.map(|n| {
let (out, work) = (
dir.path().join(format!("{n}.pdf")),
work.path().to_path_buf(),
);
std::thread::spawn(move || {
write(
Format::Pdf,
&format!("# Page{n}\n"),
None,
&target(&out, &work),
)
.expect("writes");
out
})
})
.collect();
for (n, job) in jobs.into_iter().enumerate() {
let out = job.join().expect("the job");
assert!(words(&out).contains(&format!("Page{n}")), "job {n}");
}
}
}
#[test]
fn a_disguised_script_link_does_not_follow() {
let text = "[a](< javascript:alert(1)>) [b](java&#x09;script:alert(1)) [c](java&#10;script:alert(1)) [d](< https://x.org>)\n";
let html = page(text, None, &Pictures::Inline).html;
assert_eq!(html.matches("<a>").count(), 3, "{html}");
assert!(html.contains("href=\" https://x.org\""), "{html}");
}
#[test]
fn a_failed_linked_page_takes_the_folder_it_made() {
let dir = tempfile::tempdir().expect("a dir");
std::fs::write(dir.path().join("dot.png"), b"png").expect("writes");
// A folder in the way: no page.
let out = dir.path().join("out.html");
std::fs::create_dir(&out).expect("makes");
let target = super::Target {
path: &out,
session: None,
revision: 1,
work: dir.path(),
};
super::write(
super::Format::Linked,
"![d](dot.png)\n",
Some(dir.path()),
&target,
)
.expect_err("fails");
assert!(
!dir.path().join("out_files").exists(),
"the folder it made is gone"
);
}
#[test]
fn a_formula_letter_no_font_has_stays_as_tex() {
let page = page(
"Before\n\n$\text{\u{F0000}}$ after.\n",
None,
&Pictures::Inline,
);
let lines: Vec<usize> = page.formulas.iter().map(|(line, _)| *line).collect();
assert_eq!(lines, [3]);
assert!(
page.html.contains("<code>\text{\u{F0000}}</code>"),
"{}",
page.html
);
}
#[test]
fn a_large_document_exports_whole() {
// Long paragraphs: markdown-rs slows with the block count.
let words = "word ".repeat(200);
let mut text: String = (0..2_200).map(|n| format!("Paragraph {n}: {words}\n\n")).collect();
text.push_str("The last line.\n");
assert!(text.len() > ely_gpui_component::documents::LARGEST_READ);
assert!(plain(&text).expect("text").ends_with("The last line.\n"));
assert!(page(&text, None, &Pictures::Inline).html.ends_with("<p>The last line.</p>"));
}
+90 -6
View File
@@ -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>,
@@ -51,8 +51,23 @@ pub struct Extra {
/// The last recovery point's mark, and one in flight.
pub pointed: Option<String>,
pub pointing: bool,
/// When a point last landed, and the failure last shown.
pub pointed_at: Option<std::time::SystemTime>,
pub point_failed: Option<std::io::ErrorKind>,
/// A kept tab's mode, place and text came back once.
pub restored: bool,
/// Disk reads started; only the newest lands.
pub reads: u64,
/// The writing view reached its document.
pub looked: bool,
}
impl Extra {
/// Autosave from scratch; a write in flight no longer counts.
pub fn restart_autosave(&mut self) {
self.policy = PolicyEngine::new(Policy::MarkAutoSave, true);
self.writing = None;
}
}
impl Default for Extra {
@@ -67,7 +82,11 @@ impl Default for Extra {
slot: String::new(),
pointed: None,
pointing: false,
pointed_at: None,
point_failed: None,
restored: false,
reads: 0,
looked: false,
}
}
}
@@ -191,13 +210,18 @@ impl Desk {
/// Changed: a clean tab follows, a dirty one asks.
fn read_disk(&mut self, id: u64, path: PathBuf, cx: &mut Context<Self>) {
let Some(tab) = self.tab_mut(id) else {
return;
};
tab.extra.reads += 1;
let number = tab.extra.reads;
let reading = path.clone();
cx.spawn(async move |desk, cx| {
let read = cx
.background_executor()
.spawn(async move { read_still(&reading) })
.await;
desk.update(cx, |desk, cx| desk.disk_read(id, path, read, cx))
desk.update(cx, |desk, cx| desk.disk_read(id, path, number, read, cx))
.ok();
})
.detach();
@@ -207,21 +231,37 @@ impl Desk {
&mut self,
id: u64,
path: PathBuf,
number: u64,
read: io::Result<(Fingerprint, Source)>,
cx: &mut Context<Self>,
) {
let Some(tab) = self.tab(id).filter(|tab| tab.session.path() == Some(&path)) else {
return log::info!("mark: a read for a moved or closed tab was dropped");
};
if tab.extra.reads != number {
return log::info!("mark: read {number} of tab {id} lapsed");
}
let (fingerprint, disk) = match read {
Ok(read) => read,
Err(error) => return self.set_outside(id, Outside::Unread(error.to_string()), cx),
};
// The disk moved on since the read: look again.
if Fingerprint::of(&path).ok() != Some(fingerprint) {
log::info!("mark: tab {id}'s file changed while read");
return self.look(id, cx);
}
let current = tab.doc.lock().expect("the text lock").current.clone();
let kept = disk.text == tab.extra.base;
let dirty = tab.dirty(cx);
log::info!("mark: {} changed outside; unsaved {dirty}", path.display());
match (disk.text == current, dirty) {
(true, _) | (false, false) => self.take_disk(id, disk, fingerprint, cx),
// The same text: only the file underneath is new.
(true, _) => {
self.adopt_disk(id, &disk, fingerprint, cx);
}
// The text as last saved: the edits stay unsaved.
(false, true) if kept => self.keep_edits(id, disk, fingerprint, cx),
(false, false) => self.take_disk(id, disk, fingerprint, cx),
(false, true) => {
let tab = self.tab_mut(id).expect("open");
tab.session.external_modified();
@@ -247,14 +287,50 @@ impl Desk {
fingerprint: Fingerprint,
cx: &mut Context<Self>,
) {
if let Some(view) = self.adopt_disk(id, &disk, fingerprint, cx) {
view.update(cx, |view, cx| view.refresh(disk, cx));
}
}
/// A new file with the saved text; edits stay.
fn keep_edits(&mut self, id: u64, disk: Source, fingerprint: Fingerprint, cx: &mut Context<Self>) {
let Some(tab) = self.tab_mut(id) else {
return;
};
tab.session.rebase(fingerprint);
tab.doc.lock().expect("the text lock").source = disk;
// A conflict stopped autosave, maybe mid-write; it resumes.
tab.extra.restart_autosave();
let (registry, path) = (tab.registry, tab.session.path().map(Path::to_path_buf));
if let (Some(registry), Some(path)) = (registry, path)
&& let Err(error) = self.core.sessions.track(registry, &path)
{
log::warn!("mark: the replaced file did not register: {error}");
}
self.set_outside(id, Outside::Clear, cx);
self.save_after_pause(id, cx);
}
/// The tab and its view take the file as saved.
pub(crate) fn adopt_disk(
&mut self,
id: u64,
disk: &Source,
fingerprint: Fingerprint,
cx: &mut Context<Self>,
) -> Option<gpui::Entity<crate::view::MarkView>> {
let tab = self.tab_mut(id)?;
if let Err(error) = tab.session.reload_at(fingerprint) {
return self.set_outside(id, Outside::Unread(error.to_string()), cx);
self.set_outside(id, Outside::Unread(error.to_string()), cx);
return None;
}
tab.extra.base = disk.text.clone();
tab.extra.policy = PolicyEngine::new(Policy::MarkAutoSave, true);
// The file holds the disk's text: it is saved.
let saved = crate::engine::mark(&disk.text);
tab.mark = saved.clone();
tab.extra.restart_autosave();
// Its bytes (BOM, line ends) are what saves keep now.
tab.doc.lock().expect("the text lock").source = disk.clone();
let (view, registry) = (tab.view.clone(), tab.registry);
let path = tab.session.path().map(Path::to_path_buf);
// An atomic replace is a new file underneath.
@@ -263,8 +339,9 @@ impl Desk {
{
log::warn!("mark: the replaced file did not register: {error}");
}
view.update(cx, |view, cx| view.refresh(disk, cx));
self.set_outside(id, Outside::Clear, cx);
view.update(cx, |view, cx| view.saved(&saved, cx));
Some(view)
}
pub fn set_outside(&mut self, id: u64, outside: Outside, cx: &mut Context<Self>) {
@@ -320,6 +397,10 @@ impl Desk {
fn watch(dirs: &[PathBuf], cx: &mut Context<Desk>) -> notify::Result<RecommendedWatcher> {
let (sender, mut changes) = mpsc::unbounded();
let mut watcher = notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
// Reads, ours included, are no change.
if event.as_ref().is_ok_and(|event| ely_ui::watching::only_read(&event.kind)) {
return;
}
let _ = sender.unbounded_send(event.map(|_| ()).map_err(|error| error.to_string()));
})?;
for dir in dirs {
@@ -345,3 +426,6 @@ fn watch(dirs: &[PathBuf], cx: &mut Context<Desk>) -> notify::Result<Recommended
log::info!("mark: watching {} folders", dirs.len());
Ok(watcher)
}
#[cfg(test)]
pub(crate) mod tests;
+182
View File
@@ -0,0 +1,182 @@
use std::{
path::PathBuf,
time::{Duration, Instant},
};
use ely_files::Action;
use ely_office::prelude::*;
use gpui::{Entity, TestAppContext, VisualTestContext};
use super::{Extra, GAP, Outside};
use crate::desk::{
Desk,
tests::{open, start},
};
#[test]
fn a_restarted_autosave_saves_the_next_edit() {
let mut extra = Extra::default();
extra.policy.set_has_path();
extra.policy.edit(Instant::now());
// A write was in flight when the disk changed.
extra.writing = Some(1);
extra.restart_autosave();
// As autosave settles a heard write, then edits.
if let Some(revision) = extra.writing.take() {
extra.policy.save_finished(revision, true);
}
let now = Instant::now();
extra.policy.edit(now);
assert!(matches!(
extra.policy.tick(now + Duration::from_secs(5)),
Action::SaveDue(_)
));
}
/// A saved file open: its desk, tab and path.
pub(crate) fn opened(
text: &str,
cx: &mut TestAppContext,
) -> (
Entity<Desk>,
&'static mut VisualTestContext,
u64,
PathBuf,
[tempfile::TempDir; 2],
) {
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
let path = files.path().join("a.md");
std::fs::write(&path, text).expect("a file");
let (desk, cx) = start(cx, data.path());
open(&desk, &path, cx);
let id = desk.read_with(cx, |desk, _| desk.core.active.expect("a tab"));
(desk, cx, id, path, [data, files])
}
/// The desk looks at the file, as the watcher would.
pub(crate) fn look(desk: &Entity<Desk>, id: u64, cx: &mut VisualTestContext) {
desk.update(cx, |desk, cx| desk.look(id, cx));
cx.run_until_parked();
cx.executor().advance_clock(GAP * 2);
cx.run_until_parked();
}
pub(crate) fn shown(desk: &Entity<Desk>, cx: &mut VisualTestContext) -> String {
desk.read_with(cx, |desk, cx| {
let view = desk.active_view().expect("a tab");
view.read(cx).document().read(cx).text(cx)
})
}
#[gpui::test]
fn a_clean_tab_takes_the_disk_and_keeps_its_place(cx: &mut TestAppContext) {
let (desk, cx, id, path, _dirs) = opened("one\n\ntwo\n\nthree\n", cx);
let view = desk.read_with(cx, |desk, _| desk.active_view().expect("a tab"));
let document = view.read_with(cx, |view, _| view.document().clone());
document.update(cx, |document, cx| document.set_caret(4, 2, cx));
std::fs::write(&path, "uno\n\ndos\n\ntres, longer\n").expect("an outside write");
look(&desk, id, cx);
assert_eq!(shown(&desk, cx), "uno\n\ndos\n\ntres, longer\n");
assert!(!view.read_with(cx, |view, _| view.modified()));
assert_eq!(
document.read_with(cx, |document, cx| document.caret(cx)),
(4, 2)
);
let focus = view.read_with(cx, |view, _| view.focus.clone());
cx.update(|window, cx| window.focus(&focus, cx));
cx.run_until_parked();
cx.simulate_keystrokes("secondary-z");
assert_eq!(
shown(&desk, cx),
"uno\n\ndos\n\ntres, longer\n",
"undo stops at the disk"
);
}
#[gpui::test]
fn a_deleted_file_keeps_its_text_and_says_so(cx: &mut TestAppContext) {
let (desk, cx, id, path, _dirs) = opened("kept\n", cx);
std::fs::remove_file(&path).expect("deleted");
look(&desk, id, cx);
let deleted = desk.read_with(cx, |desk, _| {
matches!(desk.tab(id).expect("open").extra.outside, Outside::Deleted)
});
assert!(deleted);
assert_eq!(shown(&desk, cx), "kept\n");
}
#[gpui::test]
fn a_renamed_file_is_followed(cx: &mut TestAppContext) {
let (desk, cx, id, path, _dirs) = opened("moved\n", cx);
let to = path.with_file_name("b.md");
std::fs::rename(&path, &to).expect("renamed");
look(&desk, id, cx);
let now = desk.read_with(cx, |desk, _| {
desk.tab(id)
.and_then(|tab| tab.session.path().map(PathBuf::from))
});
assert_eq!(
now.map(|path| path.file_name().map(ToOwned::to_owned)),
Some(Some("b.md".into()))
);
}
#[gpui::test]
fn a_path_that_cannot_be_read_says_why(cx: &mut TestAppContext) {
let (desk, cx, id, path, _dirs) = opened("text\n", cx);
std::fs::remove_file(&path).expect("removed");
std::fs::create_dir(&path).expect("a folder in its place");
look(&desk, id, cx);
let unread = desk.read_with(cx, |desk, _| {
matches!(
desk.tab(id).expect("open").extra.outside,
Outside::Unread(_)
)
});
assert!(unread);
}
#[gpui::test]
fn a_replace_that_keeps_the_time_still_reloads(cx: &mut TestAppContext) {
let (desk, cx, id, path, _dirs) = opened("old text\n", cx);
let modified = std::fs::metadata(&path)
.expect("meta")
.modified()
.expect("a time");
let next = path.with_extension("new");
std::fs::write(&next, "new text\n").expect("a new file");
let file = std::fs::File::options()
.write(true)
.open(&next)
.expect("open");
file.set_modified(modified).expect("the old time");
drop(file);
std::fs::rename(&next, &path).expect("a replace");
look(&desk, id, cx);
assert_eq!(shown(&desk, cx), "new text\n");
}
#[gpui::test]
fn a_stale_read_never_lands(cx: &mut TestAppContext) {
let (desk, cx, id, path, _dirs) = opened("first\n", cx);
std::fs::write(&path, "older\n").expect("an older write");
let older = (
ely_files::Fingerprint::of(&path).expect("its fingerprint"),
crate::text::Source::decode(b"older\n".to_vec()).expect("reads"),
);
std::fs::write(&path, "newest disk\n").expect("a newer write");
look(&desk, id, cx);
assert_eq!(shown(&desk, cx), "newest disk\n");
let reads = desk.read_with(cx, |desk, _| desk.tab(id).expect("open").extra.reads);
for number in [reads - 1, reads] {
let (path, older) = (path.clone(), (older.0, older.1.clone()));
desk.update(cx, |desk, cx| {
desk.disk_read(id, path, number, Ok(older), cx)
});
cx.run_until_parked();
assert_eq!(shown(&desk, cx), "newest disk\n", "read {number}");
}
}
-92
View File
@@ -1,92 +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::json;
/// The app's Resources, else this build's `engines/content`.
pub fn content() -> Result<PathBuf> {
let bundled = std::env::current_exe()?
.parent()
.and_then(|dir| Some(dir.parent()?.join("Resources/content")));
let built = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../engines/content/ely-mark");
for dir in bundled.into_iter().chain([built]) {
if dir.join("index.html").is_file() {
log::info!("mark: page from {}", dir.display());
return Ok(dir);
}
}
bail!("ElyMark's page is not built; run scripts/build-mark-content.sh")
}
/// How the page shows a document (MARK-02).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Mode {
Visual,
Source,
Preview,
}
impl Mode {
pub const ALL: [Mode; 3] = [Mode::Visual, Mode::Source, Mode::Preview];
pub fn name(self) -> &'static str {
match self {
Mode::Visual => "visual",
Mode::Source => "source",
Mode::Preview => "preview",
}
}
pub fn parse(name: &str) -> Option<Mode> {
Mode::ALL.into_iter().find(|mode| mode.name() == name)
}
}
/// Shows `markdown` as `mode` after load `load`, maybe placed.
pub fn open(
view: &WebView,
markdown: &str,
(mode, load, place): (Mode, u64, Option<crate::view::Place>),
folder: &Path,
cx: &App,
) {
let text = |key| ely_ui::i18n::text(cx, key, &[]).to_string();
let texts = json!({
"searchLanguage": text("mark.search_language"),
"noResult": text("mark.no_result"),
"copy": text("mark.copy"),
"preview": text("mark.preview"),
"loading": text("mark.loading"),
"edit": text("mark.edit"),
"hide": text("mark.hide"),
});
// Relative links and images resolve in the document's folder.
let base = format!("{}/", ely_web_content::file_url(folder));
view.run_js(&format!(
"ely.open({}, '{}', {texts}, {load}, {}, {})",
json!(markdown),
mode.name(),
json!(base),
json!(place)
));
}
/// The same text in another mode; pending edits go first.
pub fn switch(view: &WebView, mode: Mode) {
view.run_js(&format!("ely.mode('{}')", mode.name()));
}
/// The folder for relative links and images.
pub fn folder(view: &WebView, folder: &Path) {
let base = format!("{}/", ely_web_content::file_url(folder));
view.run_js(&format!("ely.base({})", json!(base)));
}
/// Calls `ely.<name>(argument)` in the page.
pub fn call(view: &WebView, name: &str, argument: &serde_json::Value) {
view.run_js(&format!("ely.{name}({argument})"));
}
-229
View File
@@ -1,229 +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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
/// `%XX` decoded, as links to files are written.
fn decoded(text: &str) -> String {
let bytes = text.as_bytes();
let (mut out, mut at) = (Vec::with_capacity(bytes.len()), 0);
while at < bytes.len() {
let hex = bytes
.get(at + 1..at + 3)
.and_then(|pair| u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok());
match (bytes[at], hex) {
(b'%', Some(byte)) => (out.push(byte), at += 3),
(byte, _) => (out.push(byte), at += 1),
};
}
String::from_utf8_lossy(&out).into_owned()
}
/// One path segment for a URL: reserved bytes percent-escaped.
pub fn segment(name: &str) -> String {
let mut out = String::new();
for byte in name.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(byte as char)
}
_ => out.push_str(&format!("%{byte:02X}")),
}
}
out
}
/// A file image's path; web links stay as they are.
fn local(src: &str, folder: &Path) -> Option<PathBuf> {
if src.starts_with("data:") || src.starts_with('#') {
return None;
}
if let Some(path) = src.strip_prefix("file://") {
return Some(PathBuf::from(decoded(path)));
}
if src.contains("://") {
return None;
}
let path = PathBuf::from(decoded(src.split(['?', '#']).next().unwrap_or(src)));
Some(if path.is_absolute() {
path
} else {
folder.join(path)
})
}
fn kind(path: &Path) -> &'static str {
let extension = path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or_default();
match extension.to_lowercase().as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"svg" => "image/svg+xml",
"bmp" => "image/bmp",
"avif" => "image/avif",
_ => "application/octet-stream",
}
}
/// The page's HTML; image `src` values replaced by numbered tokens.
#[derive(Clone, Debug, Default)]
pub struct Marked {
pub html: String,
/// The original `src` values, decoded, in order.
pub images: Vec<String>,
pub token: String,
}
impl Marked {
/// From the page's answer; anything missing is an error.
pub fn read(rendered: &serde_json::Value) -> std::io::Result<Marked> {
let bad = || std::io::Error::other("the page's render lacks its parts");
let html = rendered["html"].as_str().ok_or_else(bad)?.to_owned();
let token = rendered["token"].as_str().ok_or_else(bad)?.to_owned();
let images = rendered["images"].as_array().ok_or_else(bad)?;
let images = images
.iter()
.map(|src| src.as_str().map(str::to_owned).ok_or_else(bad));
let images = images.collect::<std::io::Result<_>>()?;
Ok(Marked {
html,
images,
token,
})
}
/// Each mark swapped for `each` of its file; unread listed.
fn rewrite(
&self,
folder: &Path,
mut each: impl FnMut(&Path) -> Option<String>,
) -> (String, Vec<String>) {
let (mut html, mut missing) = (self.html.clone(), Vec::new());
for (n, src) in self.images.iter().enumerate() {
let new = match local(src, folder) {
None => src.clone(),
Some(path) => each(&path).unwrap_or_else(|| {
missing.push(src.clone());
src.clone()
}),
};
// Quoted, so mark 1 never matches inside mark 10.
let mark = format!("\"{}{n}\"", self.token);
html = html.replacen(&mark, &format!("\"{}\"", escape(&new)), 1);
}
(html, missing)
}
/// Images as data URLs: one file carries all (self-contained).
pub fn inline(&self, folder: &Path) -> (String, Vec<String>) {
self.rewrite(folder, |path| {
let bytes = std::fs::read(path).ok()?;
let data = base64::engine::general_purpose::STANDARD.encode(bytes);
Some(format!("data:{};base64,{data}", kind(path)))
})
}
/// Images linked into the `assets` folder; and the copies.
pub fn linked(
&self,
folder: &Path,
assets: &str,
) -> (String, Vec<(PathBuf, String)>, Vec<String>) {
let mut copies: Vec<(PathBuf, String)> = Vec::new();
let (html, missing) = self.rewrite(folder, |path| {
if !path.is_file() {
return None;
}
if let Some((_, name)) = copies.iter().find(|(from, _)| from == path) {
return Some(format!("{}/{}", segment(assets), segment(name)));
}
let file = path.file_name()?.to_string_lossy().into_owned();
// Same names from different folders stay apart.
let name = format!("{}-{file}", copies.len() + 1);
let url = format!("{}/{}", segment(assets), segment(&name));
copies.push((path.to_path_buf(), name));
Some(url)
});
(html, copies, missing)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// HTML as the page marks it.
fn marked(images: &[&str]) -> Marked {
let token = "ely-image-t-".to_owned();
let html = (0..images.len())
.map(|n| format!("<img alt=\"a > b src='x'\" src=\"{token}{n}\">"))
.collect();
Marked {
html,
images: images.iter().map(|src| src.to_string()).collect(),
token,
}
}
#[test]
fn images_inline_and_links_stay() {
let dir = tempfile::tempdir().expect("a temp dir");
std::fs::create_dir(dir.path().join("assets")).expect("assets");
std::fs::write(dir.path().join("assets/a b.png"), b"png").expect("an image");
let page = marked(&["assets/a%20b.png", "https://e.x/y.png", "gone.png"]);
let (out, missing) = page.inline(dir.path());
assert!(out.contains(r#"src="data:image/png;base64,cG5n""#), "{out}");
assert!(out.contains(r#"src="https://e.x/y.png""#));
assert!(
out.contains(r#"alt="a > b src='x'""#),
"alt text stays: {out}"
);
assert_eq!(missing, vec!["gone.png".to_string()]);
}
#[test]
fn linked_images_copy_once_under_encoded_names() {
let dir = tempfile::tempdir().expect("a temp dir");
std::fs::write(dir.path().join("a#b.png"), b"1").expect("an image");
let mut page = marked(&["a%23b.png"; 11]);
page.images[10] = "a%23b.png".into();
let (out, copies, missing) = page.linked(dir.path(), "report#1_files");
assert_eq!(
out.matches("src=\"report%231_files/1-a%23b.png\"").count(),
11,
"{out}"
);
assert_eq!(copies.len(), 1);
assert!(missing.is_empty());
}
#[test]
fn a_page_lacking_its_parts_is_an_error() {
assert!(Marked::read(&serde_json::json!({ "html": "<p>" })).is_err());
}
#[test]
fn titles_escape() {
assert!(page("a<b>&", "", "").contains("<title>a&lt;b&gt;&amp;</title>"));
}
}
+14 -15
View File
@@ -28,7 +28,7 @@ impl Desk {
let files = cx
.background_executor()
.spawn(async move {
let real = std::fs::canonicalize(&listed)?;
let real = dunce::canonicalize(&listed)?;
Ok::<_, std::io::Error>((markdown_under(&real)?, real))
})
.await;
@@ -65,7 +65,7 @@ impl Desk {
/// Puts `path` first in the recent list.
pub fn remember(&mut self, path: &std::path::Path, cx: &mut Context<Self>) {
// One spelling per file: its real path.
let remembered = std::fs::canonicalize(path)
let remembered = dunce::canonicalize(path)
.and_then(|path| ely_files::recent::remember(&self.recent_file, &path));
match remembered {
Ok(list) => self.recent = list,
@@ -82,7 +82,7 @@ impl Desk {
/// Tab `id` shows `path`; the recent list follows.
pub fn track_recent(&mut self, id: u64, path: &std::path::Path, cx: &mut Context<Self>) {
let Ok(real) = std::fs::canonicalize(path) else {
let Ok(real) = dunce::canonicalize(path) else {
return log::warn!("mark: {} is gone; the recent list keeps it", path.display());
};
if self.remembered.get(&id) == Some(&real) {
@@ -108,47 +108,46 @@ impl Desk {
}
}
/// `place` in `tab`, else in `file` once open.
/// Line and column `at` in a tab or file.
pub fn open_at(
&mut self,
tab: Option<u64>,
file: PathBuf,
place: serde_json::Value,
at: (usize, 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(at, 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));
if let Ok(real) = dunce::canonicalize(&file) {
self.going = Some((real, at));
}
self.open_file(file, cx);
}
/// The tab showing `path`, by real path.
pub fn tab_of(&self, path: &std::path::Path) -> Option<u64> {
let real = std::fs::canonicalize(path).ok()?;
let real = dunce::canonicalize(path).ok()?;
let held = |tab: &&ely_office::Tab<crate::outside::Extra, crate::engine::Mark>| {
tab.session
.path()
.and_then(|open| std::fs::canonicalize(open).ok())
.and_then(|open| dunce::canonicalize(open).ok())
== Some(real.clone())
};
self.core.tabs.iter().find(held).map(|tab| tab.id)
}
/// Tab `id`'s text as the page last sent it.
/// Tab `id`'s text now, unsaved edits included.
pub fn text_of(&self, id: u64) -> Option<String> {
Some(
self.tab(id)?
+105 -50
View File
@@ -14,15 +14,14 @@ 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)]
pub struct Kept {
path: Option<PathBuf>,
name: String,
mode: String,
mode: Mode,
place: Option<Place>,
slot: String,
}
@@ -37,6 +36,8 @@ struct Waiting {
kept: Kept,
real: Option<PathBuf>,
text: Option<(String, SystemTime)>,
/// The untitled tab made for it, once made.
tab: Option<u64>,
}
/// The session's files and what still waits to restore.
@@ -45,6 +46,8 @@ pub struct Keeping {
crash: Option<Crash>,
waiting: Vec<Waiting>,
written: Option<String>,
/// The kind of session write failure last shown.
unwritten: Option<io::ErrorKind>,
/// A quit began: the kept list stays as then.
pub leaving: bool,
/// The list as kept when the quit began.
@@ -88,7 +91,7 @@ impl Keeping {
Marker::Clean | Marker::Discarded => None,
};
if let Some(path) = &kept.path {
match std::fs::canonicalize(path) {
match dunce::canonicalize(path) {
// The file reopens as it is on disk.
Ok(real) => waiting.push(Waiting {
kept: Kept {
@@ -97,6 +100,7 @@ impl Keeping {
},
real: Some(real),
text: None,
tab: None,
}),
Err(error) => {
log::warn!("mark: a kept file does not open: {error}");
@@ -111,6 +115,7 @@ impl Keeping {
kept,
real: None,
text,
tab: None,
});
}
}
@@ -126,19 +131,40 @@ impl Keeping {
crash: Some(crash),
waiting,
written: None,
unwritten: None,
leaving: false,
frozen: Vec::new(),
};
Ok((keeping, paths, missing))
}
/// The untitled tabs to make, with their names.
pub fn untitled(&self) -> Vec<String> {
let untitled = self
.waiting
.iter()
.filter(|waiting| waiting.kept.path.is_none());
untitled.map(|waiting| waiting.kept.name.clone()).collect()
/// The untitled tabs to make: their place here and name.
pub fn untitled(&self) -> Vec<(usize, String)> {
let waiting = self.waiting.iter().enumerate();
let untitled = waiting.filter(|(_, waiting)| waiting.kept.path.is_none() && waiting.tab.is_none());
untitled.map(|(at, waiting)| (at, waiting.kept.name.clone())).collect()
}
/// Entry `at` belongs to untitled tab `tab`.
pub fn made(&mut self, at: usize, tab: u64) {
self.waiting[at].tab = Some(tab);
}
/// What tab `id` takes: its file or its copy.
fn take(&mut self, real: Option<&PathBuf>, id: u64) -> Option<Waiting> {
let at = self.waiting.iter().position(|waiting| match (&waiting.real, real) {
(Some(kept), Some(open)) => kept == open,
(None, None) => waiting.tab == Some(id),
_ => false,
})?;
Some(self.waiting.remove(at))
}
/// An end that never asked: the lock and points stay.
pub fn hold(&mut self) {
if self.crash.take().is_some() {
log::warn!("mark: the session ends unasked; its points stay");
}
}
/// A clean end: no crash next time, no stale points.
@@ -189,27 +215,20 @@ impl Desk {
let real = tab
.session
.path()
.and_then(|path| std::fs::canonicalize(path).ok());
let untitled = tab.session.path().is_none();
let waiting = &mut self.keeping.waiting;
let Some(at) = waiting
.iter()
.position(|waiting| match (&waiting.real, &real) {
(Some(kept), Some(open)) => kept == open,
(None, None) => untitled,
_ => false,
})
.and_then(|path| dunce::canonicalize(path).ok());
let Some(Waiting {
kept, text: back, ..
}) = self.keeping.take(real.as_ref(), id)
else {
return;
};
let Waiting {
kept, text: back, ..
} = waiting.remove(at);
let mode = Mode::parse(&kept.mode).unwrap_or(Mode::Visual);
let mode = kept.mode;
log::info!("mark: restores tab {id} as {}", mode.name());
let tab = self.tab_mut(id).expect("open");
// A recovered copy keeps writing to the same points.
tab.extra.slot = kept.slot.clone();
// Its point stands until a newer lands.
tab.extra.pointed_at = back.as_ref().map(|(_, at)| *at);
let view = tab.view.clone();
if let Some((_, at)) = &back {
let at = humantime(*at);
@@ -221,7 +240,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.
@@ -239,8 +267,8 @@ impl Desk {
kept.push(Kept {
path: tab.session.path().map(Path::to_path_buf),
name: tab.name.to_string(),
mode: view.mode.name().to_owned(),
place: view.place,
mode: view.mode,
place: Some(view.place(cx)),
slot: tab.extra.slot.clone(),
});
let current = tab.doc.lock().expect("the text lock").current.clone();
@@ -261,7 +289,7 @@ impl Desk {
recovery.write_once(current.as_bytes(), None, now)
})
.await;
desk.update(cx, |desk, _| desk.pointed(id, point, wrote, &slot))
desk.update(cx, |desk, cx| desk.pointed(id, point, wrote, &slot, cx))
.ok();
})
.detach();
@@ -281,11 +309,18 @@ impl Desk {
}
if !self.keeping.leaving {
self.keeping.frozen = kept.clone();
self.write_session(Saved { tabs: kept });
self.write_session(Saved { tabs: kept }, cx);
}
}
fn pointed(&mut self, id: u64, point: String, wrote: io::Result<Option<PathBuf>>, slot: &Path) {
fn pointed(
&mut self,
id: u64,
point: String,
wrote: io::Result<Option<PathBuf>>,
slot: &Path,
cx: &mut Context<Self>,
) {
// The tab closed while it wrote: the point goes too.
let Some(tab) = self.tab_mut(id) else {
if let Err(error) = clear(slot) {
@@ -294,21 +329,51 @@ impl Desk {
return;
};
tab.extra.pointing = false;
match wrote {
Ok(_) => tab.extra.pointed = Some(point),
Err(error) => log::error!("mark: a recovery point for tab {id} failed: {error}"),
let error = match wrote {
Ok(_) => {
tab.extra.pointed = Some(point);
tab.extra.pointed_at = Some(SystemTime::now());
tab.extra.point_failed = None;
return;
}
Err(error) => error,
};
log::error!("mark: a recovery point for tab {id} failed: {error}");
// Each kind of failure shows once; the next tick retries.
if tab.extra.point_failed == Some(error.kind()) {
return;
}
tab.extra.point_failed = Some(error.kind());
let error = error.to_string();
let at = match tab.extra.pointed_at {
Some(at) => humantime(at),
None => text(cx, "office.stopped.never", &[]).to_string(),
};
let name = tab.name.to_string();
let said = text(cx, "mark.recovery.failed", &[("name", &name), ("error", &error), ("at", &at)]);
self.toast_unlogged(said, ely_gpui_component::primitives::Severity::Danger, cx);
}
fn write_session(&mut self, saved: Saved) {
fn write_session(&mut self, saved: Saved, cx: &mut Context<Self>) {
let json = serde_json::to_string_pretty(&saved).expect("the session serializes");
if self.keeping.written.as_ref() == Some(&json) {
return;
}
let file = self.keeping.dir.join("session.json");
match ely_files::save_atomic(&file, json.as_bytes(), 0) {
Ok(_) => self.keeping.written = Some(json),
Err(error) => log::error!("mark: the session did not save: {error}"),
let error = match ely_files::save_atomic(&file, json.as_bytes(), 0) {
Ok(_) => {
self.keeping.written = Some(json);
self.keeping.unwritten = None;
return;
}
Err(error) => error,
};
log::error!("mark: the session did not save: {error}");
// Each kind of failure shows once; the next tick retries.
if self.keeping.unwritten != Some(error.kind()) {
self.keeping.unwritten = Some(error.kind());
let said = text(cx, "mark.session.failed", &[("error", &error.to_string())]);
self.toast(said, ely_gpui_component::primitives::Severity::Danger, cx);
}
}
@@ -356,7 +421,7 @@ impl Desk {
}
}
let tabs = self.keeping.frozen.clone();
self.write_session(Saved { tabs });
self.write_session(Saved { tabs }, cx);
}
/// A quit or close begins: the tabs now stay kept.
@@ -382,14 +447,4 @@ fn humantime(at: SystemTime) -> String {
}
#[cfg(test)]
mod tests {
#[test]
fn a_place_without_a_column_reads_zero() {
let kept: super::Kept = serde_json::from_str(
r#"{"path":null,"name":"a.md","mode":"source","place":{"line":3,"scroll":0},"slot":"s"}"#,
)
.expect("an older place reads");
let place = kept.place.expect("a place");
assert_eq!((place.line, place.column), (3, 0));
}
}
mod tests;
+444
View File
@@ -0,0 +1,444 @@
use crate::view::Mode;
#[test]
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, place.top), (3, 0, 0));
}
#[test]
fn each_untitled_copy_goes_to_the_tab_made_for_it() {
let dir = tempfile::tempdir().expect("a temp dir");
let kept = |name: &str, mode: Mode, slot: &str| super::Kept {
path: None,
name: name.into(),
mode,
place: Some(super::Place {
line: slot.len() as u64,
column: 1,
top: 0,
}),
slot: slot.into(),
};
let tabs = vec![
kept("Untitled1", Mode::Visual, "a"),
kept("Untitled2", Mode::Source, "bb"),
];
for (slot, text) in [("a", "first"), ("bb", "second")] {
let mut points = super::Recovery::new(super::recovery(dir.path(), slot));
points
.write_once(text.as_bytes(), None, std::time::SystemTime::now())
.expect("a point");
}
let saved = serde_json::to_vec(&super::Saved { tabs: tabs.clone() }).expect("a list");
std::fs::write(dir.path().join("session.json"), saved).expect("the list");
// A pid no process has: the last run crashed.
std::fs::write(dir.path().join("session.lock"), "open 4294967295").expect("a lock");
let (mut keeping, ..) = super::Keeping::start(dir.path()).expect("a session");
let made: Vec<_> = keeping.untitled();
assert_eq!(
made,
[(0, "Untitled1".to_string()), (1, "Untitled2".to_string())]
);
for (at, _) in made {
keeping.made(at, 10 + at as u64);
}
assert!(keeping.take(None, 12).is_none(), "a new tab takes nothing");
// The second tab shows first: each still takes its own.
for (id, want, text) in [(11, &tabs[1], "second"), (10, &tabs[0], "first")] {
let got = keeping.take(None, id).expect("its copy");
assert_eq!(
(&got.kept, got.text.map(|(text, _)| text)),
(want, Some(text.to_string()))
);
}
}
#[test]
fn an_unasked_end_keeps_the_lock_and_points_past_release() {
let dir = tempfile::tempdir().expect("a temp dir");
let point = dir.path().join("recovery/s/point");
let (lock, slot) = (
dir.path().join("session.lock"),
point.parent().expect("a slot"),
);
let (mut held, ..) = super::Keeping::start(dir.path()).expect("a session");
std::fs::create_dir_all(slot).expect("a slot");
std::fs::write(&point, "unsaved").expect("a point");
held.hold();
held.close();
assert!(
lock.exists() && point.exists(),
"release after quit wiped them"
);
drop(held);
let (mut clean, ..) = super::Keeping::start(dir.path()).expect("a session");
std::fs::create_dir_all(slot).expect("a slot");
std::fs::write(&point, "unsaved").expect("a point");
clean.close();
assert!(!lock.exists() && !point.exists(), "a clean end clears");
}
mod window {
use gpui::{Entity, TestAppContext, VisualTestContext};
use crate::desk::{
Desk,
tests::{open, start},
};
use crate::view::{MarkView, Mode};
/// Shows the tab named `name`; its view.
fn show(desk: &Entity<Desk>, name: &str, cx: &mut VisualTestContext) -> Entity<MarkView> {
let view = desk.update(cx, |desk, cx| {
let tab = desk.core.tabs.iter().find(|tab| tab.name.as_ref() == name);
let tab = tab.unwrap_or_else(|| panic!("no tab {name}"));
let (id, view) = (tab.id, tab.view.clone());
desk.core.active = Some(id);
cx.notify();
view
});
cx.run_until_parked();
view
}
fn names(desk: &Entity<Desk>, cx: &mut VisualTestContext) -> Vec<String> {
desk.read_with(cx, |desk, _| {
desk.core
.tabs
.iter()
.map(|tab| tab.name.to_string())
.collect()
})
}
#[gpui::test]
fn a_new_launch_brings_back_tabs_modes_places_and_look(
first: &mut TestAppContext,
second: &mut TestAppContext,
) {
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
let long: String = (0..80).map(|n| format!("Line {n}\n\n")).collect();
let (a, b) = (files.path().join("a.md"), files.path().join("b.md"));
std::fs::write(&a, &long).expect("a");
std::fs::write(&b, "# B\n").expect("b");
let (desk, cx) = start(first, data.path());
open(&desk, &a, cx);
open(&desk, &b, cx);
let view = show(&desk, "a.md", cx);
view.update_in(cx, |view, window, cx| {
view.switch(Mode::Source, window, cx);
view.document().update(cx, |document, cx| {
document.set_caret(40, 2, cx);
document.set_top_line(30, window, cx);
});
});
cx.run_until_parked();
desk.update(cx, |desk, cx| {
desk.relook(|look| look.focus = true, cx);
desk.leave(cx);
desk.keeping.close();
});
cx.run_until_parked();
let (desk, cx) = start(second, data.path());
assert_eq!(names(&desk, cx), ["a.md", "b.md"]);
let view = show(&desk, "a.md", cx);
let (mode, place) = view.read_with(cx, |view, cx| (view.mode, view.place(cx)));
assert_eq!(mode, Mode::Source);
assert_eq!((place.line, place.column, place.top), (40, 2, 30));
assert!(
desk.read_with(cx, |desk, _| desk.look.focus),
"the look came back"
);
}
#[gpui::test]
fn a_crash_brings_unsaved_text_back_as_a_copy(
first: &mut TestAppContext,
second: &mut TestAppContext,
) {
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
let a = files.path().join("a.md");
std::fs::write(&a, "Text.\n").expect("a");
let (desk, cx) = start(first, data.path());
open(&desk, &a, cx);
let view = show(&desk, "a.md", cx);
view.update(cx, |view, cx| view.adopt("Changed.\n".into(), cx));
cx.executor()
.advance_clock(std::time::Duration::from_secs(1));
cx.run_until_parked();
desk.update(cx, |desk, cx| desk.keep(cx));
cx.run_until_parked();
// A pid no process has: this run crashed.
std::fs::write(data.path().join("session.lock"), "open 4294967295").expect("a lock");
let (desk, cx) = start(second, data.path());
assert_eq!(names(&desk, cx).len(), 2, "{:?}", names(&desk, cx));
let copy = desk.read_with(cx, |desk, _| {
let tab = desk
.core
.tabs
.iter()
.find(|tab| tab.session.path().is_none());
tab.expect("an untitled copy").name.to_string()
});
show(&desk, &copy, cx);
let (text, dirty) = desk.read_with(cx, |desk, cx| {
let tab = desk
.core
.tabs
.iter()
.find(|tab| tab.name.as_ref() == copy)
.expect("the copy");
(desk.text_of(tab.id).expect("text"), tab.dirty(cx))
});
assert_eq!(text, "Changed.\n");
assert!(dirty, "the copy is unsaved");
assert_eq!(
std::fs::read_to_string(&a).expect("reads"),
"Text.\n",
"the file stays"
);
}
#[test]
fn a_kept_mode_no_build_knows_does_not_read() {
let read = serde_json::from_str::<super::super::Kept>(
r#"{"path":null,"name":"a.md","mode":"split","place":null,"slot":"s"}"#,
);
assert!(read.is_err(), "an unknown mode is no default");
}
/// The window's toasts that hold `words`.
fn toasted(desk: &Entity<Desk>, words: &str, cx: &mut VisualTestContext) -> usize {
let root = desk.read_with(cx, |desk, _| desk.core.root.upgrade().expect("the window"));
let toasts = root.read_with(cx, |root, cx| root.toasts(cx));
toasts
.iter()
.filter(|(said, _)| said.contains(words))
.count()
}
#[gpui::test]
fn a_failed_recovery_write_shows_once_with_the_last_kept(cx: &mut TestAppContext) {
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
let a = files.path().join("a.md");
std::fs::write(
&a, "Text.
",
)
.expect("a");
let (desk, cx) = start(cx, data.path());
open(&desk, &a, cx);
let view = show(&desk, "a.md", cx);
for (typed, broken) in [
(
"One.
", false,
),
(
"Two.
", true,
),
(
"Three.
", true,
),
] {
if broken && data.path().join("recovery").is_dir() {
std::fs::remove_dir_all(data.path().join("recovery")).expect("removes");
std::fs::write(data.path().join("recovery"), "not a folder").expect("blocks");
}
view.update(cx, |view, cx| view.adopt(typed.into(), cx));
cx.executor()
.advance_clock(std::time::Duration::from_secs(1));
cx.run_until_parked();
desk.update(cx, |desk, cx| desk.keep(cx));
cx.run_until_parked();
}
assert_eq!(toasted(&desk, "was not kept for recovery", cx), 1, "once");
let never = toasted(&desk, "none yet", cx);
assert_eq!(never, 0, "the first point landed, so its time shows");
}
#[gpui::test]
fn a_failed_session_write_shows_once(cx: &mut TestAppContext) {
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
let a = files.path().join("a.md");
std::fs::write(
&a, "Text.
",
)
.expect("a");
let (desk, cx) = start(cx, data.path());
open(&desk, &a, cx);
let session = data.path().join("session.json");
if session.exists() {
std::fs::remove_file(&session).expect("removes");
}
std::fs::create_dir(&session).expect("blocks");
for line in [1, 2] {
let view = show(&desk, "a.md", cx);
view.update(cx, |view, cx| {
view.document()
.update(cx, |document, cx| document.set_caret(line, 0, cx))
});
desk.update(cx, |desk, cx| desk.keep(cx));
cx.run_until_parked();
}
assert_eq!(
toasted(&desk, "were not saved for the next launch", cx),
1,
"once"
);
}
/// Types `typed` into the shown tab, then keeps.
fn type_and_keep(
desk: &Entity<Desk>,
view: &Entity<MarkView>,
typed: &str,
cx: &mut VisualTestContext,
) {
view.update(cx, |view, cx| view.adopt(typed.into(), cx));
cx.executor()
.advance_clock(std::time::Duration::from_secs(1));
cx.run_until_parked();
desk.update(cx, |desk, cx| desk.keep(cx));
cx.run_until_parked();
}
#[gpui::test]
fn a_recovered_tab_names_its_point_when_the_next_fails(
first: &mut TestAppContext,
second: &mut TestAppContext,
) {
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
let a = files.path().join("a.md");
std::fs::write(
&a, "Text.
",
)
.expect("a");
let (desk, cx) = start(first, data.path());
open(&desk, &a, cx);
let view = show(&desk, "a.md", cx);
type_and_keep(
&desk,
&view,
"Changed.
",
cx,
);
std::fs::write(data.path().join("session.lock"), "open 4294967295").expect("a lock");
let (desk, cx) = start(second, data.path());
let copy = desk.read_with(cx, |desk, _| {
let tab = desk
.core
.tabs
.iter()
.find(|tab| tab.session.path().is_none());
tab.expect("an untitled copy").name.to_string()
});
let view = show(&desk, &copy, cx);
let recovery = data.path().join("recovery");
std::fs::remove_dir_all(&recovery).expect("removes");
std::fs::write(&recovery, "not a folder").expect("blocks");
type_and_keep(
&desk, &view, "Again.
", cx,
);
assert_eq!(toasted(&desk, "was not kept for recovery", cx), 1);
assert_eq!(
toasted(&desk, "none yet", cx),
0,
"the recovered point's time shows"
);
}
/// Every folder under `dir` read-only, or writable again.
#[cfg(unix)]
fn lock_down(dir: &std::path::Path, locked: bool) {
use std::os::unix::fs::PermissionsExt as _;
let mode = if locked { 0o500 } else { 0o700 };
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(mode)).expect("permissions");
for entry in std::fs::read_dir(dir).expect("lists") {
let path = entry.expect("an entry").path();
if path.is_dir() {
lock_down(&path, locked);
}
}
}
#[cfg(unix)]
#[gpui::test]
fn denied_writes_show_once_each(cx: &mut TestAppContext) {
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
let a = files.path().join("a.md");
std::fs::write(
&a, "Text.
",
)
.expect("a");
let (desk, cx) = start(cx, data.path());
open(&desk, &a, cx);
let view = show(&desk, "a.md", cx);
type_and_keep(
&desk, &view, "One.
", cx,
);
lock_down(&data.path().join("recovery"), true);
type_and_keep(
&desk, &view, "Two.
", cx,
);
type_and_keep(
&desk, &view, "Three.
", cx,
);
lock_down(&data.path().join("recovery"), false);
assert_eq!(
toasted(&desk, "was not kept for recovery", cx),
1,
"a point"
);
lock_down(data.path(), true);
for line in [1, 2] {
view.update(cx, |view, cx| {
view.document()
.update(cx, |document, cx| document.set_caret(line, 0, cx))
});
desk.update(cx, |desk, cx| desk.keep(cx));
cx.run_until_parked();
}
lock_down(data.path(), false);
assert_eq!(
toasted(&desk, "were not saved for the next launch", cx),
1,
"the session"
);
}
}
+24 -17
View File
@@ -6,6 +6,7 @@ use ely_gpui_component::{
buttons::{Button, SegmentedControl},
feedback::EmptyState,
forms::{InputEvent, SearchInput, TextInput},
layout::ScrollArea,
lists::{FileTree, ListItem, Outline, SelectableList},
primitives::IconName,
theme::ControlSize,
@@ -13,10 +14,10 @@ use ely_gpui_component::{
use ely_office::prelude::*;
use ely_ui::{commands, i18n::text};
use gpui::{
AnyElement, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
AnyElement, AppContext as _, Context, Entity, InteractiveElement as _, 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};
@@ -110,7 +111,7 @@ impl Side {
// Open files by real path, as the folder lists them.
let reals: Vec<(u64, PathBuf)> = open
.iter()
.filter_map(|(id, path, _)| Some((*id, std::fs::canonicalize(path).ok()?)))
.filter_map(|(id, path, _)| Some((*id, dunce::canonicalize(path).ok()?)))
.collect();
let sources: Vec<(Option<u64>, PathBuf)> = match (self.wide, &desk.folder) {
(true, Some(folder)) => folder
@@ -234,7 +235,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",
@@ -249,15 +250,18 @@ impl Side {
outline.item(at.to_string(), heading.title.clone(), heading.level)
},
);
outline
.on_select(move |key, window, cx| {
if let Ok(at) = key.parse::<usize>() {
view.update(cx, |view, cx| view.reveal(json!({ "heading": at }), cx));
// Keys go to the document, at the heading.
let focus = view.read(cx).focus.clone();
window.focus(&focus, cx);
}
})
let outline = outline.on_select(move |key, window, cx| {
let Some(heading) = key.parse::<usize>().ok().and_then(|at| headings.get(at)) else {
return log::error!("mark: the outline chose no heading");
};
// The heading in view, the caret on it.
let line = heading.line;
view.update(cx, |view, cx| view.reveal((line, 0), window, cx));
});
ScrollArea::new("mark-outline-scroll")
.vertical()
.size_full()
.child(div().debug_selector(|| "mark-outline".into()).child(outline))
.into_any_element()
}
@@ -277,7 +281,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 +316,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, at) = (*tab, hit.file.clone(), (hit.line - 1, hit.column));
opener
.update(cx, |desk, cx| desk.open_at(tab, file, place, window, cx))
.update(cx, |desk, cx| desk.open_at(tab, file, at, window, cx))
.ok();
});
let more = (self.hits.len() == MOST_HITS)
@@ -375,3 +379,6 @@ impl Render for Side {
.into_any_element()
}
}
#[cfg(test)]
mod tests;
+113
View File
@@ -0,0 +1,113 @@
use gpui::{ScrollDelta, ScrollWheelEvent, TestAppContext, point, px};
use super::Pane;
use crate::desk::tests::{open, start};
#[gpui::test]
fn a_long_outline_scrolls_to_its_last_heading(cx: &mut TestAppContext) {
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
let path = files.path().join("long.md");
let text: String = (0..100).map(|n| format!("# Heading {n}\n\n")).collect();
std::fs::write(&path, text).expect("a file");
let (desk, cx) = start(cx, data.path());
open(&desk, &path, cx);
let side = desk.read_with(cx, |desk, _| desk.side.clone());
side.update(cx, |side, cx| {
side.pane = Pane::Outline;
cx.notify();
});
cx.run_until_parked();
let before = cx
.debug_bounds("mark-outline")
.expect("the outline is drawn");
let window = cx.update(|window, _| window.viewport_size());
assert!(
before.bottom() > window.height,
"the outline is longer than the window"
);
let at = point(before.left() + px(20.0), px(300.0));
for _ in 0..20 {
cx.simulate_event(ScrollWheelEvent {
position: at,
delta: ScrollDelta::Pixels(point(px(0.0), px(-1000.0))),
..Default::default()
});
cx.run_until_parked();
}
let after = cx
.debug_bounds("mark-outline")
.expect("the outline is drawn");
assert!(
after.bottom() <= window.height,
"the last heading is in view: {after:?}"
);
}
/// Searches the folder for `words`; the hits as found.
fn search(
side: &gpui::Entity<super::Side>,
words: &str,
cx: &mut gpui::VisualTestContext,
) -> Vec<(Option<u64>, crate::folder::Hit)> {
side.update(cx, |side, cx| {
side.query.update(cx, |query, cx| query.set_text(words, cx));
side.wide = true;
side.search(cx);
});
cx.run_until_parked();
side.read_with(cx, |side, _| side.hits.clone())
}
#[gpui::test]
fn a_hit_opens_its_file_with_the_caret_on_the_match(cx: &mut TestAppContext) {
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
std::fs::write(files.path().join("a.md"), "# A\n\nNo match.\n").expect("a");
std::fs::write(files.path().join("b.md"), "# B\n\nThe Needle here.\n").expect("b");
let (desk, cx) = start(cx, data.path());
open(&desk, files.path(), cx);
let side = desk.read_with(cx, |desk, _| desk.side.clone());
let hits = search(&side, "needle", cx);
let found: Vec<_> = hits.iter().map(|(_, hit)| (hit.line, hit.column)).collect();
assert_eq!(found, [(3, 4)]);
let (tab, hit) = hits[0].clone();
assert_eq!(tab, None, "b.md is not open");
desk.update_in(cx, |desk, window, cx| {
desk.open_at(tab, hit.file, (hit.line - 1, hit.column), window, cx)
});
cx.run_until_parked();
let place = desk.read_with(cx, |desk, cx| {
desk.active_view().expect("b.md opened").read(cx).place(cx)
});
assert_eq!((place.line, place.column), (2, 4));
}
#[gpui::test]
fn a_hit_in_an_open_file_moves_its_caret(cx: &mut TestAppContext) {
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
let path = files.path().join("a.md");
std::fs::write(&path, "# A\n\nfirst\n\nsecond Word\n").expect("a");
let (desk, cx) = start(cx, data.path());
open(&desk, files.path(), cx);
open(&desk, &path, cx);
let side = desk.read_with(cx, |desk, _| desk.side.clone());
let hits = search(&side, "word", cx);
let (tab, hit) = hits[0].clone();
assert!(tab.is_some(), "the open tab searches");
desk.update_in(cx, |desk, window, cx| {
desk.open_at(tab, hit.file, (hit.line - 1, hit.column), window, cx)
});
cx.run_until_parked();
let place = desk.read_with(cx, |desk, cx| {
desk.active_view().expect("a").read(cx).place(cx)
});
assert_eq!((place.line, place.column), (4, 7));
}
+75
View File
@@ -0,0 +1,75 @@
//! The table menu's work on the shown file (MARK-06).
use ely_gpui_component::{
documents::{TableEdit, TableRefusal},
primitives::Severity,
};
use ely_office::prelude::*;
use ely_ui::i18n::text;
use gpui::{Context, InteractiveElement as _, actions};
use crate::desk::Desk;
actions!(
mark,
[
InsertTable,
RowAbove,
RowBelow,
ColumnLeft,
ColumnRight,
DeleteRow,
DeleteColumn
]
);
impl Desk {
fn table(&mut self, edit: Option<TableEdit>, cx: &mut Context<Self>) {
let Some(view) = self.active_view() else {
let said = text(cx, "command.blocked.no_document", &[]);
return self.toast(said, Severity::Warning, cx);
};
let document = view.read(cx).document().clone();
let done = document.update(cx, |document, cx| match edit {
Some(edit) => document.edit_table(edit, cx),
None => document.insert_table(cx),
});
let Err(refusal) = done else {
return;
};
let key = match refusal {
TableRefusal::Outside => "mark.table.none",
TableRefusal::Kept => "mark.table.kept",
TableRefusal::Beyond => "mark.table.beyond",
TableRefusal::Mixed => "mark.table.mixed",
TableRefusal::Preview => "mark.preview.read_only",
};
let said = text(cx, key, &[]);
self.toast(said, Severity::Info, cx);
}
pub fn table_actions(element: gpui::Div, cx: &mut Context<Self>) -> gpui::Div {
element
.on_action(cx.listener(|desk, _: &InsertTable, _, cx| desk.table(None, cx)))
.on_action(
cx.listener(|desk, _: &RowAbove, _, cx| desk.table(Some(TableEdit::RowAbove), cx)),
)
.on_action(
cx.listener(|desk, _: &RowBelow, _, cx| desk.table(Some(TableEdit::RowBelow), cx)),
)
.on_action(cx.listener(|desk, _: &ColumnLeft, _, cx| {
desk.table(Some(TableEdit::ColumnLeft), cx)
}))
.on_action(cx.listener(|desk, _: &ColumnRight, _, cx| {
desk.table(Some(TableEdit::ColumnRight), cx)
}))
.on_action(
cx.listener(|desk, _: &DeleteRow, _, cx| {
desk.table(Some(TableEdit::DeleteRow), cx)
}),
)
.on_action(cx.listener(|desk, _: &DeleteColumn, _, cx| {
desk.table(Some(TableEdit::DeleteColumn), cx)
}))
}
}
+49 -1
View File
@@ -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");
@@ -186,4 +187,51 @@ mod tests {
std::fs::write(outside.path().join("t.md"), "a").expect("a file");
assert_eq!(ask(&outside.path().join("t.md")), Tracked::Outside);
}
#[test]
fn committed_changed_and_ignored_files_say_so() {
let dir = tempfile::tempdir().expect("a temp dir");
let repo = dir.path();
git(repo, &["init", "-q"]);
std::fs::write(repo.join(".gitignore"), "skip.md
").expect("ignores");
std::fs::write(repo.join("t.md"), "a").expect("a file");
git(repo, &["add", "."]);
git(repo, &["-c", "user.name=T", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", "commit", "-qm", "one"]);
assert_eq!(ask(&repo.join("t.md")), Tracked::Unchanged);
std::fs::write(repo.join("t.md"), "b").expect("an edit");
assert_eq!(ask(&repo.join("t.md")), Tracked::Changed);
std::fs::write(repo.join("skip.md"), "c").expect("a file");
assert_eq!(ask(&repo.join("skip.md")), Tracked::Ignored);
}
#[gpui::test]
fn the_status_item_follows_a_save(cx: &mut gpui::TestAppContext) {
use ely_office::prelude::*;
let (data, dir) = (tempfile::tempdir().expect("data"), tempfile::tempdir().expect("repo"));
let repo = dir.path();
git(repo, &["init", "-q"]);
std::fs::write(repo.join("t.md"), "a
").expect("a file");
git(repo, &["add", "."]);
git(repo, &["-c", "user.name=T", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", "commit", "-qm", "one"]);
let (desk, cx) = crate::desk::tests::start(cx, data.path());
crate::desk::tests::open(&desk, &repo.join("t.md"), cx);
let said = |cx: &mut gpui::VisualTestContext| desk.read_with(cx, |desk, _| desk.tracked.clone());
assert_eq!(said(cx), Some(Tracked::Unchanged));
let (id, view) = desk.read_with(cx, |desk, _| {
let tab = desk.active_tab().expect("a tab");
(tab.id, tab.view.clone())
});
view.update(cx, |view, cx| view.adopt("b
".into(), cx));
cx.executor().advance_clock(std::time::Duration::from_secs(1));
cx.run_until_parked();
desk.update_in(cx, |desk, window, cx| desk.save(id, None, window, cx));
cx.run_until_parked();
assert_eq!(std::fs::read_to_string(repo.join("t.md")).expect("saved"), "b
");
assert_eq!(said(cx), Some(Tracked::Changed));
}
}
+218 -335
View File
@@ -1,78 +1,86 @@
//! 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, 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 read 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, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
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.
/// 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)>;
/// 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,
/// The newest change the page announced.
pub changed: u64,
/// The change whose text `doc` holds.
reported: u64,
/// Text changes so far; autosave counts them.
changed: u64,
/// The mark at the last load or save.
saved: String,
/// Page loads so far; a reload restarts its versions.
load: u64,
pub mode: Mode,
/// Headings as the shown editor reads them.
pub outline: Vec<Heading>,
/// Shown since the last page load.
pub shown: bool,
/// A place to bring into view once shown.
waiting: Option<Value>,
/// Where the page's search stands, while one runs.
pub found: Option<crate::finding::Found>,
/// The file's folder: links and images resolve here.
pub(crate) folder: PathBuf,
/// A saved file: images can go beside it.
pub attachable: bool,
/// Past `LARGE`: source alone, every byte still saved (MARK-14).
pub large: bool,
/// The caret's line and the scroll, as last reported.
pub place: Option<Place>,
/// The writing view, as the page applies it (MARK-09).
look: Value,
/// Renders asked of the page, by number.
renders: std::collections::HashMap<u64, Rendered>,
asked: u64,
/// 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,
/// Trackpad pixels toward the next size step.
wheel: f32,
pub focus: FocusHandle,
}
@@ -80,44 +88,59 @@ impl EventEmitter<Reported> for MarkView {}
impl EventEmitter<Failed> for MarkView {}
impl MarkView {
pub fn new(
web: Entity<WebView>,
doc: Doc,
saved: String,
folder: PathBuf,
cx: &mut Context<Self>,
) -> Self {
let focus = web.read(cx).focus_handle(cx);
let large = doc.lock().expect("the text lock").current.len() > LARGE;
cx.subscribe(&web, Self::heard).detach();
let paste = crate::attach::paste(cx.weak_entity());
web.update(cx, |web, _| web.intercept_paste(paste));
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,
false => Mode::Visual,
};
let view = cx.weak_entity();
let document = cx.new(|cx| {
let mut document = MarkdownDocument::new(text, mode.shown(), window, 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);
cx.emit(Reported);
});
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,
changed: 0,
reported: 0,
saved,
load: 0,
mode: if large { Mode::Source } else { Mode::Visual },
large,
outline: Vec::new(),
mode,
folder: None,
shown: false,
waiting: None,
found: None,
attachable: false,
place: None,
look: Value::Null,
renders: std::collections::HashMap::new(),
asked: 0,
folder,
wheel: 0.0,
focus,
}
}
/// Every change's text has arrived.
pub(crate) fn document(&self) -> &Entity<MarkdownDocument> {
&self.document
}
/// Every change is in the text at once.
pub fn settled(&self) -> bool {
self.reported == self.changed
true
}
pub fn changed(&self) -> u64 {
self.changed
}
/// The text's size now, in bytes.
@@ -125,6 +148,11 @@ impl MarkView {
self.doc.lock().expect("the text lock").current.len()
}
/// Too long to read: source alone (MARK-14).
pub fn large(&self) -> bool {
self.bytes() > LARGEST_READ
}
pub fn modified(&self) -> bool {
mark(&self.doc.lock().expect("the text lock").current) != self.saved
}
@@ -135,53 +163,53 @@ impl MarkView {
}
/// The same text in `mode` (MARK-02).
pub fn switch(&mut self, mode: Mode, cx: &mut Context<Self>) {
if self.large && mode != Mode::Source {
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();
return cx.emit(Failed(said));
}
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 and column `at` in view and focus, caret there.
pub fn reveal(&mut self, at: (usize, usize), window: &mut Window, cx: &mut Context<Self>) {
self.document.update(cx, |document, cx| {
document.reveal(at.0, window, cx);
document.set_caret(at.0, at.1, 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();
{
@@ -190,257 +218,112 @@ impl MarkView {
doc.source = source;
}
self.saved = mark(&text);
(self.changed, self.reported) = (0, 0);
crate::page::call(self.web.read(cx), "refresh", &Value::from(text));
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>,
) {
self.asked += 1;
self.renders.insert(self.asked, Box::new(done));
let ask = serde_json::json!({ "kind": kind, "ask": self.asked });
crate::page::call(self.web.read(cx), "render", &ask);
}
/// `text` replaces the editor's, as one undoable edit.
/// `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.load, place);
crate::page::open(self.web.read(cx), &current, at, &self.folder, cx);
let mode = if self.large() { Mode::Source } else { mode };
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 saved file's folder; pictures follow it.
pub fn moved(&mut self, folder: PathBuf, cx: &mut Context<Self>) {
if self.folder.as_ref() == Some(&folder) {
return;
}
self.document
.update(cx, |document, cx| document.set_base(folder.as_path(), cx));
self.folder = Some(folder);
}
fn heard(
&mut self,
document: &Entity<MarkdownDocument>,
event: &DocumentEvent,
window: &mut Window,
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;
if self.large() && self.mode != Mode::Source {
log::info!("mark: the text grew past the read limit");
self.switch(Mode::Source, window, cx);
}
}
self.show_once(cx);
cx.emit(Reported);
}
/// The file moved; images and links follow it.
pub fn moved(&mut self, folder: PathBuf, cx: &mut Context<Self>) {
if folder == self.folder {
return;
}
if let Err(error) = self.web.read(cx).allow_images(&folder) {
log::error!("mark: images cannot follow the file: {error:#}");
return cx.emit(Failed(format!("{error:#}")));
}
crate::page::folder(self.web.read(cx), &folder);
self.folder = folder;
}
fn heard(&mut self, web: Entity<WebView>, event: &Event, cx: &mut Context<Self>) {
match event {
// A reload shows the last text that arrived.
Event::Loaded => {
self.load += 1;
self.shown = false;
(self.changed, self.reported) = (0, 0);
let text = self.doc.lock().expect("the text lock").current.clone();
let at = (self.mode, self.load, self.place);
crate::page::open(web.read(cx), &text, at, &self.folder, cx);
cx.emit(Reported);
}
Event::Query(said, reply) => self.query(said, reply, cx),
Event::Leave(url) => log::info!("mark: a link out was not followed: {url}"),
Event::Failed(why) => cx.emit(Failed(why.clone())),
Event::Printed(path, _) => {
log::error!("mark: an editor page printed {}", path.display())
}
/// Logs the first moment the mode takes input.
fn show_once(&mut self, cx: &gpui::App) {
let ready = self.mode == Mode::Source || self.document.read(cx).read_now(cx);
if ready && !self.shown {
self.shown = true;
log::info!("mark: shown as {:?}", self.mode.name());
}
}
fn query(&mut self, said: &str, reply: &Reply, cx: &mut Context<Self>) {
let said: Value = match serde_json::from_str(said) {
Ok(said) => said,
Err(error) => {
log::error!("mark: the page sent bad JSON: {error}");
return reply.fail("bad JSON");
}
};
if said["generation"]
.as_u64()
.is_some_and(|load| load != self.load)
{
log::info!("mark: a report from an earlier page load was dropped");
return reply.ok("");
/// 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");
return cx.open_url(url);
}
let version = said["version"].as_u64();
match (said["kind"].as_str(), version, said["text"].as_str()) {
// A fallback shows source; the tab follows.
(Some("shown"), ..) => {
log::info!(
"mark: shown as {}, fallback {}",
said["mode"],
said["fallback"]
);
if let Some(mode) = said["mode"].as_str().and_then(Mode::parse) {
self.mode = mode;
cx.notify();
}
self.shown = true;
if let Some(place) = self.waiting.take() {
crate::page::call(self.web.read(cx), "reveal", &place);
}
if !self.look.is_null() {
crate::page::call(self.web.read(cx), "look", &self.look);
}
cx.emit(Reported);
}
(Some("outline"), ..) => match headings(&said["items"]) {
Some(outline) => {
self.outline = outline;
cx.notify();
}
None => {
log::error!("mark: the page sent a bad outline");
return reply.fail("bad outline");
}
},
(Some("found"), ..) => match found(&said) {
Some(found) => {
self.found = Some(found);
cx.notify();
}
None => {
log::error!("mark: the page sent a bad search state");
return reply.fail("bad search state");
}
},
(Some("table"), ..) if said["done"] == false => {
let key = match self.mode {
Mode::Visual => "mark.table.none",
_ => "mark.table.visual",
};
cx.emit(Failed(ely_ui::i18n::text(cx, key, &[]).to_string()));
}
(Some("inserted"), ..) if said["done"] == false => {
cx.emit(Failed(
ely_ui::i18n::text(cx, "mark.attach.preview", &[]).to_string(),
));
}
(Some("table" | "inserted"), ..) => {}
(Some("rendered"), ..) => {
let ask = said["ask"].as_u64().unwrap_or_default();
let Some(done) = self.renders.remove(&ask) else {
log::error!("mark: a render nobody asked for came back");
return reply.fail("unasked render");
};
let result = match said["error"].as_str() {
Some(error) => Err(error.to_owned()),
None => Ok(said.clone()),
};
done(result, cx);
}
(Some("place"), ..) => match (
said["line"].as_u64(),
said["column"].as_u64(),
said["scroll"].as_f64(),
) {
(Some(line), Some(column), Some(scroll)) => {
self.place = Some(Place {
line,
column,
scroll,
})
}
_ => {
log::error!("mark: the page sent a bad place");
return reply.fail("bad place");
}
},
(Some("changed"), Some(version), _) => {
self.changed = self.changed.max(version);
cx.emit(Reported);
}
// Only the newest text counts.
(Some("text"), Some(version), Some(text)) if version >= self.reported => {
self.doc.lock().expect("the text lock").current = text.to_owned();
self.reported = version;
cx.emit(Reported);
}
(Some("text"), Some(_), Some(_)) => log::info!("mark: an older text was dropped"),
_ => {
log::error!("mark: the page asked something unknown");
return reply.fail("unknown request");
}
}
reply.ok("");
log::info!("mark: a link of another kind was refused");
let said = ely_ui::i18n::text(cx, "office.link.refused", &[]).to_string();
cx.emit(Failed(said));
}
}
/// A search state from the page; anything malformed is None.
fn found(said: &Value) -> Option<crate::finding::Found> {
if let Some(error) = said["error"].as_str() {
let error = Some(error.to_owned());
return Some(crate::finding::Found {
current: None,
total: 0,
error,
});
}
let total = said["total"].as_u64()? as usize;
let current = match &said["current"] {
Value::Null => None,
at => Some(at.as_u64()? as usize),
};
Some(crate::finding::Found {
current,
total,
error: None,
})
}
/// Headings from the page; anything malformed is None.
fn headings(items: &Value) -> Option<Vec<Heading>> {
let heading = |item: &Value| {
let level = item["level"]
.as_u64()
.filter(|level| (1..=6).contains(level))?;
let title = item["title"].as_str()?.to_owned();
Some(Heading {
level: level as usize,
title,
})
};
items.as_array()?.iter().map(heading).collect()
}
impl Render for MarkView {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
// Dropped files: images go beside the document.
let drop = cx.listener(|view, dropped: &ExternalPaths, _, cx| {
view.attach(crate::attach::Attach::Files(dropped.paths().to_vec()), cx)
let view = cx.entity();
// Ctrl and the wheel size text, as Ctrl+= does.
let zoom = ely_ui::wheel::zoom_layer(move |event, window, cx| {
let steps = view.update(cx, |view, _| ely_ui::wheel::steps(event, &mut view.wheel));
for _ in 0..steps.unsigned_abs() {
match steps > 0 {
true => window.dispatch_action(Box::new(crate::look::Larger), cx),
false => window.dispatch_action(Box::new(crate::look::Smaller), cx),
}
}
});
div().size_full().on_drop(drop).child(self.web.clone())
div()
.relative()
.size_full()
.track_focus(&self.focus)
.child(self.document.clone())
.child(zoom)
}
}
#[cfg(test)]
mod tests;
+279
View File
@@ -0,0 +1,279 @@
use std::sync::{Arc, Mutex};
use gpui::{AppContext as _, Entity, TestAppContext, VisualTestContext};
use super::{MarkView, Mode, Reported};
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 (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)
}
#[gpui::test]
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();
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);
}
fn current(view: &Entity<MarkView>, cx: &mut VisualTestContext) -> String {
view.read_with(cx, |view, _| {
view.doc.lock().expect("the text lock").current.clone()
})
}
fn focused(view: &Entity<MarkView>, cx: &mut VisualTestContext) {
let focus = view.read_with(cx, |view, _| view.focus.clone());
cx.update(|window, cx| window.focus(&focus, cx));
cx.run_until_parked();
}
#[gpui::test]
fn switching_modes_writes_no_byte(cx: &mut TestAppContext) {
let (view, cx) = open("# A\r\n\r\n* b\r\n", cx);
for mode in [Mode::Source, Mode::Preview, Mode::Visual, Mode::Source] {
view.update_in(cx, |view, window, cx| view.switch(mode, window, cx));
cx.run_until_parked();
}
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"# A\r\n\r\n* b\r\n");
let (modified, changed) = view.read_with(cx, |view, _| (view.modified(), view.changed()));
assert!(!modified && changed == 0);
}
#[gpui::test]
fn undo_reaches_across_modes(cx: &mut TestAppContext) {
let (view, cx) = open("# A\n", cx);
view.update_in(cx, |view, window, cx| view.switch(Mode::Source, window, cx));
focused(&view, cx);
cx.simulate_input("x");
assert_eq!(current(&view, cx), "x# A\n");
view.update_in(cx, |view, window, cx| view.switch(Mode::Visual, window, cx));
focused(&view, cx);
cx.simulate_keystrokes("secondary-z");
cx.run_until_parked();
assert_eq!(
current(&view, cx),
"# A\n",
"the source edit undone in visual"
);
}
#[gpui::test]
fn undo_brings_back_a_text_emptied_in_source(cx: &mut TestAppContext) {
let (view, cx) = open("Seed\n", cx);
view.update_in(cx, |view, window, cx| view.switch(Mode::Source, window, cx));
view.update(cx, |view, cx| view.adopt(String::new(), cx));
view.update_in(cx, |view, window, cx| view.switch(Mode::Visual, window, cx));
focused(&view, cx);
cx.simulate_keystrokes("secondary-z");
cx.run_until_parked();
assert_eq!(current(&view, cx), "Seed\n");
}
#[gpui::test]
fn a_new_empty_file_takes_typing_in_visual(cx: &mut TestAppContext) {
let (view, cx) = open("", cx);
assert_eq!(view.read_with(cx, |view, _| view.mode), Mode::Visual);
focused(&view, cx);
cx.simulate_input("Hi");
assert_eq!(current(&view, cx), "Hi");
assert!(view.read_with(cx, |view, _| view.modified()));
}
#[gpui::test]
fn a_long_file_shows_once_its_blocks_are_read(cx: &mut TestAppContext) {
let long = "para\n\n".repeat(40_000);
let (view, cx) = open(&long, cx);
cx.executor()
.advance_clock(std::time::Duration::from_secs(1));
cx.run_until_parked();
let (shown, read) = view.read_with(cx, |view, cx| {
(view.shown, view.document.read(cx).read_now(cx))
});
assert!(shown && read, "shown with its blocks read");
}
#[gpui::test]
fn a_refresh_is_saved_text_and_keeps_no_undo(cx: &mut TestAppContext) {
let (view, cx) = open("one\n", cx);
view.update(cx, |view, cx| view.adopt("edited\n".into(), cx));
cx.run_until_parked();
assert!(view.read_with(cx, |view, _| view.modified()));
let disk = Source::decode(b"from disk\n".to_vec()).expect("reads");
view.update(cx, |view, cx| view.refresh(disk, cx));
cx.run_until_parked();
assert_eq!(current(&view, cx), "from disk\n");
assert!(!view.read_with(cx, |view, _| view.modified()));
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_keystrokes("secondary-z");
cx.run_until_parked();
assert_eq!(current(&view, cx), "from disk\n", "undo stops at the disk");
}
#[gpui::test]
fn the_shell_hears_a_load_it_subscribed_to(cx: &mut TestAppContext) {
cx.update(ely_gpui_component::init_for_tests);
cx.update(|cx| {
ely_ui::settings::install_defaults(cx);
ely_ui::i18n::install(cx);
});
let heard = std::rc::Rc::new(std::cell::Cell::new(0));
let (count, held) = (
heard.clone(),
std::rc::Rc::new(std::cell::RefCell::new(None)),
);
let holder = held.clone();
let (_root, cx) = cx.add_window_view(move |window, cx| {
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 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, _| {
count.set(count.get() + 1)
})
.detach();
holder.replace(Some(view));
gpui::Empty
});
cx.run_until_parked();
assert!(heard.get() > 0);
}
#[gpui::test]
fn a_text_grown_past_the_limit_goes_to_source(cx: &mut TestAppContext) {
let (view, cx) = open("# Old\n", cx);
let long = "x".repeat(ely_gpui_component::documents::LARGEST_READ + 1);
view.update(cx, |view, cx| view.adopt(long, cx));
cx.run_until_parked();
assert!(view.read_with(cx, |view, _| view.large()));
assert_eq!(view.read_with(cx, |view, _| view.mode), Mode::Source);
view.update_in(cx, |view, window, cx| {
view.switch(Mode::Preview, window, cx)
});
assert_eq!(view.read_with(cx, |view, _| view.mode), Mode::Source);
let outline = view.read_with(cx, |view, cx| view.outline(cx));
assert!(outline.is_empty(), "no stale heading: {outline:?}");
}
#[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");
}
#[gpui::test]
fn a_web_link_opens_outside(cx: &mut TestAppContext) {
let (view, cx) = open("x\n", cx);
view.update(cx, |view, cx| view.follow("https://ely.dev", cx));
assert_eq!(cx.opened_url().as_deref(), Some("https://ely.dev"));
}
#[gpui::test]
fn a_file_link_is_refused_and_said(cx: &mut TestAppContext) {
let (view, cx) = open("x\n", cx);
let said = std::rc::Rc::new(std::cell::Cell::new(false));
let heard = said.clone();
cx.update(|_, cx| {
cx.subscribe(&view, move |_, _: &super::Failed, _| heard.set(true))
.detach()
});
view.update(cx, |view, cx| view.follow("file:///etc/hosts", cx));
cx.run_until_parked();
assert!(said.get() && cx.opened_url().is_none());
}
/// A GIF whose frame never decodes.
fn broken_gif() -> Vec<u8> {
let gif =
"47494638396101000100800000000000ffffff21f90401000000002c0000000001000100000202ffff003b";
(0..gif.len())
.step_by(2)
.map(|at| u8::from_str_radix(&gif[at..at + 2], 16).expect("hex"))
.collect()
}
#[gpui::test]
fn a_broken_gif_ends_and_its_error_names_no_path(cx: &mut TestAppContext) {
let (_view, cx) = open("x\n", cx);
let folder = std::env::temp_dir().join(format!("ely-marker-{}", std::process::id()));
std::fs::create_dir_all(&folder).expect("a folder");
let path = folder.join("broken.gif");
std::fs::write(&path, broken_gif()).expect("the gif");
let resource = gpui::Resource::Path(path.into());
let loaded = loop {
let asked = cx.update(|_, cx| cx.fetch_asset::<gpui::ImageAssetLoader>(&resource));
if let Some(loaded) = asked {
break loaded;
}
cx.run_until_parked();
};
std::fs::remove_dir_all(&folder).expect("cleaned");
let error = loaded.expect_err("a broken frame fails the load");
assert!(!format!("{error:?}").contains("ely-marker"), "{error:?}");
}
#[gpui::test]
fn a_broken_inline_gif_ends(cx: &mut TestAppContext) {
let (_view, cx) = open("x\n", cx);
let renderer = cx.update(|_, cx| cx.svg_renderer());
let image = gpui::Image::from_bytes(gpui::ImageFormat::Gif, broken_gif());
assert!(image.to_image_data(renderer).is_err());
}
-89
View File
@@ -1,89 +0,0 @@
// Builds the page into the folder given; no network.
import { build } from 'esbuild'
import { copyFile, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
const out = process.argv[2]
if (!out) throw new Error('usage: node build.mjs <output folder>')
await rm(out, { recursive: true, force: true })
await mkdir(out, { recursive: true })
const built = await build({
entryPoints: ['src/main.js', 'src/diagram.js', 'src/main.css'],
outdir: out,
bundle: true,
minify: true,
format: 'iife',
target: 'chrome140',
loader: { '.woff2': 'file', '.woff': 'file', '.ttf': 'file' },
assetNames: 'fonts/[name]-[hash]',
legalComments: 'none',
metafile: true,
logLevel: 'warning',
})
// Exports carry one stylesheet: fonts inline, woff2 alone.
const exported = await build({
entryPoints: ['src/export.css'],
outdir: out,
bundle: true,
minify: true,
loader: { '.woff2': 'dataurl', '.woff': 'empty', '.ttf': 'empty' },
legalComments: 'none',
metafile: true,
logLevel: 'warning',
})
const sheet = join(out, 'export.css')
// The dropped woff and ttf left empty sources.
await writeFile(sheet, (await readFile(sheet, 'utf8')).replace(/,url\(\) format\("(?:woff|truetype)"\)/g, ''))
await copyFile('index.html', join(out, 'index.html'))
/** Each bundled package's folder under node_modules. */
function packages(inputs) {
const found = new Set()
for (const input of inputs) {
const at = input.lastIndexOf('node_modules/')
if (at < 0) continue
const parts = input.slice(at + 13).split('/')
const name = parts[0].startsWith('@') ? `${parts[0]}/${parts[1]}` : parts[0]
found.add(input.slice(0, at + 13) + name)
}
return [...found].sort()
}
/** Name, version and license; the file, else the manifest's. */
async function notice(folder) {
const manifest = JSON.parse(await readFile(join(folder, 'package.json'), 'utf8'))
const head = `${manifest.name} ${manifest.version} (${manifest.license})`
const file = (await readdir(folder)).find((name) => /^(licen[cs]e|copying)/i.test(name))
if (file) return `${head}\n\n${(await readFile(join(folder, file), 'utf8')).trim()}\n`
const author = manifest.author?.name ?? manifest.author
if (manifest.license !== 'MIT' || !author) throw new Error(`${manifest.name}: no license file`)
console.log(`${manifest.name} ships no license file; MIT from its manifest`)
return `${head}\n\nCopyright (c) ${author}\n\n${MIT}\n`
}
const MIT = `Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.`
const folders = packages([
...Object.keys(built.metafile.inputs),
...Object.keys(exported.metafile.inputs),
])
const notices = await Promise.all(folders.map(notice))
await writeFile(join(out, 'THIRD-PARTY-NOTICES.txt'), notices.join('\n' + '-'.repeat(72) + '\n\n'))
console.log(`built ${folders.length} packages into ${out}`)
-11
View File
@@ -1,11 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="main.css">
<script src="main.js"></script>
</head>
<body>
<main id="document"></main>
</body>
</html>
-4871
View File
File diff suppressed because it is too large. Load diff
-34
View File
@@ -1,34 +0,0 @@
{
"name": "ely-mark-content",
"private": true,
"type": "module",
"dependencies": {
"@codemirror/commands": "6.11.1",
"@codemirror/lang-markdown": "6.5.2",
"@codemirror/language": "6.12.4",
"@codemirror/language-data": "6.5.2",
"@codemirror/search": "6.7.2",
"@codemirror/state": "6.7.6",
"@codemirror/view": "6.43.13",
"@lezer/highlight": "1.2.5",
"@milkdown/kit": "7.22.2",
"codemirror": "6.0.2",
"hast-util-from-dom": "5.0.1",
"hast-util-to-text": "4.0.2",
"katex": "0.19.0",
"mermaid": "12.1.0",
"prosemirror-search": "1.1.1",
"rehype-katex": "7.0.1",
"rehype-stringify": "10.0.1",
"remark-gfm": "4.0.1",
"remark-math": "6.0.0",
"remark-parse": "11.0.0",
"remark-rehype": "11.1.2",
"unified": "11.0.5",
"unist-util-visit": "5.1.0",
"vfile": "6.0.3"
},
"devDependencies": {
"esbuild": "0.28.2"
}
}
-10
View File
@@ -1,10 +0,0 @@
/** One request to the app, as a promise. */
export function ask(request) {
return new Promise((resolve, reject) => {
window.elyQuery({
request: JSON.stringify(request),
onSuccess: resolve,
onFailure: (code, message) => reject(new Error(`${code}: ${message}`)),
})
})
}
-44
View File
@@ -1,44 +0,0 @@
// Code blocks in CodeMirror; LaTeX and Mermaid preview.
import { basicSetup, EditorView } from 'codemirror'
import { EditorState } from '@codemirror/state'
import { keymap } from '@codemirror/view'
import { indentWithTab } from '@codemirror/commands'
import { languages } from '@codemirror/language-data'
import { codeBlockConfig } from '@milkdown/kit/component/code-block'
import { theme } from './theme.js'
import { icons } from './icons.js'
import { render } from './math.js'
/** Previews for one editor's code blocks. */
function previews(owner) {
return (language, source, apply) => {
const kind = source.length === 0 ? '' : language.toLowerCase()
switch (kind) {
case 'latex':
return render(source, true, document.createElement('div'))
case 'mermaid':
return owner.show(source, apply)
default:
return null
}
}
}
/** Milkdown's code block config; read-only in preview. */
export function code(texts, editable, owner) {
const locked = editable ? [] : [EditorState.readOnly.of(true), EditorView.editable.of(false)]
return (ctx) =>
ctx.update(codeBlockConfig.key, (prev) => ({
...prev,
...icons,
extensions: [basicSetup, keymap.of([indentWithTab]), theme, ...locked],
languages,
searchPlaceholder: texts.searchLanguage,
noResultText: texts.noResult,
copyText: texts.copy,
previewLabel: texts.preview,
previewLoading: texts.loading,
previewToggleButton: (only) => (only ? texts.edit : texts.hide),
renderPreview: previews(owner),
}))
}
-34
View File
@@ -1,34 +0,0 @@
// Mermaid, loaded on first use; only saved source renders.
import mermaid from 'mermaid'
let count = 0
/** The theme from the page's Ely tokens. */
function variables() {
const style = getComputedStyle(document.documentElement)
const token = (name) => style.getPropertyValue(`--ely-${name}`).trim()
return {
background: token('bg'),
primaryColor: token('surface'),
primaryTextColor: token('fg'),
primaryBorderColor: token('border'),
lineColor: token('fg-muted'),
textColor: token('fg'),
fontFamily: token('font'),
}
}
/** `source` as SVG; `paper` draws Mermaid's light theme for export. */
window.elyDiagram = async (source, look) => {
const paper = look === 'paper'
mermaid.initialize({
startOnLoad: false,
securityLevel: 'strict',
suppressErrorRendering: true,
theme: paper ? 'default' : 'base',
themeVariables: paper ? {} : variables(),
})
count += 1
const { svg } = await mermaid.render(`ely-diagram-${count}`, source)
return svg
}
-90
View File
@@ -1,90 +0,0 @@
// Mermaid previews; each editor owns its records.
import { failure } from './failure.js'
// Beside this script; the page's base is the document's folder.
const here = document.currentScript.src
/** The Mermaid bundle, added once as a classic script. */
let loading
export function bundle() {
loading ??= new Promise((resolve, reject) => {
const script = document.createElement('script')
script.src = new URL('diagram.js', here).href
script.onload = () => resolve(window.elyDiagram)
script.onerror = () => reject(new Error('diagram.js did not load'))
document.head.append(script)
})
return loading
}
let queue = Promise.resolve()
let count = 0
const owners = new Set()
// New tokens mean a new theme; every owner redraws.
new MutationObserver(() => {
for (const owner of owners) owner.redraw()
}).observe(document.documentElement, { attributes: true, attributeFilter: ['style'] })
/** A block's diagram, by its id on the page. */
const box = (id, inner) => `<div class="ely-diagram" data-diagram="${id}">${inner}</div>`
const placed = (id) => document.querySelector(`[data-diagram="${id}"]`) !== null
// After the code block's next render.
const settle = () => new Promise((done) => setTimeout(done))
/** One editor's diagrams; a result lands only on its placeholder. */
export function diagrams() {
const records = new Map()
/** Drops diagrams whose placeholder has left the page. */
const sweep = () => {
for (const [id, record] of records) {
if (record.settled && !placed(id)) records.delete(id)
}
}
/** Each draw has a turn; a later draw voids earlier. */
const draw = (id, record) => {
const turn = ++record.turn
const live = () => records.get(id) === record && record.turn === turn && placed(id)
queue = queue.then(async () => {
await settle()
if (!live()) return
try {
const render = await bundle()
if (!live()) return
const svg = await render(record.source)
await settle()
if (live()) record.apply(box(id, svg))
} catch (error) {
await settle()
if (!live()) return
records.delete(id)
record.apply(failure(record.source, error.message))
}
})
}
const owner = {
/** The placeholder for `source`; its diagram follows. */
show(source, apply) {
sweep()
const id = `${++count}`
const record = { source, apply, settled: false, turn: 0 }
records.set(id, record)
settle().then(() => (record.settled = true))
draw(id, record)
return box(id, '')
},
redraw() {
sweep()
for (const [id, record] of records) draw(id, record)
},
release() {
records.clear()
owners.delete(owner)
},
}
owners.add(owner)
return owner
}
-53
View File
@@ -1,53 +0,0 @@
// Myers' diff over two lists.
/** Equal pairs between `a` and `b`, in order. */
export function common(a, b, same) {
const n = a.length
const m = b.length
const max = n + m
const reach = new Map([[1, 0]])
const trace = []
for (let d = 0; d <= max; d++) {
trace.push(new Map(reach))
for (let k = -d; k <= d; k += 2) {
const down = k === -d || (k !== d && reach.get(k - 1) < reach.get(k + 1))
let x = down ? reach.get(k + 1) : reach.get(k - 1) + 1
let y = x - k
while (x < n && y < m && same(a[x], b[y])) {
x += 1
y += 1
}
reach.set(k, x)
if (x >= n && y >= m) return walk(trace, n, m)
}
}
throw new Error('diff did not end')
}
/** Back through the trace: each equal pair, in order. */
function walk(trace, n, m) {
const pairs = []
let x = n
let y = m
for (let d = trace.length - 1; d > 0; d--) {
const reach = trace[d]
const k = x - y
const down = k === -d || (k !== d && reach.get(k - 1) < reach.get(k + 1))
const from = down ? k + 1 : k - 1
const fromX = reach.get(from)
const fromY = fromX - from
while (x > fromX + (down ? 0 : 1) && y > fromY + (down ? 1 : 0)) {
x -= 1
y -= 1
pairs.push([x, y])
}
x = fromX
y = fromY
}
while (x > 0 && y > 0) {
x -= 1
y -= 1
pairs.push([x, y])
}
return pairs.reverse()
}
-72
View File
@@ -1,72 +0,0 @@
/* Exports: KaTeX with its fonts inline, on light paper. */
@import 'katex/dist/katex.min.css';
:root {
color-scheme: light;
}
body {
margin: 0 auto;
max-width: 48rem;
padding: 2rem 1.5rem;
background: #fff;
color: #1f2328;
font: 16px / 1.6 -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif;
}
a {
color: #0969da;
}
pre,
code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.875em;
}
pre {
overflow-x: auto;
padding: 0.75rem 1rem;
border-radius: 6px;
background: #f6f8fa;
}
blockquote {
margin: 0;
padding: 0 1rem;
border-left: 3px solid #d0d7de;
color: #59636e;
}
table {
border-collapse: collapse;
}
th,
td {
padding: 0.25rem 0.75rem;
border: 1px solid #d0d7de;
}
img,
figure.diagram svg {
max-width: 100%;
}
figure.diagram {
margin: 1rem 0;
text-align: center;
}
@media print {
body {
max-width: none;
padding: 0;
}
pre,
table,
figure {
break-inside: avoid;
}
}
-11
View File
@@ -1,11 +0,0 @@
/** The source as saved, and why it did not render. */
export function failure(source, message, at) {
const box = document.createElement('div')
box.className = 'ely-failure'
const why = document.createElement('p')
why.textContent = at === undefined ? message : `${message} (${at})`
const code = document.createElement('pre')
code.textContent = source
box.append(why, code)
return box
}
-138
View File
@@ -1,138 +0,0 @@
// Find and replace in either editor (MARK-06).
import {
SearchQuery as TextQuery,
findNext as nextText,
findPrevious as previousText,
replaceAll as allText,
replaceNext as oneText,
setSearchQuery,
} from '@codemirror/search'
import {
SearchQuery as DocQuery,
findNext as nextDoc,
findPrev as previousDoc,
replaceAll as allDoc,
replaceNext as oneDoc,
setSearchState,
} from 'prosemirror-search'
import { TextSelection } from '@milkdown/kit/prose/state'
/** Why a pattern fails under the editors' `gmu` flags. */
function broken(spec) {
if (!spec.regexp || !spec.search) return null
try {
new RegExp(spec.search, 'gmu')
return null
} catch (error) {
return error.message
}
}
/** Whether two specs find the same hits. */
export function sameQuery(a, b) {
return ['search', 'caseSensitive', 'regexp', 'wholeWord'].every((key) => a[key] === b[key])
}
/** One character's width at `pos`: two for a surrogate pair. */
function width(doc, pos) {
const next = doc.textBetween(pos, Math.min(pos + 2, doc.content.size), '\n', '\n')
return next.codePointAt(0) > 0xffff ? 2 : 1
}
/** Where the selection stands among `ranges`. */
function standing(ranges, from, to) {
const at = ranges.findIndex((range) => range.from === from && range.to === to)
return { current: at < 0 ? null : at, total: ranges.length }
}
/** Find in Milkdown's view; `prosemirror-search` is installed there. */
export function docFinder(view) {
let spec = { search: '' }
const query = () => new DocQuery(spec)
const ranges = () => {
const found = []
const asked = query()
if (!asked.valid) return found
const { doc } = view.state
for (let at = 0; ;) {
const hit = asked.findNext(view.state, at)
// Mid-pair, a Unicode pattern answers before `at`.
if (!hit || hit.from < at) return found
found.push(hit)
at = hit.to > hit.from ? hit.to : hit.to + width(doc, hit.to)
}
}
const status = () => {
const error = broken(spec)
if (error) return { error }
const { from, to } = view.state.selection
return standing(ranges(), from, to)
}
const run = (command) => command(view.state, view.dispatch)
return {
seek(next) {
spec = next
const { from } = view.state.selection
const tr = setSearchState(view.state.tr, query())
// From the hit's start: the hit stays while typing.
tr.setSelection(TextSelection.near(tr.doc.resolve(from)))
view.dispatch(tr)
if (query().valid) run(nextDoc)
return status()
},
step(forward) {
if (query().valid) run(forward ? nextDoc : previousDoc)
return status()
},
replace(all) {
if (query().valid) run(all ? allDoc : oneDoc)
return status()
},
clear() {
spec = { search: '' }
view.dispatch(setSearchState(view.state.tr, query()))
},
}
}
/** Find in CodeMirror; its `search()` extension is installed there. */
export function textFinder(view) {
let spec = { search: '' }
const valid = () => new TextQuery(spec).valid
const ranges = () => {
const found = []
const asked = new TextQuery(spec)
if (!asked.valid) return found
const cursor = asked.getCursor(view.state)
for (let next = cursor.next(); !next.done; next = cursor.next()) found.push(next.value)
return found
}
const status = () => {
const error = broken(spec)
if (error) return { error }
const { from, to } = view.state.selection.main
return standing(ranges(), from, to)
}
return {
seek(next) {
spec = next
const selection = { anchor: view.state.selection.main.from }
view.dispatch({ effects: setSearchQuery.of(new TextQuery(spec)), selection })
// CodeMirror opens its own panel for invalid queries.
if (valid()) nextText(view)
return status()
},
step(forward) {
if (valid()) (forward ? nextText : previousText)(view)
return status()
},
replace(all) {
if (valid()) (all ? allText : oneText)(view)
return status()
},
clear() {
spec = { search: '' }
view.dispatch({ effects: setSearchQuery.of(new TextQuery(spec)) })
},
}
}
-12
View File
@@ -1,12 +0,0 @@
// Code block icons, drawn in the text color.
const icon = (path) =>
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`
export const icons = {
expandIcon: icon('<path d="m6 9 6 6 6-6"/>'),
searchIcon: icon('<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>'),
clearSearchIcon: icon('<path d="M18 6 6 18M6 6l12 12"/>'),
copyIcon: icon(
'<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/>',
),
}
-14
View File
@@ -1,14 +0,0 @@
// Untitled images: remark gives null, the schema wants text.
import { imageSchema } from '@milkdown/kit/preset/commonmark'
export const image = imageSchema.extendSchema((prev) => (ctx) => {
const base = prev(ctx)
return {
...base,
parseMarkdown: {
match: base.parseMarkdown.match,
runner: (state, node, type) =>
state.addNode(type, { src: node.url, alt: node.alt ?? '', title: node.title ?? '' }),
},
}
})
-22
View File
@@ -1,22 +0,0 @@
// The writing view: focus, typewriter, width, font, size (MARK-09).
let typewriter = false
/** Applies `look`; the Markdown never changes. */
export function apply(look) {
const root = document.documentElement
root.dataset.focus = look.focus
root.dataset.width = look.width
root.dataset.font = look.font
root.dataset.typewriter = look.typewriter
typewriter = look.typewriter
const sheet =
document.getElementById('ely-look') ??
document.head.appendChild(Object.assign(document.createElement('style'), { id: 'ely-look' }))
sheet.textContent = `:root { --ely-text-scale: ${look.size / 100}; }`
}
/** Typewriter scrolling: the caret's line stays mid-view. */
export function follow(editor) {
if (typewriter) editor?.center?.()
}
-320
View File
@@ -1,320 +0,0 @@
@import 'katex/dist/katex.min.css';
@import '@milkdown/kit/prose/view/style/prosemirror.css';
html,
body {
margin: 0;
height: 100%;
background: var(--ely-bg);
color: var(--ely-fg);
font: calc(16px * var(--ely-font-scale, 1) * var(--ely-text-scale, 1)) / 1.6 var(--ely-font),
sans-serif;
}
::selection {
background: var(--ely-selection);
}
a {
color: var(--ely-link);
}
#document {
box-sizing: border-box;
max-width: 48rem;
margin: 0 auto;
padding: 2rem 1.5rem;
}
:root[data-width='narrow'] #document {
max-width: 36rem;
}
:root[data-width='wide'] #document {
max-width: 64rem;
}
:root[data-font='serif'] #document:not([data-mode='source']) {
font-family: ui-serif, Georgia, serif;
}
:root[data-font='mono'] #document:not([data-mode='source']) {
font-family: var(--ely-mono), monospace;
}
/* Typewriter: room to center the first and last lines. */
:root[data-typewriter='true'] #document:not([data-mode='source']) {
padding-block: 50vh;
}
:root[data-typewriter='true'] .cm-content {
padding-block: 50vh;
}
/* Focus mode: the caret's block, or line, stays bright. */
:root[data-focus='true'] .milkdown .ProseMirror > :not(.ely-current),
:root[data-focus='true'] .cm-line:not(.cm-activeLine) {
opacity: 0.35;
}
@media (prefers-reduced-motion: no-preference) {
.milkdown .ProseMirror > *,
.cm-line {
transition: opacity 150ms;
}
}
#document[data-mode='source'] {
max-width: none;
height: 100%;
padding: 0;
}
#document[data-mode='source'] > div,
#document[data-mode='source'] .cm-editor {
height: 100%;
}
.milkdown .ProseMirror {
outline: none;
}
.milkdown blockquote {
margin: 0;
padding-left: 1rem;
border-left: 3px solid var(--ely-border);
color: var(--ely-fg-muted);
}
.milkdown code {
font-family: var(--ely-mono), monospace;
font-size: 0.875em;
background: var(--ely-sunken);
border-radius: 4px;
padding: 0 0.25em;
}
.milkdown table {
border-collapse: collapse;
}
.milkdown th,
.milkdown td {
border: 1px solid var(--ely-border);
padding: 0.25rem 0.75rem;
}
.milkdown li > p,
.milkdown th > p,
.milkdown td > p {
margin: 0.25em 0;
}
.milkdown li[data-item-type='task'] {
display: flex;
gap: 0.5em;
list-style: none;
}
.milkdown li[data-item-type='task']::before {
content: '☐ ';
}
.milkdown li[data-item-type='task'][data-checked='true']::before {
content: '☑ ';
}
.milkdown hr {
border: none;
border-top: 1px solid var(--ely-border);
}
.milkdown img {
max-width: 100%;
}
.milkdown-code-block {
display: block;
position: relative;
margin: 0.5rem 0;
padding: 0.5rem 1rem 1rem;
background: var(--ely-surface);
border: 1px solid var(--ely-border);
border-radius: 6px;
}
.milkdown-code-block.selected {
outline: 1px solid var(--ely-focus);
}
.milkdown-code-block .hidden {
display: none !important;
}
.milkdown-code-block .tools {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
font-size: 0.75rem;
}
.milkdown-code-block button,
.milkdown-code-block .language-button {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border: 1px solid var(--ely-border);
border-radius: 4px;
background: var(--ely-sunken);
color: var(--ely-fg-muted);
font: inherit;
cursor: pointer;
}
[data-mode='preview'] .milkdown-code-block .language-button {
pointer-events: none;
}
.milkdown-code-block .tools-button-group {
display: flex;
gap: 4px;
}
.milkdown-code-block button:hover,
.milkdown-code-block .language-button:hover {
background: var(--ely-hover);
}
.milkdown-code-block .tools svg,
.milkdown-code-block .language-picker svg {
width: 14px;
height: 14px;
}
.ely-diagram svg {
max-width: 100%;
height: auto;
}
.milkdown-code-block .language-picker {
position: absolute;
z-index: 1;
padding-top: 4px;
}
.milkdown-code-block .list-wrapper {
width: 15rem;
padding-top: 0.5rem;
background: var(--ely-surface);
border: 1px solid var(--ely-border);
border-radius: 6px;
}
.milkdown-code-block .search-box {
display: flex;
align-items: center;
gap: 6px;
margin: 0 0.5rem 0.5rem;
padding: 4px 8px;
border: 1px solid var(--ely-border);
border-radius: 4px;
}
.milkdown-code-block .search-box input {
width: 100%;
border: none;
outline: none;
background: transparent;
color: var(--ely-fg);
font: inherit;
}
.milkdown-code-block .search-icon {
display: none;
}
.milkdown-code-block .language-list {
max-height: 20rem;
overflow-y: auto;
margin: 0;
padding: 0;
}
.milkdown-code-block .language-list-item {
display: flex;
gap: 8px;
padding: 2px 1rem;
cursor: pointer;
}
.milkdown-code-block .language-list-item:hover,
.milkdown-code-block .language-list-item:focus-visible {
outline: none;
background: var(--ely-hover);
}
.milkdown-code-block .language-list-item.no-result {
cursor: default;
color: var(--ely-fg-subtle);
}
.milkdown-code-block .preview-panel .preview-divider {
height: 1px;
margin: 0.5rem 0;
background: var(--ely-border);
}
.milkdown-code-block .preview-panel .preview-label {
margin: 0.5rem 0;
font-size: 0.75rem;
color: var(--ely-fg-muted);
}
.milkdown-code-block .preview {
overflow-x: auto;
text-align: center;
}
.ely-failure {
text-align: left;
color: var(--ely-danger);
}
.ely-failure pre {
margin: 0;
white-space: pre-wrap;
color: var(--ely-fg);
font-family: var(--ely-mono), monospace;
}
.ely-front {
margin: 0 0 1rem;
padding: 0.5rem 1rem;
border: 1px solid var(--ely-border);
border-radius: 6px;
color: var(--ely-fg-muted);
font-family: var(--ely-mono), monospace;
white-space: pre-wrap;
}
.ely-raw {
margin: 0.5rem 0;
padding: 0.25rem 0.5rem;
background: var(--ely-sunken);
border-radius: 4px;
font-family: var(--ely-mono), monospace;
font-size: 0.875em;
white-space: pre-wrap;
}
.ProseMirror-search-match,
.cm-searchMatch {
background: var(--ely-selection);
}
.ProseMirror-active-search-match,
.cm-searchMatch-selected {
outline: 1px solid var(--ely-focus);
}
-305
View File
@@ -1,305 +0,0 @@
// The page the app drives: one document, three modes.
import { visual } from './visual.js'
import { source } from './source.js'
import { ask } from './bridge.js'
import { sameQuery } from './find.js'
import { apply, follow } from './look.js'
import { html, text } from './render.js'
let texts = {}
let latest = 0
let version = 0
// The app's count of page loads; reports carry it.
let generation = 0
let timer
let pending = null
/** The editor shown. */
let current = null
// A mode asked for while an editor builds.
let asked = null
// A place to show once the editor is built.
let revealing = null
// The search in force, kept across modes.
let seeking = null
/** The file's texts where a mode with edits ended (§14.3). */
let times = []
let at = 0
/** YAML front matter, kept out of the visual editor. */
function split(text) {
const front = /^---\n[\s\S]*?\n(?:---|\.\.\.)[ \t]*(?:\n|$)/.exec(text)
return front ? [front[0], text.slice(front[0].length)] : ['', text]
}
const tell = (said) => ask(said).catch((error) => console.error(`ely: ${error.message}`))
/** Sends the waiting text now. */
function flush() {
clearTimeout(timer)
if (pending) {
tell(pending())
outline()
}
pending = null
}
/** The shown editor's headings, for the app's outline. */
function outline() {
if (current) tell({ kind: 'outline', generation, items: current.outline() })
}
/** A new text: told now, its words once typing rests. */
function revised(text) {
version += 1
const now = version
tell({ kind: 'changed', version: now, generation })
clearTimeout(timer)
pending = () => ({ kind: 'text', version: now, generation, text: text() })
timer = setTimeout(flush, 300)
}
/** An edit: later texts in the timeline are gone. */
function changed(text) {
times.length = at + 1
revised(text)
}
/** An editor for `mode`; visual falls back to source. */
async function build(text, mode) {
const holder = document.createElement('div')
holder.hidden = true
document.getElementById('document').append(holder)
const entry = { mode, holder, fallback: false }
if (mode !== 'source') {
const [front, body] = split(text)
if (front) {
const raw = document.createElement('pre')
raw.className = 'ely-front'
raw.textContent = front
holder.append(raw)
}
const place = document.createElement('div')
holder.append(place)
const edited = (write) => changed(() => front + write())
const made = await visual(place, body, mode === 'visual', texts, edited, edge)
if (made) {
// Lines count from the file's top, front matter included.
const above = front ? front.split('\n').length - 1 : 0
const line = (n, query) => {
if (n > above) return made.line(n - above, query)
holder.firstChild.scrollIntoView({ block: 'center' })
return true
}
const redone = () => {
const ahead = made.redone()
return ahead === null ? null : front + ahead
}
const where = () => {
const { line, column } = made.where()
return { line: above + line, column }
}
const at = (n, column) => (n > above ? made.at(n - above, column) : line(n, ''))
return { ...entry, ...made, text: () => front + made.text(), redone, line, where, at }
}
// Blocks the editor cannot map open as source.
holder.replaceChildren()
Object.assign(entry, { mode: 'source', fallback: true })
}
return { ...entry, ...source(holder, text, changed, edge) }
}
function drop(entry) {
entry.close()
entry.holder.remove()
}
/** Shows `text` as `want`; the newest request alone lands. */
async function show(text, want) {
const turn = ++latest
asked = null
if (current) drop(current)
current = null
const entry = await build(text, want)
if (turn !== latest) return drop(entry)
current = entry
entry.holder.hidden = false
document.getElementById('document').dataset.mode = entry.mode
entry.focus()
await ask({ kind: 'shown', mode: entry.mode, fallback: entry.fallback })
// A newer request owns what is queued now.
if (turn !== latest) return
outline()
if (seeking) found(entry.find.seek(seeking))
const place = revealing
revealing = null
if (place) reveal(place)
const next = asked
asked = null
if (next && next !== entry.mode) return mode(next)
}
/** Undo or redo past an editor's own history. */
function edge(direction) {
const to = direction === 'undo' ? at - 1 : at + 1
if (!current || to < 0 || to >= times.length) return false
flush()
at = to
const text = times[at]
revised(() => text)
show(text, current.mode)
return true
}
/** Relative links and images resolve under `href`. */
function base(href) {
const link =
document.querySelector('base') ?? document.head.appendChild(document.createElement('base'))
link.href = href
}
/** Puts the caret on `place.line`, the page at `place.scroll`. */
function settle(place) {
if (!current || !place) return
current.at(place.line, place.column)
document.scrollingElement.scrollTop = place.scroll
}
/** A document, after each page load; `place` restores a session. */
async function open(text, want, words, load, folder, place) {
texts = words
generation = load
times = [text]
at = 0
base(folder)
await show(text, want)
settle(place)
}
/** Changed on disk: the caret and scroll stay near. */
async function refresh(text) {
const place = current && { ...current.where(), scroll: document.scrollingElement.scrollTop }
clearTimeout(timer)
pending = null
times = [text]
at = 0
await show(text, current?.mode ?? 'visual')
settle(place)
}
/** `text` in place of the editor's, as one edit. */
function adopt(text) {
if (current) flush()
times.length = at + 1
times.push(text)
at += 1
revised(() => text)
return show(text, current?.mode ?? 'visual')
}
let placing
/** The caret's line and the scroll, once they rest. */
function placed() {
clearTimeout(placing)
placing = setTimeout(() => {
if (!current) return
const scroll = document.scrollingElement.scrollTop
tell({ kind: 'place', generation, ...current.where(), scroll })
}, 500)
}
document.addEventListener('selectionchange', () => {
placed()
follow(current)
})
window.addEventListener('scroll', placed, { passive: true })
/** Another mode for the same text; edits are sent first. */
function mode(next) {
if (!current) {
asked = next
return
}
const text = current.text()
const ahead = current.redone?.() ?? null
flush()
// Leaving a mode with edits marks the timeline.
if (text !== times[at]) {
times.length = at + 1
times.push(text)
at += 1
}
// Undone steps stay ahead, for redo.
if (ahead !== null && ahead !== text) {
times.length = at + 1
times.push(ahead)
}
return show(text, next)
}
/** Shows `{ heading }` or `{ line, query }`. */
function reveal(place) {
if (!current) {
revealing = place
return
}
const shown =
'heading' in place ? current.heading(place.heading) : current.line(place.line, place.query)
if (shown === false) console.warn(`ely: nothing at ${JSON.stringify(place)}`)
}
/** The app's find box hears where the search stands. */
const found = (status) => tell({ kind: 'found', generation, ...status })
/** Searches for `spec`: search, caseSensitive, regexp, wholeWord, replace. */
function find(spec) {
seeking = spec
if (current) found(current.find.seek(spec))
}
/** The box's spec rules; a new query starts afresh. */
function step({ spec, forward }) {
const fresh = !seeking || !sameQuery(seeking, spec)
find(spec)
if (current && !fresh) found(current.find.step(forward))
}
function replace({ spec, all }) {
find(spec)
if (current && current.mode !== 'preview') found(current.find.replace(all))
}
function unfind() {
seeking = null
current?.find.clear()
}
/** The writing view's settings (MARK-09). */
function look(settings) {
apply(settings)
follow(current)
}
/** The whole text as `kind`: html or text, tagged `ask`. */
async function render({ kind, ask: asked }) {
try {
if (!current) throw new Error('no document is shown')
const [, body] = split(current.text())
const result = kind === 'text' ? { text: await text(body) } : await html(body)
tell({ kind: 'rendered', generation, ask: asked, ...result })
} catch (error) {
tell({ kind: 'rendered', generation, ask: asked, error: error.message })
}
}
/** A table step; the app hears whether it ran. */
function table(op) {
const done = current?.table?.(op) ?? false
tell({ kind: 'table', generation, op, done })
}
/** Image links at the caret; preview takes none. */
function insert(images) {
const done = current?.insert?.(images) ?? false
tell({ kind: 'inserted', generation, done })
}
window.ely = { open, refresh, adopt, look, render, mode, base, reveal, find, step, replace, unfind, table, insert }
-100
View File
@@ -1,100 +0,0 @@
// KaTeX math, from Milkdown Crepe's latex feature.
import katex from 'katex'
import remarkMath from 'remark-math'
import { visit } from 'unist-util-visit'
import { $nodeSchema, $remark } from '@milkdown/kit/utils'
import { codeBlockSchema } from '@milkdown/kit/preset/commonmark'
import { failure } from './failure.js'
/** Saved source as HTML; a bad one shows where. */
export function render(source, display, dom) {
try {
katex.render(source, dom, { displayMode: display, throwOnError: true })
} catch (error) {
const at = error instanceof katex.ParseError ? error.position : undefined
dom.replaceChildren(failure(source, error.message, at))
}
return dom
}
// remark-math, plus each inline formula's `$` count from the source.
const remark = $remark('remarkMath', () => function (options) {
remarkMath.call(this, options)
return (tree, file) => {
const text = String(file.value)
visit(tree, 'inlineMath', (node) => {
let at = node.position?.start.offset ?? 0
let fence = 0
while (text[at++] === '$') fence++
node.data = { ...node.data, fence: Math.max(fence, 1) }
})
}
})
// Block math becomes a code block in language LaTeX.
const blocks = $remark(
'remarkMathBlock',
() => () => (tree) =>
visit(tree, 'math', (node, index, parent) => {
parent.children.splice(index, 1, {
type: 'code',
lang: 'LaTeX',
value: node.value,
position: node.position,
})
}),
)
const block = codeBlockSchema.extendSchema((prev) => (ctx) => {
const base = prev(ctx)
return {
...base,
toMarkdown: {
match: base.toMarkdown.match,
runner: (state, node) => {
if ((node.attrs.language ?? '').toLowerCase() !== 'latex') {
return base.toMarkdown.runner(state, node)
}
state.addNode('math', undefined, node.content.firstChild?.text || '')
},
},
}
})
const inline = $nodeSchema('math_inline', () => ({
group: 'inline',
inline: true,
atom: true,
attrs: { value: { default: '' }, fence: { default: 1 } },
parseDOM: [
{
tag: 'span[data-type="math_inline"]',
getAttrs: (dom) => ({ value: dom.dataset.value ?? '', fence: Number(dom.dataset.fence ?? 1) }),
},
],
toDOM: (node) => {
const dom = document.createElement('span')
dom.dataset.type = 'math_inline'
dom.dataset.value = node.attrs.value
dom.dataset.fence = node.attrs.fence
// `$$…$$` displays, as GitHub shows it.
return render(node.attrs.value, node.attrs.fence > 1, dom)
},
parseMarkdown: {
match: (node) => node.type === 'inlineMath',
runner: (state, node, type) =>
state.addNode(type, { value: node.value, fence: node.data?.fence ?? 1 }),
},
toMarkdown: {
match: (node) => node.type.name === 'math_inline',
runner: (state, node) => {
const { value, fence } = node.attrs
if (fence === 1) return state.addNode('inlineMath', undefined, value)
// The stringifier knows one `$`; the source's own count stays.
const dollars = '$'.repeat(fence)
state.addNode('html', undefined, `${dollars}${value}${dollars}`)
},
},
}))
export const math = [remark, blocks, block, inline].flat()
-144
View File
@@ -1,144 +0,0 @@
// Blocks Milkdown cannot write back stay raw text (§14.3).
import { editorViewCtx, remarkCtx, schemaCtx, serializerCtx } from '@milkdown/kit/core'
import { Fragment } from '@milkdown/kit/prose/model'
import { Selection } from '@milkdown/kit/prose/state'
import { $nodeSchema } from '@milkdown/kit/utils'
import { common } from './diff.js'
/** Source shown and written as it is. */
export const raw = $nodeSchema('ely_raw', () => ({
group: 'block',
content: 'text*',
marks: '',
code: true,
defining: true,
isolating: true,
parseDOM: [{ tag: 'pre.ely-raw', preserveWhitespace: 'full' }],
toDOM: () => ['pre', { class: 'ely-raw' }, 0],
parseMarkdown: { match: () => false, runner: () => {} },
toMarkdown: {
match: (node) => node.type.name === 'ely_raw',
runner: (state, node) => state.addNode('html', undefined, node.textContent),
},
}))
/** Each block's span, as Milkdown reads it; null if unplaced. */
function spans(ctx, text) {
const remark = ctx.get(remarkCtx)
const tree = remark.runSync(remark.parse(text), text)
const at = tree.children.map(
(node) => node.position && [node.position.start.offset, node.position.end.offset],
)
return at.every(Boolean) ? at : null
}
/** The text in `from`..`to` past its outer whitespace. */
function inner(text, from, to) {
const piece = text.slice(from, to)
const start = piece.search(/\S/)
if (start < 0) return null
return [from + start, from + piece.trimEnd().length]
}
/** Raw blocks in place; edits then rewrite only their blocks. */
export function faithful(ctx, original) {
const view = ctx.get(editorViewCtx)
const parsed = view.state.doc
const at = spans(ctx, original)
// No blocks: Milkdown still holds one empty paragraph.
const blank = at?.length === 0 && parsed.childCount === 1 && parsed.textContent === ''
if (!at || (at.length !== parsed.childCount && !blank)) return null
const schema = ctx.get(schemaCtx)
const serializer = ctx.get(serializerCtx)
const written = (nodes) => serializer(schema.topNodeType.create(null, nodes)).replace(/\n+$/, '')
const rawOf = ([from, to]) => {
const text = original.slice(from, to)
return schema.nodes.ely_raw.create(null, text ? schema.text(text) : null)
}
const nodes = []
const places = []
// Text Milkdown drops between blocks, as raw blocks.
const gap = (from, to) => {
const place = inner(original, from, to)
if (!place) return
nodes.push(rawOf(place))
places.push(place)
}
let last = 0
if (!blank)
parsed.forEach((node, _, index) => {
gap(last, at[index][0])
const exact = written([node]) === original.slice(...at[index])
nodes.push(exact ? node : rawOf(at[index]))
places.push(at[index])
last = at[index][1]
})
gap(last, original.length)
if (nodes.length) {
const swap = view.state.tr.replaceWith(0, parsed.content.size, Fragment.from(nodes))
swap.setSelection(Selection.atStart(swap.doc))
view.dispatch(swap.setMeta('addToHistory', false))
}
const first = view.state.doc
const before = []
first.forEach((node) => before.push(node))
/** Where each fresh block sits in `text` from `at`. */
const found = (nodes, text, at, spans) => {
let from = 0
for (const node of nodes) {
const own = written([node])
const place = text.indexOf(own, from)
spans.push(place < 0 ? null : [at + place, at + place + own.length])
if (place >= 0) from = place + own.length
}
}
// The text and each block's span in it.
return (doc) => {
if (doc.eq(first)) return { text: original, spans: places }
const after = []
doc.forEach((node) => after.push(node))
if (!places.length) {
const fresh = written(after)
const spans = []
found(after, fresh, original.length, spans)
return { text: `${original}${fresh}\n`, spans }
}
const pairs = common(before, after, (x, y) => x === y || x.eq(y))
pairs.push([before.length, after.length])
let out = ''
let cursor = 0
let [i, j] = [0, 0]
const spans = []
for (const [x, y] of pairs) {
if (x > i || y > j) {
const fresh = y > j ? written(after.slice(j, y)) : ''
let start, end, text
if (x > i && fresh) {
;[start, end, text] = [places[i][0], places[x - 1][1], fresh]
} else if (x > i && i > 0) {
// Removed blocks take the gap before them.
;[start, end, text] = [places[i - 1][1], places[x - 1][1], '']
} else if (x > i && x < places.length) {
;[start, end, text] = [places[0][0], places[x][0], '']
} else if (x > i) {
;[start, end, text] = [places[0][0], places[x - 1][1], '']
} else if (i > 0) {
;[start, end, text] = [places[i - 1][1], places[i - 1][1], `\n\n${fresh}`]
} else {
;[start, end, text] = [places[0][0], places[0][0], `${fresh}\n\n`]
}
out += original.slice(cursor, start)
found(after.slice(j, y), text, out.length, spans)
out += text
cursor = end
}
// Unchanged blocks keep their bytes, shifted.
if (x < places.length) {
const shift = out.length - cursor
spans.push([places[x][0] + shift, places[x][1] + shift])
}
;[i, j] = [x + 1, y + 1]
}
return { text: out + original.slice(cursor), spans }
}
}
-110
View File
@@ -1,110 +0,0 @@
// The whole document as one page or as text (MARK-11).
import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import remarkRehype from 'remark-rehype'
import rehypeKatex from 'rehype-katex'
import { fromDom } from 'hast-util-from-dom'
import rehypeStringify from 'rehype-stringify'
import { toText } from 'hast-util-to-text'
import { visit } from 'unist-util-visit'
import { VFile } from 'vfile'
import { bundle } from './diagrams.js'
const markdown = () => unified().use(remarkParse).use(remarkGfm).use(remarkMath)
/** Mermaid drawn for paper; a broken one stays code. */
async function draw(tree) {
const blocks = []
visit(tree, 'code', (node) => {
if (node.lang === 'mermaid') blocks.push(node)
})
const failed = []
if (!blocks.length) return failed
const paint = await bundle()
for (const node of blocks) {
try {
const svg = await paint(node.value, 'paper')
Object.assign(node, { type: 'html', value: `<figure class="diagram">${svg}</figure>` })
} catch (error) {
// Lines only: Mermaid's message quotes the document.
console.warn(`ely: a diagram did not draw: ${error.name}`)
failed.push(node.position.start.line)
}
}
return failed
}
/** A `srcset`'s first URL, split as the spec does. */
function first(srcset) {
const url = srcset.replace(/^[\s,]+/, '').split(/\s/)[0]
return url.replace(/,+$/, '')
}
/** One `src` per image, swapped for the app's mark. */
function marked(html) {
const doc = new DOMParser().parseFromString(html, 'text/html')
const token = `ely-image-${crypto.randomUUID()}-`
// Responsive sources go: the page carries one file per image.
for (const source of doc.querySelectorAll('picture > source')) source.remove()
const images = []
for (const image of doc.querySelectorAll('img')) {
const srcset = image.getAttribute('srcset')
image.removeAttribute('srcset')
image.removeAttribute('sizes')
const src = image.getAttribute('src') || (srcset && first(srcset))
if (!src) continue
images.push(src)
image.setAttribute('src', token + (images.length - 1))
}
return { html: doc.body.innerHTML, images, token }
}
/** KaTeX's failures: line and its message, never logged. */
function formulas(file) {
return file.messages
.filter((message) => message.source === 'rehype-katex')
.map((message) => ({
line: message.line,
error: message.cause?.rawMessage ?? message.cause?.name ?? message.reason
}))
}
/** `body` as HTML, images marked; the failed diagrams and formulas. */
export async function html(body) {
const tree = markdown().parse(body)
displayed(tree, body)
const failed = await draw(tree)
const page = unified()
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeKatex)
.use(rehypeStringify, { allowDangerousHtml: true })
const file = new VFile()
const done = page.stringify(await page.run(tree, file), file)
return { ...marked(done), failed, formulas: formulas(file) }
}
/** `$$…$$` inline displays, as in the editor. */
function displayed(tree, body) {
visit(tree, 'inlineMath', (node) => {
const at = node.position?.start.offset ?? 0
if (body.startsWith('$$', at)) {
node.data.hProperties = { className: ['language-math', 'math-display'] }
}
})
}
/** `body` as plain text: blocks on lines, math as TeX. */
export async function text(body) {
const page = unified().use(remarkRehype, { allowDangerousHtml: true })
const hast = await page.run(markdown().parse(body))
// Raw HTML alone goes through the DOM; blocks keep lines.
visit(hast, 'raw', (node, index, parent) => {
const parsed = new DOMParser().parseFromString(node.value, 'text/html').body
const shown = fromDom(parsed).children
parent.children.splice(index, 1, ...shown)
return index + shown.length
})
return toText(hast)
}
-109
View File
@@ -1,109 +0,0 @@
// CodeMirror 6: the Markdown source.
import { basicSetup, EditorView } from 'codemirror'
import { Prec } from '@codemirror/state'
import { indentWithTab, redo, redoDepth, undoDepth } from '@codemirror/commands'
import { keymap } from '@codemirror/view'
import { markdown as language } from '@codemirror/lang-markdown'
import { languages } from '@codemirror/language-data'
import { ensureSyntaxTree } from '@codemirror/language'
import { theme } from './theme.js'
import { search } from '@codemirror/search'
import { textFinder } from './find.js'
/** Alt text that cannot close its brackets. */
const escape = (alt) => alt.replace(/[\\[\]]/g, '\\$&')
export function source(root, markdown, changed, edge) {
const view = new EditorView({
doc: markdown,
extensions: [
basicSetup,
language({ codeLanguages: languages }),
EditorView.lineWrapping,
theme,
search(),
// Tab indents; it never leaves the editor.
keymap.of([indentWithTab]),
// Past their history, undo and redo reach the page.
Prec.low(
keymap.of([
{ key: 'Mod-z', run: (view) => undoDepth(view.state) === 0 && edge('undo') },
{ key: 'Mod-Shift-z', run: (view) => redoDepth(view.state) === 0 && edge('redo') },
{ key: 'Mod-y', run: (view) => redoDepth(view.state) === 0 && edge('redo') },
]),
),
EditorView.updateListener.of((update) => {
if (update.docChanged) changed(() => update.state.doc.toString())
}),
],
parent: root,
})
// ATX and setext headings from the parse, with their starts.
const headings = () => {
const found = []
const tree = ensureSyntaxTree(view.state, view.state.doc.length, 200)
tree?.iterate({
enter: ({ name, from, to }) => {
const level = /^(?:ATX|Setext)Heading(\d)$/.exec(name)?.[1]
if (!level) return
const head = view.state.doc.sliceString(from, to).split('\n')[0]
const title = head.replace(/^#{1,6}[ \t]*/, '').replace(/[ \t]+#+[ \t]*$/, '')
found.push({ level: Number(level), title: title.trim(), from })
return false
},
})
return found
}
const reveal = (from) => {
view.dispatch({ selection: { anchor: from }, scrollIntoView: true })
view.focus()
}
return {
// Images at the caret, as Markdown.
insert: (images) => {
const text = images.map(({ src, alt }) => `![${escape(alt)}](${src})`).join('\n')
view.dispatch(view.state.replaceSelection(text))
view.focus()
return true
},
find: textFinder(view),
outline: () => headings().map(({ level, title }) => ({ level, title })),
heading: (key) => {
const found = headings()[key]
if (found) reveal(found.from)
return Boolean(found)
},
line: (n, query) => {
if (n < 1 || n > view.state.doc.lines) return false
const line = view.state.doc.line(n)
const column = line.text.toLowerCase().indexOf(query.toLowerCase())
reveal(line.from + Math.max(0, column))
return true
},
close: () => view.destroy(),
center: () => {
const { head } = view.state.selection.main
view.dispatch({ effects: EditorView.scrollIntoView(head, { y: 'center' }) })
},
where: () => {
const { head } = view.state.selection.main
const line = view.state.doc.lineAt(head)
return { line: line.number, column: head - line.from }
},
at: (n, column) => {
if (n < 1 || n > view.state.doc.lines) return false
const line = view.state.doc.line(n)
reveal(line.from + Math.min(column, line.length))
return true
},
text: () => view.state.doc.toString(),
// The text once every undone step is redone.
redone: () => {
let state = view.state
if (!redoDepth(state)) return null
while (redoDepth(state)) redo({ state, dispatch: (tr) => (state = tr.state) })
return state.doc.toString()
},
focus: () => view.focus(),
}
}
-41
View File
@@ -1,41 +0,0 @@
// The document's own markers, so blocks write back unchanged.
import { unified } from 'unified'
import remarkParse from 'remark-parse'
import { visit } from 'unist-util-visit'
/** remark-stringify options most of `text` already uses. */
export function style(text) {
const votes = {}
const vote = (key, value) => {
votes[key] ??= new Map()
votes[key].set(value, (votes[key].get(value) ?? 0) + 1)
}
visit(unified().use(remarkParse).parse(text), (node) => {
const at = node.position?.start.offset
if (at === undefined) return
const head = text.slice(at, at + 12)
switch (node.type) {
case 'listItem': {
const ordered = /^\d+([.)])/.exec(head)
if (ordered) vote('bulletOrdered', ordered[1])
else if (/^[*+-]/.test(head)) vote('bullet', head[0])
break
}
case 'emphasis':
case 'strong':
vote(node.type, head[0])
break
case 'code':
if (/^[`~]/.test(head)) vote('fence', head[0])
break
case 'thematicBreak':
vote('rule', head[0])
break
case 'heading':
if (node.depth <= 2) vote('setext', head[0] !== '#')
break
}
})
const most = (key) => [...votes[key].entries()].sort((a, b) => b[1] - a[1])[0][0]
return Object.fromEntries(Object.keys(votes).map((key) => [key, most(key)]))
}
-42
View File
@@ -1,42 +0,0 @@
// CodeMirror colors from the page's Ely tokens.
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
import { EditorView } from '@codemirror/view'
import { tags } from '@lezer/highlight'
const token = (name) => `var(--ely-${name})`
const colors = HighlightStyle.define([
{ tag: [tags.keyword, tags.operatorKeyword, tags.modifier], color: token('keyword') },
{ tag: [tags.string, tags.regexp, tags.special(tags.string)], color: token('string') },
{ tag: [tags.number, tags.bool, tags.null, tags.atom], color: token('number') },
{ tag: [tags.comment, tags.meta], color: token('comment') },
{
tag: [tags.function(tags.variableName), tags.function(tags.propertyName)],
color: token('function'),
},
{ tag: [tags.typeName, tags.className, tags.namespace], color: token('type') },
{ tag: [tags.tagName, tags.heading], color: token('tag'), fontWeight: 'bold' },
{ tag: [tags.attributeName, tags.propertyName], color: token('attribute') },
{
tag: [tags.punctuation, tags.bracket, tags.processingInstruction],
color: token('punctuation'),
},
{ tag: [tags.link, tags.url], color: token('link') },
{ tag: tags.emphasis, fontStyle: 'italic' },
{ tag: tags.strong, fontWeight: 'bold' },
{ tag: tags.strikethrough, textDecoration: 'line-through' },
])
const frame = EditorView.theme({
'&': { color: token('fg'), backgroundColor: 'transparent' },
'.cm-content': { caretColor: token('fg'), fontFamily: 'var(--ely-mono), monospace' },
'.cm-cursor': { borderLeftColor: token('fg') },
'.cm-gutters': { backgroundColor: 'transparent', color: token('fg-subtle'), border: 'none' },
'.cm-activeLine, .cm-activeLineGutter': { backgroundColor: token('hover') },
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground': {
backgroundColor: token('selection'),
},
'&.cm-focused': { outline: 'none' },
})
export const theme = [frame, syntaxHighlighting(colors)]
-313
View File
@@ -1,313 +0,0 @@
// Milkdown: visual editing, or read-only preview.
import {
Editor,
defaultValueCtx,
remarkStringifyOptionsCtx,
editorViewCtx,
editorViewOptionsCtx,
parserCtx,
remarkCtx,
rootCtx,
} from '@milkdown/kit/core'
import { commonmark } from '@milkdown/kit/preset/commonmark'
import { gfm } from '@milkdown/kit/preset/gfm'
import { history } from '@milkdown/kit/plugin/history'
import { codeBlockComponent } from '@milkdown/kit/component/code-block'
import { Plugin, TextSelection } from '@milkdown/kit/prose/state'
import { Decoration, DecorationSet } from '@milkdown/kit/prose/view'
import { keymap } from '@milkdown/kit/prose/keymap'
import { liftListItem, sinkListItem } from '@milkdown/kit/prose/schema-list'
import { redo, redoDepth, undoDepth } from '@milkdown/kit/prose/history'
import { $prose, callCommand } from '@milkdown/kit/utils'
import { insertTableCommand } from '@milkdown/kit/preset/gfm'
import {
addColumnAfter,
addColumnBefore,
addRowAfter,
addRowBefore,
deleteColumn,
deleteRow,
} from '@milkdown/kit/prose/tables'
import { visit } from 'unist-util-visit'
import { image } from './image.js'
import { math } from './math.js'
import { code } from './code.js'
import { diagrams } from './diagrams.js'
import { faithful, raw } from './preserve.js'
import { docFinder } from './find.js'
import { search } from 'prosemirror-search'
import { style } from './style.js'
/** An editor over `markdown`; null when blocks cannot map. */
export async function visual(root, markdown, editable, texts, changed, edge) {
const owner = diagrams()
let write = () => ({ text: markdown, spans: [] })
// Setting up raw blocks is no edit.
let ready = false
const watch = $prose(
() =>
new Plugin({
view: () => ({
update: (view, before) => {
if (ready && !view.state.doc.eq(before.doc)) changed(() => write(view.state.doc).text)
},
}),
}),
)
// Focus mode dims all blocks but the caret's.
const current = $prose(
() =>
new Plugin({
props: {
decorations: (state) => {
const { $from } = state.selection
if ($from.depth < 1) return null
const start = $from.before(1)
const block = Decoration.node(start, start + $from.node(1).nodeSize, {
class: 'ely-current',
})
return DecorationSet.create(state.doc, [block])
},
},
}),
)
// Past their history, undo and redo reach the page.
const ends = $prose(() =>
keymap({
'Mod-z': (state) => undoDepth(state) === 0 && edge('undo'),
'Mod-Shift-z': (state) => redoDepth(state) === 0 && edge('redo'),
'Mod-y': (state) => redoDepth(state) === 0 && edge('redo'),
// Tab moves list items; it never leaves the editor.
Tab: (state, dispatch) => sinkListItem(state.schema.nodes.list_item)(state, dispatch) || true,
'Shift-Tab': (state, dispatch) =>
liftListItem(state.schema.nodes.list_item)(state, dispatch) || true,
}),
)
const made = Editor.make()
.config((ctx) => {
ctx.set(rootCtx, root)
ctx.set(defaultValueCtx, markdown)
ctx.update(remarkStringifyOptionsCtx, (prev) => ({ ...prev, ...style(markdown) }))
ctx.update(editorViewOptionsCtx, (prev) => ({ ...prev, editable: () => editable }))
})
.use(commonmark)
.use(gfm)
.use(history)
.use(image)
.use(math)
.use(raw)
.config(code(texts, editable, owner))
.use(codeBlockComponent)
.use(watch)
.use(current)
.use(ends)
.use($prose(() => search()))
.create()
const editor = await made.catch((error) => {
owner.release()
throw error
})
const view = editor.action((ctx) => ctx.get(editorViewCtx))
const close = () => {
editor.destroy()
owner.release()
}
const keep = editor.action((ctx) => faithful(ctx, markdown))
if (!keep) {
close()
return null
}
write = keep
ready = true
// The text once every undone step is redone.
const redone = () => {
let state = view.state
if (!redoDepth(state)) return null
while (redoDepth(state)) redo(state, (tr) => (state = state.apply(tr)))
return write(state.doc).text
}
// Headings, raw ones too, by the title their source gives.
const [remark, parse] = editor.action((ctx) => [ctx.get(remarkCtx), ctx.get(parserCtx)])
/** An mdast node's words. */
const words = (node) => node.value ?? (node.children ?? []).map(words).join('')
// Headings; inside raw blocks, as remark reads their source.
const headings = () => {
const found = []
view.state.doc.descendants((node, pos) => {
if (node.type.name === 'heading')
found.push({ level: node.attrs.level, title: node.textContent, pos })
if (node.type.name !== 'ely_raw') return true
visit(remark.parse(node.textContent), 'heading', (heading) => {
const start = (heading.children[0] ?? heading).position.start.offset
found.push({ level: heading.depth, title: words(heading), pos, at: pos + 1 + start })
})
return false
})
return found
}
// `exact`: the caret at `pos`; else in that block.
const reveal = (pos, exact) => {
if (editable) {
const doc = view.state.doc
const place = exact
? TextSelection.create(doc, pos)
: TextSelection.near(doc.resolve(pos + 1))
view.dispatch(view.state.tr.setSelection(place).scrollIntoView())
return view.focus()
}
const { node } = view.domAtPos(pos + 1)
;(node.nodeType === 1 ? node : node.parentElement).scrollIntoView({ block: 'center' })
}
/** `node`'s text, and where a character of it sits. */
const reading = (node, start) => {
const chunks = []
// Text and line breaks, as `textContent` reads them.
node.descendants((child, at) => {
const words = child.isText
? child.text
: child.isLeaf
? child.type.spec.leafText?.(child)
: null
if (words) chunks.push({ text: words.toLowerCase(), at: start + 1 + at })
})
const at = (offset) => {
for (const [index, chunk] of chunks.entries()) {
const last = index === chunks.length - 1
if (offset < chunk.text.length || (last && offset === chunk.text.length))
return chunk.at + offset
offset -= chunk.text.length
}
return null
}
return { joined: chunks.map((chunk) => chunk.text).join(''), at }
}
// Non-overlapping hits, the same rule as `split`.
const hits = (text, needle) => text.split(needle).length - 1
/** The block holding file line `n`: its index and start. */
const blockOf = (n) => {
const { text, spans } = write(view.state.doc)
let offset = 0
for (let k = 1; k < n && offset >= 0; k++) offset = text.indexOf('\n', offset) + 1
if (n < 1 || offset < 0) return null
const index = spans.findIndex((span) => span && span[1] >= offset)
if (index < 0) return null
let pos = 0
for (let k = 0; k < index; k++) pos += view.state.doc.child(k).nodeSize
return { text, spans, offset, index, pos }
}
/** Line `n`: the query's place in its block. */
const line = (n, query) => {
const block = blockOf(n)
if (!block) return false
const { text, spans, offset, index, pos } = block
const needle = query.toLowerCase()
const node = view.state.doc.child(index)
// Text before and through the line, as shown.
const shown = (end) => {
const source = text.slice(spans[index][0], Math.max(spans[index][0], end))
return (node.type.name === 'ely_raw' ? source : parse(source).textContent).toLowerCase()
}
const ends = text.indexOf('\n', offset)
const before = shown(offset)
const through = shown(ends < 0 ? text.length : ends)
const { joined, at } = reading(node, pos)
const k = needle ? hits(before, needle) : 0
let found = -1
if (needle && hits(through, needle) > k) {
let from = 0
for (let seen = 0; seen <= k; seen++) {
found = joined.indexOf(needle, from)
if (found < 0) break
from = found + needle.length
}
}
// A hit hidden, as in a link: the line's start.
const place = found >= 0 ? at(found) : at(before.length)
reveal(place ?? pos, place !== null)
return true
}
/** The caret: its block's file line, and how far in. */
const where = () => {
const { text, spans } = write(view.state.doc)
const { from } = view.state.selection
let [start, pos] = [0, 0]
view.state.doc.forEach((node, offset, index) => {
if (offset <= from && spans[index]) [start, pos] = [spans[index][0], offset]
})
return { line: text.slice(0, start).split('\n').length, column: from - pos }
}
/** The caret back where `where` read it. */
const at = (n, column) => {
const block = blockOf(n)
if (!block) return false
const size = view.state.doc.child(block.index).nodeSize
reveal(block.pos + Math.min(Math.max(column, 1), size - 1) - 1, false)
return true
}
// Rows and columns; false when the caret is outside tables.
const tables = {
'row-above': addRowBefore,
'row-below': addRowAfter,
'column-left': addColumnBefore,
'column-right': addColumnAfter,
'delete-row': deleteRow,
'delete-column': deleteColumn,
}
const tablesIn = (doc) => {
const found = []
doc.descendants((node, pos) => {
if (node.type.name === 'table') found.push({ node, pos })
})
return found
}
// Milkdown may leave the caret before the new table.
const newTable = () => {
const old = new Set(tablesIn(view.state.doc).map(({ node }) => node))
editor.action(callCommand(insertTableCommand.key, { row: 3, col: 3 }))
const made = tablesIn(view.state.doc).find(({ node }) => !old.has(node))
if (!made) throw new Error('the inserted table is missing')
const cell = TextSelection.near(view.state.doc.resolve(made.pos + 1))
view.dispatch(view.state.tr.setSelection(cell).scrollIntoView())
return true
}
const table = (op) => {
if (!editable) return false
const done = op === 'insert' ? newTable() : tables[op](view.state, view.dispatch)
view.focus()
return done
}
/** Images at the caret: `{ src, alt }` each. */
const insert = (images) => {
if (!editable) return false
const image = view.state.schema.nodes.image
let tr = view.state.tr
for (const { src, alt } of images)
tr = tr.replaceSelectionWith(image.create({ src, alt, title: '' }), false)
view.dispatch(tr.scrollIntoView())
view.focus()
return true
}
return {
insert,
table,
find: docFinder(view),
close,
text: () => write(view.state.doc).text,
redone,
focus: () => editable && view.focus(),
outline: () => headings().map(({ level, title }) => ({ level, title })),
heading: (key) => {
const found = headings()[key]
if (found) reveal(found.at ?? found.pos, found.at !== undefined)
return Boolean(found)
},
line,
where,
at,
// The caret's line to the middle of the view.
center: () => {
const { top } = view.coordsAtPos(view.state.selection.head)
window.scrollBy(0, top - window.innerHeight / 2)
},
}
}
+5
View File
@@ -5,6 +5,10 @@ edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
build = "../../packaging/windows/icon.rs"
[build-dependencies]
embed-resource.workspace = true
[dependencies]
ely-files.workspace = true
@@ -18,6 +22,7 @@ image.workspace = true
log.workspace = true
[dev-dependencies]
dunce.workspace = true
ely-ui = { workspace = true, features = ["test-support"] }
ely-pdf-engine = { workspace = true, features = ["test-support"] }
ely-gpui-component = { workspace = true, features = ["test-support"] }
+38 -16
View File
@@ -9,10 +9,10 @@ use gpui::{AnyElement, App, Context, IntoElement, Pixels, Point, Styled as _, ca
use crate::content::{Aim, Tool};
use crate::layout::Placed;
use crate::pages::{rect_on, to_view};
use crate::view::PdfView;
use crate::view::{PdfView, Unread};
/// How far a click may move, in window pixels.
const CLICK: f32 = 3.0;
pub(crate) const CLICK: f32 = 3.0;
/// Pages read for their annotations at once.
const BATCH: usize = 20;
@@ -77,31 +77,49 @@ impl PdfView {
if self.reading_notes {
return;
}
let wanted: Vec<usize> = pages.into_iter().filter(|page| !self.notes.contains_key(page)).take(BATCH).collect();
let wanted: Vec<usize> = (pages.into_iter())
.filter(|page| !self.notes.contains_key(page) && !self.unread.contains(&Unread::Notes(*page)))
.take(BATCH)
.collect();
if wanted.is_empty() {
return;
}
let asked = wanted.clone();
self.reading_notes = true;
let (doc, version) = (self.doc, self.version);
self.off(
// Page by page: one bad page spares the rest.
move |worker| {
(wanted.into_iter())
.map(|page| Ok((page, worker.marks(doc, page)?, worker.links(doc, page)?)))
.collect::<std::io::Result<Vec<_>>>()
let read = (wanted.into_iter())
.map(|page| (page, worker.marks(doc, page).and_then(|marks| Ok((marks, worker.links(doc, page)?)))))
.collect::<Vec<_>>();
Ok(read)
},
move |view, read, cx| {
move |view, read: std::io::Result<Vec<_>>, cx| {
view.reading_notes = false;
if view.version != version {
return cx.notify();
}
match read {
Ok(read) => {
for (page, marks, links) in read {
// No worker at all: every page asked failed.
let read = read.unwrap_or_else(|error| {
let why = error.to_string();
asked.into_iter().map(|page| (page, Err(std::io::Error::other(why.clone())))).collect()
});
let mut first = None;
for (page, page_read) in read {
match page_read {
Ok((marks, links)) => {
view.notes.insert(page, marks);
view.links.insert(page, links);
}
Err(error) => {
view.unread.insert(Unread::Notes(page));
first.get_or_insert(error);
}
}
Err(error) => view.fail("reading annotations", &error, cx),
}
if let Some(error) = first {
view.fail("reading annotations", &error, cx);
}
cx.notify();
},
@@ -111,7 +129,7 @@ impl PdfView {
/// Reads every field name in the form, once an edit.
pub fn ask_names(&mut self, cx: &mut Context<Self>) {
if self.form_names.is_some() || self.reading_names {
if self.form_names.is_some() || self.reading_names || self.unread.contains(&Unread::Names) {
return;
}
self.reading_names = true;
@@ -125,7 +143,10 @@ impl PdfView {
}
match read {
Ok(names) => view.form_names = Some(names),
Err(error) => view.fail("reading field names", &error, cx),
Err(error) => {
view.unread.insert(Unread::Names);
view.fail("reading field names", &error, cx)
}
}
cx.notify();
},
@@ -253,11 +274,12 @@ impl PdfView {
if annots.is_empty() {
return;
}
self.settled_edit(what, cx, move |worker, doc| {
let name = ely_platform::user::name()?;
let author = ely_ui::settings::current(cx).author.clone();
self.edit_at(what, move |worker, doc| {
let name = ely_platform::user::named(&author)?;
annots.iter_mut().for_each(|annot| annot.author = name.clone());
worker.annotate(doc, &annots)
});
}, cx);
}
/// A press with a drawing or form tool.
+4 -5
View File
@@ -72,7 +72,7 @@ impl Viewer {
let turned = unturn(margins, view.read(cx).rotate);
let points = turned.map(|side| side * MM);
view.update(cx, |view, cx| {
view.edit("cropping pages", move |worker, doc| worker.crop_pages(doc, &pages, points), cx)
view.edit_pages("cropping pages", move |worker, doc| worker.crop_pages(doc, &pages, points), cx)
});
cx.notify();
}
@@ -136,7 +136,7 @@ impl Viewer {
}
fn failed_write(&self, name: &str, error: &io::Error, cx: &mut App) {
if error.kind() == io::ErrorKind::PermissionDenied {
if ely_pdf_engine::refused(error).is_some() {
return self.failed(error, cx);
}
let text = Self::text(cx, "office.error.save", &[("name", name), ("error", &error.to_string())]);
@@ -183,7 +183,7 @@ impl Viewer {
written.map(|()| targets.len())
},
move |viewer, done, _, cx| match done {
Ok(count) => viewer.toast_output(Self::text(cx, "pdf.split.done", &[("count", &count.to_string())]), opened.clone(), cx),
Ok(count) => viewer.toast_output(ely_ui::i18n::counted(cx, "pdf.split.done", count as u64, &[("count", &count.to_string())]), opened.clone(), cx),
Err(error) => viewer.failed_write(&stem, &error, cx),
},
cx,
@@ -205,8 +205,7 @@ impl Viewer {
};
let card = match self.ask.as_ref()? {
Ask::Crop { pages, margins, .. } => {
let count = pages.len().to_string();
let title = Self::text(cx, "pdf.crop.title", &[("count", &count)]);
let title = ely_ui::i18n::counted(cx, "pdf.crop.title", pages.len() as u64, &[("count", &pages.len().to_string())]);
let sides = ["top", "right", "bottom", "left"];
let rows = sides.iter().enumerate().fold(DescriptionList::new(), |rows, (at, side)| {
let name = label(&format!("pdf.crop.{side}"));
Loaded 100 of 1282 files, more files were not shown because too many files have changed in this diff. Show more