97 Commits
Author SHA1 Message Date
雷电芽衣 5b9f87791c Relicense Ely Office's own code from AGPL-3.0 to MIT
release / macos (push) Canceled after 0s
release / windows (push) Canceled after 0s
release / linux (push) Canceled after 0s
Applied on top of the released v0.1.3 so the tag says MIT, as main does
(271468d). Bundled engines keep their licences: LibreOffice (MPL-2.0),
Git (GPL-2.0) and MuPDF (AGPL-3.0).
2026-10-20 01:33:32 -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
581 changed files with 131359 additions and 1997 deletions

No files matched your search

+57
View File
@@ -0,0 +1,57 @@
# 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 }}
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 }}"
'
+1
View File
@@ -16,3 +16,4 @@ node_modules/
/docs/acceptance.md /docs/acceptance.md
/docs/test-map.md /docs/test-map.md
/assets/papirus_icons-master.zip /assets/papirus_icons-master.zip
/Gitea.md
Generated
+28 -21
View File
@@ -1919,15 +1919,16 @@ checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
[[package]] [[package]]
name = "ely-agent" name = "ely-agent"
version = "0.1.2" version = "0.1.3"
dependencies = [ dependencies = [
"ely-platform", "ely-platform",
"ely-ui", "ely-ui",
"embed-resource",
] ]
[[package]] [[package]]
name = "ely-doc" name = "ely-doc"
version = "0.1.2" version = "0.1.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"csv", "csv",
@@ -1938,6 +1939,7 @@ dependencies = [
"ely-office", "ely-office",
"ely-platform", "ely-platform",
"ely-ui", "ely-ui",
"embed-resource",
"env_logger", "env_logger",
"gpui-pre", "gpui-pre",
"image", "image",
@@ -1948,7 +1950,7 @@ dependencies = [
[[package]] [[package]]
name = "ely-files" name = "ely-files"
version = "0.1.2" version = "0.1.3"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"ely-platform", "ely-platform",
@@ -1964,7 +1966,7 @@ dependencies = [
[[package]] [[package]]
name = "ely-gpui-component" name = "ely-gpui-component"
version = "0.1.1" version = "0.1.1"
source = "git+https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components?rev=5d6efd46ec0c93b381ec7578561a1273c366025b#5d6efd46ec0c93b381ec7578561a1273c366025b" source = "git+https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components?rev=bd014707fd47cd9d0651a9af8c1db6033718a6ea#bd014707fd47cd9d0651a9af8c1db6033718a6ea"
dependencies = [ dependencies = [
"alacritty_terminal", "alacritty_terminal",
"anyhow", "anyhow",
@@ -2001,14 +2003,14 @@ dependencies = [
[[package]] [[package]]
name = "ely-ipc" name = "ely-ipc"
version = "0.1.2" version = "0.1.3"
dependencies = [ dependencies = [
"log", "log",
] ]
[[package]] [[package]]
name = "ely-lok" name = "ely-lok"
version = "0.1.2" version = "0.1.3"
dependencies = [ dependencies = [
"base64 0.23.1", "base64 0.23.1",
"ely-gpui-component", "ely-gpui-component",
@@ -2028,15 +2030,16 @@ dependencies = [
[[package]] [[package]]
name = "ely-mail" name = "ely-mail"
version = "0.1.2" version = "0.1.3"
dependencies = [ dependencies = [
"ely-platform", "ely-platform",
"ely-ui", "ely-ui",
"embed-resource",
] ]
[[package]] [[package]]
name = "ely-mark" name = "ely-mark"
version = "0.1.2" version = "0.1.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64 0.23.1", "base64 0.23.1",
@@ -2046,6 +2049,7 @@ dependencies = [
"ely-platform", "ely-platform",
"ely-ui", "ely-ui",
"ely-web-content", "ely-web-content",
"embed-resource",
"futures", "futures",
"gpui-pre", "gpui-pre",
"jiff", "jiff",
@@ -2058,7 +2062,7 @@ dependencies = [
[[package]] [[package]]
name = "ely-office" name = "ely-office"
version = "0.1.2" version = "0.1.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"directories", "directories",
@@ -2078,7 +2082,7 @@ dependencies = [
[[package]] [[package]]
name = "ely-pdf" name = "ely-pdf"
version = "0.1.2" version = "0.1.3"
dependencies = [ dependencies = [
"ely-files", "ely-files",
"ely-gpui-component", "ely-gpui-component",
@@ -2086,6 +2090,7 @@ dependencies = [
"ely-pdf-engine", "ely-pdf-engine",
"ely-platform", "ely-platform",
"ely-ui", "ely-ui",
"embed-resource",
"gpui-pre", "gpui-pre",
"image", "image",
"log", "log",
@@ -2094,7 +2099,7 @@ dependencies = [
[[package]] [[package]]
name = "ely-pdf-engine" name = "ely-pdf-engine"
version = "0.1.2" version = "0.1.3"
dependencies = [ dependencies = [
"cc", "cc",
"ely-ipc", "ely-ipc",
@@ -2110,13 +2115,14 @@ dependencies = [
[[package]] [[package]]
name = "ely-platform" name = "ely-platform"
version = "0.1.2" version = "0.1.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arboard", "arboard",
"core-foundation 0.10.0", "core-foundation 0.10.0",
"core-video", "core-video",
"directories", "directories",
"ely-ipc",
"env_logger", "env_logger",
"image", "image",
"libc", "libc",
@@ -2143,7 +2149,7 @@ dependencies = [
[[package]] [[package]]
name = "ely-show" name = "ely-show"
version = "0.1.2" version = "0.1.3"
dependencies = [ dependencies = [
"ely-files", "ely-files",
"ely-gpui-component", "ely-gpui-component",
@@ -2151,6 +2157,7 @@ dependencies = [
"ely-office", "ely-office",
"ely-platform", "ely-platform",
"ely-ui", "ely-ui",
"embed-resource",
"gpui-pre", "gpui-pre",
"gpui-pre-platform", "gpui-pre-platform",
"image", "image",
@@ -2162,13 +2169,14 @@ dependencies = [
[[package]] [[package]]
name = "ely-table" name = "ely-table"
version = "0.1.2" version = "0.1.3"
dependencies = [ dependencies = [
"ely-gpui-component", "ely-gpui-component",
"ely-lok", "ely-lok",
"ely-office", "ely-office",
"ely-platform", "ely-platform",
"ely-ui", "ely-ui",
"embed-resource",
"encoding_rs", "encoding_rs",
"gpui-pre", "gpui-pre",
"log", "log",
@@ -2178,12 +2186,14 @@ dependencies = [
[[package]] [[package]]
name = "ely-tree" name = "ely-tree"
version = "0.1.2" version = "0.1.3"
dependencies = [ dependencies = [
"dunce",
"ely-files", "ely-files",
"ely-gpui-component", "ely-gpui-component",
"ely-platform", "ely-platform",
"ely-ui", "ely-ui",
"embed-resource",
"futures", "futures",
"gpui-pre", "gpui-pre",
"jiff", "jiff",
@@ -2196,7 +2206,7 @@ dependencies = [
[[package]] [[package]]
name = "ely-ui" name = "ely-ui"
version = "0.1.2" version = "0.1.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"directories", "directories",
@@ -2214,7 +2224,7 @@ dependencies = [
[[package]] [[package]]
name = "ely-web-content" name = "ely-web-content"
version = "0.1.2" version = "0.1.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"cef", "cef",
@@ -2229,6 +2239,7 @@ dependencies = [
"objc2 0.6.4", "objc2 0.6.4",
"serde_json", "serde_json",
"tempfile", "tempfile",
"windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -2956,8 +2967,6 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre" name = "gpui-pre"
version = "0.3.8" version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7f0bc3ebba070f8981b3f8e905a12f47d27794a30d029e59cdb9edf7a4b3360"
dependencies = [ dependencies = [
"accesskit", "accesskit",
"anyhow", "anyhow",
@@ -3345,8 +3354,6 @@ dependencies = [
[[package]] [[package]]
name = "gpui-pre-windows" name = "gpui-pre-windows"
version = "0.3.8" version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "371f9bb13f97e3bea2f8b8e5e81232bb74d735153c244ae67f7400e743925b1c"
dependencies = [ dependencies = [
"accesskit", "accesskit",
"accesskit_windows", "accesskit_windows",
+10 -4
View File
@@ -5,19 +5,21 @@ members = ["apps/*", "crates/*"]
exclude = ["engines", "reference"] exclude = ["engines", "reference"]
[workspace.package] [workspace.package]
version = "0.1.2" version = "0.1.3"
edition = "2024" edition = "2024"
rust-version = "1.95" rust-version = "1.95"
license = "AGPL-3.0-or-later" license = "MIT"
publish = false publish = false
[workspace.dependencies] [workspace.dependencies]
embed-resource = "=3.0.11"
csv = "1" csv = "1"
encoding_rs = "0.8" encoding_rs = "0.8"
jiff = "0.2" jiff = "0.2"
anyhow = "1" anyhow = "1"
directories = "6" directories = "6"
ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "5d6efd46ec0c93b381ec7578561a1273c366025b" } dunce = "1"
ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "bd014707fd47cd9d0651a9af8c1db6033718a6ea" }
env_logger = "0.11" env_logger = "0.11"
gpui = { package = "gpui-pre", version = "=0.3.8", default-features = false, features = ["stacker"] } 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"] }
@@ -40,7 +42,7 @@ serde_json = "1"
sha2 = "0.10" sha2 = "0.10"
tempfile = "3" tempfile = "3"
ureq = "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-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-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-media = { version = "0.3", default-features = false, features = ["std", "CMTime"] }
@@ -64,3 +66,7 @@ ely-ui = { path = "crates/ely-ui" }
[workspace.lints.clippy] [workspace.lints.clippy]
all = { level = "warn", priority = -1 } all = { level = "warn", priority = -1 }
[patch.crates-io]
gpui-pre = { path = "vendor/gpui-pre" }
gpui-pre-windows = { path = "vendor/gpui-pre-windows" }
+21 -661
View File
@@ -1,661 +1,21 @@
GNU AFFERO GENERAL PUBLIC LICENSE MIT License
Version 3, 19 November 2007
Copyright (c) 2026 Yang Zhang
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies Permission is hereby granted, free of charge, to any person obtaining a copy
of this license document, but changing it is not allowed. of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
Preamble to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
The GNU Affero General Public License is a free, copyleft license for furnished to do so, subject to the following conditions:
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software. The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast, THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
our General Public Licenses are intended to guarantee your freedom to IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
share and change all versions of a program--to make sure it remains free FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
software for all its users. AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
When we speak of free software, we are referring to freedom, not OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
price. Our General Public Licenses are designed to make sure that you SOFTWARE.
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<http://www.gnu.org/licenses/>.
+1 -1
View File
@@ -757,7 +757,7 @@ Doc、Table、Show 的 PDF 输出由 LOK 完成,不要求安装 ElyPDF。ElyMa
### 21.1 自有代码默认许可 ### 21.1 自有代码默认许可
本 PRD 对新增应用代码采用 AGPL-3.0-or-later 作为发行默认,便于与 MuPDF 的开源使用路线保持一致。Ely GPUI Components 继续保留现有 MIT/Apache-2.0 许可,不因被应用使用而改写其许可证。[S1] 本 PRD 对新增应用代码采用 MIT 作为发行默认(用户 2026-10-19 定,原为 AGPL-3.0-or-later)。MIT 只管 Ely 的自有代码:链接 MuPDF 的 ElyPDF 发行包整体仍须按 MuPDF 的 AGPL 条款提供对应源码。Ely GPUI Components 继续保留现有 MIT/Apache-2.0 许可,不因被应用使用而改写其许可证。[S1]
第三方代码保持原许可证。不得把 LibreOffice、Git 或其他依赖的版权声明统一替换成 Ely 的声明。最终发行须按实际链接、进程调用、修改和打包方式审查,进程隔离不作为免除许可义务的依据。 第三方代码保持原许可证。不得把 LibreOffice、Git 或其他依赖的版权声明统一替换成 Ely 的声明。最终发行须按实际链接、进程调用、修改和打包方式审查,进程隔离不作为免除许可义务的依据。
+1 -1
View File
@@ -44,4 +44,4 @@ Packages: `packaging/macos/release.sh` writes each app's `.app` and `.dmg`, the
## Licence ## Licence
AGPL-3.0-or-later (`LICENSE`). Third-party components keep their own licences; see `docs/licenses.md`. MIT (`LICENSE`). Third-party components keep their own licences; see `docs/licenses.md`.
+22 -8
View File
@@ -27,10 +27,11 @@ crates/ ely-ui ely-platform ely-ipc(应用与引擎 worker 之间的帧
reference/ 上游源码,只读,不属于本项目,不审查 reference/ 上游源码,只读,不属于本项目,不审查
engines/ 下载的二进制与引擎构建产物,不入库,不审查;mupdf-src 是 reference/mupdf 加 patches/mupdf 的构建副本;content/<应用> 是网页内容的构建产物 engines/ 下载的二进制与引擎构建产物,不入库,不审查;mupdf-src 是 reference/mupdf 加 patches/mupdf 的构建副本;content/<应用> 是网页内容的构建产物
patches/ 对上游的补丁,随源码发行(libreoffice、mupdf) patches/ 对上游的补丁,随源码发行(libreoffice、mupdf)
vendor/ crates.io 上游 crate 的副本加本项目的修复,经工作区 `[patch.crates-io]` 换入(gpui-pre、gpui-pre-windows);首个提交为原样,其后是修复
packaging/ 三端打包脚本,apps.tsv 是八个应用的二进制名、名字、桌面分类与发布批次;icons/ 由图标母版渲染各端格式;libreoffice/ely.xcd 是内核的默认配置层 packaging/ 三端打包脚本,apps.tsv 是八个应用的二进制名、名字、桌面分类与发布批次;icons/ 由图标母版渲染各端格式;libreoffice/ely.xcd 是内核的默认配置层
assets/ icons/ 是八个应用的图标母版(SVG)与渲染出的 icns、png、ico,规则见其 README assets/ icons/ 是八个应用的图标母版(SVG)与渲染出的 icns、png、ico,规则见其 README
dist/ 打包产物,不入库 dist/ 打包产物,不入库
docs/ 工程风险与已知限制 docs/ 兼容清单、无障碍、帮助、许可、性能记录;engineering-risks.md、qa.md 等内部笔记只在本机,不入库
scripts/ fetch-references.sh 取上游 doctor.sh 查工具 review.sh 开分栏审查 build-*.sh 构建引擎 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) 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 进度真相 tasks/ TASKS.md 进度真相
@@ -39,7 +40,7 @@ site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木
## 组件映射 ## 组件映射
依赖 `ely-gpui-component`(pin 5d6efd4,gpui 为 crates.io 的 `gpui-pre` =0.3.8)。名字取自 `reference/Ely-GPUI-Components/src`。gpui 用法规则见该仓库 `AGENTS.md`。`tests/scripts/agents_md.py` 核对下表每个名字都是组件库公开项。 依赖 `ely-gpui-component`(pin bd01470,gpui 为 crates.io 的 `gpui-pre` =0.3.8)。名字取自 `reference/Ely-GPUI-Components/src`。gpui 用法规则见该仓库 `AGENTS.md`。`tests/scripts/agents_md.py` 核对下表每个名字都是组件库公开项。
| 需要 | 用 | | 需要 | 用 |
| --- | --- | | --- | --- |
@@ -146,6 +147,7 @@ tests/scripts/lo-unregistered.sh “打开方式”里没有 LibreOffice,构
tests/scripts/lokview.sh 真窗口里的 LokView:输入法、键入、点击、拖动,AX 树里的段落文本;ELY_CLIPBOARD_TEST=1 再测复制粘贴(会覆盖系统剪贴板) tests/scripts/lokview.sh 真窗口里的 LokView:输入法、键入、点击、拖动,AX 树里的段落文本;ELY_CLIPBOARD_TEST=1 再测复制粘贴(会覆盖系统剪贴板)
tests/scripts/doc-shell.sh 真窗口 ElyDoc:空白标签进 AX 树,启动后 Cmd+N 打开新建对话框(需辅助功能权限) tests/scripts/doc-shell.sh 真窗口 ElyDoc:空白标签进 AX 树,启动后 Cmd+N 打开新建对话框(需辅助功能权限)
tests/scripts/doc-props.sh 真窗口 ElyDoc 属性栏:段前段后连续键入、回车、失焦,读 AX 值与命令数(需辅助功能权限) tests/scripts/doc-props.sh 真窗口 ElyDoc 属性栏:段前段后连续键入、回车、失焦,读 AX 值与命令数(需辅助功能权限)
tests/scripts/doc-review.sh 真窗口 ElyDoc:无日期的批注逐条写明作者与原因(AX 可读),多条时批注与修订列表仍在窗口内(需辅助功能权限)
tests/scripts/theme.sh 真窗口 ElyTable 以深、浅两种外观启动,截窗口量网格亮度(需屏幕录制权限) tests/scripts/theme.sh 真窗口 ElyTable 以深、浅两种外观启动,截窗口量网格亮度(需屏幕录制权限)
tests/scripts/lag.sh ely-doc 真窗口键入,从调试日志算输入落地、图块、按键到新图块的分位数(需辅助功能权限);ELY_LAG_LOG=路径 留下日志 tests/scripts/lag.sh ely-doc 真窗口键入,从调试日志算输入落地、图块、按键到新图块的分位数(需辅助功能权限);ELY_LAG_LOG=路径 留下日志
tests/scripts/pdf-idle.sh 真窗口 ElyPDF 无文件启动时无 worker 子进程 tests/scripts/pdf-idle.sh 真窗口 ElyPDF 无文件启动时无 worker 子进程
@@ -156,6 +158,8 @@ tests/scripts/mark-modes.sh 真窗口 ElyMark:文档旁图片、三种模
tests/scripts/mark-places.sh 真窗口 ElyMark:文件夹树、大纲跳转、文件夹搜索、最近列表(需辅助功能权限) tests/scripts/mark-places.sh 真窗口 ElyMark:文件夹树、大纲跳转、文件夹搜索、最近列表(需辅助功能权限)
tests/scripts/mark-output.sh 真窗口 ElyMark:专注模式与外观持久、四种导出、打印、Git 状态与打开 ElyTree、大文件(需辅助功能权限与 pdftotext;ELY_CLIPBOARD_TEST=1 再测富文本复制) tests/scripts/mark-output.sh 真窗口 ElyMark:专注模式与外观持久、四种导出、打印、Git 状态与打开 ElyTree、大文件(需辅助功能权限与 pdftotext;ELY_CLIPBOARD_TEST=1 再测富文本复制)
tests/scripts/mark-outside.sh 真窗口 ElyMark:外部替换、改名、删除、冲突比较、自动保存、退出与崩溃后的恢复(需辅助功能权限) tests/scripts/mark-outside.sh 真窗口 ElyMark:外部替换、改名、删除、冲突比较、自动保存、退出与崩溃后的恢复(需辅助功能权限)
tests/scripts/mark-links.mjs ElyMark 页面(Playwright,三端):可视模式单击编辑,Cmd(他处 Ctrl)单击交应用,预览单击交应用,地址取文档原文(需 site 的依赖)
tests/scripts/mark-broken.mjs ElyMark 页面(Playwright,三端):可视编辑器建不成即退回源码并告知,两者都建不成即报失败(需 site 的依赖)
tests/scripts/mark-tools.sh 真窗口 ElyMark:查找替换、正则、Tab、列表、表格、拖放与粘贴图片(需辅助功能权限;ELY_CLIPBOARD_TEST=1 再测粘贴) tests/scripts/mark-tools.sh 真窗口 ElyMark:查找替换、正则、Tab、列表、表格、拖放与粘贴图片(需辅助功能权限;ELY_CLIPBOARD_TEST=1 再测粘贴)
tests/scripts/comments-rules.sh comments.py 对长注释与跨行注释报错 tests/scripts/comments-rules.sh comments.py 对长注释与跨行注释报错
tests/scripts/bad-settings.sh 通用设置读不出时 ElyDoc 照样启动,文件不被覆盖 tests/scripts/bad-settings.sh 通用设置读不出时 ElyDoc 照样启动,文件不被覆盖
@@ -187,11 +191,11 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
## 决策 ## 决策
- 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)、组件库 5d6efd4(2026-10-09 起)。 - 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)、组件库 bd01470(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-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-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;本机 `codex` 别名已带 `--yolo`,重复传参会报错)审查,最多三轮。此前各轮用 gpt-6-astra xhigh。 - 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-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`(macOS 另加 `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-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: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-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 输出与导航(DOC-15、16)。导出 PDF 经 worker 的 `EXPORT_PDF` 请求,滤镜选项为 JSON(页码范围、批注印在页边、标出修订);打印先导出同一 PDF,macOS 用 PDFKit 的 `NSPrintOperation` 弹系统打印面板(`ely_platform::print`),其他平台报错,未实现。加密文档经 LOK 的 `DOCUMENT_PASSWORD` 回调询问密码,错的密码再问一次,取消即不打开。左栏导航默认打开(大纲、页面、书签),跳转前记下所在处,“返回”逐级回到原处;上一跳的输入未落到内核、或落下后光标尚未移动时,以其目标为所在处,连续跳转不丢位置;落下之后即按实际光标。当前标题取内核的标记,当前页按光标所在页。导出 PDF 与另存为的目标按文件身份(符号链接与硬链接)比对打开着的文档:导出遇之即拒绝,另存为遇本文件时只许原格式。缺字体时页面上方给出提示条。QA-10 与 Microsoft Office 互开未验证:本机无 Office。
@@ -223,8 +227,8 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
- 2026-10-06(用户定):OCR 随包语言数据用 tessdata_fast,英文、简体中文、繁体中文(eng、chi_sim、chi_tra)。 - 2026-10-06(用户定):OCR 随包语言数据用 tessdata_fast,英文、简体中文、繁体中文(eng、chi_sim、chi_tra)。
- 2026-10-06(用户定):ElyMail 在八个应用里最后做:任务表中 C12 移到 C15 之后、C16 之前;CEF 内容宿主随 ElyMark 先建(C14.0),邮件编号依次前移。 - 2026-10-06(用户定):ElyMail 在八个应用里最后做:任务表中 C12 移到 C15 之后、C16 之前;CEF 内容宿主随 ElyMark 先建(C14.0),邮件编号依次前移。
- 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-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:本地 Web 内容宿主(C14.0,§17.3)。`crates/ely-web-content`:CEF 154.0.32(Chromium 154.0.8037.58,cef-rs a712ad9,`default-features = false`),`scripts/fetch-cef.sh` 取 minimal 包到 `engines/cef`,`.cargo/config.toml` 的 `CEF_PATH` 让构建用它。进程:应用的 `main` 先调 `ely_web_content::subprocess()`(参数里有 `--type=` 即为 CEF 子进程,载入框架后 `execute_process`,不碰 GPUI);开发构建子进程即应用本身的二进制;macOS 包里是五个辅助应用(`<二进制名> Helper`、`(GPU)`、`(Renderer)`、`(Plugin)`、`(Alerts)`,`Contents/Frameworks`,`LSUIElement`,可执行文件为 `ely-web-helper`),框架在 `Contents/Frameworks`,`packaging/macos/bundle.sh` 对依赖 ely-web-content 的应用自动放入并先签内层;查找次序 `ELY_CEF`、包内、辅助应用所在、构建时的 `engines/cef`。启动:`start` 在 GPUI 里只做准备(载入框架、给 NSApp 的类补上 Chromium 要的 `isHandlingSendEvent`、`setHandlingSendEvent:` 与包住 `sendEvent:`,并加 `CrAppProtocol`、`CrAppControlProtocol`),`initialize` 与建浏览器都在消息泵任务里、GPUI 的更新之外进行(实测:在更新里同步调 CEF,CEF 转动 AppKit 的循环,GPUI 报 RefCell 已借用、整窗不画);浏览器异步建(`on_after_created` 交回)。凡在 GPUI 里对 CEF 的调用(输入、焦点、尺寸、脚本、缩放、重新载入、关闭)都排进主线程的队列,由泵在下一轮、更新之外依次执行(实测:在实体释放时同步关浏览器,CEF 转动 AppKit 的循环去校验菜单,GPUI 的菜单回调遇已借用的应用状态而中止,约三成复现)。泵:外部消息泵,按 `on_schedule_message_pump_work` 的时间,另照 cefclient 每轮后以 1/30 秒兜底(实测:只按请求时第一轮后 CEF 再不请求,页面不载入);空闲时 CPU 约 1.5%。离屏渲染须用 Alloy 风格(`runtime_style`;Chrome 风格不出画面)。同一缓存目录只允许一个实例,第二个启动时报出占用它的进程号。画面:`on_paint` 的 BGRA 直接成 GPUI 图像,上一帧在下一次绘制时从图集释放(`drop_image`);光标随页面。输入:鼠标点击、移动、滚轮(行按 40 点),键照 cefclient 的 macOS 做法:键盘按下带字符与系统键码发 `KEYDOWN` 再发 `CHAR`(退格、回车、方向等用 AppKit 的字符),可打出字的键等 GPUI 的输入法回调后以所打的字发同样两个事件,多字或组字经 `ime_commit_text`/`ime_set_composition`(无替换范围时用 CEF 的“无范围”,两端为最大值;传空指针 CEF 不收);松开带同一字符(不带时 Chromium 当作又按一次,实测退格删两个字);拷贝、剪切、粘贴、全选走框架命令。焦点随 GPUI 的焦点给页面。页面与应用之间:CEF 的消息路由,页面调 `window.elyQuery({request, onSuccess, onFailure})`(`onSuccess` 必须有,缺了路由不发),应用收到 `Event::Query(请求, 回复)`;应用以 `run_js` 调页面。主题:每次载入与主题变化时把调色板、代码色、字体与字号比例、深浅写成 `:root` 的 `--ely-*` 变量与 `color-scheme`。缩放 `set_zoom(倍数)`。页面只许载入所在内容文件夹里的文件:按真实路径判定(`..`、百分号转义、指向外面的符号链接都算外面,文件须存在),导航与每个资源请求(脚本、图片、样式、请求等)都过这道门,资源另许 `data:` 与本页的 `blob:`,其余一律取消;另在每个 CEF 进程以 `--proxy-server=127.0.0.1:9` 与 `--proxy-bypass-list=<-loopback>` 把一切网络(含本机回环与 WebSocket,后者不经资源回调)送往不通的代理;WebRTC 走 UDP 不经代理,故另以 `--force-webrtc-ip-handling-policy=disable_non_proxied_udp` 只许它经代理,并在每个页面上下文建立时、页面脚本之前删去 `RTCPeerConnection` 等构造(渲染进程的 `on_context_created`);页面的地址由真实路径逐字节转义生成(文件名含 `#`、`?` 照样载入);用户点出的链接以 `Event::Leave` 交给应用;页面的警告与错误写入日志。载入失败(主框架,停止不算)、渲染进程退出、CEF 起不来都报 `Event::Failed`,页面区改显示组件库的错误视图与“重新载入”(浏览器在即重载,否则重建;CEF 起不来时只说明),载入成功才报 `Event::Loaded`。浏览器关闭时断开它与表面的互相持有,页面的内存随之释放(释放时写日志)。真窗口(macOS,开发构建与 `dist/ElyMark.app`):页面以应用深色主题画出,点选、键入中英文、回车、退格、方向键、拷贝粘贴、`elyQuery` 往返;`tests/scripts/webview.sh` 以输入法回调组“你好”、键入、读回焦点、主题变量与缩放 1.25,核对页面跳不出Line truncated - 2026-10-07:本地 Web 内容宿主(C14.0,§17.3)。`crates/ely-web-content`:CEF 154.0.32(Chromium 154.0.8037.58,cef-rs a712ad9,`default-features = false`),`scripts/fetch-cef.sh` 取 minimal 包到 `engines/cef`,`.cargo/config.toml` 的 `CEF_PATH` 让构建用它。进程:应用的 `main` 先调 `ely_web_content::subprocess()`(参数里有 `--type=` 即为 CEF 子进程,载入框架后 `execute_process`,不碰 GPUI);开发构建子进程即应用本身的二进制;macOS 包里是五个辅助应用(`<二进制名> Helper`、`(GPU)`、`(Renderer)`、`(Plugin)`、`(Alerts)`,`Contents/Frameworks`,`LSUIElement`,可执行文件为 `ely-web-helper`),框架在 `Contents/Frameworks`,`packaging/macos/bundle.sh` 对依赖 ely-web-content 的应用自动放入并先签内层;查找次序 `ELY_CEF`、包内、辅助应用所在、构建时的 `engines/cef`。启动:`start` 在 GPUI 里只做准备(载入框架、给 NSApp 的类补上 Chromium 要的 `isHandlingSendEvent`、`setHandlingSendEvent:` 与包住 `sendEvent:`,并加 `CrAppProtocol`、`CrAppControlProtocol`),`initialize` 与建浏览器都在消息泵任务里、GPUI 的更新之外进行(实测:在更新里同步调 CEF,CEF 转动 AppKit 的循环,GPUI 报 RefCell 已借用、整窗不画);浏览器在泵里同步建(`browser_host_create_browser_sync`;原为异步,建好之前视图已释放时浏览器无人关闭),视图要么在建之前已走(不建),要么有浏览器可关。凡在 GPUI 里对 CEF 的调用(输入、焦点、尺寸、脚本、缩放、重新载入、关闭)都排进主线程的队列,由泵在下一轮、更新之外依次执行(实测:在实体释放时同步关浏览器,CEF 转动 AppKit 的循环去校验菜单,GPUI 的菜单回调遇已借用的应用状态而中止,约三成复现)。泵:外部消息泵,按 `on_schedule_message_pump_work` 的时间,另照 cefclient 每轮后以 1/30 秒兜底(实测:只按请求时第一轮后 CEF 再不请求,页面不载入);空闲时 CPU 约 1.5%。离屏渲染须用 Alloy 风格(`runtime_style`;Chrome 风格不出画面)。同一缓存目录只允许一个实例,第二个启动时报出占用它的进程号。画面:`on_paint` 的 BGRA 直接成 GPUI 图像,上一帧在下一次绘制时从图集释放(`drop_image`);光标随页面。输入:鼠标点击、移动、滚轮(行按 40 点),键照 cefclient 的 macOS 做法:键盘按下带字符与系统键码发 `KEYDOWN` 再发 `CHAR`(退格、回车、方向等用 AppKit 的字符),可打出字的键等 GPUI 的输入法回调后以所打的字发同样两个事件,多字或组字经 `ime_commit_text`/`ime_set_composition`(无替换范围时用 CEF 的“无范围”,两端为最大值;传空指针 CEF 不收);松开带同一字符(不带时 Chromium 当作又按一次,实测退格删两个字);拷贝、剪切、粘贴、全选走框架命令。焦点随 GPUI 的焦点给页面。页面与应用之间:CEF 的消息路由,页面调 `window.elyQuery({request, onSuccess, onFailure})`(`onSuccess` 与 `onFailure` 都必须有,缺一路由即不发;页面载入时路由未就绪,早到的查询丢失),应用收到 `Event::Query(请求, 回复)`;应用以 `run_js` 调页面。主题:每次载入与主题变化时把调色板、代码色、字体与字号比例、深浅写成 `:root` 的 `--ely-*` 变量与 `color-scheme`。缩放 `set_zoom(倍数)`。页面只许载入所在内容文件夹里的文件:按真实路径判定(`..`、百分号转义、指向外面的符号链接都算外面,文件须存在),导航与每个资源请求(脚本、图片、样式、请求等)都过这道门,资源另许 `data:` 与本页的 `blob:`,其余一律取消;另在每个 CEF 进程以 `--proxy-server=127.0.0.1:9` 与 `--proxy-bypass-list=<-loopback>` 把一切网络(含本机回环与 WebSocket,后者不经资源回调)送往不通的代理;WebRTC 走 UDP 不经代理,故另以 `--force-webrtc-ip-handling-policy=disable_non_proxied_udp` 只许它经代理,并在每个页面上下文建立时、页面脚本之前删去 `RTCPeerConnection` 等构造(渲染进程的 `on_context_created`);页面的地址由真实路径逐字节转义生成(文件名含 `#`、`?` 照样载入);用户点出的链接以 `Event::Leave` 交给应用;`WebView::open_inert` 开的页面不运行脚本(`BrowserSettings.javascript` 关),ElyMark 的打印与导出 PDF 暂存页用它,文档里的 HTML 脚本不运行(M-15);页面的警告与错误只记行号(警告文字与来源都可由文档伪造,不进日志);页面自己的诊断经 `elyQuery` 交应用记录。载入失败(主框架,停止不算)、渲染进程退出、CEF 起不来都报 `Event::Failed`,页面区改显示组件库的错误视图与“重新载入”(Line truncated
- 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);状态栏显示所用 Git 的路径与版本。真窗口(macOS):仓库页工具行、清理预览对话框、文件标题栏的编辑器与 ElyMark 按钮,按下后同级的 ely-mark 以该文件启动。未验证:Windows 与 Linux 的终端;ElyMark 打开文件的内容(C14)。 - 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: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-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 数据、CEF 与页面的所在路径都写日志,`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(发布包里没有,按标签与散列取);CEF 的 LICENSE 与 Chromium 的 CREDITS、页面的第三方通知;缺任何一份即打包失败(`packaging/licenses.sh`)。`release.sh` 另出 `ElyOffice-<版本>-source.tar.gz`:自有源码、锁文件、上游补丁与构建脚本(上游各自钉在提交,取得方式即其中的 fetch 脚本)。正式签名(hardened runtime)时 CEF 的渲染与 GPU 辅助程序带 `allow-jit`,插件辅助程序带 `allow-unsigned-executable-memory` 与 `disable-library-validation`(`packaging/macos/entitlements`;原先不带,V8 保留不到代码区,页面载入失败);`tests/scripts/hardened-macos.sh` 以 `ELY_SIGN_IDENTITY` 签 ElyMark 并核对页面画出。ElyTable 同时收到多个文本文件(访达一次交来多份 CSV)时,读完的导入排队,没有对话框开着才逐个询问(原先后到的一份替换前一份,前一份静默丢失)。`packaging/macos/release.sh` 按 `apps.tsv` 的批次出各应用的 dmg;本机只有 Apple Development 证书,无 Developer ID,未公证。未验证:Intel 包(只建了 arm64)、公证、Linux 与 Windows 的包(本机不能建)。 - 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 数据、CEF 与页面的所在路径都写日志,`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(发布包里没有,按标签与散列取);CEF 的 LICENSE 与 Chromium 的 CREDITS、页面的第三方通知;缺任何一份即打包失败(`packaging/licenses.sh`)。`release.sh` 另出 `ElyOffice-<版本>-source.tar.gz`:自有源码、锁文件、上游补丁与构建脚本(上游各自钉在提交,取得方式即其中的 fetch 脚本)。正式签名(hardened runtime)时 CEF 的渲染与 GPU 辅助程序带 `allow-jit`,插件辅助程序带 `allow-unsigned-executable-memory` 与 `disable-library-validation`(`packaging/macos/entitlements`;原先不带,V8 保留不到代码区,页面载入失败);`tests/scripts/hardened-macos.sh` 以 `ELY_SIGN_IDENTITY` 签 ElyMark 并核对页面画出。ElyTable 同时收到多个文本文件(访达一次交来多份 CSV)时,读完的导入排队,没有对话框开着才逐个询问(原先后到的一份替换前一份,前一份静默丢失)。`packaging/macos/release.sh` 按 `apps.tsv` 的批次出各应用的 dmg;本机只有 Apple Development 证书,无 Developer ID,未公证。未验证:Intel 包(只建了 arm64)、公证、Linux 与 Windows 的包(本机不能建)。
@@ -256,7 +260,7 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
- 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 提交、历史、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 差异与暂存(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 的 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 版本、随附或系统与所用 Git 的路径。中间区在 C13.2 前只显示改动数或“没有要提交的改动”。组件库 4f8f3bd 与 37c5187:`Changed::lines` 可缺。真窗口(macOS):五个分区、行数、状态栏。未验证:Windows 与 Linux(停止克隆在非 Unix 上报未构建);LFS 仓库的克隆。 - 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-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.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 内容删除与文档清理(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。
@@ -277,4 +281,14 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
- 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-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-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-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`);CEF 照 cef-dll-sys 放在可执行文件旁(Windows、Linux 都是),不另载入;`%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 的常驻克隆里按需从零构建引擎,Windows 在本机运行器上把 `engines` 与 `reference` 接到开发克隆,Linux 由同一台 Windows 运行器经 WSL 构建(服务器上的 Linux 运行器是一次性容器,建不了内核);各自打包后上传到该标签的发布。Windows 为 NSIS 安装程序,Linux 为 AppImage,都由 `packaging/stage.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-11:ElyMark 的 AppImage(0.1.3 的 Linux 发布查出)。`linuxdeploy` 把可执行文件的 RUNPATH 改成 `$ORIGIN/../lib`,并把它找得到的 `libcef.so` 拷进 `usr/lib`:原先 `libcef.so` 与 CEF 的资料在 `usr/bin`,结果是两份 1.45 GB 的内核、程序载入 `usr/lib` 那份、CEF 在它旁边找不到 ICU 数据而退出(从未在 AppImage 里启动过)。现在 `appimage.sh` 把从 `engines/cef` 分装来的全部条目移进 `usr/lib`(按名字对照 CEF 文件夹,清单仍只在 `stage.sh`),以 `LD_LIBRARY_PATH=AppDir/usr/lib` 运行 `linuxdeploy`,`libcef.so` 只此一份、依赖照样带上,CEF 按所载 `libcef.so` 的文件夹取资料(`ely-web-content` 的 `open`)。ElyMark 的 AppImage 由 715 MB 降到 423 MB;WSL 里启动后 CEF 载入、窗口打开(WSL 里各应用都不出首帧,未在真桌面上验证)。
- 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-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 地址取。
+4
View File
@@ -5,6 +5,10 @@ edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license.workspace = true license.workspace = true
publish.workspace = true publish.workspace = true
build = "../../packaging/windows/icon.rs"
[build-dependencies]
embed-resource.workspace = true
[dependencies] [dependencies]
ely-platform.workspace = true 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; use ely_platform::AppId;
fn main() { fn main() {
+5
View File
@@ -5,6 +5,10 @@ edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license.workspace = true license.workspace = true
publish.workspace = true publish.workspace = true
build = "../../packaging/windows/icon.rs"
[build-dependencies]
embed-resource.workspace = true
[dependencies] [dependencies]
csv.workspace = true csv.workspace = true
@@ -22,6 +26,7 @@ ely-ui.workspace = true
[dev-dependencies] [dev-dependencies]
ely-ui = { workspace = true, features = ["test-support"] } ely-ui = { workspace = true, features = ["test-support"] }
ely-gpui-component = { workspace = true, features = ["test-support"] }
env_logger.workspace = true env_logger.workspace = true
image.workspace = true image.workspace = true
tempfile.workspace = true tempfile.workspace = true
+2 -1
View File
@@ -80,7 +80,8 @@ impl Docs {
let shown = self let shown = self
.active_tab() .active_tab()
.map(|tab| (tab.view.clone(), tab.doc.clone())); .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 self.navigator
.update(cx, |navigator, cx| navigator.show(tab, cx)); .update(cx, |navigator, cx| navigator.show(tab, cx));
shown.filter(|_| self.navigating)?; shown.filter(|_| self.navigating)?;
+19
View File
@@ -46,8 +46,14 @@ pub struct Docs {
pub back: Vec<(u64, ely_lok::Rect)>, pub back: Vec<(u64, ely_lok::Rect)>,
/// Last jump: tab, input, prior cursor moves, target. /// Last jump: tab, input, prior cursor moves, target.
pub going: Option<(u64, u64, u64, ely_lok::Rect)>, 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. /// The right column's panels.
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
pub enum Side { pub enum Side {
@@ -171,6 +177,7 @@ impl Docs {
failed(docs, error, cx) failed(docs, error, cx)
}) })
.detach(); .detach();
cx.observe_self(|docs, _| docs.watch_dialogs()).detach();
Docs { Docs {
core: Core::new(worker, work, cx), core: Core::new(worker, work, cx),
dialog: None, dialog: None,
@@ -183,6 +190,18 @@ impl Docs {
navigating: true, navigating: true,
back: Vec::new(), back: Vec::new(),
going: None, 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;
} }
} }
+22 -23
View File
@@ -128,29 +128,28 @@ impl Docs {
let text = Docs::text(cx, "command.blocked.no_document", &[]); let text = Docs::text(cx, "command.blocked.no_document", &[]);
return self.toast(text, Severity::Warning, cx); return self.toast(text, Severity::Warning, cx);
}; };
let listed = self let id = tab.id;
.core // Page style names are read off the window thread.
.worker self.ask_in(
.lock() id,
.expect("the worker lock") |worker, doc| ely_lok::commands::styles(&worker.values(doc, ".uno:StyleApply")?, "8"),
.values(&tab.doc, ".uno:StyleApply") move |docs, listed, _, cx| {
.and_then(|json| ely_lok::commands::styles(&json, "8")); let names = match listed {
let names = match listed { Ok(names) => names,
Ok(names) => names, Err(error) => return docs.engine_failed(&error.to_string(), cx),
Err(error) => { };
let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]); let name = (2..)
return self.toast(text, Severity::Danger, cx); .map(|n| Docs::text(cx, "doc.section.name", &[("n", &n.to_string())]).to_string())
} .find(|name| !names.contains(name))
}; .expect("a free section name");
let name = (2..) log::info!("doc: a new section");
.map(|n| Docs::text(cx, "doc.section.name", &[("n", &n.to_string())]).to_string()) docs.run_in(Some(id), "style.new", &[&name, "8"], cx);
.find(|name| !names.contains(name)) docs.run_in(Some(id), "page.section", &["3", &name], cx);
.expect("a free section name"); docs.props.update(cx, |props, _| props.refresh_lists());
log::info!("doc: a new section"); cx.notify();
self.run_active("style.new", &[&name, "8"], cx); },
self.run_active("page.section", &["3", &name], cx); cx,
self.props.update(cx, |props, _| props.refresh_lists()); );
cx.notify();
} }
pub fn on_page_number(&mut self, _: &InsertPageNumber, _: &mut Window, cx: &mut Context<Self>) { 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 actions;
mod chrome; mod chrome;
mod compare; mod compare;
+5 -3
View File
@@ -247,9 +247,11 @@ impl Docs {
let mut worker = worker.lock().expect("the worker lock"); let mut worker = worker.lock().expect("the worker lock");
let staged = template.with_file_name(format!("record.{extension}")); let staged = template.with_file_name(format!("record.{extension}"));
let made = worker.load(&ely_lok::file_url(&template)).and_then(|doc| { let made = worker.load(&ely_lok::file_url(&template)).and_then(|doc| {
fill(&mut worker, &doc, &table.headers, row)?; let saved = fill(&mut worker, &doc, &table.headers, row)
doc.save_as(&mut worker, &ely_lok::file_url(&staged), extension)?; .and_then(|()| doc.save_as(&mut worker, &ely_lok::file_url(&staged), extension));
worker.close_doc(doc)?; // Closed even when this record failed.
let closed = worker.close_doc(doc);
saved.and(closed)?;
publish(&staged, target) publish(&staged, target)
}); });
made.map_err(|error| (n + 1, error))?; made.map_err(|error| (n + 1, error))?;
+4 -6
View File
@@ -272,15 +272,13 @@ impl Render for Navigator {
if let Some(at) = self.outline.iter().position(|heading| heading.current) { if let Some(at) = self.outline.iter().position(|heading| heading.current) {
outline = outline.current(at.to_string()); 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 outline
.on_select(move |key, _, cx| { .on_select(move |key, _, cx| {
let at: usize = key.parse().expect("an outline index"); let at: usize = key.parse().expect("an outline index");
let (to, docs) = { Navigator::jump(&docs, places[at], cx);
let navigator = this.read(cx);
(navigator.outline[at].rect, navigator.docs.clone())
};
Navigator::jump(&docs, to, cx);
}) })
.into_any_element() .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)); settle(&cx, |app| current(app) == Some(0));
} }
#[cfg(unix)]
#[test] #[test]
fn pdfs_export_a_range_and_locked_files_ask() { fn pdfs_export_a_range_and_locked_files_ask() {
let dir = tempfile::tempdir().expect("a temp dir"); 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); written(&cx, &pdf, None);
let source = dir.path().join("long.fodt"); 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"); let link = dir.path().join("link.pdf");
std::fs::hard_link(&source, &link).expect("the hard link"); 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| { cx.update(|app| {
docs.update(app, |docs, cx| { docs.update(app, |docs, cx| {
docs.export_to(id, target.clone(), ely_lok::Pdf::default(), 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" "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");
}
+2 -4
View File
@@ -81,10 +81,8 @@ impl Docs {
let path = source.work.join("print.pdf"); let path = source.work.join("print.pdf");
let printing = path.clone(); let printing = path.clone();
return self.write_pdf(tab, path, pdf, cx, move |docs, written, cx| match written { return self.write_pdf(tab, path, pdf, cx, move |docs, written, cx| match written {
Ok(()) => ely_office::print_later(printing, cx, |docs, error, cx| { Ok(()) => ely_office::print_later(printing, cx),
docs.output_failed(&error, cx) Err(error) => docs.print_failed(&error.to_string(), cx),
}),
Err(error) => docs.output_failed(&error.to_string(), cx),
}); });
} }
let Some(dir) = ely_office::folder(source) else { let Some(dir) = ely_office::folder(source) else {
+38 -26
View File
@@ -113,43 +113,55 @@ impl Props {
self.read_styles(cx); self.read_styles(cx);
} }
/// The installed fonts, read off the window thread.
fn read_fonts(&mut self, doc: &Doc, cx: &mut Context<Self>) { fn read_fonts(&mut self, doc: &Doc, cx: &mut Context<Self>) {
let read = self ely_lok::read_off(
.worker &self.worker,
.lock() doc,
.expect("the worker lock") |worker, doc| ely_lok::commands::fonts(&worker.values(doc, ".uno:CharFontName")?),
.values(doc, ".uno:CharFontName") |props: &mut Props, read, cx| match read {
.and_then(|json| ely_lok::commands::fonts(&json)); Ok(fonts) => {
match read { props.fonts = fonts.into_iter().map(Into::into).collect();
Ok(fonts) => self.fonts = fonts.into_iter().map(Into::into).collect(), cx.notify();
Err(error) => cx.emit(LokError(format!("the font list is unreadable: {error}"))), }
} 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>) { 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; return;
}; };
let read = self let view = shown.view.entity_id();
.worker ely_lok::read_off(
.lock() &self.worker,
.expect("the worker lock") &shown.doc,
.values(&shown.doc, ".uno:StyleApply") |worker, doc| {
.and_then(|json| { let json = worker.values(doc, ".uno:StyleApply")?;
let list = |family| -> std::io::Result<Vec<SharedString>> { let list = |family| -> std::io::Result<Vec<SharedString>> {
let names = ely_lok::commands::styles(&json, family)?; let names = ely_lok::commands::styles(&json, family)?;
Ok(names.into_iter().map(Into::into).collect()) Ok(names.into_iter().map(Into::into).collect())
}; };
Ok((list("2")?, list("1")?, list("8")?)) Ok((list("2")?, list("1")?, list("8")?))
}); },
match read { move |props: &mut Props, read, cx| {
Ok((paragraph, character, pages)) => { // Another tab shown since: its own read follows.
shown.paragraph = paragraph; let Some(shown) = props.shown.as_mut().filter(|shown| shown.view.entity_id() == view) else {
shown.character = character; return;
shown.pages = pages; };
} match read {
Err(error) => cx.emit(LokError(format!("the style list is unreadable: {error}"))), 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. /// 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. /// The panel, shown on the active tab.
pub(super) fn panel(cx: &mut TestAppContext, docs: &Entity<Docs>) -> Entity<Props> { 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 tab = docs.read(app).active_tab().expect("a tab");
let (view, doc) = (tab.view.clone(), tab.doc.clone()); let (view, doc) = (tab.view.clone(), tab.doc.clone());
let props = docs.read(app).props.clone(); let props = docs.read(app).props.clone();
props.update(app, |props, cx| props.show(Some((&view, &doc)), cx)); props.update(app, |props, cx| props.show(Some((&view, &doc)), cx));
props 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]) { 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 { impl Docs {
/// A kernel list for the active document; failures toast. /// Reads a kernel list off the window thread, then `then`.
fn read(&self, command: &str, cx: &mut Context<Self>) -> Option<String> { 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 Some(tab) = self.active_tab() else {
let text = Docs::text(cx, "command.blocked.no_document", &[]); let text = Docs::text(cx, "command.blocked.no_document", &[]);
self.toast(text, Severity::Warning, cx); return self.toast(text, Severity::Warning, cx);
return None;
}; };
let read = self let id = tab.id;
.core self.dialog_asked += 1;
.worker let asked = self.dialog_asked;
.lock() self.ask_in(
.expect("the worker lock") id,
.values(&tab.doc, command); move |worker, doc| worker.values(doc, command),
read.map_err(|error| { move |docs, read, window, cx| {
let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]); // A dialog opened since, or a later ask, wins.
self.toast(text, Severity::Danger, cx); 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");
.ok() }
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>> { /// The bookmarks' names, then `then`.
let listed = ely_lok::review::bookmarks(&self.read(".uno:Bookmarks", cx)?); fn with_bookmarks(
listed &mut self,
.map(|marks| marks.into_iter().map(|mark| mark.name).collect()) then: impl FnOnce(&mut Self, Vec<String>, &mut Window, &mut Context<Self>) + 'static,
.map_err(|error| { cx: &mut Context<Self>,
let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]); ) {
self.toast(text, Severity::Danger, cx); self.read_then(
}) ".uno:Bookmarks",
.ok() 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>) { 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); self.run_active("note.endnote", &[], cx);
} }
pub fn on_bookmark(&mut self, _: &InsertBookmark, window: &mut Window, cx: &mut Context<Self>) { pub fn on_bookmark(&mut self, _: &InsertBookmark, _: &mut Window, cx: &mut Context<Self>) {
if let Some(taken) = self.bookmarks(cx) { self.with_bookmarks(|docs, taken, window, cx| docs.ask(Ask::Bookmark(taken), "", window, cx), cx);
self.ask(Ask::Bookmark(taken), "", window, cx);
}
} }
pub fn on_link(&mut self, _: &InsertLink, window: &mut Window, cx: &mut Context<Self>) { 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>) { pub fn on_reference(&mut self, _: &InsertReference, _: &mut Window, cx: &mut Context<Self>) {
let Some(marks) = self.bookmarks(cx) else { self.with_bookmarks(
return; |docs, marks, _, cx| {
}; let Some(mark) = marks.first().cloned() else {
let Some(mark) = marks.first().cloned() else { let text = Docs::text(cx, "doc.reference.none", &[]);
let text = Docs::text(cx, "doc.reference.none", &[]); return docs.toast(text, Severity::Warning, cx);
return self.toast(text, Severity::Warning, cx); };
}; docs.dialog = Some(Dialog::Reference { marks, mark, show: "2" });
self.dialog = Some(Dialog::Reference { cx.notify();
marks, },
mark, cx,
show: "2", );
});
cx.notify();
} }
pub fn on_caption(&mut self, _: &InsertCaption, window: &mut Window, cx: &mut Context<Self>) { 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); self.ask(Ask::Caption(category), "", window, cx);
} }
pub fn on_formula(&mut self, _: &InsertFormula, window: &mut Window, cx: &mut Context<Self>) { pub fn on_formula(&mut self, _: &InsertFormula, _: &mut Window, cx: &mut Context<Self>) {
let Some(json) = self.read(".uno:Formula", cx) else { self.read_then(
return; ".uno:Formula",
}; |docs, json, window, cx| match ely_lok::review::formula(&json) {
match ely_lok::review::formula(&json) { Ok(Some(formula)) => docs.ask(Ask::Formula(true), &formula, window, cx),
Ok(Some(formula)) => self.ask(Ask::Formula(true), &formula, window, cx), Ok(None) => docs.ask(Ask::Formula(false), "", window, cx),
Ok(None) => self.ask(Ask::Formula(false), "", window, cx), Err(error) => docs.engine_failed(&error.to_string(), cx),
Err(error) => { },
let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]); cx,
self.toast(text, Severity::Danger, cx); );
}
}
} }
pub fn on_comment(&mut self, _: &InsertComment, window: &mut Window, cx: &mut Context<Self>) { 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. /// The open prompt's request and field.
fn asked(cx: &TestAppContext, docs: &Entity<Docs>) -> (Ask, Entity<TextInput>) { 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 { cx.update(|app| match &docs.read(app).dialog {
Some(Dialog::Ask { ask, field }) => (ask.clone(), field.clone()), Some(Dialog::Ask { ask, field }) => (ask.clone(), field.clone()),
_ => panic!("a prompt is open"), _ => panic!("a prompt is open"),
@@ -35,6 +37,7 @@ fn references_go_in_through_their_dialogs() {
with_window(&mut cx, &docs, |docs, window, cx| { with_window(&mut cx, &docs, |docs, window, cx| {
docs.on_reference(&InsertReference, window, cx) docs.on_reference(&InsertReference, window, cx)
}); });
cx.run_until_parked();
assert!( assert!(
cx.update(|app| docs.read(app).dialog.is_none()), cx.update(|app| docs.read(app).dialog.is_none()),
"no bookmark, no cross-reference dialog" "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| { with_window(&mut cx, &docs, |docs, window, cx| {
docs.on_reference(&InsertReference, window, cx) docs.on_reference(&InsertReference, window, cx)
}); });
cx.run_until_parked();
cx.update(|app| docs.update(app, |docs, cx| docs.insert_reference(cx))); cx.update(|app| docs.update(app, |docs, cx| docs.insert_reference(cx)));
with_window(&mut cx, &docs, |docs, window, cx| { with_window(&mut cx, &docs, |docs, window, cx| {
docs.on_link(&InsertLink, window, cx) docs.on_link(&InsertLink, window, cx)
@@ -188,3 +192,49 @@ fn a_long_formula_prefills_whole() {
let (_, field) = asked(&cx, &docs); let (_, field) = asked(&cx, &docs);
assert_eq!(cx.update(|app| field.read(app).text().to_string()), long); 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::{ use ely_gpui_component::{
buttons::{Button, ButtonVariant, IconButton, SegmentedControl}, buttons::{Button, ButtonVariant, IconButton, SegmentedControl},
collab::{Comment, CommentSidebar, Peer, Thread}, collab::{Comment, CommentSidebar, Peer, Thread},
feedback::EmptyState, feedback::{EmptyState, InlineMessage},
forms::{Switch, TextInput}, forms::{Switch, TextInput},
layout::ScrollArea, layout::ScrollArea,
lists::{List, ListItem}, lists::{List, ListItem},
primitives::IconName, primitives::{IconName, Severity},
theme::ControlSize, theme::ControlSize,
}; };
use ely_lok::{ use ely_lok::{
@@ -20,7 +20,7 @@ use ely_lok::{
review::{Change, Note}, review::{Change, Note},
}; };
use gpui::{ 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, SharedString, Styled as _, Subscription, Window, actions, div,
}; };
@@ -86,6 +86,8 @@ pub struct Review {
shown: Option<Shown>, shown: Option<Shown>,
notes: Vec<Note>, notes: Vec<Note>,
threads: Vec<Thread>, threads: Vec<Thread>,
/// Notes whose time does not read: time and why.
unread: Vec<Unread>,
pub(crate) changes: Vec<Change>, pub(crate) changes: Vec<Change>,
active: Option<SharedString>, active: Option<SharedString>,
pane: Pane, pane: Pane,
@@ -100,6 +102,7 @@ impl Review {
shown: None, shown: None,
notes: Vec::new(), notes: Vec::new(),
threads: Vec::new(), threads: Vec::new(),
unread: Vec::new(),
changes: Vec::new(), changes: Vec::new(),
active: None, active: None,
pane: Pane::Comments, pane: Pane::Comments,
@@ -117,6 +120,7 @@ impl Review {
self.active = None; self.active = None;
self.notes.clear(); self.notes.clear();
self.threads.clear(); self.threads.clear();
self.unread.clear();
self.changes.clear(); self.changes.clear();
cx.notify(); cx.notify();
let Some((view, doc)) = tab else { let Some((view, doc)) = tab else {
@@ -161,21 +165,8 @@ impl Review {
return; return;
} }
shown.reading = false; 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 { match read {
Ok((notes, threads, changes)) => { Ok((notes, changes)) => review.take_lists(notes, changes),
log::info!(
"doc: review lists {} notes, {} changes",
notes.len(),
changes.len()
);
review.notes = notes;
review.threads = threads;
review.changes = changes;
}
Err(error) => cx.emit(LokError(format!( Err(error) => cx.emit(LokError(format!(
"the review lists are unreadable: {error}" "the review lists are unreadable: {error}"
))), ))),
@@ -366,12 +357,61 @@ impl Render for Review {
.p_2() .p_2()
.child(head) .child(head)
.child(pane) .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)) .child(div().flex_1().min_h_0().child(body))
} }
} }
/// The notes as threads under their first comment. /// A note whose time does not read, and why.
fn threads(notes: &[Note]) -> Result<Vec<Thread>, String> { #[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 let parents: HashMap<&str, &str> = notes
.iter() .iter()
.map(|note| (note.id.as_str(), note.parent.as_str())) .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() id.to_string()
}; };
let authors: Vec<&str> = notes.iter().map(|note| note.author.as_str()).collect(); 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 { for note in notes {
let at = note let read = note.at.parse::<jiff::civil::DateTime>().and_then(|at| at.to_zoned(jiff::tz::TimeZone::system()));
.at let at = match read {
.parse::<jiff::civil::DateTime>() Ok(at) => at,
.and_then(|at| at.to_zoned(jiff::tz::TimeZone::system())) Err(error) => {
.map_err(|error| format!("a bad comment time {}: {error}", note.at))?; unread.push(Unread { author: note.author.clone(), at: note.at.clone(), error: error.to_string() });
continue;
}
};
let hue = authors let hue = authors
.iter() .iter()
.position(|name| *name == note.author) .position(|name| *name == note.author)
@@ -412,7 +455,7 @@ fn threads(notes: &[Note]) -> Result<Vec<Thread>, String> {
}), }),
} }
} }
Ok(threads) (threads, unread)
} }
#[cfg(test)] #[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"); 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]);
}
+1 -1
View File
@@ -365,7 +365,7 @@ fn a_macro_file_opens_in_risk_mode_and_saves_only_as_a_copy() {
); );
cx.update(|app| { cx.update(|app| {
assert!( 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" "the DOCX copy holds no macros"
) )
}); });
+38
View File
@@ -263,3 +263,41 @@ fn closing_a_tab_takes_its_export_dialog() {
// Drawn now, a dialog for the gone tab would crash. // 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"); 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));
}
+4
View File
@@ -5,6 +5,10 @@ edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license.workspace = true license.workspace = true
publish.workspace = true publish.workspace = true
build = "../../packaging/windows/icon.rs"
[build-dependencies]
embed-resource.workspace = true
[dependencies] [dependencies]
ely-platform.workspace = true 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; use ely_platform::AppId;
fn main() { fn main() {
+5
View File
@@ -5,6 +5,10 @@ edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license.workspace = true license.workspace = true
publish.workspace = true publish.workspace = true
build = "../../packaging/windows/icon.rs"
[build-dependencies]
embed-resource.workspace = true
[dependencies] [dependencies]
anyhow.workspace = true anyhow.workspace = true
@@ -27,4 +31,5 @@ serde_json.workspace = true
workspace = true workspace = true
[dev-dependencies] [dev-dependencies]
gpui = { workspace = true, features = ["test-support"] }
tempfile.workspace = true tempfile.workspace = true
+3 -12
View File
@@ -112,19 +112,10 @@ impl Desk {
if open > 0 { if open > 0 {
return log::error!("mark: a merge with {open} open conflicts"); return log::error!("mark: a merge with {open} open conflicts");
} }
let Some(tab) = self.tab_mut(id) else { // Disk text is saved; the merge edits it.
return; if let Some(view) = self.adopt_disk(id, &disk, fingerprint, cx) {
}; view.update(cx, |view, cx| view.adopt(merged, cx));
if let Err(error) = tab.session.reload_at(fingerprint) {
let why = error.to_string();
return self.set_outside(id, Outside::Unread(why), cx);
} }
tab.extra.base = disk.text.clone();
tab.extra.restart_autosave();
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);
} }
} }
} }
+19 -6
View File
@@ -53,7 +53,9 @@ pub struct Desk {
pub keeping: crate::restore::Keeping, pub keeping: crate::restore::Keeping,
/// The export dialog's choice, while it is open. /// The export dialog's choice, while it is open.
pub output: Option<crate::export::Output>, pub output: Option<crate::export::Output>,
pub printing: Option<crate::export::Printing>, /// Pages printing to PDF, by job; several may run.
pub printing: HashMap<u64, crate::export::Printing>,
pub print_jobs: u64,
pub look: crate::look::Look, pub look: crate::look::Look,
pub look_file: PathBuf, pub look_file: PathBuf,
/// Git's word on the shown file, and which tab asked. /// Git's word on the shown file, and which tab asked.
@@ -113,7 +115,7 @@ impl Host for Desk {
self.watch_folders(cx); self.watch_folders(cx);
let look = serde_json::to_value(&self.look).expect("a look serializes"); let look = serde_json::to_value(&self.look).expect("a look serializes");
view.update(cx, |view, cx| view.set_look(look, cx)); view.update(cx, |view, cx| view.set_look(look, cx));
let changed = view.read(cx).changed; let changed = view.read(cx).changed();
self.autosave(id, changed, cx); self.autosave(id, changed, cx);
let recheck = self.tab_mut(id).is_some_and(|tab| { let recheck = self.tab_mut(id).is_some_and(|tab| {
if !tab.dirty(cx) { if !tab.dirty(cx) {
@@ -200,7 +202,8 @@ pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
comparing: None, comparing: None,
keeping, keeping,
output: None, output: None,
printing: None, printing: Default::default(),
print_jobs: 0,
look: look.clone(), look: look.clone(),
look_file, look_file,
tracked: None, tracked: None,
@@ -228,8 +231,13 @@ pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
desk.open_path(path, cx); 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); desk.new_from_text(&name, "", cx);
if desk.core.next > id {
desk.keeping.made(at, id);
}
} }
for path in asked { for path in asked {
desk.open_path(path, cx); desk.open_path(path, cx);
@@ -246,8 +254,13 @@ pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
} }
}) })
.detach(); .detach();
cx.on_app_quit(|desk, _| { cx.on_app_quit(|desk, cx| {
desk.keeping.close(); // 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 {} async {}
}) })
.detach(); .detach();
+1 -1
View File
@@ -132,7 +132,7 @@ impl Engine for Mark {
} }
fn edited(view: &MarkView) -> u64 { fn edited(view: &MarkView) -> u64 {
view.changed view.changed()
} }
fn edit_pending(view: &MarkView) -> bool { fn edit_pending(view: &MarkView) -> bool {
+45 -37
View File
@@ -15,7 +15,7 @@ use ely_ui::{commands, i18n::text};
use ely_web_content::{Event, WebView}; use ely_web_content::{Event, WebView};
use gpui::{ use gpui::{
AnyElement, App, AppContext as _, Context, Entity, IntoElement as _, ParentElement as _, AnyElement, App, AppContext as _, Context, Entity, IntoElement as _, ParentElement as _,
Subscription, Window, Subscription, Task, Window,
}; };
use serde_json::Value; use serde_json::Value;
@@ -62,7 +62,7 @@ pub struct Lost {
} }
impl Lost { impl Lost {
fn of(images: Vec<String>, rendered: &Value) -> Lost { pub fn of(images: Vec<String>, rendered: &Value) -> Lost {
let diagrams = rendered["failed"].as_array().into_iter().flatten(); let diagrams = rendered["failed"].as_array().into_iter().flatten();
let formulas = rendered["formulas"].as_array().into_iter().flatten(); let formulas = rendered["formulas"].as_array().into_iter().flatten();
Lost { Lost {
@@ -108,8 +108,7 @@ impl Desk {
let Some(id) = self.active_tab().map(|tab| tab.id) else { let Some(id) = self.active_tab().map(|tab| tab.id) else {
return; return;
}; };
let pdf = self.core.work.join(format!("print-{id}.pdf")); self.render_pdf(id, None, cx);
self.render_pdf(id, pdf, None, cx);
} }
/// Asks where, renders the whole text, then writes it. /// Asks where, renders the whole text, then writes it.
@@ -126,24 +125,27 @@ impl Desk {
let dir = ely_office::folder(tab).unwrap_or_else(std::env::temp_dir); 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()))); let chosen = cx.prompt_for_new_path(&dir, Some(&format!("{stem}.{}", output.extension())));
cx.spawn(async move |desk, cx| { cx.spawn(async move |desk, cx| {
let Ok(Ok(Some(path))) = chosen.await else { let chosen = match chosen.await {
return log::info!("mark: the export panel closed without a path"); Ok(Ok(path)) => Ok(path),
Ok(Err(error)) => Err(error.to_string()),
Err(error) => Err(error.to_string()),
}; };
desk.update(cx, |desk, cx| { let done = desk.update(cx, |desk, cx| match chosen {
if let Err(error) = not_open(desk, &path) { Ok(Some(path)) => {
return desk.exported(Err(error), cx); if let Err(error) = not_open(desk, &path) {
return desk.exported(Err(error), cx);
}
match output {
Output::Pdf => desk.render_pdf(id, Some(path), cx),
_ => desk.write_output(id, output, path, cx),
}
} }
match output { Ok(None) => log::info!("mark: the export panel was cancelled"),
Output::Pdf => desk.render_pdf( Err(why) => desk.panel_failed(&why, cx),
id, });
desk.core.work.join(format!("export-{id}.pdf")), if done.is_err() {
Some(path), log::info!("mark: the window closed before the export panel");
cx, }
),
_ => desk.write_output(id, output, path, cx),
}
})
.ok();
}) })
.detach(); .detach();
} }
@@ -257,7 +259,11 @@ impl Desk {
} }
/// The whole text as PDF; then to `to`, else printed. /// 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>) { fn render_pdf(&mut self, id: u64, to: Option<PathBuf>, cx: &mut Context<Self>) {
// Each job has its own files; none cancels another.
self.print_jobs += 1;
let n = self.print_jobs;
let pdf = self.core.work.join(format!("print-{n}.pdf"));
let Some(tab) = self.tab(id) else { let Some(tab) = self.tab(id) else {
return; return;
}; };
@@ -273,14 +279,16 @@ impl Desk {
view.render( view.render(
"html", "html",
move |rendered, cx: &mut App| { move |rendered, cx: &mut App| {
let staged = work.join(format!("print-{id}.html")); let staged = work.join(format!("print-{n}.html"));
let page = rendered.map_err(io::Error::other).and_then(|rendered| { let page = match rendered {
let (html, images) = paper::Marked::read(&rendered)?.inline(&folder); Ok(rendered) => {
let style = format!("<style>{}</style>", std::fs::read_to_string(&style)?); let print = paper::Print { folder, style, name, page: staged.clone() };
std::fs::write(&staged, paper::page(&name, &style, &html))?; paper::stage(cx.background_executor(), rendered, print)
Ok(Lost::of(images, &rendered)) }
}); Err(why) => Task::ready(Err(io::Error::other(why))),
};
cx.spawn(async move |cx| { cx.spawn(async move |cx| {
let page = page.await;
in_window(&desk, cx, |desk, window, cx| match page { in_window(&desk, cx, |desk, window, cx| match page {
Ok(lost) => { Ok(lost) => {
let job = Job { let job = Job {
@@ -289,7 +297,7 @@ impl Desk {
open, open,
lost, lost,
}; };
desk.print_page(&staged, job, window, cx) desk.print_page(n, &staged, job, window, cx)
} }
Err(error) => desk.exported(Err(error), cx), Err(error) => desk.exported(Err(error), cx),
}) })
@@ -303,32 +311,34 @@ impl Desk {
} }
/// Chromium prints the staged page; the PDF goes where asked. /// 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>) { fn print_page(&mut self, n: u64, page: &Path, job: Job, window: &mut Window, cx: &mut Context<Self>) {
let root = page let root = page
.parent() .parent()
.expect("a staged page sits in a folder") .expect("a staged page sits in a folder")
.to_path_buf(); .to_path_buf();
let view = match WebView::open(page, &root, window, cx) { // A document's own scripts never run here.
let view = match WebView::open_inert(page, &root, window, cx) {
Ok(view) => view, Ok(view) => view,
Err(error) => return self.exported(Err(io::Error::other(error)), cx), Err(error) => return self.exported(Err(io::Error::other(error)), cx),
}; };
let heard = cx.subscribe(&view, move |desk, view, event: &Event, cx| match event { let heard = cx.subscribe(&view, move |desk, view, event: &Event, cx| match event {
Event::Loaded => view.read(cx).print_pdf(&job.pdf), Event::Loaded => view.read(cx).print_pdf(&job.pdf),
Event::Printed(path, ok) => { Event::Printed(path, ok) => {
desk.printing = None; log::info!("mark: job {n} printed {}, ok {ok}", path.display());
desk.printing.remove(&n);
let printed = ok let printed = ok
.then(|| path.clone()) .then(|| path.clone())
.ok_or_else(|| io::Error::other("Chromium did not print the page")); .ok_or_else(|| io::Error::other("Chromium did not print the page"));
desk.printed(printed, job.clone(), cx); desk.printed(printed, job.clone(), cx);
} }
Event::Failed(why) => { Event::Failed(why) => {
desk.printing = None; desk.printing.remove(&n);
desk.exported(Err(io::Error::other(why.clone())), cx); desk.exported(Err(io::Error::other(why.clone())), cx);
} }
Event::Query(_, reply) => reply.ok(""), Event::Query(_, reply) => reply.ok(""),
Event::Leave(_) => {} Event::Leave(_) => {}
}); });
self.printing = Some(Printing { self.printing.insert(n, Printing {
_view: view, _view: view,
_heard: heard, _heard: heard,
}); });
@@ -343,9 +353,7 @@ impl Desk {
if !job.lost.whole() { if !job.lost.whole() {
self.warn_lost(&job.lost, None, cx); self.warn_lost(&job.lost, None, cx);
} }
return ely_office::print_later(pdf, cx, |desk, why, cx| { return ely_office::print_later(pdf, cx);
desk.exported(Err(io::Error::other(why)), cx)
});
}; };
// Never over the open file, not even through a link. // Never over the open file, not even through a link.
let moved = not_open(self, &to) let moved = not_open(self, &to)
+5 -1
View File
@@ -115,8 +115,12 @@ impl Desk {
fn close_find(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn close_find(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.finder = None; self.finder = None;
if let Some(view) = self.active_view() { // Every tab's page holds its own highlights.
let views: Vec<_> = self.core.tabs.iter().map(|tab| tab.view.clone()).collect();
for view in views {
view.update(cx, |view, cx| view.unfind(cx)); view.update(cx, |view, cx| view.unfind(cx));
}
if let Some(view) = self.active_view() {
let focus = view.read(cx).focus.clone(); let focus = view.read(cx).focus.clone();
window.focus(&focus, cx); window.focus(&focus, cx);
} }
+4
View File
@@ -1,3 +1,6 @@
// Release builds open no console on Windows.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod attach; mod attach;
mod autosave; mod autosave;
mod compare; mod compare;
@@ -15,6 +18,7 @@ mod places;
mod rename; mod rename;
mod restore; mod restore;
mod side; mod side;
mod tally;
mod text; mod text;
mod vcs; mod vcs;
mod view; mod view;
+7 -7
View File
@@ -236,11 +236,7 @@ impl Desk {
match (disk.text == current, dirty) { match (disk.text == current, dirty) {
// The same text: only the file underneath is new. // The same text: only the file underneath is new.
(true, _) => { (true, _) => {
if let Some(view) = self.adopt_disk(id, &disk, fingerprint, cx) { self.adopt_disk(id, &disk, fingerprint, cx);
// What shows is now saved: the tab is clean.
let saved = crate::engine::mark(&disk.text);
view.update(cx, |view, cx| view.saved(&saved, cx));
}
} }
// The text as last saved: the edits stay unsaved. // The text as last saved: the edits stay unsaved.
(false, true) if kept => self.keep_edits(id, disk, fingerprint, cx), (false, true) if kept => self.keep_edits(id, disk, fingerprint, cx),
@@ -294,8 +290,8 @@ impl Desk {
self.save_after_pause(id, cx); self.save_after_pause(id, cx);
} }
/// The tab takes the file as saved; returns its view. /// The tab and its view take the file as saved.
fn adopt_disk( pub(crate) fn adopt_disk(
&mut self, &mut self,
id: u64, id: u64,
disk: &Source, disk: &Source,
@@ -308,6 +304,9 @@ impl Desk {
return None; return None;
} }
tab.extra.base = disk.text.clone(); tab.extra.base = disk.text.clone();
// 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(); tab.extra.restart_autosave();
// Its bytes (BOM, line ends) are what saves keep now. // Its bytes (BOM, line ends) are what saves keep now.
tab.doc.lock().expect("the text lock").source = disk.clone(); tab.doc.lock().expect("the text lock").source = disk.clone();
@@ -320,6 +319,7 @@ impl Desk {
log::warn!("mark: the replaced file did not register: {error}"); log::warn!("mark: the replaced file did not register: {error}");
} }
self.set_outside(id, Outside::Clear, cx); self.set_outside(id, Outside::Clear, cx);
view.update(cx, |view, cx| view.saved(&saved, cx));
Some(view) Some(view)
} }
+27 -6
View File
@@ -5,15 +5,16 @@ use std::path::{Path, PathBuf};
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use ely_web_content::WebView; use ely_web_content::WebView;
use gpui::App; use gpui::App;
use serde_json::json; use serde_json::{Value, json};
/// The app's Resources, else this build's `engines/content`. use crate::view::Heading;
/// Beside the app, its Resources, else this build's.
pub fn content() -> Result<PathBuf> { pub fn content() -> Result<PathBuf> {
let bundled = std::env::current_exe()? let exe = std::env::current_exe()?;
.parent() let dir = exe.parent().unwrap_or(Path::new("."));
.and_then(|dir| Some(dir.parent()?.join("Resources/content")));
let built = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../engines/content/ely-mark"); let built = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../engines/content/ely-mark");
for dir in bundled.into_iter().chain([built]) { for dir in [dir.join("content"), dir.join("../Resources/content"), built] {
if dir.join("index.html").is_file() { if dir.join("index.html").is_file() {
log::info!("mark: page from {}", dir.display()); log::info!("mark: page from {}", dir.display());
return Ok(dir); return Ok(dir);
@@ -75,6 +76,11 @@ pub fn open(
)); ));
} }
/// The disk's text in place; reports start a new generation.
pub fn refresh(view: &WebView, markdown: &str, load: u64) {
view.run_js(&format!("ely.refresh({}, {load})", json!(markdown)));
}
/// The same text in another mode; pending edits go first. /// The same text in another mode; pending edits go first.
pub fn switch(view: &WebView, mode: Mode) { pub fn switch(view: &WebView, mode: Mode) {
view.run_js(&format!("ely.mode('{}')", mode.name())); view.run_js(&format!("ely.mode('{}')", mode.name()));
@@ -90,3 +96,18 @@ pub fn folder(view: &WebView, folder: &Path) {
pub fn call(view: &WebView, name: &str, argument: &serde_json::Value) { pub fn call(view: &WebView, name: &str, argument: &serde_json::Value) {
view.run_js(&format!("ely.{name}({argument})")); view.run_js(&format!("ely.{name}({argument})"));
} }
/// Headings from the page; anything malformed is None.
pub fn headings(items: &Value) -> Option<Vec<Heading>> {
let heading = |item: &Value| {
let level = item["level"]
.as_u64()
.filter(|level| (1..=6).contains(level))?;
let title = item["title"].as_str()?.to_owned();
Some(Heading {
level: level as usize,
title,
})
};
items.as_array()?.iter().map(heading).collect()
}
+47
View File
@@ -85,6 +85,28 @@ fn kind(path: &Path) -> &'static str {
} }
} }
/// What the print page is made of.
pub struct Print {
pub folder: std::path::PathBuf,
pub style: std::path::PathBuf,
pub name: String,
pub page: std::path::PathBuf,
}
/// Writes the print page off the window thread, images inline.
pub fn stage(
background: &gpui::BackgroundExecutor,
rendered: serde_json::Value,
print: Print,
) -> gpui::Task<std::io::Result<crate::export::Lost>> {
background.spawn(async move {
let (html, images) = Marked::read(&rendered)?.inline(&print.folder);
let style = format!("<style>{}</style>", std::fs::read_to_string(&print.style)?);
std::fs::write(&print.page, page(&print.name, &style, &html))?;
Ok(crate::export::Lost::of(images, &rendered))
})
}
/// The page's HTML; image `src` values replaced by numbered tokens. /// The page's HTML; image `src` values replaced by numbered tokens.
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
pub struct Marked { pub struct Marked {
@@ -222,6 +244,31 @@ mod tests {
assert!(Marked::read(&serde_json::json!({ "html": "<p>" })).is_err()); assert!(Marked::read(&serde_json::json!({ "html": "<p>" })).is_err());
} }
#[gpui::test]
fn the_print_page_is_written_off_the_window_thread(cx: &mut gpui::TestAppContext) {
let dir = tempfile::tempdir().expect("a temp dir");
std::fs::write(dir.path().join("a.png"), b"png").expect("an image");
std::fs::write(dir.path().join("export.css"), "p {}").expect("a style");
let rendered = serde_json::json!({
"html": "<p>hi</p><img src=\"T0\"><img src=\"T1\">",
"token": "T",
"images": ["a.png", "gone.png"],
"failed": [3],
});
let print = Print {
folder: dir.path().into(),
style: dir.path().join("export.css"),
name: "notes".into(),
page: dir.path().join("print-1.html"),
};
let staging = stage(&cx.executor(), rendered, print);
assert!(!dir.path().join("print-1.html").exists(), "staged on the window thread");
let lost = cx.foreground_executor().block_test(staging).expect("staged");
let written = std::fs::read_to_string(dir.path().join("print-1.html")).expect("the page");
assert!(written.contains("<style>p {}</style>") && written.contains("data:image/png;base64,cG5n"));
assert_eq!(format!("{lost:?}"), r#"Lost { images: ["gone.png"], diagrams: [3], formulas: [] }"#);
}
#[test] #[test]
fn titles_escape() { fn titles_escape() {
assert!(page("a<b>&", "", "").contains("<title>a&lt;b&gt;&amp;</title>")); assert!(page("a<b>&", "", "").contains("<title>a&lt;b&gt;&amp;</title>"));
+86 -19
View File
@@ -37,6 +37,8 @@ struct Waiting {
kept: Kept, kept: Kept,
real: Option<PathBuf>, real: Option<PathBuf>,
text: Option<(String, SystemTime)>, 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. /// The session's files and what still waits to restore.
@@ -97,6 +99,7 @@ impl Keeping {
}, },
real: Some(real), real: Some(real),
text: None, text: None,
tab: None,
}), }),
Err(error) => { Err(error) => {
log::warn!("mark: a kept file does not open: {error}"); log::warn!("mark: a kept file does not open: {error}");
@@ -111,6 +114,7 @@ impl Keeping {
kept, kept,
real: None, real: None,
text, text,
tab: None,
}); });
} }
} }
@@ -132,13 +136,33 @@ impl Keeping {
Ok((keeping, paths, missing)) Ok((keeping, paths, missing))
} }
/// The untitled tabs to make, with their names. /// The untitled tabs to make: their place here and name.
pub fn untitled(&self) -> Vec<String> { pub fn untitled(&self) -> Vec<(usize, String)> {
let untitled = self let waiting = self.waiting.iter().enumerate();
.waiting let untitled = waiting.filter(|(_, waiting)| waiting.kept.path.is_none() && waiting.tab.is_none());
.iter() untitled.map(|(at, waiting)| (at, waiting.kept.name.clone())).collect()
.filter(|waiting| waiting.kept.path.is_none()); }
untitled.map(|waiting| 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. /// A clean end: no crash next time, no stale points.
@@ -190,21 +214,12 @@ impl Desk {
.session .session
.path() .path()
.and_then(|path| std::fs::canonicalize(path).ok()); .and_then(|path| std::fs::canonicalize(path).ok());
let untitled = tab.session.path().is_none(); let Some(Waiting {
let waiting = &mut self.keeping.waiting; kept, text: back, ..
let Some(at) = waiting }) = self.keeping.take(real.as_ref(), id)
.iter()
.position(|waiting| match (&waiting.real, &real) {
(Some(kept), Some(open)) => kept == open,
(None, None) => untitled,
_ => false,
})
else { else {
return; return;
}; };
let Waiting {
kept, text: back, ..
} = waiting.remove(at);
let mode = Mode::parse(&kept.mode).unwrap_or(Mode::Visual); let mode = Mode::parse(&kept.mode).unwrap_or(Mode::Visual);
log::info!("mark: restores tab {id} as {}", mode.name()); log::info!("mark: restores tab {id} as {}", mode.name());
let tab = self.tab_mut(id).expect("open"); let tab = self.tab_mut(id).expect("open");
@@ -392,4 +407,56 @@ mod tests {
let place = kept.place.expect("a place"); let place = kept.place.expect("a place");
assert_eq!((place.line, place.column), (3, 0)); assert_eq!((place.line, place.column), (3, 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: &str, slot: &str| super::Kept {
path: None,
name: name.into(),
mode: mode.into(),
place: Some(super::Place { line: slot.len() as u64, column: 1, scroll: 0.0 }),
slot: slot.into(),
};
let tabs = vec![kept("Untitled1", "visual", "a"), kept("Untitled2", "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");
}
} }
+151
View File
@@ -0,0 +1,151 @@
//! Which page reports count, by page load and by version.
use std::collections::HashMap;
/// One page load's change counts and the renders it owes.
pub struct Tally<R> {
load: u64,
/// The newest change the page announced.
changed: u64,
/// The change whose text arrived.
reported: u64,
renders: HashMap<u64, R>,
asked: u64,
/// Why the page is down, until it loads again.
down: Option<String>,
}
impl<R> Tally<R> {
pub fn new() -> Self {
Tally { load: 0, changed: 0, reported: 0, renders: HashMap::new(), asked: 0, down: None }
}
pub fn load(&self) -> u64 {
self.load
}
pub fn changed(&self) -> u64 {
self.changed
}
/// Every change's text has arrived.
pub fn settled(&self) -> bool {
self.reported == self.changed
}
/// A reload or refresh: older reports lapse, owed renders end.
pub fn restart(&mut self) -> Vec<R> {
self.load += 1;
(self.changed, self.reported) = (0, 0);
self.lapse()
}
/// A page loaded: it answers again.
pub fn loaded(&mut self) -> Vec<R> {
self.down = None;
self.restart()
}
/// The page failed: owed and later renders end with `why`.
pub fn fail(&mut self, why: String) -> Vec<R> {
self.down = Some(why);
self.lapse()
}
fn lapse(&mut self) -> Vec<R> {
self.renders.drain().map(|(_, render)| render).collect()
}
/// Whether a report from page load `generation` still counts.
pub fn current(&self, generation: Option<u64>) -> bool {
generation.is_none_or(|load| load == self.load)
}
pub fn change(&mut self, version: u64) {
self.changed = self.changed.max(version);
}
/// Takes text `version` if it is the newest.
pub fn text(&mut self, version: u64) -> bool {
let newest = version >= self.reported;
if newest {
self.reported = version;
}
newest
}
/// Owes `render`; its number goes to the page.
pub fn ask(&mut self, render: R) -> Result<u64, (R, String)> {
if let Some(why) = &self.down {
return Err((render, why.clone()));
}
self.asked += 1;
self.renders.insert(self.asked, render);
Ok(self.asked)
}
pub fn answered(&mut self, ask: u64) -> Option<R> {
self.renders.remove(&ask)
}
}
#[cfg(test)]
mod tests {
use super::Tally;
#[test]
fn a_key_from_before_a_refresh_leaves_it_settled() {
let mut tally = Tally::<()>::new();
let old = tally.load();
// The old page counted two versions.
tally.change(2);
assert!(tally.text(2));
tally.restart();
// The renderer's change for the old text lands late.
if tally.current(Some(old)) {
tally.change(3);
}
assert!(tally.settled(), "an old change does not count");
assert_eq!(tally.changed(), 0, "versions start over");
// The refreshed page's own edits count again.
tally.change(1);
assert!(!tally.settled());
assert!(tally.text(1), "the new page's first text counts");
assert!(tally.settled());
}
#[test]
fn a_load_or_failure_ends_every_owed_render() {
let mut tally = Tally::new();
let (html, text) = (tally.ask("html").expect("up"), tally.ask("text").expect("up"));
let mut owed = tally.loaded();
owed.sort();
assert_eq!(owed, ["html", "text"]);
// Late answers find nobody waiting.
assert_eq!((tally.answered(html), tally.answered(text)), (None, None));
let pdf = tally.ask("pdf").expect("up");
assert_eq!(tally.fail("denied".into()), ["pdf"]);
assert_eq!(tally.answered(pdf), None);
}
#[test]
fn a_failed_page_refuses_renders_until_it_loads() {
let mut tally = Tally::new();
tally.fail("denied".into());
assert_eq!(tally.ask("html"), Err(("html", "denied".to_string())));
// A refresh is no load: the page is still down.
assert!(tally.restart().is_empty());
assert_eq!(tally.ask("text"), Err(("text", "denied".to_string())));
assert!(tally.loaded().is_empty());
assert!(tally.ask("pdf").is_ok());
}
#[test]
fn only_the_newest_text_counts() {
let mut tally = Tally::<()>::new();
tally.change(2);
assert!(tally.text(2));
assert!(!tally.text(1), "an older text is dropped");
assert!(tally.settled());
}
}
+92 -54
View File
@@ -37,20 +37,30 @@ pub const LARGE: usize = 2 * 1024 * 1024;
/// A render's answer: the page's reply, or why not. /// A render's answer: the page's reply, or why not.
type Rendered = Box<dyn FnOnce(Result<Value, String>, &mut gpui::App)>; type Rendered = Box<dyn FnOnce(Result<Value, String>, &mut gpui::App)>;
/// Renders the page will never answer end with `why`.
fn end_renders(pending: Vec<Rendered>, why: &str, cx: &mut gpui::App) {
if pending.is_empty() {
return;
}
log::warn!("mark: {} renders end: {why}", pending.len());
let why = why.to_string();
cx.defer(move |cx| {
for done in pending {
done(Err(why.clone()), cx);
}
});
}
/// The page failed; the shell shows why. /// The page failed; the shell shows why.
pub struct Failed(pub String); pub struct Failed(pub String);
pub struct MarkView { pub struct MarkView {
pub(crate) web: Entity<WebView>, pub(crate) web: Entity<WebView>,
doc: Doc, doc: Doc,
/// The newest change the page announced. /// Page loads, changes and owed renders.
pub changed: u64, tally: crate::tally::Tally<Rendered>,
/// The change whose text `doc` holds.
reported: u64,
/// The mark at the last load or save. /// The mark at the last load or save.
saved: String, saved: String,
/// Page loads so far; a reload restarts its versions.
load: u64,
pub mode: Mode, pub mode: Mode,
/// Headings as the shown editor reads them. /// Headings as the shown editor reads them.
pub outline: Vec<Heading>, pub outline: Vec<Heading>,
@@ -70,9 +80,6 @@ pub struct MarkView {
pub place: Option<Place>, pub place: Option<Place>,
/// The writing view, as the page applies it (MARK-09). /// The writing view, as the page applies it (MARK-09).
look: Value, look: Value,
/// Renders asked of the page, by number.
renders: std::collections::HashMap<u64, Rendered>,
asked: u64,
pub focus: FocusHandle, pub focus: FocusHandle,
} }
@@ -95,10 +102,8 @@ impl MarkView {
MarkView { MarkView {
web, web,
doc, doc,
changed: 0, tally: crate::tally::Tally::new(),
reported: 0,
saved, saved,
load: 0,
mode: if large { Mode::Source } else { Mode::Visual }, mode: if large { Mode::Source } else { Mode::Visual },
large, large,
outline: Vec::new(), outline: Vec::new(),
@@ -108,8 +113,6 @@ impl MarkView {
attachable: false, attachable: false,
place: None, place: None,
look: Value::Null, look: Value::Null,
renders: std::collections::HashMap::new(),
asked: 0,
folder, folder,
focus, focus,
} }
@@ -117,7 +120,12 @@ impl MarkView {
/// Every change's text has arrived. /// Every change's text has arrived.
pub fn settled(&self) -> bool { pub fn settled(&self) -> bool {
self.reported == self.changed self.tally.settled()
}
/// The newest change the page announced.
pub fn changed(&self) -> u64 {
self.tally.changed()
} }
/// The text's size now, in bytes. /// The text's size now, in bytes.
@@ -190,8 +198,10 @@ impl MarkView {
doc.source = source; doc.source = source;
} }
self.saved = mark(&text); self.saved = mark(&text);
(self.changed, self.reported) = (0, 0); // Reports and renders from before belong to the old text.
crate::page::call(self.web.read(cx), "refresh", &Value::from(text)); let lapsed = self.tally.restart();
end_renders(lapsed, "the text was replaced from disk", cx);
crate::page::refresh(self.web.read(cx), &text, self.tally.load());
cx.emit(Reported); cx.emit(Reported);
} }
@@ -213,10 +223,13 @@ impl MarkView {
done: impl FnOnce(Result<Value, String>, &mut gpui::App) + 'static, done: impl FnOnce(Result<Value, String>, &mut gpui::App) + 'static,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
self.asked += 1; match self.tally.ask(Box::new(done)) {
self.renders.insert(self.asked, Box::new(done)); Ok(ask) => {
let ask = serde_json::json!({ "kind": kind, "ask": self.asked }); let ask = serde_json::json!({ "kind": kind, "ask": ask });
crate::page::call(self.web.read(cx), "render", &ask); crate::page::call(self.web.read(cx), "render", &ask);
}
Err((done, why)) => end_renders(vec![done], &why, cx),
}
} }
/// `text` replaces the editor's, as one undoable edit. /// `text` replaces the editor's, as one undoable edit.
@@ -240,7 +253,7 @@ impl MarkView {
drop(doc); drop(doc);
let mode = if self.large { Mode::Source } else { mode }; let mode = if self.large { Mode::Source } else { mode };
self.mode = mode; self.mode = mode;
let at = (mode, self.load, place); let at = (mode, self.tally.load(), place);
crate::page::open(self.web.read(cx), &current, at, &self.folder, cx); crate::page::open(self.web.read(cx), &current, at, &self.folder, cx);
cx.emit(Reported); cx.emit(Reported);
} }
@@ -262,23 +275,38 @@ impl MarkView {
match event { match event {
// A reload shows the last text that arrived. // A reload shows the last text that arrived.
Event::Loaded => { Event::Loaded => {
self.load += 1; let lapsed = self.tally.loaded();
end_renders(lapsed, "the page reloaded", cx);
self.shown = false; self.shown = false;
(self.changed, self.reported) = (0, 0);
let text = self.doc.lock().expect("the text lock").current.clone(); let text = self.doc.lock().expect("the text lock").current.clone();
let at = (self.mode, self.load, self.place); let at = (self.mode, self.tally.load(), self.place);
crate::page::open(web.read(cx), &text, at, &self.folder, cx); crate::page::open(web.read(cx), &text, at, &self.folder, cx);
cx.emit(Reported); cx.emit(Reported);
} }
Event::Query(said, reply) => self.query(said, reply, cx), Event::Query(said, reply) => self.query(said, reply, cx),
Event::Leave(url) => log::info!("mark: a link out was not followed: {url}"), Event::Leave(url) => self.follow(url, cx),
Event::Failed(why) => cx.emit(Failed(why.clone())), Event::Failed(why) => {
let lapsed = self.tally.fail(why.clone());
end_renders(lapsed, why, cx);
cx.emit(Failed(why.clone()));
}
Event::Printed(path, _) => { Event::Printed(path, _) => {
log::error!("mark: an editor page printed {}", path.display()) log::error!("mark: an editor page printed {}", path.display())
} }
} }
} }
/// A clicked link: web and mail open outside.
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);
}
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));
}
fn query(&mut self, said: &str, reply: &Reply, cx: &mut Context<Self>) { fn query(&mut self, said: &str, reply: &Reply, cx: &mut Context<Self>) {
let said: Value = match serde_json::from_str(said) { let said: Value = match serde_json::from_str(said) {
Ok(said) => said, Ok(said) => said,
@@ -287,10 +315,7 @@ impl MarkView {
return reply.fail("bad JSON"); return reply.fail("bad JSON");
} }
}; };
if said["generation"] if !self.tally.current(said["generation"].as_u64()) {
.as_u64()
.is_some_and(|load| load != self.load)
{
log::info!("mark: a report from an earlier page load was dropped"); log::info!("mark: a report from an earlier page load was dropped");
return reply.ok(""); return reply.ok("");
} }
@@ -307,6 +332,10 @@ impl MarkView {
self.mode = mode; self.mode = mode;
cx.notify(); cx.notify();
} }
if let Some(what) = said["broke"].as_str() {
log::error!("mark: the visual editor broke ({what}); source shows");
cx.emit(Failed(ely_ui::i18n::text(cx, "mark.visual.broke", &[("what", what)]).to_string()));
}
self.shown = true; self.shown = true;
if let Some(place) = self.waiting.take() { if let Some(place) = self.waiting.take() {
crate::page::call(self.web.read(cx), "reveal", &place); crate::page::call(self.web.read(cx), "reveal", &place);
@@ -316,7 +345,19 @@ impl MarkView {
} }
cx.emit(Reported); cx.emit(Reported);
} }
(Some("outline"), ..) => match headings(&said["items"]) { (Some("failed"), ..) => {
let Some(what) = said["what"].as_str() else {
log::error!("mark: the page sent a failure without its name");
return reply.fail("bad failure");
};
log::error!("mark: the page built no editor ({what})");
cx.emit(Failed(ely_ui::i18n::text(cx, "mark.page.broke", &[("what", what)]).to_string()));
}
(Some("missed"), ..) => match said["line"].as_u64() {
Some(line) => log::warn!("mark: nothing to reveal at line {line}"),
None => log::warn!("mark: no such heading to reveal"),
},
(Some("outline"), ..) => match crate::page::headings(&said["items"]) {
Some(outline) => { Some(outline) => {
self.outline = outline; self.outline = outline;
cx.notify(); cx.notify();
@@ -336,6 +377,13 @@ impl MarkView {
return reply.fail("bad search state"); return reply.fail("bad search state");
} }
}, },
(Some("link"), ..) => match said["href"].as_str() {
Some(url) => self.follow(url, cx),
None => {
log::error!("mark: the page sent a link without its address");
return reply.fail("bad link");
}
},
(Some("table"), ..) if said["done"] == false => { (Some("table"), ..) if said["done"] == false => {
let key = match self.mode { let key = match self.mode {
Mode::Visual => "mark.table.none", Mode::Visual => "mark.table.none",
@@ -351,7 +399,7 @@ impl MarkView {
(Some("table" | "inserted"), ..) => {} (Some("table" | "inserted"), ..) => {}
(Some("rendered"), ..) => { (Some("rendered"), ..) => {
let ask = said["ask"].as_u64().unwrap_or_default(); let ask = said["ask"].as_u64().unwrap_or_default();
let Some(done) = self.renders.remove(&ask) else { let Some(done) = self.tally.answered(ask) else {
log::error!("mark: a render nobody asked for came back"); log::error!("mark: a render nobody asked for came back");
return reply.fail("unasked render"); return reply.fail("unasked render");
}; };
@@ -379,16 +427,18 @@ impl MarkView {
} }
}, },
(Some("changed"), Some(version), _) => { (Some("changed"), Some(version), _) => {
self.changed = self.changed.max(version); self.tally.change(version);
cx.emit(Reported); cx.emit(Reported);
} }
// Only the newest text counts. // Only the newest text counts.
(Some("text"), Some(version), Some(text)) if version >= self.reported => { (Some("text"), Some(version), Some(text)) => {
self.doc.lock().expect("the text lock").current = text.to_owned(); if !self.tally.text(version) {
self.reported = version; log::info!("mark: an older text was dropped");
cx.emit(Reported); } else {
self.doc.lock().expect("the text lock").current = text.to_owned();
cx.emit(Reported);
}
} }
(Some("text"), Some(_), Some(_)) => log::info!("mark: an older text was dropped"),
_ => { _ => {
log::error!("mark: the page asked something unknown"); log::error!("mark: the page asked something unknown");
return reply.fail("unknown request"); return reply.fail("unknown request");
@@ -420,21 +470,6 @@ fn found(said: &Value) -> Option<crate::finding::Found> {
}) })
} }
/// 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 { impl Render for MarkView {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
// Dropped files: images go beside the document. // Dropped files: images go beside the document.
@@ -444,3 +479,6 @@ impl Render for MarkView {
div().size_full().on_drop(drop).child(self.web.clone()) div().size_full().on_drop(drop).child(self.web.clone())
} }
} }
#[cfg(test)]
mod tests;
+18
View File
@@ -0,0 +1,18 @@
use std::{cell::RefCell, rc::Rc};
use serde_json::Value;
#[gpui::test]
fn each_owed_render_ends_once_with_the_reason(cx: &mut gpui::TestAppContext) {
let got = Rc::new(RefCell::new(Vec::<(u8, Result<Value, String>)>::new()));
let pending = (0..2)
.map(|n| {
let got = got.clone();
Box::new(move |rendered, _: &mut gpui::App| got.borrow_mut().push((n, rendered))) as super::Rendered
})
.collect();
cx.update(|cx| super::end_renders(pending, "denied", cx));
cx.run_until_parked();
let denied = || Err("denied".to_string());
assert_eq!(*got.borrow(), [(0, denied()), (1, denied())]);
}
+43 -6
View File
@@ -17,6 +17,8 @@ let pending = null
let current = null let current = null
// A mode asked for while an editor builds. // A mode asked for while an editor builds.
let asked = null let asked = null
// The text no editor could show; a mode retries it.
let broken = null
// A place to show once the editor is built. // A place to show once the editor is built.
let revealing = null let revealing = null
// The search in force, kept across modes. // The search in force, kept across modes.
@@ -43,6 +45,9 @@ function flush() {
pending = null pending = null
} }
/** A link the user opens; the app decides. */
const linked = (href) => tell({ kind: 'link', generation, href })
/** The shown editor's headings, for the app's outline. */ /** The shown editor's headings, for the app's outline. */
function outline() { function outline() {
if (current) tell({ kind: 'outline', generation, items: current.outline() }) if (current) tell({ kind: 'outline', generation, items: current.outline() })
@@ -64,11 +69,21 @@ function changed(text) {
revised(text) revised(text)
} }
/** An editor for `mode`; visual falls back to source. */ /** An editor for `mode`; a failed one leaves no holder. */
async function build(text, mode) { async function build(text, mode) {
const holder = document.createElement('div') const holder = document.createElement('div')
holder.hidden = true holder.hidden = true
document.getElementById('document').append(holder) document.getElementById('document').append(holder)
try {
return await fill(holder, text, mode)
} catch (error) {
holder.remove()
throw error
}
}
/** Builds in `holder`; visual falls back to source. */
async function fill(holder, text, mode) {
const entry = { mode, holder, fallback: false } const entry = { mode, holder, fallback: false }
if (mode !== 'source') { if (mode !== 'source') {
const [front, body] = split(text) const [front, body] = split(text)
@@ -81,7 +96,13 @@ async function build(text, mode) {
const place = document.createElement('div') const place = document.createElement('div')
holder.append(place) holder.append(place)
const edited = (write) => changed(() => front + write()) const edited = (write) => changed(() => front + write())
const made = await visual(place, body, mode === 'visual', texts, edited, edge) let made
try {
made = await visual(place, body, mode === 'visual', texts, edited, edge, linked)
} catch (error) {
// A broken editor falls back to source and says so.
entry.broke = error.name
}
if (made) { if (made) {
// Lines count from the file's top, front matter included. // Lines count from the file's top, front matter included.
const above = front ? front.split('\n').length - 1 : 0 const above = front ? front.split('\n').length - 1 : 0
@@ -117,15 +138,27 @@ function drop(entry) {
async function show(text, want) { async function show(text, want) {
const turn = ++latest const turn = ++latest
asked = null asked = null
broken = null
if (current) drop(current) if (current) drop(current)
current = null current = null
const entry = await build(text, want) let entry
try {
entry = await build(text, want)
} catch (error) {
if (turn !== latest) return
// Source failed too: tell the app, keep the text.
broken = text
tell({ kind: 'failed', generation, what: error.name })
const next = asked
asked = null
return next && mode(next)
}
if (turn !== latest) return drop(entry) if (turn !== latest) return drop(entry)
current = entry current = entry
entry.holder.hidden = false entry.holder.hidden = false
document.getElementById('document').dataset.mode = entry.mode document.getElementById('document').dataset.mode = entry.mode
entry.focus() entry.focus()
await ask({ kind: 'shown', mode: entry.mode, fallback: entry.fallback }) await ask({ kind: 'shown', mode: entry.mode, fallback: entry.fallback, broke: entry.broke ?? null })
// A newer request owns what is queued now. // A newer request owns what is queued now.
if (turn !== latest) return if (turn !== latest) return
outline() outline()
@@ -176,10 +209,12 @@ async function open(text, want, words, load, folder, place) {
} }
/** Changed on disk: the caret and scroll stay near. */ /** Changed on disk: the caret and scroll stay near. */
async function refresh(text) { async function refresh(text, load) {
const place = current && { ...current.where(), scroll: document.scrollingElement.scrollTop } const place = current && { ...current.where(), scroll: document.scrollingElement.scrollTop }
clearTimeout(timer) clearTimeout(timer)
pending = null pending = null
generation = load
version = 0
times = [text] times = [text]
at = 0 at = 0
await show(text, current?.mode ?? 'visual') await show(text, current?.mode ?? 'visual')
@@ -215,6 +250,7 @@ window.addEventListener('scroll', placed, { passive: true })
/** Another mode for the same text; edits are sent first. */ /** Another mode for the same text; edits are sent first. */
function mode(next) { function mode(next) {
if (!current) { if (!current) {
if (broken !== null) return show(broken, next)
asked = next asked = next
return return
} }
@@ -243,7 +279,8 @@ function reveal(place) {
} }
const shown = const shown =
'heading' in place ? current.heading(place.heading) : current.line(place.line, place.query) '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 logs a line number, never the query.
if (shown === false) tell({ kind: 'missed', generation, line: place.line ?? null })
} }
/** The app's find box hears where the search stands. */ /** The app's find box hears where the search stands. */
+1 -2
View File
@@ -27,9 +27,8 @@ async function draw(tree) {
try { try {
const svg = await paint(node.value, 'paper') const svg = await paint(node.value, 'paper')
Object.assign(node, { type: 'html', value: `<figure class="diagram">${svg}</figure>` }) Object.assign(node, { type: 'html', value: `<figure class="diagram">${svg}</figure>` })
} catch (error) { } catch {
// Lines only: Mermaid's message quotes the document. // Lines only: Mermaid's message quotes the document.
console.warn(`ely: a diagram did not draw: ${error.name}`)
failed.push(node.position.start.line) failed.push(node.position.start.line)
} }
} }
+25 -1
View File
@@ -39,7 +39,7 @@ import { search } from 'prosemirror-search'
import { style } from './style.js' import { style } from './style.js'
/** An editor over `markdown`; null when blocks cannot map. */ /** An editor over `markdown`; null when blocks cannot map. */
export async function visual(root, markdown, editable, texts, changed, edge) { export async function visual(root, markdown, editable, texts, changed, edge, linked) {
const owner = diagrams() const owner = diagrams()
let write = () => ({ text: markdown, spans: [] }) let write = () => ({ text: markdown, spans: [] })
// Setting up raw blocks is no edit. // Setting up raw blocks is no edit.
@@ -71,6 +71,29 @@ export async function visual(root, markdown, editable, texts, changed, edge) {
}, },
}), }),
) )
// Preview: a click opens. Editor: Cmd (Ctrl elsewhere).
const mac = navigator.platform.startsWith('Mac')
const links = $prose(
() =>
new Plugin({
props: {
handleDOMEvents: {
click: (view, event) => {
const anchor = event.target.closest?.('a')
if (!anchor || !view.dom.contains(anchor)) return false
event.preventDefault()
if (editable && !(mac ? event.metaKey : event.ctrlKey)) return true
// The DOM href is sanitized; the mark is not.
const node = view.state.doc.nodeAt(view.posAtDOM(anchor, 0))
const mark = node?.marks.find((mark) => mark.type.name === 'link')
if (!mark) throw new Error('ely: a link without its mark')
linked(mark.attrs.href)
return true
},
},
},
}),
)
// Past their history, undo and redo reach the page. // Past their history, undo and redo reach the page.
const ends = $prose(() => const ends = $prose(() =>
keymap({ keymap({
@@ -103,6 +126,7 @@ export async function visual(root, markdown, editable, texts, changed, edge) {
.use(watch) .use(watch)
.use(current) .use(current)
.use(ends) .use(ends)
.use(links)
.use($prose(() => search())) .use($prose(() => search()))
.create() .create()
const editor = await made.catch((error) => { const editor = await made.catch((error) => {
+4
View File
@@ -5,6 +5,10 @@ edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license.workspace = true license.workspace = true
publish.workspace = true publish.workspace = true
build = "../../packaging/windows/icon.rs"
[build-dependencies]
embed-resource.workspace = true
[dependencies] [dependencies]
ely-files.workspace = true ely-files.workspace = true
+35 -14
View File
@@ -9,7 +9,7 @@ use gpui::{AnyElement, App, Context, IntoElement, Pixels, Point, Styled as _, ca
use crate::content::{Aim, Tool}; use crate::content::{Aim, Tool};
use crate::layout::Placed; use crate::layout::Placed;
use crate::pages::{rect_on, to_view}; 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. /// How far a click may move, in window pixels.
pub(crate) const CLICK: f32 = 3.0; pub(crate) const CLICK: f32 = 3.0;
@@ -77,31 +77,49 @@ impl PdfView {
if self.reading_notes { if self.reading_notes {
return; 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() { if wanted.is_empty() {
return; return;
} }
let asked = wanted.clone();
self.reading_notes = true; self.reading_notes = true;
let (doc, version) = (self.doc, self.version); let (doc, version) = (self.doc, self.version);
self.off( self.off(
// Page by page: one bad page spares the rest.
move |worker| { move |worker| {
(wanted.into_iter()) let read = (wanted.into_iter())
.map(|page| Ok((page, worker.marks(doc, page)?, worker.links(doc, page)?))) .map(|page| (page, worker.marks(doc, page).and_then(|marks| Ok((marks, worker.links(doc, page)?)))))
.collect::<std::io::Result<Vec<_>>>() .collect::<Vec<_>>();
Ok(read)
}, },
move |view, read, cx| { move |view, read: std::io::Result<Vec<_>>, cx| {
view.reading_notes = false; view.reading_notes = false;
if view.version != version { if view.version != version {
return cx.notify(); return cx.notify();
} }
match read { // No worker at all: every page asked failed.
Ok(read) => { let read = read.unwrap_or_else(|error| {
for (page, marks, links) in read { 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.notes.insert(page, marks);
view.links.insert(page, links); 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(); cx.notify();
}, },
@@ -111,7 +129,7 @@ impl PdfView {
/// Reads every field name in the form, once an edit. /// Reads every field name in the form, once an edit.
pub fn ask_names(&mut self, cx: &mut Context<Self>) { 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; return;
} }
self.reading_names = true; self.reading_names = true;
@@ -125,7 +143,10 @@ impl PdfView {
} }
match read { match read {
Ok(names) => view.form_names = Some(names), 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(); cx.notify();
}, },
@@ -254,11 +275,11 @@ impl PdfView {
return; return;
} }
let author = ely_ui::settings::current(cx).author.clone(); let author = ely_ui::settings::current(cx).author.clone();
self.settled_edit(what, cx, move |worker, doc| { self.edit_at(what, move |worker, doc| {
let name = ely_platform::user::named(&author)?; let name = ely_platform::user::named(&author)?;
annots.iter_mut().for_each(|annot| annot.author = name.clone()); annots.iter_mut().for_each(|annot| annot.author = name.clone());
worker.annotate(doc, &annots) worker.annotate(doc, &annots)
}); }, cx);
} }
/// A press with a drawing or form tool. /// A press with a drawing or form tool.
+6 -3
View File
@@ -15,7 +15,7 @@ use gpui::{
}; };
use crate::document::{RemoveAttachment, SaveAttachment}; use crate::document::{RemoveAttachment, SaveAttachment};
use crate::view::PdfView; use crate::view::{PdfView, Unread};
use crate::viewer::Viewer; use crate::viewer::Viewer;
/// A size as people read it. /// A size as people read it.
@@ -30,7 +30,7 @@ pub fn size(bytes: u64) -> String {
impl PdfView { impl PdfView {
/// Reads the attachments once an edit. /// Reads the attachments once an edit.
pub fn ask_files(&mut self, cx: &mut Context<Self>) { pub fn ask_files(&mut self, cx: &mut Context<Self>) {
if self.files.is_some() || self.reading_files { if self.files.is_some() || self.reading_files || self.unread.contains(&Unread::Files) {
return; return;
} }
self.reading_files = true; self.reading_files = true;
@@ -44,7 +44,10 @@ impl PdfView {
} }
match read { match read {
Ok(files) => view.files = Some(files), Ok(files) => view.files = Some(files),
Err(error) => view.fail("reading attachments", &error, cx), Err(error) => {
view.unread.insert(Unread::Files);
view.fail("reading attachments", &error, cx)
}
} }
cx.notify(); cx.notify();
}, },
+8 -3
View File
@@ -8,7 +8,7 @@ use gpui::{AnyElement, App, Context, IntoElement, Pixels, Point, Styled as _, di
use crate::layout::Placed; use crate::layout::Placed;
use crate::pages::rect_on; use crate::pages::rect_on;
use crate::view::PdfView; use crate::view::{PdfView, Unread};
/// What a press on a page does. /// What a press on a page does.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
@@ -139,7 +139,9 @@ impl PdfView {
if !self.tool.edits() || self.reading_layer { if !self.tool.edits() || self.reading_layer {
return; return;
} }
let Some(page) = pages.iter().copied().find(|page| !self.layers.contains_key(page)) else { let Some(page) = (pages.iter().copied())
.find(|page| !self.layers.contains_key(page) && !self.unread.contains(&Unread::Layer(*page)))
else {
return; return;
}; };
self.reading_layer = true; self.reading_layer = true;
@@ -156,7 +158,10 @@ impl PdfView {
Ok(layer) => { Ok(layer) => {
view.layers.insert(page, layer); view.layers.insert(page, layer);
} }
Err(error) => view.fail("reading what the page holds", &error, cx), Err(error) => {
view.unread.insert(Unread::Layer(page));
view.fail("reading what the page holds", &error, cx)
}
} }
cx.notify(); cx.notify();
}, },
+28 -4
View File
@@ -19,6 +19,8 @@ pub struct Queued {
doc: Doc, doc: Doc,
/// The edit may add, drop, move or reshape pages. /// The edit may add, drop, move or reshape pages.
pages: bool, pages: bool,
/// Aimed at a point: runs on the state it meets.
follow: bool,
} }
/// The document as an edit left it. /// The document as an edit left it.
@@ -41,6 +43,23 @@ impl PdfView {
self.queue_edit(what, None, false, op, cx); self.queue_edit(what, None, false, op, cx);
} }
/// Queues a point-aimed edit; only moving pages refuse it.
pub fn edit_at(
&mut self,
what: &'static str,
op: impl FnOnce(&mut Worker, Doc) -> io::Result<String> + Send + 'static,
cx: &mut Context<Self>,
) {
if self.moving || self.queue.iter().any(|queued| queued.pages) {
let text = crate::viewer::Viewer::text(cx, "pdf.pages.changed", &[]);
cx.emit(crate::view::Failed(text.to_string()));
return cx.notify();
}
log::info!("pdf: queues {what}");
self.queue.push_back(Queued { what, op: Box::new(op), pick: None, doc: self.doc, pages: false, follow: true });
self.next_edit(cx);
}
/// Queues an edit to the pages themselves. /// Queues an edit to the pages themselves.
pub fn edit_pages( pub fn edit_pages(
&mut self, &mut self,
@@ -62,7 +81,7 @@ impl PdfView {
) { ) {
log::info!("pdf: queues {what}"); log::info!("pdf: queues {what}");
// Aimed at what is shown now; later states refuse it. // Aimed at what is shown now; later states refuse it.
self.queue.push_back(Queued { what, op: Box::new(op), pick, doc: self.doc, pages }); self.queue.push_back(Queued { what, op: Box::new(op), pick, doc: self.doc, pages, follow: false });
self.next_edit(cx); self.next_edit(cx);
} }
@@ -70,10 +89,12 @@ impl PdfView {
if self.editing { if self.editing {
return; return;
} }
let Some(Queued { what, op, pick, doc, pages }) = self.queue.pop_front() else { let Some(Queued { what, op, pick, doc, pages, follow }) = self.queue.pop_front() else {
return; return;
}; };
self.editing = true; // Earlier edits have landed; a point still means the same.
let doc = if follow { self.doc } else { doc };
(self.editing, self.moving) = (true, pages);
self.off( self.off(
move |worker| { move |worker| {
let mark = op(worker, doc)?; let mark = op(worker, doc)?;
@@ -83,7 +104,7 @@ impl PdfView {
Ok(Fresh { doc, mark, pages, first, marks: worker.outline(doc)? }) Ok(Fresh { doc, mark, pages, first, marks: worker.outline(doc)? })
}, },
move |view, fresh, cx| { move |view, fresh, cx| {
view.editing = false; (view.editing, view.moving) = (false, false);
match fresh { match fresh {
Ok(fresh) => { Ok(fresh) => {
view.refresh(fresh, pages, cx); view.refresh(fresh, pages, cx);
@@ -128,6 +149,7 @@ impl PdfView {
} }
self.marks = fresh.marks; self.marks = fresh.marks;
self.failed.clear(); self.failed.clear();
self.unread.clear();
self.text.clear(); self.text.clear();
self.asking_text.clear(); self.asking_text.clear();
self.thumbs_failed.clear(); self.thumbs_failed.clear();
@@ -151,8 +173,10 @@ impl PdfView {
/// The file now holds history `mark`. /// The file now holds history `mark`.
pub fn saved(&mut self, mark: &str, cx: &mut Context<Self>) { pub fn saved(&mut self, mark: &str, cx: &mut Context<Self>) {
self.saved = mark.to_string(); self.saved = mark.to_string();
self.saves += 1;
// The file changed; read it again. // The file changed; read it again.
self.signatures = None; self.signatures = None;
self.unread.retain(|unread| !matches!(unread, crate::view::Unread::Signatures(_)));
cx.emit(Reported); cx.emit(Reported);
} }
+3 -8
View File
@@ -5,17 +5,12 @@ use gpui::Context;
use crate::view::{Failed, PdfView}; use crate::view::{Failed, PdfView};
/// Addresses that may leave the document.
pub fn outward(uri: &str) -> bool {
["https://", "http://", "mailto:"].iter().any(|scheme| uri.starts_with(scheme))
}
/// A target as typed: a page number or an address. /// A target as typed: a page number or an address.
pub fn parse(typed: &str, pages: usize) -> Option<Target> { pub fn parse(typed: &str, pages: usize) -> Option<Target> {
let typed = typed.trim(); let typed = typed.trim();
match typed.parse::<usize>() { match typed.parse::<usize>() {
Ok(n) => (1..=pages).contains(&n).then(|| Target::Page(n - 1, 0.0)), Ok(n) => (1..=pages).contains(&n).then(|| Target::Page(n - 1, 0.0)),
Err(_) => outward(typed).then(|| Target::Web(typed.to_string())), Err(_) => ely_office::outward(typed).then(|| Target::Web(typed.to_string())),
} }
} }
@@ -35,12 +30,12 @@ impl PdfView {
log::info!("pdf: follows a link to page {}", page + 1); log::info!("pdf: follows a link to page {}", page + 1);
self.show(page, link.y, cx) self.show(page, link.y, cx)
} }
None if outward(&link.uri) => { None if ely_office::outward(&link.uri) => {
log::info!("pdf: opens a link outside"); log::info!("pdf: opens a link outside");
cx.open_url(&link.uri) cx.open_url(&link.uri)
} }
None => { None => {
let text = crate::viewer::Viewer::text(cx, "pdf.link.refused", &[]); let text = crate::viewer::Viewer::text(cx, "office.link.refused", &[]);
cx.emit(Failed(text.to_string())) cx.emit(Failed(text.to_string()))
} }
} }
+3
View File
@@ -1,3 +1,6 @@
// Release builds open no console on Windows.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod annotate; mod annotate;
mod ask; mod ask;
mod attach; mod attach;
+4 -2
View File
@@ -165,10 +165,12 @@ impl Viewer {
let Some(view) = self.tab(tab).map(|tab| tab.view.clone()).filter(|_| !pages.is_empty()) else { let Some(view) = self.tab(tab).map(|tab| tab.view.clone()).filter(|_| !pages.is_empty()) else {
return self.toast(summary, Severity::Info, cx); return self.toast(summary, Severity::Info, cx);
}; };
self.on_same(&view, run.version, cx, |view, cx| { let laid = self.on_same(&view, run.version, cx, |view, cx| {
view.edit("adding recognised text", move |worker, doc| worker.text_layer(doc, &pages, replace), cx) view.edit("adding recognised text", move |worker, doc| worker.text_layer(doc, &pages, replace), cx)
}); });
self.toast(summary, Severity::Success, cx); if laid {
self.toast(summary, Severity::Success, cx);
}
} }
/// The OCR dialog: the ask, then the progress. /// The OCR dialog: the ask, then the progress.
+4 -2
View File
@@ -95,7 +95,7 @@ impl Viewer {
id, id,
move |worker, doc| worker.get()?.print_copy(*doc, &out), move |worker, doc| worker.get()?.print_copy(*doc, &out),
move |viewer, done, _, cx| match done { move |viewer, done, _, cx| match done {
Ok(()) => ely_office::print_later(printing, cx, |viewer, error, cx| viewer.panel_failed(&error, cx)), Ok(()) => ely_office::print_later(printing, cx),
Err(error) => viewer.failed(&error, cx), Err(error) => viewer.failed(&error, cx),
}, },
cx, cx,
@@ -203,6 +203,8 @@ impl Viewer {
Err(error) => return self.failed(&error, cx), Err(error) => return self.failed(&error, cx),
} }
let (work, path, revision) = (tab.work.join("export.out"), tab.session.path().map(PathBuf::from), tab.session.revision()); let (work, path, revision) = (tab.work.join("export.out"), tab.session.path().map(PathBuf::from), tab.session.revision());
// Unsaved edits: the file on disk is not "before".
let compared = path.clone().filter(|_| !tab.view.read(cx).modified());
let opened = target.clone(); let opened = target.clone();
log::info!("pdf: exports {}", if kind == Kind::Text { "text" } else { "an optimized copy" }); log::info!("pdf: exports {}", if kind == Kind::Text { "text" } else { "an optimized copy" });
self.read_in( self.read_in(
@@ -214,7 +216,7 @@ impl Viewer {
} }
let bytes = std::fs::read(&work)?; let bytes = std::fs::read(&work)?;
std::fs::remove_file(&work)?; std::fs::remove_file(&work)?;
let before = path.as_deref().map(std::fs::metadata).transpose()?.map(|meta| meta.len()); let before = compared.as_deref().map(std::fs::metadata).transpose()?.map(|meta| meta.len());
ely_files::export(&target, path.as_deref(), &bytes, revision)?; ely_files::export(&target, path.as_deref(), &bytes, revision)?;
Ok((before, bytes.len() as u64)) Ok((before, bytes.len() as u64))
}, },
+2 -1
View File
@@ -236,7 +236,8 @@ impl Viewer {
} }
fn protect_rows(&self, on: bool, user: &Entity<TextInput>, owner: &Entity<TextInput>, allow: u32, this: &Entity<Self>, cx: &mut Context<Self>) -> AnyElement { fn protect_rows(&self, on: bool, user: &Entity<TextInput>, owner: &Entity<TextInput>, allow: u32, this: &Entity<Self>, cx: &mut Context<Self>) -> AnyElement {
let set = |change: Box<dyn Fn(&mut bool, &mut u32, bool)>| { type Change = Box<dyn Fn(&mut bool, &mut u32, bool)>;
let set = |change: Change| {
let this = this.clone(); let this = this.clone();
move |value: bool, _: &mut Window, cx: &mut App| { move |value: bool, _: &mut Window, cx: &mut App| {
this.update(cx, |viewer, cx| { this.update(cx, |viewer, cx| {
+15 -7
View File
@@ -17,7 +17,7 @@ use ely_platform::trust::{self, Trust};
use gpui::{AnyElement, App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, StatefulInteractiveElement as _, Styled as _, TaskExt as _, Window, div}; use gpui::{AnyElement, App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, StatefulInteractiveElement as _, Styled as _, TaskExt as _, Window, div};
use crate::redact::beside; use crate::redact::beside;
use crate::view::PdfView; use crate::view::{PdfView, Unread};
use crate::viewer::Viewer; use crate::viewer::Viewer;
/// A signature field and the system's word on its certificate. /// A signature field and the system's word on its certificate.
@@ -47,26 +47,34 @@ pub struct Signer {
impl PdfView { impl PdfView {
/// Reads and checks the signatures of the file at `path`. /// Reads and checks the signatures of the file at `path`.
pub fn ask_signatures(&mut self, path: PathBuf, cx: &mut Context<Self>) { pub fn ask_signatures(&mut self, path: PathBuf, cx: &mut Context<Self>) {
if self.signatures.as_ref().is_some_and(|(read, _)| *read == path) || self.reading_signatures { let failed = self.unread.contains(&Unread::Signatures(path.clone()));
if self.signatures.as_ref().is_some_and(|(read, _)| *read == path) || self.reading_signatures || failed {
return; return;
} }
self.reading_signatures = true; self.reading_signatures = true;
let (doc, asked) = (self.doc, path.clone()); // A save in between makes this read stale: dropped.
let (doc, asked, saves) = (self.doc, path.clone(), self.saves);
self.off( self.off(
move |worker| { move |worker| {
let fields = worker.signatures(doc, &path)?; let fields = worker.signatures(doc, &path)?;
Ok(fields.into_iter().map(|field| { Ok(Iterator::collect::<Vec<_>>(fields.into_iter().map(|field| {
let chain = field.signed.as_ref().map(|signed| signed.certs.clone()).filter(|certs| !certs.is_empty()); let chain = field.signed.as_ref().map(|signed| signed.certs.clone()).filter(|certs| !certs.is_empty());
let trust = chain.map(|chain| trust::check(&chain).map_err(|error| error.to_string())); let trust = chain.map(|chain| trust::check(&chain).map_err(|error| error.to_string()));
Checked { field, trust } Checked { field, trust }
})) })))
.map(Iterator::collect::<Vec<_>>)
}, },
move |view, read: io::Result<Vec<Checked>>, cx| { move |view, read: io::Result<Vec<Checked>>, cx| {
view.reading_signatures = false; view.reading_signatures = false;
if view.saves != saves {
log::info!("pdf: a signature read from before a save is dropped");
return cx.notify();
}
match read { match read {
Ok(read) => view.signatures = Some((asked, read)), Ok(read) => view.signatures = Some((asked, read)),
Err(error) => view.fail("reading signatures", &error, cx), Err(error) => {
view.unread.insert(Unread::Signatures(asked));
view.fail("reading signatures", &error, cx)
}
} }
cx.notify(); cx.notify();
}, },
+7
View File
@@ -18,6 +18,7 @@ mod notes;
mod organize; mod organize;
mod output; mod output;
mod protect; mod protect;
mod reads;
mod removal; mod removal;
mod sign; mod sign;
mod structure; mod structure;
@@ -71,6 +72,12 @@ pub(crate) fn windowed<'a>(
ely_ui::i18n::install(cx); ely_ui::i18n::install(cx);
}); });
cx.update(crate::keys::bind); cx.update(crate::keys::bind);
cx.update(|cx| {
// As document_root does: menus before the window.
let menus = crate::keys::menus(cx);
let file = &ely_ui::commands::FILE;
cx.set_menus(ely_ui::commands::menus(ely_platform::AppId::Pdf, file, true, menus, cx));
});
let held = match worker { let held = match worker {
true => Mupdf::with(Worker::spawn().expect("the worker")), true => Mupdf::with(Worker::spawn().expect("the worker")),
false => Mupdf::default(), false => Mupdf::default(),
+30
View File
@@ -397,3 +397,33 @@ fn a_small_picture_on_text_under_a_page_form_still_aims() {
click(cx, &view, (140.0, 66.0)); click(cx, &view, (140.0, 66.0));
assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Text { .. })), "the text, not the form around it"); assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Text { .. })), "the text, not the form around it");
} }
#[test]
fn a_preview_overtaken_by_an_edit_says_so() {
use ely_gpui_component::primitives::Severity;
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx, view) = editing(&mut cx, dir.path());
// The window's toasts live on its root.
let root = cx.update(|window, app| {
let root = ely_ui::document_root(ely_platform::AppId::Pdf, viewer.clone().into(), &ely_ui::commands::FILE, Vec::new(), window, app);
viewer.update(app, |viewer, _| viewer.core.root = root.downgrade());
root
});
click(cx, &view, (80.0, 66.0));
type_in(cx, &viewer, "Hi world\nSecond line");
cx.update(|_, app| viewer.update(app, |viewer, cx| {
// A new font asks for a preview first.
viewer.edit_panel.update(cx, |panel, _| panel.font = "times".into());
viewer.write_text(false, cx);
// An edit lands before the preview answers.
if let Some(view) = viewer.active_view() {
view.update(cx, |view, _| view.version += 1);
}
}));
let changed = cx.update(|_, app| Viewer::text(app, "pdf.object.changed", &[]));
settle(cx, |app| root.read(app).toasts(app).iter().any(|(text, severity)| *severity == Some(Severity::Warning) && *text == changed));
frames(cx);
assert!(cx.update(|_, app| viewer.read(app).preview.is_none()), "no stale preview");
assert_eq!(cx.update(|_, app| view.read(app).edited), 0, "nothing written");
}
+45
View File
@@ -315,3 +315,48 @@ fn a_default_name_avoids_a_field_with_no_widget() {
send(cx, &viewer, Change::Design(typed, Vec::new())); send(cx, &viewer, Change::Design(typed, Vec::new()));
assert_eq!(notes(cx, &view).fields.len(), 1, "the field lands"); assert_eq!(notes(cx, &view).fields.len(), 1, "the field lands");
} }
#[test]
fn quick_strokes_queue_behind_each_other() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let path = dir.path().join("one.pdf");
let page: (f32, f32, &[&str]) = (612.0, 792.0, &["Hello"]);
std::fs::write(&path, pdf_with(&[page], &[], &Extras::default())).expect("writes");
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
let view = shown(cx, &viewer);
let note = |y: f32| ely_pdf_engine::NewAnnot { page: 0, shape: ely_pdf_engine::Shape::Note((100.0, y)), rgb: None, contents: "n".into(), author: String::new() };
// The second lands while the first still runs.
cx.update(|_, app| view.update(app, |view, cx| {
view.annotate("a first note", vec![note(500.0)], cx);
view.annotate("a second note", vec![note(400.0)], cx);
}));
settle(cx, |app| view.read(app).settled());
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.side.update(cx, |side, cx| {
side.pane = crate::side::Pane::Notes;
cx.notify();
})));
let count = |cx: &mut gpui::VisualTestContext| {
settle(cx, |app| view.read(app).settled() && view.read(app).notes.contains_key(&0));
cx.update(|_, app| view.read(app).notes[&0].annots.len())
};
assert_eq!(count(cx), 2, "both notes landed");
let rotate = |view: &mut PdfView, cx: &mut gpui::Context<PdfView>| {
view.edit_pages("rotating", |worker, doc| worker.rotate_pages(doc, &[0], 90), cx)
};
// A running rotation would move the point: refused.
cx.update(|_, app| view.update(app, |view, cx| {
rotate(view, cx);
view.annotate("a note while turning", vec![note(300.0)], cx);
}));
assert_eq!(count(cx), 2, "refused while pages move");
// A rotation waiting behind another edit: refused too.
cx.update(|_, app| view.update(app, |view, cx| {
view.annotate("a third note", vec![note(250.0)], cx);
rotate(view, cx);
view.annotate("a note behind a turn", vec![note(200.0)], cx);
}));
assert_eq!(count(cx), 3, "only the note before the turn");
cx.update(|_, app| view.update(app, |view, cx| view.annotate("a settled note", vec![note(150.0)], cx)));
assert_eq!(count(cx), 4, "taken once pages are still");
}
+27 -3
View File
@@ -76,7 +76,7 @@ fn a_damaged_file_opens_repaired_and_saves_as_a_new_file() {
bytes.truncate(at); bytes.truncate(at);
bytes.extend_from_slice(b"startxref\n9\n%%EOF\n"); bytes.extend_from_slice(b"startxref\n9\n%%EOF\n");
std::fs::write(&path, &bytes).expect("writes"); std::fs::write(&path, &bytes).expect("writes");
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path.clone()], true); let (viewer, cx) = windowed(&mut cx, dir.path(), std::slice::from_ref(&path), true);
let view = shown(cx, &viewer); let view = shown(cx, &viewer);
assert_eq!(cx.update(|_, app| viewer.read(app).active_tab().and_then(|tab| tab.risk)), Some(ely_office::Risk::Repaired)); assert_eq!(cx.update(|_, app| viewer.read(app).active_tab().and_then(|tab| tab.risk)), Some(ely_office::Risk::Repaired));
assert!(cx.update(|_, app| view.read(app).repair.as_ref().is_some_and(|report| !report.is_empty()))); assert!(cx.update(|_, app| view.read(app).repair.as_ref().is_some_and(|report| !report.is_empty())));
@@ -158,7 +158,7 @@ fn a_picture_that_breaks_as_it_draws_stops_an_overwrite() {
let dir = tempfile::tempdir().expect("a temp dir"); let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single(); let mut cx = TestAppContext::single();
let (path, bytes) = broken_picture(dir.path(), 792.0, 600.0); let (path, bytes) = broken_picture(dir.path(), 792.0, 600.0);
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path.clone()], true); let (viewer, cx) = windowed(&mut cx, dir.path(), std::slice::from_ref(&path), true);
shown(cx, &viewer); shown(cx, &viewer);
settle(cx, |app| viewer.read(app).active_tab().and_then(|tab| tab.risk) == Some(ely_office::Risk::Repaired)); settle(cx, |app| viewer.read(app).active_tab().and_then(|tab| tab.risk) == Some(ely_office::Risk::Repaired));
let id = cx.update(|_, app| viewer.read(app).core.tabs[0].id); let id = cx.update(|_, app| viewer.read(app).core.tabs[0].id);
@@ -184,7 +184,7 @@ fn a_picture_never_drawn_is_checked_before_an_overwrite() {
let path = dir.path().join("long.pdf"); let path = dir.path().join("long.pdf");
worker.save(doc, &path).expect("saves"); worker.save(doc, &path).expect("saves");
let bytes = std::fs::read(&path).expect("reads"); let bytes = std::fs::read(&path).expect("reads");
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path.clone()], true); let (viewer, cx) = windowed(&mut cx, dir.path(), std::slice::from_ref(&path), true);
shown(cx, &viewer); shown(cx, &viewer);
for _ in 0..10 { for _ in 0..10 {
super::frames(cx); super::frames(cx);
@@ -195,3 +195,27 @@ fn a_picture_never_drawn_is_checked_before_an_overwrite() {
settle(cx, |app| viewer.read(app).active_tab().and_then(|tab| tab.risk) == Some(ely_office::Risk::Repaired)); settle(cx, |app| viewer.read(app).active_tab().and_then(|tab| tab.risk) == Some(ely_office::Risk::Repaired));
assert_eq!(std::fs::read(&path).expect("reads"), bytes, "Save turned into Save As"); assert_eq!(std::fs::read(&path).expect("reads"), bytes, "Save turned into Save As");
} }
#[test]
fn an_edited_document_does_not_quote_the_files_size() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let path = file(dir.path(), "doc.pdf", &[(612.0, 792.0, &["First"])]);
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
let view = shown(cx, &viewer);
// The window's toasts live on its root.
let root = cx.update(|window, app| {
let root = ely_ui::document_root(ely_platform::AppId::Pdf, viewer.clone().into(), &ely_ui::commands::FILE, Vec::new(), window, app);
viewer.update(app, |viewer, _| viewer.core.root = root.downgrade());
root
});
let note = ely_pdf_engine::NewAnnot { page: 0, shape: ely_pdf_engine::Shape::Note((100.0, 500.0)), rgb: None, contents: "n".into(), author: String::new() };
cx.update(|_, app| view.update(app, |view, cx| view.annotate("a note", vec![note], cx)));
settle(cx, |app| view.read(app).settled() && view.read(app).modified());
let id = cx.update(|_, app| viewer.read(app).core.tabs[0].id);
let out = dir.path().join("small.pdf");
let how = Optimizing { compress: true, from: 300, to: 150 };
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_export(id, Kind::Optimized, how, out.clone(), cx)));
let done = cx.update(|_, app| crate::viewer::Viewer::text(app, "pdf.export.done", &[("name", "small.pdf")]));
settle(cx, |app| root.read(app).toasts(app).iter().any(|(text, _)| *text == done));
}
+98
View File
@@ -0,0 +1,98 @@
//! Reads that fail are kept, not asked each frame (P-08).
use ely_pdf_engine::sample::{Extras, pdf_with};
use gpui::TestAppContext;
use super::{settle, shown, windowed};
/// Two pages; the first's square has an unreadable rectangle.
fn bad_then_good() -> Vec<u8> {
let nines = "9".repeat(50);
let objects = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>".to_string(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Annots [5 0 R] >>".to_string(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Annots [6 0 R] >>".to_string(),
format!("<< /Type /Annot /Subtype /Square /Rect [0 0 {nines} 100] >>"),
"<< /Type /Annot /Subtype /Text /Rect [100 500 120 520] /Contents (fine) >>".to_string(),
];
let mut pdf = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, body) in objects.iter().enumerate() {
offsets.push(pdf.len());
pdf.extend(format!("{} 0 obj\n{body}\nendobj\n", i + 1).bytes());
}
let xref = pdf.len();
pdf.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).bytes());
for offset in offsets {
pdf.extend(format!("{offset:010} 00000 n \n").bytes());
}
pdf.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).bytes());
pdf
}
#[test]
fn one_bad_page_spares_the_rest_of_its_batch() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let path = dir.path().join("two.pdf");
std::fs::write(&path, bad_then_good()).expect("writes");
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
let view = shown(cx, &viewer);
settle(cx, |app| !view.read(app).reading_notes);
cx.update(|_, app| view.update(app, |view, cx| {
view.notes.clear();
view.unread.clear();
view.ask_notes([0, 1], cx)
}));
settle(cx, |app| !view.read(app).reading_notes);
let (good, bad) = cx.update(|_, app| {
let view = view.read(app);
(view.notes.contains_key(&1), view.unread.contains(&crate::view::Unread::Notes(0)))
});
assert!(bad, "the bad page is kept as failed");
assert!(good, "the good page is read all the same");
assert!(!cx.update(|_, app| view.read(app).unread.contains(&crate::view::Unread::Notes(1))));
}
#[test]
fn a_signature_read_from_before_a_save_counts_for_nothing() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let path = dir.path().join("one.pdf");
let page: (f32, f32, &[&str]) = (612.0, 792.0, &["Hello"]);
std::fs::write(&path, pdf_with(&[page], &[], &Extras::default())).expect("writes");
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
let view = shown(cx, &viewer);
let gone = dir.path().join("gone.pdf");
// The read fails, but a save lands before its answer.
cx.update(|_, app| view.update(app, |view, cx| {
view.ask_signatures(gone.clone(), cx);
let same = view.saved.clone();
view.saved(&same, cx);
}));
settle(cx, |app| !view.read(app).reading_signatures);
assert!(cx.update(|_, app| view.read(app).unread.is_empty()), "a stale failure blocks nothing");
cx.update(|_, app| view.update(app, |view, cx| view.ask_signatures(gone.clone(), cx)));
assert!(cx.update(|_, app| view.read(app).reading_signatures), "asked again after the save");
}
#[test]
fn a_failed_read_is_not_asked_again() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let path = dir.path().join("one.pdf");
let page: (f32, f32, &[&str]) = (612.0, 792.0, &["Hello"]);
std::fs::write(&path, pdf_with(&[page], &[], &Extras::default())).expect("writes");
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
let view = shown(cx, &viewer);
assert!(cx.update(|_, app| view.read(app).form_names.is_none()), "not read yet");
// A dead worker fails every read.
let pid = cx.update(|_, app| viewer.read(app).core.worker.lock().expect("the lock").get().expect("a worker").pid());
std::process::Command::new("kill").args(["-9", &pid.to_string()]).status().expect("kills");
cx.update(|_, app| view.update(app, |view, cx| view.ask_names(cx)));
settle(cx, |app| !view.read(app).reading_names);
assert!(cx.update(|_, app| view.read(app).unread.contains(&crate::view::Unread::Names)), "the failure is kept");
cx.update(|_, app| view.update(app, |view, cx| view.ask_names(cx)));
assert!(!cx.update(|_, app| view.read(app).reading_names), "not asked again");
}
+2 -2
View File
@@ -158,7 +158,7 @@ fn a_signed_file_saved_as_a_copy_reopens_as_the_copy() {
settle(cx, |app| view.read(app).edited == 1); settle(cx, |app| view.read(app).edited == 1);
let copy = dir.path().join("copy.pdf"); let copy = dir.path().join("copy.pdf");
let reopened = save_signed_as(cx, &viewer, &view, &copy); let reopened = save_signed_as(cx, &viewer, &view, &copy);
let tab = cx.update(|_, app| (viewer.read(app).active_tab().map(|tab| (tab.risk, tab.dirty(app))))); let tab = cx.update(|_, app| viewer.read(app).active_tab().map(|tab| (tab.risk, tab.dirty(app))));
assert_eq!(tab, Some((None, false)), "an unsigned, saved copy"); assert_eq!(tab, Some((None, false)), "an unsigned, saved copy");
assert!(!shows_signed(cx, &viewer, &reopened), "the copy holds no signature"); assert!(!shows_signed(cx, &viewer, &reopened), "the copy holds no signature");
assert_eq!(cx.update(|_, app| reopened.read(app).sizes[0]), (792.0, 612.0), "the edit is in the copy"); assert_eq!(cx.update(|_, app| reopened.read(app).sizes[0]), (792.0, 612.0), "the edit is in the copy");
@@ -212,7 +212,7 @@ fn reopening_the_dialog_checks_the_file_as_it_is_now() {
let dir = tempfile::tempdir().expect("a temp dir"); let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single(); let mut cx = TestAppContext::single();
let path = signed(dir.path()); let path = signed(dir.path());
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path.clone()], true); let (viewer, cx) = windowed(&mut cx, dir.path(), std::slice::from_ref(&path), true);
let view = shown(cx, &viewer); let view = shown(cx, &viewer);
let digest = |cx: &mut VisualTestContext| { let digest = |cx: &mut VisualTestContext| {
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.show_signatures(true, cx))); cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.show_signatures(true, cx)));
+30
View File
@@ -234,3 +234,33 @@ fn a_cancelled_run_cannot_touch_the_next() {
assert!(cx.update(|_, app| viewer.read(app).ocr.is_some()), "the second run goes on"); assert!(cx.update(|_, app| viewer.read(app).ocr.is_some()), "the second run goes on");
settle(cx, |app| viewer.read(app).ocr.is_none() && view.read(app).edited == 1); settle(cx, |app| viewer.read(app).ocr.is_none() && view.read(app).edited == 1);
} }
#[test]
fn a_refused_text_layer_reports_no_success() {
use ely_gpui_component::primitives::Severity;
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let path = scanned(dir.path());
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
let view = shown(cx, &viewer);
// The window's toasts live on its root.
let root = cx.update(|window, app| {
let root = ely_ui::document_root(ely_platform::AppId::Pdf, viewer.clone().into(), &ely_ui::commands::FILE, Vec::new(), window, app);
viewer.update(app, |viewer, _| viewer.core.root = root.downgrade());
root
});
let tab = cx.update(|_, app| viewer.read(app).core.tabs[0].id);
let run = cx.update(|window, app| viewer.update(app, |viewer, cx| {
viewer.ask_ocr(window, cx);
viewer.start_ocr(cx);
viewer.ocr_runs - 1
}));
// An edit still runs when the text is laid.
cx.update(|_, app| view.update(app, |view, _| view.editing = true));
let line = ely_pdf_engine::OcrLine { rect: [72.0, 700.0, 300.0, 720.0], text: "Hello".into() };
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.ocr_read((tab, run, 0), Ok(Some(vec![line])), cx)));
settle(cx, |app| viewer.read(app).ocr.is_none());
let toasts = cx.update(|_, app| root.read(app).toasts(app));
assert!(toasts.iter().any(|(_, severity)| *severity == Some(Severity::Warning)), "{toasts:?}");
assert!(!toasts.iter().any(|(_, severity)| *severity == Some(Severity::Success)), "{toasts:?}");
}
+20
View File
@@ -23,6 +23,17 @@ pub struct Failed(pub String);
/// The document changed what it shows. /// The document changed what it shows.
pub struct Reported; pub struct Reported;
/// A page or document read that failed once.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum Unread {
Notes(usize),
Layer(usize),
Names,
Files,
/// The signatures of the file at this path.
Signatures(std::path::PathBuf),
}
pub struct PdfView { pub struct PdfView {
worker: Arc<Mutex<Mupdf>>, worker: Arc<Mutex<Mupdf>>,
pub doc: Doc, pub doc: Doc,
@@ -41,6 +52,8 @@ pub struct PdfView {
pub painted: f64, pub painted: f64,
/// Tiles the worker could not draw; not asked again. /// Tiles the worker could not draw; not asked again.
pub failed: HashSet<TileKey>, pub failed: HashSet<TileKey>,
/// Reads that failed; not asked again until an edit.
pub unread: HashSet<Unread>,
/// Old tiles shown under their page until covered. /// Old tiles shown under their page until covered.
pub backdrop: Vec<crate::backdrop::Old>, pub backdrop: Vec<crate::backdrop::Old>,
pub text: HashMap<usize, Arc<Vec<Char>>>, pub text: HashMap<usize, Arc<Vec<Char>>>,
@@ -65,9 +78,13 @@ pub struct PdfView {
/// Edits waiting their turn, and whether one runs. /// Edits waiting their turn, and whether one runs.
pub queue: VecDeque<crate::edits::Queued>, pub queue: VecDeque<crate::edits::Queued>,
pub editing: bool, pub editing: bool,
/// The running edit may move pages.
pub moving: bool,
/// History marks: now, and at the last load or save. /// History marks: now, and at the last load or save.
pub mark: String, pub mark: String,
pub saved: String, pub saved: String,
/// Saves so far: a read from before one is stale.
pub saves: u64,
/// Page pictures for the side panel, by page and darkness. /// Page pictures for the side panel, by page and darkness.
pub thumbs: HashMap<(usize, bool), Arc<RenderImage>>, pub thumbs: HashMap<(usize, bool), Arc<RenderImage>>,
pub thumbs_failed: HashSet<usize>, pub thumbs_failed: HashSet<usize>,
@@ -171,6 +188,7 @@ impl PdfView {
retired: Default::default(), retired: Default::default(),
painted: 0.0, painted: 0.0,
failed: HashSet::new(), failed: HashSet::new(),
unread: HashSet::new(),
backdrop: Vec::new(), backdrop: Vec::new(),
text: HashMap::new(), text: HashMap::new(),
asking_text: HashSet::new(), asking_text: HashSet::new(),
@@ -186,7 +204,9 @@ impl PdfView {
edited: 0, edited: 0,
queue: VecDeque::new(), queue: VecDeque::new(),
editing: false, editing: false,
moving: false,
saved: loaded.mark.clone(), saved: loaded.mark.clone(),
saves: 0,
mark: loaded.mark, mark: loaded.mark,
thumbs: HashMap::new(), thumbs: HashMap::new(),
thumbs_failed: HashSet::new(), thumbs_failed: HashSet::new(),
+1 -1
View File
@@ -69,7 +69,7 @@ impl Host for Viewer {
} }
fn read_at(&self, tab: u64, cx: &gpui::App) -> Option<ely_pdf_engine::Doc> { fn read_at(&self, tab: u64, cx: &gpui::App) -> Option<ely_pdf_engine::Doc> {
self.tab(tab).map(|tab| tab.view.read(cx).doc.clone()) self.tab(tab).map(|tab| tab.view.read(cx).doc)
} }
fn observed(&mut self, id: u64, view: &Entity<PdfView>, cx: &mut Context<Self>) { fn observed(&mut self, id: u64, view: &Entity<PdfView>, cx: &mut Context<Self>) {
+9 -4
View File
@@ -45,8 +45,11 @@ pub struct Making {
picture: Option<(PathBuf, f32)>, picture: Option<(PathBuf, f32)>,
} }
/// Points of each stroke.
type Strokes = Vec<Vec<(f32, f32)>>;
/// Strokes moved to the corner, the longer side 1. /// Strokes moved to the corner, the longer side 1.
pub fn fitted(strokes: &[Vec<(f32, f32)>]) -> Option<(Vec<Vec<(f32, f32)>>, f32)> { pub fn fitted(strokes: &[Vec<(f32, f32)>]) -> Option<(Strokes, f32)> {
if strokes.iter().all(|stroke| stroke.len() < 2) { if strokes.iter().all(|stroke| stroke.len() < 2) {
return None; return None;
} }
@@ -92,15 +95,17 @@ impl PdfView {
rgb: 0, rgb: 0,
bx: [at[0], at[1], self.sizes[page].0 - at[0]], bx: [at[0], at[1], self.sizes[page].0 - at[0]],
}; };
self.settled_edit("placing a typed signature", cx, move |worker, doc| match worker.put_text(doc, page, &writing, 0.0)? { let write = move |worker: &mut ely_pdf_engine::Worker, doc| match worker.put_text(doc, page, &writing, 0.0)? {
Wrote::Done(mark) => Ok(mark), Wrote::Done(mark) => Ok(mark),
Wrote::Missing(c) => Err(io::Error::new(io::ErrorKind::InvalidInput, format!("the font has no “{c}”"))), Wrote::Missing(c) => Err(io::Error::new(io::ErrorKind::InvalidInput, format!("the font has no “{c}”"))),
Wrote::Unwritable => Err(io::Error::other("the document's font cannot write")),
Wrote::Preview(..) => Err(io::Error::other("a preview where a write was asked")), Wrote::Preview(..) => Err(io::Error::other("a preview where a write was asked")),
}); };
self.edit_at("placing a typed signature", write, cx);
} }
Stamp::Picture { path, aspect } => { Stamp::Picture { path, aspect } => {
let at = placed(aspect, rect, click); let at = placed(aspect, rect, click);
self.settled_edit("placing a signature picture", cx, move |worker, doc| worker.add_image(doc, page, &path, at)); self.edit_at("placing a signature picture", move |worker, doc| worker.add_image(doc, page, &path, at), cx);
} }
} }
} }
+21 -5
View File
@@ -71,7 +71,8 @@ impl Viewer {
let (asked, viewer) = (writing.clone(), cx.weak_entity()); let (asked, viewer) = (writing.clone(), cx.weak_entity());
view.update(cx, |view, cx| { view.update(cx, |view, cx| {
let doc = view.doc; let doc = view.doc;
view.read( // A stale answer still reaches `previewed`, which says so.
view.off(
move |worker| worker.put_text(doc, page, &asked, PREVIEW), move |worker| worker.put_text(doc, page, &asked, PREVIEW),
move |_, answer, cx| { move |_, answer, cx| {
// The viewer edits this view; wait until it is free. // The viewer edits this view; wait until it is free.
@@ -95,7 +96,19 @@ impl Viewer {
replacing: bool, replacing: bool,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
let same = self.tab(tab).is_some_and(|open| open.view.read(cx).version == version);
if !same {
let text = Self::text(cx, "pdf.object.changed", &[]);
return self.toast(text, Severity::Warning, cx);
}
match answer { match answer {
Ok(Wrote::Unwritable) => {
let note = Self::text(cx, "pdf.text.unwritable", &[]);
self.edit_panel.update(cx, |panel, cx| {
panel.note = Some(note);
cx.notify();
});
}
Ok(Wrote::Missing(c)) => { Ok(Wrote::Missing(c)) => {
let note = Self::text(cx, "pdf.text.missing", &[("char", &c.to_string())]); let note = Self::text(cx, "pdf.text.missing", &[("char", &c.to_string())]);
self.edit_panel.update(cx, |panel, cx| { self.edit_panel.update(cx, |panel, cx| {
@@ -128,6 +141,7 @@ impl Viewer {
move |worker, doc| match worker.put_text(doc, page, &writing, 0.0)? { move |worker, doc| match worker.put_text(doc, page, &writing, 0.0)? {
Wrote::Done(mark) => Ok(mark), Wrote::Done(mark) => Ok(mark),
Wrote::Missing(c) => Err(io::Error::other(format!("the font has no {c}"))), Wrote::Missing(c) => Err(io::Error::other(format!("the font has no {c}"))),
Wrote::Unwritable => Err(io::Error::other("the document's font cannot write")),
Wrote::Preview(..) => Err(io::Error::other("a write came back as a preview")), Wrote::Preview(..) => Err(io::Error::other("a write came back as a preview")),
}, },
cx, cx,
@@ -168,25 +182,27 @@ impl Viewer {
self.pick(false, cx, move |viewer, path: PathBuf, cx| { self.pick(false, cx, move |viewer, path: PathBuf, cx| {
viewer.on_same(&view, version, cx, |view, cx| { viewer.on_same(&view, version, cx, |view, cx| {
view.edit("replacing an image", move |worker, doc| worker.replace_image(doc, page, index, &path), cx) view.edit("replacing an image", move |worker, doc| worker.replace_image(doc, page, index, &path), cx)
}) });
}); });
} }
/// Runs `step` on `view` if no edit came since `version`. /// Whether `step` ran: no edit came since `version`.
pub(crate) fn on_same( pub(crate) fn on_same(
&self, &self,
view: &gpui::Entity<crate::view::PdfView>, view: &gpui::Entity<crate::view::PdfView>,
version: u64, version: u64,
cx: &mut Context<Self>, cx: &mut Context<Self>,
step: impl FnOnce(&mut crate::view::PdfView, &mut Context<crate::view::PdfView>), step: impl FnOnce(&mut crate::view::PdfView, &mut Context<crate::view::PdfView>),
) { ) -> bool {
// Landed or queued, an edit since may move the target. // Landed or queued, an edit since may move the target.
let open = self.core.tabs.iter().any(|tab| tab.view == *view); let open = self.core.tabs.iter().any(|tab| tab.view == *view);
if !open || view.read(cx).version != version || !view.read(cx).settled() { if !open || view.read(cx).version != version || !view.read(cx).settled() {
let text = Self::text(cx, "pdf.object.changed", &[]); let text = Self::text(cx, "pdf.object.changed", &[]);
return self.toast(text, Severity::Warning, cx); self.toast(text, Severity::Warning, cx);
return false;
} }
view.update(cx, step); view.update(cx, step);
true
} }
/// Asks for a picture; it lands mid-page. /// Asks for a picture; it lands mid-page.
+4
View File
@@ -5,6 +5,10 @@ edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license.workspace = true license.workspace = true
publish.workspace = true publish.workspace = true
build = "../../packaging/windows/icon.rs"
[build-dependencies]
embed-resource.workspace = true
[dependencies] [dependencies]
ely-files.workspace = true ely-files.workspace = true
+2 -2
View File
@@ -186,8 +186,8 @@ impl Deck {
return log::info!("show: a chart dialog for tab {tab} came late"); return log::info!("show: a chart dialog for tab {tab} came late");
} }
let editing = Editing::new(tab, chart, window, cx); let editing = Editing::new(tab, chart, window, cx);
deck.dialog = Some(Dialog::Chart(Box::new(editing))); // Refused while another dialog is open; it says so.
cx.notify(); let _ = deck.open_dialog(Dialog::Chart(Box::new(editing)), cx);
}) })
}) })
.detach_and_log_err(cx); .detach_and_log_err(cx);
+20 -10
View File
@@ -108,6 +108,21 @@ pub fn findings(check: &Check, slides: &[ShowSlide], missing_fonts: &[String]) -
} }
impl Deck { impl Deck {
/// Shows a finding's slide; master view numbers masters instead.
pub(crate) fn go_to_finding(&mut self, part: usize, cx: &mut Context<Self>) {
let Some(tab) = self.active_tab() else {
return;
};
if tab.extra.masters {
let text = Deck::text(cx, "show.masters.busy", &[]);
return self.toast(text, Severity::Warning, cx);
}
let view = tab.view.clone();
view.update(cx, |view, _| view.show_part(part));
self.dialog = None;
cx.notify();
}
/// Reads every slide and master, then lists what to fix. /// Reads every slide and master, then lists what to fix.
pub fn check_show(&mut self, cx: &mut Context<Self>) { pub fn check_show(&mut self, cx: &mut Context<Self>) {
let Some(tab) = self.active_tab() else { let Some(tab) = self.active_tab() else {
@@ -128,8 +143,8 @@ impl Deck {
Ok((check, slides)) if deck.dialog_due(asked, id) => { Ok((check, slides)) if deck.dialog_due(asked, id) => {
let found = findings(&check, &slides, &missing); let found = findings(&check, &slides, &missing);
log::info!("show: the check found {} things", found.len()); log::info!("show: the check found {} things", found.len());
deck.dialog = Some(Dialog::Check(found)); // Refused while another dialog is open; it says so.
cx.notify(); let _ = deck.open_dialog(Dialog::Check(found), cx);
} }
Ok(_) => log::info!("show: the check is no longer due"), Ok(_) => log::info!("show: the check is no longer due"),
Err(error) => deck.engine_failed(&format!("the check: {error}"), cx), Err(error) => deck.engine_failed(&format!("the check: {error}"), cx),
@@ -183,14 +198,9 @@ impl Deck {
let firsts: Vec<Option<usize>> = found.iter().map(|f| f.slides.first().copied()).collect(); let firsts: Vec<Option<usize>> = found.iter().map(|f| f.slides.first().copied()).collect();
let list = list.on_change(move |keys, _, cx| { let list = list.on_change(move |keys, _, cx| {
let first = keys.first().and_then(|key| key.parse::<usize>().ok()).and_then(|at| firsts[at]); let first = keys.first().and_then(|key| key.parse::<usize>().ok()).and_then(|at| firsts[at]);
this.update(cx, |deck, cx| { if let Some(part) = first {
if let (Some(part), Some(tab)) = (first, deck.active_tab()) { this.update(cx, |deck, cx| deck.go_to_finding(part, cx));
let view = tab.view.clone(); }
view.update(cx, |view, _| view.show_part(part));
deck.dialog = None;
}
cx.notify();
})
}); });
card.child(list).into_any_element() card.child(list).into_any_element()
} }
+2 -6
View File
@@ -220,6 +220,7 @@ impl Deck {
} }
(tab.extra.reading, tab.extra.read) = (true, Some(seen)); (tab.extra.reading, tab.extra.read) = (true, Some(seen));
let landed = tab.extra.transition_landed(view.read(cx)); let landed = tab.extra.transition_landed(view.read(cx));
let sized = tab.extra.size_landed(view.read(cx));
let doc = tab.doc.clone(); let doc = tab.doc.clone();
ely_lok::read_off( ely_lok::read_off(
&self.core.worker, &self.core.worker,
@@ -296,12 +297,6 @@ impl Deck {
{ {
tab.extra.stale = tab.extra.thumbs.keys().cloned().collect(); tab.extra.stale = tab.extra.thumbs.keys().cloned().collect();
} }
let read = crate::design::mm100(size);
if tab.extra.size_asked.is_some_and(|(w, h)| {
(w - read.0).abs() <= 50 && (h - read.1).abs() <= 50
}) {
tab.extra.size_asked = None;
}
(tab.extra.slides, tab.extra.design) = (slides, Some(design)); (tab.extra.slides, tab.extra.design) = (slides, Some(design));
tab.extra.objects = Some(objects); tab.extra.objects = Some(objects);
(tab.extra.notes, tab.extra.remarks) = (Some(notes), remarks); (tab.extra.notes, tab.extra.remarks) = (Some(notes), remarks);
@@ -309,6 +304,7 @@ impl Deck {
tab.extra.settle_transition(landed); tab.extra.settle_transition(landed);
tab.extra.reads += 1; tab.extra.reads += 1;
tab.extra.size = size; tab.extra.size = size;
tab.extra.settle_size(sized);
tab.extra.failed.clear(); tab.extra.failed.clear();
deck.paint_next(id, cx); deck.paint_next(id, cx);
deck.read_fonts(id, cx); deck.read_fonts(id, cx);
+12 -3
View File
@@ -59,9 +59,18 @@ impl Deck {
log::info!("show: the slides already measure {w} x {h}"); log::info!("show: the slides already measure {w} x {h}");
return; return;
} }
tab.extra.size_asked = Some((w, h)); let (id, view) = (tab.id, tab.view.clone());
let (w, h) = (w.to_string(), h.to_string()); let before = view.read(cx).sent();
self.design_command("slide.size", &[&w, &h, "true"], cx); tab.extra.size_asked = Some(((w, h), before + 1));
let (sw, sh) = (w.to_string(), h.to_string());
self.run_in(Some(id), "slide.size", &[&sw, &sh, "true"], cx);
// Refused before sending: run_in said so already.
if view.read(cx).sent() == before
&& let Some(tab) = self.tab_mut(id)
{
tab.extra.size_asked = None;
}
self.force_reread(id, cx);
} }
/// Enters or leaves the master view. /// Enters or leaves the master view.
+16 -3
View File
@@ -1,6 +1,7 @@
//! ElyShow's one open dialog. //! ElyShow's one open dialog.
use ely_gpui_component::forms::TextInput; use ely_gpui_component::{forms::TextInput, primitives::Severity};
use ely_office::prelude::*;
use gpui::{App, Context, Entity, IntoElement as _}; use gpui::{App, Context, Entity, IntoElement as _};
use crate::deck::Deck; use crate::deck::Deck;
@@ -41,6 +42,18 @@ pub(crate) fn edit(
} }
impl Deck { impl Deck {
/// Opens `dialog`, or hands it back while another is open.
pub(crate) fn open_dialog(&mut self, dialog: Dialog, cx: &mut Context<Self>) -> Result<(), Dialog> {
if self.dialog.is_some() {
let text = Deck::text(cx, "show.dialog.busy", &[]);
self.toast(text, Severity::Warning, cx);
return Err(dialog);
}
self.dialog = Some(dialog);
cx.notify();
Ok(())
}
/// A read that will open a dialog; earlier ones lapse. /// A read that will open a dialog; earlier ones lapse.
pub fn ask_dialog(&mut self) -> u64 { pub fn ask_dialog(&mut self) -> u64 {
self.dialog_asked += 1; self.dialog_asked += 1;
@@ -56,9 +69,9 @@ impl Deck {
} }
} }
/// Whether read `asked` may open its dialog now. /// Whether read `asked` still stands; `open_dialog` checks the rest.
pub fn dialog_due(&self, asked: u64, id: u64) -> bool { pub fn dialog_due(&self, asked: u64, id: u64) -> bool {
self.dialog_asked == asked && self.dialog.is_none() && self.core.active == Some(id) self.dialog_asked == asked && self.core.active == Some(id)
} }
/// The open dialog, drawn. /// The open dialog, drawn.
+19 -3
View File
@@ -37,8 +37,8 @@ pub struct Extra {
pub size: (i64, i64), pub size: (i64, i64),
/// Undo and redo lists at the last read. /// Undo and redo lists at the last read.
pub history: Option<(Vec<Step>, Vec<Step>)>, pub history: Option<(Vec<Step>, Vec<Step>)>,
/// A size sent, in 1/100 mm, until read back. /// A size sent (1/100 mm) and its input, until read.
pub size_asked: Option<(i64, i64)>, pub size_asked: Option<((i64, i64), u64)>,
/// A transition sent for a slide, until read back. /// A transition sent for a slide, until read back.
pub transition_asked: Option<(String, ely_lok::show::Transition, u64)>, pub transition_asked: Option<(String, ely_lok::show::Transition, u64)>,
/// Pictures by slide name; stale ones show until repainted. /// Pictures by slide name; stale ones show until repainted.
@@ -60,6 +60,22 @@ impl Extra {
/// The size to show, in 1/100 mm: asked, else read. /// The size to show, in 1/100 mm: asked, else read.
pub fn shown_size(&self) -> (i64, i64) { pub fn shown_size(&self) -> (i64, i64) {
self.size_asked self.size_asked
.unwrap_or_else(|| crate::design::mm100(self.size)) .map_or_else(|| crate::design::mm100(self.size), |(size, _)| size)
}
/// The size input that landed before this read began.
pub fn size_landed(&self, view: &ely_lok::LokView) -> Option<u64> {
let (_, seq) = self.size_asked?;
view.reached(seq).then_some(seq)
}
/// Forgets the asked size once read back, taken or refused.
pub fn settle_size(&mut self, landed: Option<u64>) {
let read = crate::design::mm100(self.size);
if self.size_asked.is_some_and(|((w, h), seq)| {
landed == Some(seq) || (w - read.0).abs() <= 50 && (h - read.1).abs() <= 50
}) {
self.size_asked = None;
}
} }
} }
+30 -2
View File
@@ -31,7 +31,8 @@ actions!(
/// Insert and View; View checks what is on. /// Insert and View; View checks what is on.
pub fn menus((grid, guides, remarks): (bool, bool, bool), cx: &App) -> [Menu; 2] { pub fn menus((grid, guides, remarks): (bool, bool, bool), cx: &App) -> [Menu; 2] {
let label = |key: &str| Deck::text(cx, key, &[]); let label = |key: &str| Deck::text(cx, key, &[]);
let insert = Menu::new(label("menu.insert")).items([ let media = ely_platform::media::BUILT.then(|| MenuItem::action(label("show.insert.media"), InsertMedia));
let insert = [
MenuItem::action(label("show.insert.text"), InsertTextBox), MenuItem::action(label("show.insert.text"), InsertTextBox),
MenuItem::separator(), MenuItem::separator(),
MenuItem::action(label("show.insert.rectangle"), InsertRectangle), MenuItem::action(label("show.insert.rectangle"), InsertRectangle),
@@ -42,12 +43,16 @@ pub fn menus((grid, guides, remarks): (bool, bool, bool), cx: &App) -> [Menu; 2]
MenuItem::action(label("show.insert.connector"), InsertConnector), MenuItem::action(label("show.insert.connector"), InsertConnector),
MenuItem::separator(), MenuItem::separator(),
MenuItem::action(label("show.insert.picture"), InsertPicture), MenuItem::action(label("show.insert.picture"), InsertPicture),
MenuItem::action(label("show.insert.media"), InsertMedia), ]
.into_iter()
.chain(media)
.chain([
MenuItem::action(label("show.insert.table"), InsertTable), MenuItem::action(label("show.insert.table"), InsertTable),
MenuItem::action(label("show.insert.chart"), InsertChart), MenuItem::action(label("show.insert.chart"), InsertChart),
MenuItem::separator(), MenuItem::separator(),
MenuItem::action(label("show.insert.remark"), InsertRemark), MenuItem::action(label("show.insert.remark"), InsertRemark),
]); ]);
let insert = Menu::new(label("menu.insert")).items(insert);
let view = Menu::new(label("menu.view")).items([ let view = Menu::new(label("menu.view")).items([
MenuItem::action(label("show.view.remarks"), ShowRemarks).checked(remarks), MenuItem::action(label("show.view.remarks"), ShowRemarks).checked(remarks),
MenuItem::separator(), MenuItem::separator(),
@@ -150,3 +155,26 @@ impl Deck {
self.object_command("guide.add", &[kind, &x.to_string(), &y.to_string()], cx); self.object_command("guide.add", &[kind, &x.to_string(), &y.to_string()], cx);
} }
} }
#[cfg(test)]
mod tests {
#[test]
fn insert_media_shows_only_where_it_plays() {
let cx = gpui::TestAppContext::single();
cx.update(ely_gpui_component::init_for_tests);
let (offered, total) = cx.update(|cx| {
ely_ui::settings::install_defaults(cx);
ely_ui::i18n::install(cx);
let media = crate::deck::Deck::text(cx, "show.insert.media", &[]);
let [insert, _] = super::menus((false, false, false), cx);
let names = insert.items.iter().filter_map(|item| match item {
gpui::MenuItem::Action { name, .. } => Some(name.clone()),
_ => None,
});
let names: Vec<_> = names.collect();
(names.contains(&media), names.len())
});
assert_eq!(offered, ely_platform::media::BUILT);
assert!(total >= 11, "the rest stay: {total}");
}
}
+3
View File
@@ -1,3 +1,6 @@
// Release builds open no console on Windows.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod chart; mod chart;
mod check; mod check;
mod deck; mod deck;
+2 -1
View File
@@ -13,7 +13,7 @@ use ely_lok::{LokView, show::Media};
use ely_office::prelude::*; use ely_office::prelude::*;
use ely_platform::media::{Player, State}; use ely_platform::media::{Player, State};
use gpui::{ use gpui::{
AnyElement, App, Context, Entity, IntoElement, ParentElement as _, Styled as _, SurfaceSource, AnyElement, App, Context, Entity, ParentElement as _, Styled as _, SurfaceSource,
TaskExt as _, Window, div, TaskExt as _, Window, div,
}; };
@@ -275,6 +275,7 @@ pub(crate) fn video(
frame: SurfaceSource, frame: SurfaceSource,
(at, size): (gpui::Point<gpui::Pixels>, gpui::Point<gpui::Pixels>), (at, size): (gpui::Point<gpui::Pixels>, gpui::Point<gpui::Pixels>),
) -> AnyElement { ) -> AnyElement {
use gpui::IntoElement as _;
gpui::surface(frame) gpui::surface(frame)
.absolute() .absolute()
.left(at.x) .left(at.x)
+2 -2
View File
@@ -34,8 +34,8 @@ impl Deck {
match ely_lok::templates("presnt") { match ely_lok::templates("presnt") {
Ok(templates) => { Ok(templates) => {
log::info!("show: {} bundled templates", templates.len()); log::info!("show: {} bundled templates", templates.len());
self.dialog = Some(Dialog::New(Choosing { templates })); // Refused while another dialog is open; it says so.
cx.notify(); let _ = self.open_dialog(Dialog::New(Choosing { templates }), cx);
} }
Err(error) => self.engine_failed(&error.to_string(), cx), Err(error) => self.engine_failed(&error.to_string(), cx),
} }
+5 -2
View File
@@ -20,7 +20,7 @@ pub struct NotesField {
field: Option<Entity<TextInput>>, field: Option<Entity<TextInput>>,
shown: Option<(u64, usize, String)>, shown: Option<(u64, usize, String)>,
/// The state the shown slide number was read in. /// The state the shown slide number was read in.
at: Option<ely_lok::Doc>, pub(crate) at: Option<ely_lok::Doc>,
_events: Option<gpui::Subscription>, _events: Option<gpui::Subscription>,
} }
@@ -66,7 +66,7 @@ impl Deck {
return log::warn!("show: notes for slide {part} have no read state"); return log::warn!("show: notes for slide {part} have no read state");
}; };
log::info!("show: notes for slide {part} changed"); log::info!("show: notes for slide {part} changed");
self.notes.shown = Some((tab, part, text.clone())); // `shown` stays the kernel's until a read confirms the send.
self.run_on(tab, &at, "notes.set", &[&text, &part.to_string()], cx); self.run_on(tab, &at, "notes.set", &[&text, &part.to_string()], cx);
self.force_reread(tab, cx); self.force_reread(tab, cx);
} }
@@ -90,6 +90,9 @@ impl Deck {
if !typing && self.notes.shown.as_ref() != Some(&read) { if !typing && self.notes.shown.as_ref() != Some(&read) {
field.update(cx, |field, cx| field.set_text(notes.text, cx)); field.update(cx, |field, cx| field.set_text(notes.text, cx));
self.notes.shown = Some(read); self.notes.shown = Some(read);
} else if let Some(shown) = self.notes.shown.as_mut().filter(|shown| (shown.0, shown.1) == (id, part)) {
// A read while typing moves the base, not the draft.
shown.2 = notes.text;
} }
Some(field) Some(field)
} }
+5 -4
View File
@@ -148,7 +148,8 @@ impl Deck {
|worker, doc| ely_lok::show::slide_show(&worker.values(doc, ely_lok::show::SLIDE_SHOW)?), |worker, doc| ely_lok::show::slide_show(&worker.values(doc, ely_lok::show::SLIDE_SHOW)?),
move |deck, read, _, cx| match read { move |deck, read, _, cx| match read {
Ok(slides) if deck.dialog_due(asked, id) => { Ok(slides) if deck.dialog_due(asked, id) => {
deck.dialog = Some(Dialog::Output(Output { // Refused while another dialog is open; it says so.
let output = Output {
tab: id, tab: id,
print, print,
kind: Kind::Slides, kind: Kind::Slides,
@@ -156,8 +157,8 @@ impl Deck {
per: 3, per: 3,
current, current,
hidden: slides.iter().map(|slide| slide.hidden).collect(), hidden: slides.iter().map(|slide| slide.hidden).collect(),
})); };
cx.notify(); let _ = deck.open_dialog(Dialog::Output(output), cx);
} }
Ok(_) => log::info!("show: the output dialog is no longer due"), Ok(_) => log::info!("show: the output dialog is no longer due"),
Err(error) => deck.engine_failed(&format!("the slides: {error}"), cx), Err(error) => deck.engine_failed(&format!("the slides: {error}"), cx),
@@ -185,7 +186,7 @@ impl Deck {
let path = work.join("print.pdf"); let path = work.join("print.pdf");
let printing = path.clone(); let printing = path.clone();
return self.write_pdf(&output, path, cx, move |_, cx| { return self.write_pdf(&output, path, cx, move |_, cx| {
ely_office::print_later(printing, cx, |deck, error, cx| deck.output_failed(&error, cx)) ely_office::print_later(printing, cx)
}); });
} }
let Some(dir) = ely_office::folder(source) else { let Some(dir) = ely_office::folder(source) else {
+19 -1
View File
@@ -13,6 +13,11 @@ use super::{Kind, Output, Range};
use crate::deck::Deck; use crate::deck::Deck;
use crate::dialog::Dialog; use crate::dialog::Dialog;
/// A typed slide number on the deck; 0 means all.
fn slide_number(value: f64, last: u32) -> u32 {
value.round().clamp(1.0, f64::from(last.max(1))) as u32
}
/// What goes out, which slides, how many a page. /// What goes out, which slides, how many a page.
pub fn output_dialog( pub fn output_dialog(
this: gpui::Entity<Deck>, this: gpui::Entity<Deck>,
@@ -79,7 +84,7 @@ pub fn output_dialog(
let bound = |id: &'static str, value: u32, first: bool| { let bound = |id: &'static str, value: u32, first: bool| {
// A typed number counts before blur commits it. // A typed number counts before blur commits it.
let set = move |this: &gpui::Entity<Deck>, value: f64, cx: &mut gpui::App| { let set = move |this: &gpui::Entity<Deck>, value: f64, cx: &mut gpui::App| {
let value = value.round() as u32; let value = slide_number(value, last);
change(this, cx, &|output| { change(this, cx, &|output| {
if let Range::Slides(from, to) = &mut output.range { if let Range::Slides(from, to) = &mut output.range {
*(if first { from } else { to }) = value; *(if first { from } else { to }) = value;
@@ -135,3 +140,16 @@ pub fn output_dialog(
.on_click(move |_, _, cx| this.update(cx, |deck, cx| deck.output(cx))) .on_click(move |_, _, cx| this.update(cx, |deck, cx| deck.output(cx)))
}) })
} }
#[cfg(test)]
mod tests {
use super::slide_number;
#[test]
fn a_typed_number_stays_on_the_deck() {
assert_eq!(slide_number(0.0, 5), 1, "0 would export every slide");
assert_eq!(slide_number(-3.0, 5), 1);
assert_eq!(slide_number(2.6, 5), 3);
assert_eq!(slide_number(9.0, 5), 5);
}
}
+50 -16
View File
@@ -19,20 +19,21 @@ use gpui::{
use crate::deck::Deck; use crate::deck::Deck;
use crate::dialog::Dialog; use crate::dialog::Dialog;
/// Each comment as a thread quoted with its slide. /// Threads quoted with their slides; unreadable comments apart.
fn threads(remarks: &[Remark], cx: &App) -> Result<Vec<Thread>, String> { fn threads(remarks: &[Remark], cx: &App) -> (Vec<Thread>, Vec<(String, String)>) {
let authors: Vec<&str> = remarks let authors: Vec<&str> = remarks
.iter() .iter()
.map(|remark| remark.author.as_str()) .map(|remark| remark.author.as_str())
.collect(); .collect();
remarks let (good, bad): (Vec<_>, Vec<_>) = remarks
.iter() .iter()
.map(|remark| { .map(|remark| {
let at = remark let at = remark
.at .at
.parse::<jiff::civil::DateTime>() .parse::<jiff::civil::DateTime>()
.and_then(|at| at.to_zoned(jiff::tz::TimeZone::system())) .and_then(|at| at.to_zoned(jiff::tz::TimeZone::system()))
.map_err(|error| format!("a bad comment time {}: {error}", remark.at))?; .map_err(|error| (remark.at.clone(), error.to_string()))?
.timestamp();
let hue = authors let hue = authors
.iter() .iter()
.position(|name| *name == remark.author) .position(|name| *name == remark.author)
@@ -43,14 +44,16 @@ fn threads(remarks: &[Remark], cx: &App) -> Result<Vec<Thread>, String> {
quote: Some(Deck::text(cx, "show.remark.slide", &[("n", &slide)])), quote: Some(Deck::text(cx, "show.remark.slide", &[("n", &slide)])),
comments: vec![Comment { comments: vec![Comment {
author: Peer::new(remark.author.clone(), remark.author.clone(), hue), author: Peer::new(remark.author.clone(), remark.author.clone(), hue),
at: at.timestamp(), at,
body: remark.text.clone().into(), body: remark.text.clone().into(),
reactions: Vec::new(), reactions: Vec::new(),
}], }],
resolved: false, resolved: false,
}) })
}) })
.collect() .partition(Result::is_ok);
let bad = bad.into_iter().filter_map(Result::err).collect();
(good.into_iter().filter_map(Result::ok).collect(), bad)
} }
impl Deck { impl Deck {
@@ -77,8 +80,8 @@ impl Deck {
input.set_text(text, cx); input.set_text(text, cx);
input input
}); });
self.dialog = Some(Dialog::Remark { tab, edit, field }); // Refused while another dialog is open; it says so.
cx.notify(); let _ = self.open_dialog(Dialog::Remark { tab, edit, field }, cx);
} }
/// Sends the dialog's text: a new comment or an edit. /// Sends the dialog's text: a new comment or an edit.
@@ -166,14 +169,15 @@ impl Deck {
.active_tab() .active_tab()
.map(|tab| tab.extra.remarks.clone()) .map(|tab| tab.extra.remarks.clone())
.unwrap_or_default(); .unwrap_or_default();
let threads = match threads(&remarks, cx) { let (threads, bad) = threads(&remarks, cx);
Ok(threads) => threads, for (at, error) in &bad {
Err(error) => { log::error!("show: a bad comment time {at}: {error}");
log::error!("show: {error}"); }
let message = InlineMessage::new(Severity::Danger, error); // One line however many: the comments below stay in view.
return div().p_2().child(message).into_any_element(); let bad = bad.first().map(|(at, _)| {
} let n = bad.len().to_string();
}; InlineMessage::new(Severity::Danger, Deck::text(cx, "show.remark.bad", &[("n", &n), ("at", at)]))
});
let active = self.shown_remark(); let active = self.shown_remark();
let act = |id: &'static str, let act = |id: &'static str,
key: &str, key: &str,
@@ -227,6 +231,7 @@ impl Deck {
.gap_2() .gap_2()
.p_2() .p_2()
.child(head) .child(head)
.children(bad)
.child(div().flex_1().min_h_0().child(sidebar)) .child(div().flex_1().min_h_0().child(sidebar))
.into_any_element() .into_any_element()
} }
@@ -289,3 +294,32 @@ impl Deck {
(!pins.is_empty()).then(|| div().absolute().inset_0().children(pins).into_any_element()) (!pins.is_empty()).then(|| div().absolute().inset_0().children(pins).into_any_element())
} }
} }
#[cfg(test)]
mod tests {
use ely_lok::show::Remark;
use gpui::TestAppContext;
use super::threads;
#[test]
fn one_unreadable_time_spares_the_other_comments() {
let cx = TestAppContext::single();
cx.update(|cx| ely_ui::i18n::install_as("en-US", cx));
let remark = |id: &str, at: &str| Remark {
id: id.into(),
author: "A".into(),
text: "t".into(),
at: at.into(),
part: 0,
anchor: (0, 0, 0, 0),
};
// The kernel writes an empty date as 0-00-00.
let remarks = [remark("1", "2025-03-01"), remark("2", "0-00-00"), remark("3", "2025-03-01T10:15:00")];
let (good, bad) = cx.update(|cx| threads(&remarks, cx));
let keys: Vec<_> = good.iter().map(|thread| thread.key.to_string()).collect();
assert_eq!(keys, ["1", "3"]);
assert_eq!(bad.len(), 1);
assert_eq!(bad[0].0, "0-00-00");
}
}
+54 -7
View File
@@ -141,7 +141,9 @@ impl Deck {
/// Plays a copy from the first or shown slide. /// Plays a copy from the first or shown slide.
pub fn start_show(&mut self, here: bool, cx: &mut Context<Self>) { pub fn start_show(&mut self, here: bool, cx: &mut Context<Self>) {
if self.show.is_some() || self.show_starting { if self.show.is_some() || self.show_starting {
return log::info!("show: already playing or starting"); log::info!("show: already playing or starting");
let text = Deck::text(cx, "show.play.busy", &[]);
return self.toast(text, Severity::Warning, cx);
} }
let Some(id) = self.core.active else { let Some(id) = self.core.active else {
return; return;
@@ -180,7 +182,9 @@ impl Deck {
let Some(first) = first.filter(|_| deck.core.active == Some(id)) else { let Some(first) = first.filter(|_| deck.core.active == Some(id)) else {
discard(worker, doc, path, cx); discard(worker, doc, path, cx);
if deck.core.active != Some(id) { if deck.core.active != Some(id) {
return log::info!("show: tab {id} is no longer shown"); log::info!("show: tab {id} is no longer shown");
let text = Deck::text(cx, "show.play.gone", &[]);
return deck.toast(text, Severity::Warning, cx);
} }
let text = Deck::text(cx, "show.play.none", &[]); let text = Deck::text(cx, "show.play.none", &[]);
return deck.toast(text, Severity::Warning, cx); return deck.toast(text, Severity::Warning, cx);
@@ -196,11 +200,11 @@ impl Deck {
}; };
match cx.displays().len() { match cx.displays().len() {
1 => deck.open_show(starting, cx), 1 => deck.open_show(starting, cx),
_ => { _ => match deck.open_dialog(Dialog::Show(starting), cx) {
deck.show_starting = true; Ok(()) => deck.show_starting = true,
deck.dialog = Some(Dialog::Show(starting)); Err(Dialog::Show(refused)) => discard(worker, refused.doc, refused.copy, cx),
cx.notify(); Err(_) => unreachable!("the show dialog comes back"),
} },
} }
}, },
cx, cx,
@@ -214,6 +218,8 @@ impl Deck {
let (Some(tab), Some(audience)) = (self.tab(starting.tab), displays.get(starting.audience)) let (Some(tab), Some(audience)) = (self.tab(starting.tab), displays.get(starting.audience))
else { else {
log::warn!("show: the tab or screen {} is gone", starting.audience); log::warn!("show: the tab or screen {} is gone", starting.audience);
let text = Deck::text(cx, "show.play.gone", &[]);
self.toast(text, Severity::Warning, cx);
return discard(worker, starting.doc, starting.copy, cx); return discard(worker, starting.doc, starting.copy, cx);
}; };
let presenter = (displays.iter()) let presenter = (displays.iter())
@@ -347,4 +353,45 @@ mod tests {
}; };
assert_eq!(super::bounds(area), want); assert_eq!(super::bounds(area), want);
} }
#[test]
fn a_start_whose_screen_is_gone_says_so_and_drops_its_copy() {
use ely_office::prelude::*;
use gpui::AppContext as _;
use crate::tests::{settle, update};
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = gpui::TestAppContext::single();
let Some(deck) = crate::tests::deck(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
update(&mut cx, &deck, |deck, cx| deck.new_blank(cx));
settle(&cx, |app| deck.read(app).active_tab().is_some_and(|tab| !tab.extra.slides.list.is_empty()));
// The window's toasts live on its root.
let handle = cx.update(|app| deck.read(app).core.window).expect("a window");
let root = cx
.update_window(handle, |_, window, app| {
let root = ely_ui::document_root(ely_platform::AppId::Show, deck.clone().into(), &ely_ui::commands::FILE, Vec::new(), window, app);
deck.update(app, |deck, _| deck.core.root = root.downgrade());
root
})
.expect("the window lives");
let path = dir.path().join("copy.odp");
let starting = cx.update(|app| {
let deck = deck.read(app);
let tab = deck.active_tab().expect("a tab");
let mut worker = deck.core.worker.lock().expect("the worker lock");
let (_, doc, slides) = super::copy(&mut worker, &tab.doc, (&path, None)).expect("a copy");
super::Starting { tab: tab.id, slides, first: 0, doc, copy: path.clone(), audience: 99, presenter: false }
});
update(&mut cx, &deck, |deck, cx| {
deck.show_starting = true;
deck.open_show(starting, cx);
});
settle(&cx, |_| !path.exists());
let (toasts, gone) = cx.update(|app| (root.read(app).toasts(app), crate::deck::Deck::text(app, "show.play.gone", &[])));
assert!(toasts.iter().any(|(text, _)| *text == gone), "{toasts:?}");
assert!(cx.update(|app| !deck.read(app).show_starting && deck.read(app).show.is_none()));
}
} }
+2 -6
View File
@@ -21,12 +21,8 @@ use crate::dialog::{Dialog, edit};
impl Deck { impl Deck {
pub fn ask_table(&mut self, cx: &mut Context<Self>) { pub fn ask_table(&mut self, cx: &mut Context<Self>) {
if let Some(tab) = self.core.active { if let Some(tab) = self.core.active {
self.dialog = Some(Dialog::Table { // Refused while another dialog is open; it says so.
tab, let _ = self.open_dialog(Dialog::Table { tab, columns: 3, rows: 3 }, cx);
columns: 3,
rows: 3,
});
cx.notify();
} }
} }
+3 -2
View File
@@ -9,6 +9,7 @@ use gpui::{AppContext as _, Entity, TestAppContext};
use crate::deck::Deck; use crate::deck::Deck;
mod motion; mod motion;
mod notes;
mod objects; mod objects;
mod output; mod output;
mod play; mod play;
@@ -44,7 +45,7 @@ pub(super) fn deck(cx: &mut TestAppContext, dir: &Path) -> Option<Entity<Deck>>
/// Steps the scheduler until `done` or ten seconds pass. /// Steps the scheduler until `done` or ten seconds pass.
#[track_caller] #[track_caller]
fn settle(cx: &TestAppContext, done: impl Fn(&gpui::App) -> bool) { pub(super) fn settle(cx: &TestAppContext, done: impl Fn(&gpui::App) -> bool) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while std::time::Instant::now() < deadline { while std::time::Instant::now() < deadline {
if !cx.executor().tick() { if !cx.executor().tick() {
@@ -58,7 +59,7 @@ fn settle(cx: &TestAppContext, done: impl Fn(&gpui::App) -> bool) {
panic!("the state never settled"); panic!("the state never settled");
} }
fn update( pub(super) fn update(
cx: &mut TestAppContext, cx: &mut TestAppContext,
deck: &Entity<Deck>, deck: &Entity<Deck>,
f: impl FnOnce(&mut Deck, &mut gpui::Context<Deck>), f: impl FnOnce(&mut Deck, &mut gpui::Context<Deck>),
+93
View File
@@ -0,0 +1,93 @@
//! The notes field under the slide (SHOW-08).
use ely_office::prelude::*;
use gpui::TestAppContext;
use super::objects::blank;
use super::slides::{in_window, type_in};
use super::{settle, update};
#[test]
fn typed_notes_reach_the_slide() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let notes = |app: &gpui::App| {
deck.read(app)
.active_tab()
.and_then(|tab| tab.extra.notes.clone())
};
settle(&cx, |app| notes(app).is_some());
let field = in_window(&mut cx, &deck, |deck, window, cx| {
deck.notes_input(window, cx)
});
let field = field.expect("the field");
type_in(&mut cx, &field, "Start with the totals");
update(&mut cx, &deck, |deck, cx| deck.commit_notes(cx));
settle(&cx, |app| {
notes(app).is_some_and(|notes| notes.text == "Start with the totals")
});
}
#[test]
fn notes_refused_as_stale_stay_typed_and_go_next_time() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let notes = |app: &gpui::App| deck.read(app).active_tab().and_then(|tab| tab.extra.notes.clone());
let text = |app: &gpui::App| notes(app).map(|notes| notes.text);
settle(&cx, |app| notes(app).is_some());
let field = in_window(&mut cx, &deck, |deck, window, cx| deck.notes_input(window, cx)).expect("the field");
let stale = cx.update(|app| deck.read(app).notes.at.clone());
type_in(&mut cx, &field, "first");
update(&mut cx, &deck, |deck, cx| deck.commit_notes(cx));
settle(&cx, |app| text(app).as_deref() == Some("first"));
in_window(&mut cx, &deck, |deck, window, cx| deck.notes_input(window, cx));
// Typing began before the edit above was read back.
type_in(&mut cx, &field, "second");
update(&mut cx, &deck, |deck, cx| {
deck.notes.at = stale;
deck.commit_notes(cx);
});
settle(&cx, |app| deck.read(app).active_tab().is_some_and(|tab| tab.view.read(app).settled()));
cx.run_until_parked();
in_window(&mut cx, &deck, |deck, window, cx| deck.notes_input(window, cx));
assert_eq!(cx.update(|app| field.read(app).text().to_string()), "second", "the typing stays");
assert_eq!(cx.update(|app| text(app)).as_deref(), Some("first"), "the stale send was refused");
update(&mut cx, &deck, |deck, cx| deck.commit_notes(cx));
settle(&cx, |app| text(app).as_deref() == Some("second"));
}
#[test]
fn a_read_while_typing_keeps_the_new_draft() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let text = |app: &gpui::App| deck.read(app).active_tab().and_then(|tab| tab.extra.notes.clone()).map(|notes| notes.text);
settle(&cx, |app| text(app).is_some());
let field = in_window(&mut cx, &deck, |deck, window, cx| deck.notes_input(window, cx)).expect("the field");
type_in(&mut cx, &field, "first");
update(&mut cx, &deck, |deck, cx| deck.commit_notes(cx));
// Back in the field before the send is read back.
in_window(&mut cx, &deck, |_, window, cx| window.focus(&gpui::Focusable::focus_handle(field.read(cx), cx), cx));
type_in(&mut cx, &field, "second");
settle(&cx, |app| text(app).as_deref() == Some("first"));
in_window(&mut cx, &deck, |deck, window, cx| deck.notes_input(window, cx));
// The blur redraws before its commit runs.
in_window(&mut cx, &deck, |deck, window, cx| {
window.blur(cx);
deck.notes_input(window, cx)
});
assert_eq!(cx.update(|app| field.read(app).text().to_string()), "second", "the draft stays");
update(&mut cx, &deck, |deck, cx| deck.commit_notes(cx));
settle(&cx, |app| text(app).as_deref() == Some("second"));
}
+53
View File
@@ -145,3 +145,56 @@ fn a_click_on_media_finds_it_and_elsewhere_does_not() {
assert_eq!(cx.update(|app| show.read(app).media_at(centre)), Some(0)); assert_eq!(cx.update(|app| show.read(app).media_at(centre)), Some(0));
assert_eq!(cx.update(|app| show.read(app).media_at((0.01, 0.01))), None); assert_eq!(cx.update(|app| show.read(app).media_at((0.01, 0.01))), None);
} }
#[test]
fn a_second_start_says_the_show_is_busy() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
// The window's toasts live on its root.
let root = super::slides::in_window(&mut cx, &deck, |deck, window, cx| {
let this = cx.entity();
let root = ely_ui::document_root(ely_platform::AppId::Show, this.into(), &ely_ui::commands::FILE, Vec::new(), window, cx);
deck.core.root = root.downgrade();
root
});
update(&mut cx, &deck, |deck, cx| {
deck.show_starting = true;
deck.start_show(false, cx);
});
cx.run_until_parked();
let (toasts, busy) = cx.update(|app| (root.read(app).toasts(app), Deck::text(app, "show.play.busy", &[])));
assert!(toasts.iter().any(|(text, _)| *text == busy), "{toasts:?}");
}
#[test]
fn a_start_left_for_another_tab_says_so() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let first = cx.update(|app| deck.read(app).core.active.expect("a tab"));
update(&mut cx, &deck, |deck, cx| deck.new_blank(cx));
settle(&cx, |app| deck.read(app).core.tabs.len() == 2 && deck.read(app).core.active != Some(first));
let second = cx.update(|app| deck.read(app).core.active.expect("a tab"));
let root = super::slides::in_window(&mut cx, &deck, |deck, window, cx| {
let this = cx.entity();
let root = ely_ui::document_root(ely_platform::AppId::Show, this.into(), &ely_ui::commands::FILE, Vec::new(), window, cx);
deck.core.root = root.downgrade();
root
});
update(&mut cx, &deck, |deck, cx| {
deck.core.active = Some(first);
deck.start_show(false, cx);
// Another tab comes forward while the copy is read.
deck.core.active = Some(second);
});
let gone = cx.update(|app| Deck::text(app, "show.play.gone", &[]));
settle(&cx, |app| root.read(app).toasts(app).iter().any(|(text, _)| *text == gone));
assert!(cx.update(|app| !deck.read(app).show_starting && deck.read(app).show.is_none()));
}
+71 -18
View File
@@ -11,7 +11,7 @@ use crate::deck::Deck;
use crate::dialog::Dialog; use crate::dialog::Dialog;
/// Runs `f` on the deck inside its window. /// Runs `f` on the deck inside its window.
fn in_window<R>( pub(super) fn in_window<R>(
cx: &mut TestAppContext, cx: &mut TestAppContext,
deck: &Entity<Deck>, deck: &Entity<Deck>,
f: impl FnOnce(&mut Deck, &mut Window, &mut gpui::Context<Deck>) -> R, f: impl FnOnce(&mut Deck, &mut Window, &mut gpui::Context<Deck>) -> R,
@@ -25,7 +25,7 @@ fn in_window<R>(
.expect("the window lives") .expect("the window lives")
} }
fn type_in(cx: &mut TestAppContext, field: &Entity<TextInput>, text: &str) { pub(super) fn type_in(cx: &mut TestAppContext, field: &Entity<TextInput>, text: &str) {
let text = text.to_string(); let text = text.to_string();
cx.update(|app| field.update(app, |field, cx| field.set_text(text, cx))); cx.update(|app| field.update(app, |field, cx| field.set_text(text, cx)));
} }
@@ -105,28 +105,20 @@ fn the_table_dialog_inserts_a_table() {
} }
#[test] #[test]
fn typed_notes_reach_the_slide() { fn an_open_dialog_is_not_pushed_aside() {
let dir = tempfile::tempdir().expect("a temp dir"); let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single(); let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else { let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build"); eprintln!("SKIP: no LOK build");
return; return;
}; };
let notes = |app: &gpui::App| { update(&mut cx, &deck, |deck, cx| deck.ask_table(cx));
deck.read(app) update(&mut cx, &deck, |deck, cx| deck.ask_new(cx));
.active_tab() // The chart dialog opens after a background read.
.and_then(|tab| tab.extra.notes.clone()) update(&mut cx, &deck, |deck, cx| deck.ask_chart(cx));
}; cx.run_until_parked();
settle(&cx, |app| notes(app).is_some()); let kept = cx.update(|app| matches!(deck.read(app).dialog, Some(crate::dialog::Dialog::Table { .. })));
let field = in_window(&mut cx, &deck, |deck, window, cx| { assert!(kept, "the table dialog stays");
deck.notes_input(window, cx)
});
let field = field.expect("the field");
type_in(&mut cx, &field, "Start with the totals");
update(&mut cx, &deck, |deck, cx| deck.commit_notes(cx));
settle(&cx, |app| {
notes(app).is_some_and(|notes| notes.text == "Start with the totals")
});
} }
#[test] #[test]
@@ -408,3 +400,64 @@ fn a_zoom_stays_through_later_reports() {
assert_eq!(view.read(app).zoom(), zoom, "the zoom stays"); assert_eq!(view.read(app).zoom(), zoom, "the zoom stays");
}); });
} }
#[test]
fn a_check_row_does_not_jump_among_masters() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
update(&mut cx, &deck, |deck, cx| deck.masters(true, cx));
settle(&cx, |app| deck.read(app).active_tab().is_some_and(|tab| tab.extra.masters));
update(&mut cx, &deck, |deck, cx| {
deck.dialog = Some(crate::dialog::Dialog::Check(Vec::new()));
deck.go_to_finding(0, cx);
});
assert!(cx.update(|app| deck.read(app).dialog.is_some()), "the check stays open");
}
#[test]
fn a_refused_size_does_not_stay_shown() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let before = cx.update(|app| deck.read(app).active_tab().expect("a tab").extra.size);
update(&mut cx, &deck, |deck, cx| deck.resize((-500, -500), cx));
settle(&cx, |app| {
deck.read(app).active_tab().is_some_and(|tab| tab.extra.size_asked.is_none())
});
let extra = cx.update(|app| {
let tab = deck.read(app).active_tab().expect("a tab");
(tab.extra.size, tab.extra.shown_size())
});
assert_eq!(extra.0, before, "the kernel kept its size");
assert_eq!(extra.1, crate::design::mm100(before), "the fields show it");
}
#[test]
fn a_size_the_kernel_does_not_take_clears_after_its_read() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(deck) = blank(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
update(&mut cx, &deck, |deck, cx| {
deck.resize((30_000, 19_050), cx);
// As if the kernel refused: it keeps another size.
let id = deck.core.active.expect("a tab");
let tab = deck.tab_mut(id).expect("a tab");
let (_, seq) = tab.extra.size_asked.expect("asked");
tab.extra.size_asked = Some(((40_000, 19_050), seq));
});
settle(&cx, |app| {
deck.read(app).active_tab().is_some_and(|tab| tab.extra.size_asked.is_none())
});
let read = cx.update(|app| crate::design::mm100(deck.read(app).active_tab().expect("a tab").extra.size));
assert!((read.0 - 30_000).abs() <= 50, "the kernel's own size shows: {read:?}");
}
+4
View File
@@ -5,6 +5,10 @@ edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license.workspace = true license.workspace = true
publish.workspace = true publish.workspace = true
build = "../../packaging/windows/icon.rs"
[build-dependencies]
embed-resource.workspace = true
[dependencies] [dependencies]
encoding_rs.workspace = true encoding_rs.workspace = true
+3 -3
View File
@@ -81,7 +81,7 @@ impl Book {
} }
(Asked::Refresh, Ok(_)) => book.warn_key("table.pivot.none", cx), (Asked::Refresh, Ok(_)) => book.warn_key("table.pivot.none", cx),
(_, Ok(Found::Pivot(Some(pivot)))) => { (_, Ok(Found::Pivot(Some(pivot)))) => {
book.dialog = Some(Dialog::Pivot { book.open_dialog(Dialog::Pivot {
tab: id, tab: id,
source: pivot.source, source: pivot.source,
fields: pivot.fields, fields: pivot.fields,
@@ -120,7 +120,7 @@ impl Book {
if picked.top.iter().any(String::is_empty) { if picked.top.iter().any(String::is_empty) {
return self.warn_key("table.pivot.headers", cx); return self.warn_key("table.pivot.headers", cx);
} }
self.dialog = Some(Dialog::Pivot { self.open_dialog(Dialog::Pivot {
tab: id, tab: id,
source: picked.data, source: picked.data,
fields: picked.top, fields: picked.top,
@@ -146,7 +146,7 @@ impl Book {
range: ely_office::chart::field(range, "table.chart.range", window, cx), range: ely_office::chart::field(range, "table.chart.range", window, cx),
look: ely_office::chart::LookFields::new(&look, window, cx), look: ely_office::chart::LookFields::new(&look, window, cx),
}; };
self.dialog = Some(Dialog::Chart { self.open_dialog(Dialog::Chart {
tab: id, tab: id,
chart: chart.map(Box::new), chart: chart.map(Box::new),
look: Box::new(look), look: Box::new(look),
+13 -2
View File
@@ -101,6 +101,17 @@ impl Host for Book {
self.sync_bars(view, cx); self.sync_bars(view, cx);
} }
} }
/// A closed tab's dialog and list go with it.
fn tab_closed(&mut self, tab: &ely_office::Tab<Extra, ely_office::Lok>, cx: &mut Context<Self>) {
if self.dialog.as_ref().and_then(crate::dialog::Dialog::tab) == Some(tab.id) {
self.dialog = None;
}
if self.choices.as_ref().is_some_and(|choices| choices.tab == tab.id) {
self.choices = None;
}
cx.notify();
}
} }
/// The window root with a blank workbook. /// The window root with a blank workbook.
@@ -408,6 +419,8 @@ fn frozen_index(state: Option<&str>) -> Option<u32> {
(index > 0).then_some(index) (index > 0).then_some(index)
} }
impl Zooming for Book {}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[test] #[test]
@@ -417,5 +430,3 @@ mod tests {
assert_eq!(super::frozen_index(None), None); assert_eq!(super::frozen_index(None), None);
} }
} }
impl Zooming for Book {}
+2 -2
View File
@@ -121,7 +121,7 @@ impl Book {
|worker, doc| doc.selection_text(worker), |worker, doc| doc.selection_text(worker),
move |book, text, _, cx| match text { move |book, text, _, cx| match text {
Ok(text) if filled(&text) > 1 => { Ok(text) if filled(&text) > 1 => {
book.dialog = Some(Dialog::Merge { tab }); book.open_dialog(Dialog::Merge { tab });
cx.notify(); cx.notify();
} }
Ok(_) => book.run_in(id, "cells.merge", &["false"], cx), Ok(_) => book.run_in(id, "cells.merge", &["false"], cx),
@@ -150,7 +150,7 @@ impl Book {
return self.toast(text, ely_gpui_component::primitives::Severity::Warning, cx); return self.toast(text, ely_gpui_component::primitives::Severity::Warning, cx);
}; };
let field = cx.new(|cx| TextInput::new(window, cx)); let field = cx.new(|cx| TextInput::new(window, cx));
self.dialog = Some(Dialog::Size { tab, column, field }); self.open_dialog(Dialog::Size { tab, column, field });
cx.notify(); cx.notify();
} }
} }
+7 -3
View File
@@ -228,7 +228,7 @@ impl Book {
fn data_dialog(&mut self, id: u64, asked: Asked, picked: Selection, state: ReadState, cx: &mut Context<Self>) { fn data_dialog(&mut self, id: u64, asked: Asked, picked: Selection, state: ReadState, cx: &mut Context<Self>) {
log::info!("table: {asked:?} on {}", picked.range); log::info!("table: {asked:?} on {}", picked.range);
let header = picked.header; let header = picked.header;
self.dialog = Some(match asked { self.open_dialog(match asked {
Asked::Sort => { Asked::Sort => {
let (first, ..) = picked.area.expect("a data area"); let (first, ..) = picked.area.expect("a data area");
Dialog::Sort { Dialog::Sort {
@@ -314,7 +314,7 @@ impl Book {
return book.warn_key("table.columns.same", cx); return book.warn_key("table.columns.same", cx);
} }
let other = cx.new(|cx| TextInput::new(window, cx)); let other = cx.new(|cx| TextInput::new(window, cx));
book.dialog = Some(Dialog::Columns { book.open_dialog(Dialog::Columns {
tab: id, tab: id,
separators: vec!["9".into()], separators: vec!["9".into()],
other, other,
@@ -394,9 +394,10 @@ impl Book {
.filter(|entry| entry.selected) .filter(|entry| entry.selected)
.map(|entry| SharedString::from(entry.text.clone())) .map(|entry| SharedString::from(entry.text.clone()))
.collect(); .collect();
book.dialog = Some(Dialog::Column { book.open_dialog(Dialog::Column {
tab: id, tab: id,
column, column,
row,
entries: filter.entries, entries: filter.entries,
picked, picked,
}); });
@@ -413,12 +414,15 @@ impl Book {
let Some(Dialog::Column { let Some(Dialog::Column {
tab, tab,
column, column,
row,
entries, entries,
picked, picked,
}) = self.dialog.take() }) = self.dialog.take()
else { else {
return; return;
}; };
// The kernel filters the cursor's range: go to the button.
self.run_in(Some(tab), "cell.goto", &[&sheet::address(column, row)], cx);
match sort { match sort {
Some(rising) => { Some(rising) => {
let key = SortKey { let key = SortKey {
+31
View File
@@ -94,6 +94,8 @@ pub enum Dialog {
Column { Column {
tab: u64, tab: u64,
column: u32, column: u32,
/// The button's row: its cell names the filtered range.
row: u32,
entries: Vec<Entry>, entries: Vec<Entry>,
picked: Vec<SharedString>, picked: Vec<SharedString>,
}, },
@@ -150,6 +152,35 @@ pub enum Dialog {
}, },
} }
impl Dialog {
/// The workbook it asks about; imports have none yet.
pub fn tab(&self) -> Option<u64> {
match self {
Dialog::Rename { tab, .. }
| Dialog::Show { tab, .. }
| Dialog::Delete { tab, .. }
| Dialog::Size { tab, .. }
| Dialog::Merge { tab, .. }
| Dialog::Define { tab, .. }
| Dialog::Names { tab, .. }
| Dialog::Sort { tab, .. }
| Dialog::Filter { tab, .. }
| Dialog::Dedupe { tab, .. }
| Dialog::Columns { tab, .. }
| Dialog::Column { tab, .. }
| Dialog::Validation { tab, .. }
| Dialog::Conditional { tab, .. }
| Dialog::Formats { tab, .. }
| Dialog::Pivot { tab, .. }
| Dialog::Chart { tab, .. }
| Dialog::Export { tab, .. }
| Dialog::Page { tab, .. }
| Dialog::Protect { tab, .. } => Some(*tab),
Dialog::Import(_) => None,
}
}
}
impl Book { impl Book {
/// The define dialog's sheets: its own workbook's. /// The define dialog's sheets: its own workbook's.
pub(crate) fn define_scopes(&self) -> Option<Vec<String>> { pub(crate) fn define_scopes(&self) -> Option<Vec<String>> {
+2 -2
View File
@@ -116,7 +116,7 @@ impl Book {
let name = cx.new(|cx| TextInput::new(window, cx)); let name = cx.new(|cx| TextInput::new(window, cx));
let range = cx.new(|cx| TextInput::new(window, cx)); let range = cx.new(|cx| TextInput::new(window, cx));
range.update(cx, |range, cx| range.set_text(refers, cx)); range.update(cx, |range, cx| range.set_text(refers, cx));
book.dialog = Some(Dialog::Define { book.open_dialog(Dialog::Define {
tab: id, tab: id,
name, name,
range, range,
@@ -206,7 +206,7 @@ impl Book {
let range = cx.new(|cx| TextInput::new(window, cx)); let range = cx.new(|cx| TextInput::new(window, cx));
let formula = first.formula.clone(); let formula = first.formula.clone();
range.update(cx, |range, cx| range.set_text(formula, cx)); range.update(cx, |range, cx| range.set_text(formula, cx));
book.dialog = Some(Dialog::Names { book.open_dialog(Dialog::Names {
tab: id, tab: id,
names, names,
chosen: 0, chosen: 0,
+3
View File
@@ -1,3 +1,6 @@
// Release builds open no console on Windows.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod analysis; mod analysis;
mod bars; mod bars;
mod book; mod book;
+3 -5
View File
@@ -49,7 +49,7 @@ impl Book {
let Some(tab) = self.core.active else { let Some(tab) = self.core.active else {
return self.warn_key("command.blocked.no_document", cx); return self.warn_key("command.blocked.no_document", cx);
}; };
self.dialog = Some(Dialog::Export { self.open_dialog(Dialog::Export {
tab, tab,
format: Format::Pdf, format: Format::Pdf,
}); });
@@ -144,10 +144,8 @@ impl Book {
tab, tab,
move |worker, doc| doc.export_pdf(worker, &url, &WHOLE), move |worker, doc| doc.export_pdf(worker, &url, &WHOLE),
move |book, written, _, cx| match written { move |book, written, _, cx| match written {
Ok(()) => ely_office::print_later(path, cx, |book, error, cx| { Ok(()) => ely_office::print_later(path, cx),
book.output_failed(&error, cx) Err(error) => book.print_failed(&error.to_string(), cx),
}),
Err(error) => book.output_failed(&error.to_string(), cx),
}, },
cx, cx,
); );
+18 -2
View File
@@ -82,6 +82,15 @@ fn cell_ok(cell: &str) -> bool {
&& row_number(digits).is_some() && row_number(digits).is_some()
} }
/// Whole columns or rows, like `$A:$D`; one `$` each.
fn whole(from: &str, to: &str, number: fn(&str) -> Option<u32>) -> bool {
let end = |end: &str| {
let end = end.strip_prefix('$').unwrap_or(end);
end.bytes().all(|byte| byte.is_ascii_alphanumeric()).then(|| number(&end.to_ascii_uppercase()))?
};
matches!((end(from), end(to)), (Some(first), Some(last)) if first <= last)
}
/// Areas as the kernel takes them, or None. /// Areas as the kernel takes them, or None.
fn areas(text: &str) -> Option<String> { fn areas(text: &str) -> Option<String> {
let tokens: Vec<&str> = text let tokens: Vec<&str> = text
@@ -90,7 +99,9 @@ fn areas(text: &str) -> Option<String> {
.filter(|token| !token.is_empty()) .filter(|token| !token.is_empty())
.collect(); .collect();
let ok = |token: &&str| match token.split_once(':') { let ok = |token: &&str| match token.split_once(':') {
Some((from, to)) => cell_ok(from) && cell_ok(to), Some((from, to)) => {
cell_ok(from) && cell_ok(to) || whole(from, to, column_number) || whole(from, to, row_number)
}
None => cell_ok(token), None => cell_ok(token),
}; };
tokens.iter().all(ok).then(|| tokens.join(",")) tokens.iter().all(ok).then(|| tokens.join(","))
@@ -143,7 +154,7 @@ impl Book {
rows: field(sheet::rows_ref(layout.rows), "table.page.rows_hint", cx), rows: field(sheet::rows_ref(layout.rows), "table.page.rows_hint", cx),
columns: field(sheet::columns_ref(layout.columns), "table.page.columns_hint", cx), columns: field(sheet::columns_ref(layout.columns), "table.page.columns_hint", cx),
}; };
self.dialog = Some(Dialog::Page { self.open_dialog(Dialog::Page {
tab: id, tab: id,
landscape: layout.landscape, landscape: layout.landscape,
scale: layout.scale, scale: layout.scale,
@@ -228,6 +239,11 @@ mod tests {
assert_eq!(areas("A1:B"), None); assert_eq!(areas("A1:B"), None);
assert_eq!(areas("XFE1"), None); assert_eq!(areas("XFE1"), None);
assert_eq!(areas("S!A1"), None); assert_eq!(areas("S!A1"), None);
assert_eq!(areas("$A:$D, 1:3").as_deref(), Some("$A:$D,1:3"));
assert_eq!(areas("A:3"), None);
for bad in ["$$A:$$D", "$$1:$$3", "$A:$$D", "A:D,$$E:$$F", "+1:2", "D:A"] {
assert_eq!(areas(bad), None, "{bad}");
}
assert_eq!(span("$1:$3", row_number), Some(Some((1, 3)))); assert_eq!(span("$1:$3", row_number), Some(Some((1, 3))));
assert_eq!(span("2", row_number), Some(Some((2, 2)))); assert_eq!(span("2", row_number), Some(Some((2, 2))));
assert_eq!(span("3:1", row_number), None); assert_eq!(span("3:1", row_number), None);
+1 -1
View File
@@ -58,7 +58,7 @@ impl Book {
confirm: masked("table.protect.confirm", cx), confirm: masked("table.protect.confirm", cx),
now, now,
}; };
book.dialog = Some(Dialog::Protect { book.open_dialog(Dialog::Protect {
tab: id, tab: id,
protecting: Box::new(protecting), protecting: Box::new(protecting),
}); });
Loaded 100 of 581 files, more files were not shown because too many files have changed in this diff. Show more