22 Commits
Author SHA1 Message Date
SirBlob 43c8cfd85a Bump version to 1.1.0
CI / frontend (push) Failing after 10s
CI / backend (push) Successful in 2m39s
2026-07-22 18:00:07 -04:00
SirBlob e7cca246ed Add logo and local preview images to README 2026-07-22 17:54:19 -04:00
SirBlob cc9c9f940d Add Arch PKGBUILD packaging, keep releases as manual GitHub uploads 2026-07-22 17:42:50 -04:00
SirBlob 65b041b6c9 Vendor Typst compiler locally instead of depending on typstdrive folder 2026-07-22 16:34:31 -04:00
SirBlob 30bfa0d7a8 Add ARIA roles to drag/context-menu card wrappers 2026-07-22 16:34:31 -04:00
SirBlob d678de28e6 Downgrade upload-artifact to v3 for Gitea Actions compat 2026-07-22 16:15:08 -04:00
SirBlob ea53f92f62 HotKeys Bug Fixes 2026-07-22 10:39:29 -04:00
SirBlob 5f6b2b3883 Compact editor UI, move LSP info to settings, redesign status badges
Claude-Session: https://claude.ai/code/session_01PoLmSR1pFVyHf8i5b1rNWk
2026-07-21 15:56:40 -04:00
SirBlob a16edeb876 Add realtime Yjs collaboration for cloud-linked files
Claude-Session: https://claude.ai/code/session_01PoLmSR1pFVyHf8i5b1rNWk
2026-07-21 15:55:46 -04:00
SirBlob 566bf1a6cb Connect to server over websocket for live cloud sync
Desktop app now holds a persistent websocket to the server and syncs
on push notifications instead of a fixed timer, falling back to the
old polling interval only when the socket is down. Cloud folder/file
listings are cached locally so the browser stays usable offline.

Claude-Session: https://claude.ai/code/session_01PoLmSR1pFVyHf8i5b1rNWk
2026-07-21 12:44:25 -04:00
SirBlob d7b113c59a Add copy to clipboard button in editor
Claude-Session: https://claude.ai/code/session_01PoLmSR1pFVyHf8i5b1rNWk
2026-07-21 11:29:24 -04:00
SirBlob 62f2f8483f Cloud folder organization, file uploads, appearance themes, title bar rework, faster autosync 2026-07-20 23:58:53 -04:00
SirBlob 46e2de2af9 Regenerate app icons from new favicon 2026-07-20 23:58:45 -04:00
SirBlob 117f5b499c Version checks, drop native drag plugin, cloud menus and delete, about links 2026-07-20 18:37:28 -04:00
SirBlob 4b685311a0 Documents by default, cloud project rename, multi-select drag, new settings 2026-07-20 17:48:20 -04:00
SirBlob f498fcba1c Install xdg-utils for AppImage bundling 2026-07-20 16:12:40 -04:00
SirBlob 47f0cbcf93 Fix AppImage glibc mismatch and pin working Typst commit 2026-07-20 15:26:11 -04:00
SirBlob 843c0c8eb3 Update Typst Version to Latest 2026-07-19 12:15:52 -04:00
SirBlobandGitHub 185d7ccdcb Add images to README 2026-07-19 11:17:44 -04:00
SirBlobandGitHub 00f5797256 Update README 2026-07-19 10:45:49 -04:00
SirBlob e5dfc8e472 release note rm 2026-07-19 00:39:07 -04:00
SirBlob 44fbb97ac9 Add release notes 2026-07-19 00:30:50 -04:00
54 changed files with 4153 additions and 894 deletions
+46 -40
View File
@@ -7,13 +7,13 @@ on:
workflow_dispatch:
env:
TYPST_COMMIT: 44b3f78ed37fedea75e911dde2269ef86c45316f
TYPST_COMMIT: 9dfd3a08500b7896045f907433cf7b4b02434fad
APPIMAGE_EXTRACT_AND_RUN: 1
NO_STRIP: 1
jobs:
build:
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
steps:
- name: Checkout repository
@@ -23,8 +23,8 @@ jobs:
- name: Clone Typst compiler
run: |
git clone https://github.com/typst/typst.git typstdrive/typst
git -C typstdrive/typst checkout "$TYPST_COMMIT"
git clone https://github.com/typst/typst.git typst-desktop/typst
git -C typst-desktop/typst checkout "$TYPST_COMMIT"
- name: Install build dependencies
run: |
@@ -41,7 +41,8 @@ jobs:
file \
curl \
wget \
jq
jq \
xdg-utils
- name: Set up Bun
uses: oven-sh/setup-bun@v2
@@ -78,49 +79,54 @@ jobs:
ls -la packages
- name: Upload packages
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: typst-desktop-linux-x86_64
path: packages/*
if-no-files-found: error
- name: Publish to GitHub release
if: startsWith(github.ref, 'refs/tags/v')
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
REPOSITORY: sirblobby/typst-desktop
TAG: ${{ github.ref_name }}
build-arch:
runs-on: ubuntu-22.04
container:
image: archlinux:base-devel
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
path: typst-desktop
- name: Update pacman and install build dependencies
run: |
set -euo pipefail
pacman -Syu --noconfirm
pacman -S --noconfirm --needed \
git rust bun webkit2gtk-4.1 gtk3 openssl
api="https://api.github.com/repos/$REPOSITORY"
auth="Authorization: Bearer $TOKEN"
json="Content-Type: application/json"
- name: Create unprivileged build user
run: |
useradd -m builder
chown -R builder:builder "$GITHUB_WORKSPACE"
existing=$(curl -sS -H "$auth" "$api/releases/tags/$TAG" | jq -r '.id // empty')
- name: Set package version from tag
if: startsWith(github.ref, 'refs/tags/v')
working-directory: typst-desktop/packaging/arch
run: sed -i "s/^pkgver=.*/pkgver=${TAG#v}/" PKGBUILD
env:
TAG: ${{ github.ref_name }}
if [ -n "$existing" ]; then
release_id=$existing
else
release_id=$(curl -sS -X POST -H "$auth" -H "$json" "$api/releases" \
-d "$(jq -n --arg tag "$TAG" \
'{tag_name: $tag, name: ("Typst Desktop " + $tag), body: "Download the package for your distribution below.", draft: true, prerelease: false}')" \
| jq -r '.id')
fi
- name: Build package
working-directory: typst-desktop/packaging/arch
run: runuser -u builder -- env TYPST_COMMIT="$TYPST_COMMIT" makepkg --noconfirm
if [ -z "$release_id" ] || [ "$release_id" = "null" ]; then
echo "Could not create or find the release"
exit 1
fi
- name: Collect package
run: |
mkdir -p packages
find typst-desktop/packaging/arch -maxdepth 1 -name "*.pkg.tar.zst" -exec cp {} packages/ \;
ls -la packages
for package in packages/*; do
name=$(basename "$package")
echo "Uploading $name"
curl -sS --fail -X POST -H "$auth" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$package" \
"https://uploads.github.com/repos/$REPOSITORY/releases/$release_id/assets?name=$name" \
> /dev/null
done
echo "Uploaded to the draft release for $TAG"
- name: Upload package
uses: actions/upload-artifact@v3
with:
name: typst-desktop-arch-x86_64
path: packages/*
if-no-files-found: error
+3 -3
View File
@@ -8,7 +8,7 @@ on:
workflow_dispatch:
env:
TYPST_COMMIT: 44b3f78ed37fedea75e911dde2269ef86c45316f
TYPST_COMMIT: 9dfd3a08500b7896045f907433cf7b4b02434fad
jobs:
frontend:
@@ -41,8 +41,8 @@ jobs:
- name: Clone Typst compiler
run: |
git clone https://github.com/typst/typst.git typstdrive/typst
git -C typstdrive/typst checkout "$TYPST_COMMIT"
git clone https://github.com/typst/typst.git typst-desktop/typst
git -C typst-desktop/typst checkout "$TYPST_COMMIT"
- name: Install build dependencies
run: |
+2
View File
@@ -1,5 +1,6 @@
.DS_Store
node_modules
/typst
/build
/.svelte-kit
/package
@@ -8,3 +9,4 @@ node_modules
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
release.md
+24 -54
View File
@@ -1,8 +1,10 @@
<img src="src-tauri/icons/icon.png" width="96" alt="Typst Desktop icon" />
# Typst Desktop
[![Version](https://img.shields.io/badge/version-1.0.0-blue.svg)](https://github.com/sirblobby/typst-desktop)
[![Version](https://img.shields.io/badge/version-1.1.0-blue.svg)](https://github.com/sirblobby/typst-desktop)
[![License](https://img.shields.io/badge/license-Apache_2.0-green.svg)](LICENSE)
[![Typst Version](https://img.shields.io/badge/Typst-0.14.2-239dad?logo=typst&logoColor=white)](https://typst.app/)
[![Typst Version](https://img.shields.io/badge/Typst-0.15.1-239dad?logo=typst&logoColor=white)](https://typst.app/)
[![Rust](https://img.shields.io/badge/Rust-1.82+-orange?logo=rust&logoColor=white)](https://www.rust-lang.org/)
[![Tauri](https://img.shields.io/badge/Tauri-2-24C8DB?logo=tauri&logoColor=white)](https://tauri.app/)
[![SvelteKit](https://img.shields.io/badge/SvelteKit-5-ff3e00?logo=svelte)](https://kit.svelte.dev/)
@@ -18,7 +20,7 @@ The Typst compiler is built into the app — there is nothing extra to install t
Packages are attached to each [release](https://github.com/sirblobby/typst-desktop/releases). Set the version you want first:
```bash
VERSION=1.0.0
VERSION=1.1.0
BASE=https://github.com/sirblobby/typst-desktop/releases/download/v$VERSION
```
@@ -44,24 +46,16 @@ Remove it with `sudo dnf remove typst-desktop`.
### Arch
There is no package in the AUR. Use the AppImage, which needs FUSE:
```bash
sudo pacman -S fuse2
wget "$BASE/typst-desktop_${VERSION}_amd64.AppImage"
chmod +x "typst-desktop_${VERSION}_amd64.AppImage"
./typst-desktop_${VERSION}_amd64.AppImage
wget "$BASE/typst-desktop-${VERSION}-1-x86_64.pkg.tar.zst"
sudo pacman -U "typst-desktop-${VERSION}-1-x86_64.pkg.tar.zst"
```
To keep it on your `PATH`:
Remove it with `sudo pacman -R typst-desktop`.
```bash
sudo install -Dm755 "typst-desktop_${VERSION}_amd64.AppImage" /usr/local/bin/typst-desktop
```
There is no AUR package; the `.pkg.tar.zst` above is attached to each release directly. Prefer it over the AppImage on Arch specifically — a [known WebKitGTK issue](https://bugs.webkit.org/show_bug.cgi?id=280239) on Wayland crashes the AppImage build (`Could not create default EGL display`) against Arch's current webkit2gtk version. The native package links against your system's webkit2gtk instead, which avoids it.
Remove it with `sudo rm /usr/local/bin/typst-desktop`.
The AppImage runs on any distribution, so it also works as a fallback on Debian or Fedora. Building from source is covered under [Development](#development).
The AppImage runs on any distribution, so it also works as a fallback on Debian or Fedora — and on Arch too, but may hit the WebKitGTK/EGL crash described above there. Building from source is covered under [Development](#development).
## Features
@@ -169,10 +163,10 @@ The editor header shows the language server status. Without `tinymist` the edito
## Development
The app compiles Typst from source, so clone the compiler into TypstDrive's `typst/` folder first — both projects share it:
The app compiles Typst from source, so clone the compiler into this repo's `typst/` folder first:
```bash
git clone https://github.com/typst/typst.git ../typstdrive/typst
git clone https://github.com/typst/typst.git typst
```
Then:
@@ -190,36 +184,6 @@ bun run tauri build
On Linux, use `bun run build:linux` instead. It is the same command with `NO_STRIP=1` set, which the AppImage bundler needs on a rolling-release distribution. See [Platform notes](#platform-notes).
## Distribution
Workflows live in `.gitea/workflows` and run on Gitea Actions.
| Workflow | Trigger | Output |
|---|---|---|
| `ci.yml` | push, pull requests | Type check, frontend build, backend check. |
| `build.yml` | `v*` tags, manual | Linux packages as workflow artifacts, and on a tag, a draft GitHub release. |
| Platform | Artifacts |
|---|---|
| Linux | `.deb`, `.rpm`, `.AppImage` |
Builds run on a Linux runner, so Linux packages are the only artifacts produced. Windows and macOS installers have to be built on their own platforms and attached to the release by hand.
Publishing to GitHub needs one repository secret:
| Secret | Value |
|---|---|
| `RELEASE_TOKEN` | A classic GitHub personal access token with the `repo` scope. |
To cut a release:
```bash
git tag v1.0.0
git push origin v1.0.0
```
The release is drafted rather than published, so you can add other platforms and review the artifacts before making it public.
### Platform notes
`.msi` packages can only be built on Windows, because the WiX Toolset is Windows-only. Building on the target platform is the supported path for both Windows and macOS.
@@ -230,11 +194,11 @@ macOS builds are unsigned. Gatekeeper blocks unsigned apps on first launch, so o
### Building locally for another platform
Building on the target platform is the supported path. The Typst compiler is a path dependency, so any machine or runner needs it checked out beside this repository:
Building on the target platform is the supported path. The Typst compiler is a path dependency, so any machine or runner needs it checked out inside this repository:
```bash
git clone https://github.com/typst/typst.git ../typstdrive/typst
git -C ../typstdrive/typst checkout 44b3f78ed37fedea75e911dde2269ef86c45316f
git clone https://github.com/typst/typst.git typst
git -C typst checkout 9dfd3a08500b7896045f907433cf7b4b02434fad
```
Linux builds also need the WebKit and GTK development packages:
@@ -243,14 +207,20 @@ Linux builds also need the WebKit and GTK development packages:
sudo apt-get install libwebkit2gtk-4.1-dev libgtk-3-dev librsvg2-dev patchelf
```
On rolling-release distributions such as Arch, the AppImage step fails with `failed to run linuxdeploy`. The `strip` bundled inside `linuxdeploy` predates the `.relr.dyn` relocation section that a current toolchain emits, so it rejects every system library it is asked to strip. Setting `NO_STRIP=1` skips that pass, which is what `build:linux` does:
```bash
bun run build:linux
```
The `.deb` and `.rpm` targets are unaffected, so a build that produces those two and then fails is almost always this.
Build on Ubuntu 22.04 (the CI baseline) rather than a rolling-release distro. glibc compatibility only works forward, so a binary is only guaranteed to run on distros with a glibc version equal to or newer than the one it was built against. This won't cover musl-based distros like Alpine, or glibc-based distros older than the build machine.
On Arch, `packaging/arch/PKGBUILD` builds and installs a native package via `makepkg -si` from within `packaging/arch/`, instead of `bun run build:linux`.
## License
Apache License 2.0. See [LICENSE](LICENSE).
<img src="images/preview1.png" alt="Typst Desktop editor preview" />
<img src="images/preview2.png" alt="Typst Desktop project editor preview" />
<img src="images/preview3.png" alt="Typst Desktop editor preview" />
+42 -18
View File
@@ -5,31 +5,37 @@
"": {
"name": "typst-desktop",
"dependencies": {
"@codemirror/autocomplete": "^6.20.2",
"@codemirror/commands": "^6.10.3",
"@codemirror/language": "^6.10.8",
"@codemirror/legacy-modes": "^6.5.1",
"@codemirror/lint": "^6.9.6",
"@codemirror/lsp-client": "^6.2.4",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.43.0",
"@iconify/svelte": "^5.2.1",
"@lezer/highlight": "^1.2.1",
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.10.4",
"@codemirror/language": "^6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
"@codemirror/lint": "^6.9.7",
"@codemirror/lsp-client": "^6.2.5",
"@codemirror/merge": "^6.12.2",
"@codemirror/state": "^6.7.1",
"@codemirror/view": "^6.43.6",
"@iconify/svelte": "^5.2.2",
"@lezer/highlight": "^1.2.3",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-dialog": "^2.4.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2.7.2",
"@tauri-apps/plugin-opener": "^2.5.4",
"codemirror": "^6.0.2",
"codemirror-lang-typst": "^0.4.0",
"hotkeys-js": "^4.0.4",
"y-codemirror.next": "^0.3.5",
"y-websocket": "^3.0.0",
"yjs": "^13.6.31",
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.70.0",
"@sveltejs/kit": "^2.70.1",
"@sveltejs/vite-plugin-svelte": "^5.1.1",
"@tailwindcss/vite": "^4.3.0",
"@tailwindcss/vite": "^4.3.3",
"@tauri-apps/cli": "^2.11.4",
"svelte": "^5.56.6",
"svelte": "^5.56.7",
"svelte-check": "^4.7.3",
"tailwindcss": "^4.3.0",
"tailwindcss": "^4.3.3",
"typescript": "~5.6.3",
"vite": "^6.4.3",
"vite-plugin-top-level-await": "^1.6.0",
@@ -50,6 +56,8 @@
"@codemirror/lsp-client": ["@codemirror/[email protected]", "", { "dependencies": { "@codemirror/autocomplete": "^6.20.0", "@codemirror/language": "^6.11.0", "@codemirror/lint": "^6.8.5", "@codemirror/state": "^6.5.2", "@codemirror/view": "^6.37.0", "@lezer/highlight": "^1.2.1", "marked": "^15.0.12", "vscode-languageserver-protocol": "^3.17.5" } }, "sha512-1EqhGRmCZOV7Me+rRuwwkTuvkNoD4Nz6UcE1yx5gdwTVTLD4D9xIy48MJc0LeBQGFLn/HNRW/pHmet4EAEkJFQ=="],
"@codemirror/merge": ["@codemirror/[email protected]", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/highlight": "^1.0.0", "style-mod": "^4.1.0" } }, "sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w=="],
"@codemirror/search": ["@codemirror/[email protected]", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA=="],
"@codemirror/state": ["@codemirror/[email protected]", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A=="],
@@ -190,7 +198,7 @@
"@sveltejs/adapter-static": ["@sveltejs/[email protected]", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew=="],
"@sveltejs/kit": ["@sveltejs/[email protected].0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.9", "@types/cookie": "^0.6.0", "acorn": "^8.16.0", "cookie": "^0.6.0", "devalue": "^5.8.1", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-5pBnJwdNzxbrxp1TLK1NPMFF0Cx57iZUDKInznKcfifYR9m9poWfZI2Tfhw6BZIjYor5dXvcibt4EQgar3k6ww=="],
"@sveltejs/kit": ["@sveltejs/[email protected].1", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.9", "@types/cookie": "^0.6.0", "acorn": "^8.16.0", "cookie": "^0.6.0", "devalue": "^5.8.1", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-nY9SPHGOZro3doud9vZXDBwl9tCZIouuJztjgSHs6PAIrv9M/z5O7eOhPV5xU7CgVHA976Jwu3BA1hIFvXztkA=="],
"@sveltejs/load-config": ["@sveltejs/[email protected]", "", {}, "sha512-1LgZ/qUqSoq+QorD83lk2hka79Px0wXNW2q5V1nZlxGhQgw1jrsIbVz5YiCeucVLo4XvFLjXukUaQjIiqowkcg=="],
@@ -286,7 +294,9 @@
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw=="],
"@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-[email protected]", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ=="],
"@tauri-apps/plugin-clipboard-manager": ["@tauri-apps/plugin-[email protected]", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ=="],
"@tauri-apps/plugin-dialog": ["@tauri-apps/[email protected]", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg=="],
"@tauri-apps/plugin-opener": ["@tauri-apps/[email protected]", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ=="],
@@ -336,12 +346,18 @@
"graceful-fs": ["[email protected]", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"hotkeys-js": ["[email protected]", "", {}, "sha512-hseNiqaskxSnujuGp8aRMLJfcjaFiTSS0I2GQhqru82N/sx6CGyUf6pvU5X1iycvw2EqmvILkFIb5OzYFXY+9A=="],
"is-reference": ["[email protected]", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
"isomorphic.js": ["[email protected]", "", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="],
"jiti": ["[email protected]", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
"kleur": ["[email protected]", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"lib0": ["[email protected]", "", { "dependencies": { "isomorphic.js": "^0.2.4" }, "bin": { "0serve": "bin/0serve.js", "0gentesthtml": "bin/gentesthtml.js", "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js" } }, "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw=="],
"lightningcss": ["[email protected]", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
@@ -400,7 +416,7 @@
"style-mod": ["[email protected]", "", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="],
"svelte": ["[email protected].6", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-p4HDLDogGHKRKCrgckQHNs5PEfXkju6JI5jTywueaKJI5hAdjPohEhRtQ0M1SWC/+TA73SPln+r7srr+7e4nZA=="],
"svelte": ["[email protected].7", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-5qERUZX80oQj6XrDMUmD2Uhd/cIpCPDWWKBK3ZHmyRUC9apPyamWM8xMo31mbWsIQxwG2hVoSnOJ/EcnhVkkzQ=="],
"svelte-check": ["[email protected]", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "@sveltejs/load-config": "^0.2.0", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-DHdTCGX62R0fCxBEaT+USdASAnoaRBaaNczkRJl0K7o3WyoCeVUbVxo6fKqpOll/B+WMWCsiFK0eFrJSNBKZIg=="],
@@ -432,6 +448,14 @@
"w3c-keyname": ["[email protected]", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
"y-codemirror.next": ["[email protected]", "", { "dependencies": { "lib0": "^0.2.42" }, "peerDependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "yjs": "^13.5.6" } }, "sha512-VluNu3e5HfEXybnypnsGwKAj+fKLd4iAnR7JuX1Sfyydmn1jCBS5wwEL/uS04Ch2ib0DnMAOF6ZRR/8kK3wyGw=="],
"y-protocols": ["[email protected]", "", { "dependencies": { "lib0": "^0.2.85" }, "peerDependencies": { "yjs": "^13.0.0" } }, "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw=="],
"y-websocket": ["[email protected]", "", { "dependencies": { "lib0": "^0.2.102", "y-protocols": "^1.0.5" }, "peerDependencies": { "yjs": "^13.5.6" } }, "sha512-mUHy7AzkOZ834T/7piqtlA8Yk6AchqKqcrCXjKW8J1w2lPtRDjz8W5/CvXz9higKAHgKRKqpI3T33YkRFLkPtg=="],
"yjs": ["[email protected]", "", { "dependencies": { "lib0": "^0.2.99" } }, "sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw=="],
"zimmerframe": ["[email protected]", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/[email protected]", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="],
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

+23 -17
View File
@@ -1,6 +1,6 @@
{
"name": "typst-desktop",
"version": "1.0.0",
"version": "1.1.0",
"description": "",
"type": "module",
"scripts": {
@@ -14,29 +14,35 @@
},
"license": "Apache-2.0",
"dependencies": {
"@codemirror/autocomplete": "^6.20.2",
"@codemirror/commands": "^6.10.3",
"@codemirror/language": "^6.10.8",
"@codemirror/legacy-modes": "^6.5.1",
"@codemirror/lint": "^6.9.6",
"@codemirror/lsp-client": "^6.2.4",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.43.0",
"@iconify/svelte": "^5.2.1",
"@lezer/highlight": "^1.2.1",
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.10.4",
"@codemirror/language": "^6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
"@codemirror/lint": "^6.9.7",
"@codemirror/lsp-client": "^6.2.5",
"@codemirror/merge": "^6.12.2",
"@codemirror/state": "^6.7.1",
"@codemirror/view": "^6.43.6",
"@iconify/svelte": "^5.2.2",
"@lezer/highlight": "^1.2.3",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-dialog": "^2.4.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2.7.2",
"@tauri-apps/plugin-opener": "^2.5.4",
"codemirror": "^6.0.2",
"codemirror-lang-typst": "^0.4.0"
"codemirror-lang-typst": "^0.4.0",
"hotkeys-js": "^4.0.4",
"y-codemirror.next": "^0.3.5",
"y-websocket": "^3.0.0",
"yjs": "^13.6.31"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.10",
"@tailwindcss/vite": "^4.3.0",
"tailwindcss": "^4.3.0",
"@sveltejs/kit": "^2.70.0",
"@tailwindcss/vite": "^4.3.3",
"tailwindcss": "^4.3.3",
"@sveltejs/kit": "^2.70.1",
"@sveltejs/vite-plugin-svelte": "^5.1.1",
"svelte": "^5.56.6",
"svelte": "^5.56.7",
"svelte-check": "^4.7.3",
"typescript": "~5.6.3",
"vite": "^6.4.3",
+55
View File
@@ -0,0 +1,55 @@
# Maintainer: SirBlobby
pkgname=typst-desktop
pkgver=1.1.0
pkgrel=1
pkgdesc="A desktop editor for Typst documents with a built-in compiler, live preview, and optional TypstDrive sync"
arch=('x86_64')
url="https://github.com/sirblobby/typst-desktop"
license=('Apache-2.0')
depends=('webkit2gtk-4.1' 'gtk3' 'openssl')
makedepends=('rust' 'git' 'bun' 'webkit2gtk-4.1' 'gtk3' 'openssl')
options=('!lto')
source=("$pkgname.desktop")
sha256sums=('SKIP')
_typst_commit=${TYPST_COMMIT:-9dfd3a08500b7896045f907433cf7b4b02434fad}
prepare() {
rm -rf "$srcdir/$pkgname-src"
git -C "$startdir/../.." archive --format=tar HEAD | \
(mkdir -p "$srcdir/$pkgname-src" && tar -x -C "$srcdir/$pkgname-src")
git clone https://github.com/typst/typst.git "$srcdir/$pkgname-src/typst"
git -C "$srcdir/$pkgname-src/typst" checkout "$_typst_commit"
}
build() {
cd "$srcdir/$pkgname-src"
export CARGO_TARGET_DIR="$srcdir/target"
bun install --frozen-lockfile
bun run tauri build -- --no-bundle
}
package() {
cd "$srcdir/$pkgname-src"
install -Dm755 "$srcdir/target/release/typst-desktop" \
"$pkgdir/usr/bin/typst-desktop"
install -Dm644 "$srcdir/$pkgname.desktop" \
"$pkgdir/usr/share/applications/$pkgname.desktop"
install -Dm644 src-tauri/icons/32x32.png \
"$pkgdir/usr/share/icons/hicolor/32x32/apps/$pkgname.png"
install -Dm644 src-tauri/icons/64x64.png \
"$pkgdir/usr/share/icons/hicolor/64x64/apps/$pkgname.png"
install -Dm644 src-tauri/icons/128x128.png \
"$pkgdir/usr/share/icons/hicolor/128x128/apps/$pkgname.png"
install -Dm644 "src-tauri/icons/[email protected]" \
"$pkgdir/usr/share/icons/hicolor/256x256/apps/$pkgname.png"
install -Dm644 src-tauri/icons/icon.png \
"$pkgdir/usr/share/icons/hicolor/512x512/apps/$pkgname.png"
install -Dm644 LICENSE \
"$pkgdir/usr/share/licenses/$pkgname/LICENSE"
}
+9
View File
@@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=Typst Desktop
Comment=A desktop editor for Typst documents with a built-in compiler, live preview, and optional TypstDrive sync
Exec=typst-desktop %U
Icon=typst-desktop
Terminal=false
Categories=Office;Utility;TextEditor;
StartupWMClass=typst-desktop
+482 -291
View File
File diff suppressed because it is too large Load Diff
+11 -9
View File
@@ -1,6 +1,6 @@
[package]
name = "typst-desktop"
version = "1.0.0"
version = "1.1.0"
description = "A Tauri App"
authors = ["SirBlobby"]
license = "Apache-2.0"
@@ -22,18 +22,19 @@ tauri-build = { version = "2", features = [] }
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
tauri-plugin-dialog = "2"
tauri-plugin-clipboard-manager = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.32", features = ["bundled"] }
typst = { version = "0.14.2", path = "../../typstdrive/typst/crates/typst" }
typst-kit = { path = "../../typstdrive/typst/crates/typst-kit", features = ["system-downloader", "system-packages", "embedded-fonts"] }
typst-pdf = { path = "../../typstdrive/typst/crates/typst-pdf" }
typst-render = { path = "../../typstdrive/typst/crates/typst-render" }
typst-svg = { path = "../../typstdrive/typst/crates/typst-svg" }
typst-html = { path = "../../typstdrive/typst/crates/typst-html" }
typst-layout = { path = "../../typstdrive/typst/crates/typst-layout" }
typst-assets = { version = "0.14.2", features = ["fonts"] }
typst = { version = "0.15.1", path = "../typst/crates/typst" }
typst-kit = { path = "../typst/crates/typst-kit", features = ["system-downloader", "system-packages", "embedded-fonts"] }
typst-pdf = { path = "../typst/crates/typst-pdf" }
typst-render = { path = "../typst/crates/typst-render" }
typst-svg = { path = "../typst/crates/typst-svg" }
typst-html = { path = "../typst/crates/typst-html" }
typst-layout = { path = "../typst/crates/typst-layout" }
typst-assets = { version = "0.15.1", features = ["fonts"] }
chrono = { version = "0.4", features = ["serde"] }
sha2 = "0.10"
@@ -41,4 +42,5 @@ base64 = "0.22"
diffy = "0.4"
walkdir = "2"
ureq = { version = "2.12", features = ["json"] }
tungstenite = { version = "0.24", features = ["native-tls"] }
+3 -1
View File
@@ -17,6 +17,8 @@
"opener:allow-reveal-item-in-dir",
"dialog:default",
"dialog:allow-open",
"dialog:allow-save"
"dialog:allow-save",
"clipboard-manager:allow-write-text",
"clipboard-manager:allow-write-image"
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

After

Width:  |  Height:  |  Size: 825 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

After

Width:  |  Height:  |  Size: 828 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 12 KiB

+4 -3
View File
@@ -2,8 +2,9 @@ use serde::Serialize;
use std::collections::HashMap;
use typst::diag::Warned;
use typst::layout::{Frame, FrameItem};
use typst::utils::Scalar;
use typst::WorldExt;
use typst_html::HtmlDocument;
use typst_html::{HtmlDocument, HtmlOptions};
use typst_layout::PagedDocument;
use typst_pdf::{pdf, PdfOptions};
use typst_render::{render, RenderOptions};
@@ -174,7 +175,7 @@ pub fn export_png(
warnings: _,
} => {
let options = RenderOptions {
pixel_per_pt: 2.0,
pixel_per_pt: Scalar::new(2.0),
..RenderOptions::default()
};
match document.pages().first() {
@@ -207,7 +208,7 @@ pub fn export_html(
} => return Err(collect_diagnostics(&world, &source_text, errors)),
};
typst_html::html(&document)
typst_html::html(&document, &HtmlOptions::default())
.map(|html| html.into_bytes())
.map_err(|errors| collect_diagnostics(&world, &source_text, errors))
}
+46 -13
View File
@@ -19,7 +19,7 @@ pub struct DocumentLink {
pub synced_at: Option<String>,
}
const SCHEMA: [&str; 5] = [
const SCHEMA: [&str; 6] = [
"CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
@@ -27,7 +27,7 @@ const SCHEMA: [&str; 5] = [
"CREATE TABLE IF NOT EXISTS projects (
path TEXT PRIMARY KEY,
entrypoint TEXT NOT NULL DEFAULT 'main.typ',
space_id TEXT,
cloud_project_id TEXT,
last_synced_at TEXT
)",
"CREATE TABLE IF NOT EXISTS base_files (
@@ -51,10 +51,17 @@ const SCHEMA: [&str; 5] = [
data TEXT NOT NULL,
source_modified INTEGER NOT NULL
)",
"CREATE TABLE IF NOT EXISTS cloud_cache (
key TEXT PRIMARY KEY,
payload TEXT NOT NULL,
cached_at TEXT NOT NULL
)",
];
const MIGRATIONS: [&str; 1] =
["ALTER TABLE document_links ADD COLUMN synced_at TEXT"];
const MIGRATIONS: [&str; 2] = [
"ALTER TABLE document_links ADD COLUMN synced_at TEXT",
"ALTER TABLE projects RENAME COLUMN space_id TO cloud_project_id",
];
impl Store {
pub fn open(app: &AppHandle) -> Result<Self, String> {
@@ -130,14 +137,14 @@ impl Store {
let row: Option<(String, Option<String>, Option<String>)> = self.with(|connection| {
connection
.query_row(
"SELECT entrypoint, space_id, last_synced_at FROM projects WHERE path = ?1",
"SELECT entrypoint, cloud_project_id, last_synced_at FROM projects WHERE path = ?1",
params![project],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)
.optional()
})?;
let Some((entrypoint, space_id, last_synced_at)) = row else {
let Some((entrypoint, cloud_project_id, last_synced_at)) = row else {
return Ok(ProjectMeta::default());
};
@@ -158,7 +165,7 @@ impl Store {
Ok(ProjectMeta {
entrypoint,
space_id,
cloud_project_id,
last_synced_at,
base_hashes,
})
@@ -180,16 +187,16 @@ impl Store {
pub fn save_meta(&self, project: &str, meta: &ProjectMeta) -> Result<(), String> {
self.with(|connection| {
connection.execute(
"INSERT INTO projects (path, entrypoint, space_id, last_synced_at)
"INSERT INTO projects (path, entrypoint, cloud_project_id, last_synced_at)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(path) DO UPDATE SET
entrypoint = excluded.entrypoint,
space_id = excluded.space_id,
cloud_project_id = excluded.cloud_project_id,
last_synced_at = excluded.last_synced_at",
params![
project,
meta.entrypoint,
meta.space_id,
meta.cloud_project_id,
meta.last_synced_at
],
)?;
@@ -335,11 +342,11 @@ impl Store {
})
}
pub fn all_space_links(&self) -> Result<Vec<(String, String, Option<String>)>, String> {
pub fn all_cloud_project_links(&self) -> Result<Vec<(String, String, Option<String>)>, String> {
self.with(|connection| {
let mut statement = connection.prepare(
"SELECT path, space_id, last_synced_at FROM projects
WHERE space_id IS NOT NULL",
"SELECT path, cloud_project_id, last_synced_at FROM projects
WHERE cloud_project_id IS NOT NULL",
)?;
let rows = statement.query_map([], |row| {
Ok((
@@ -414,4 +421,30 @@ impl Store {
Ok(())
})
}
pub fn cloud_cache(&self, key: &str) -> Result<Option<String>, String> {
self.with(|connection| {
connection
.query_row(
"SELECT payload FROM cloud_cache WHERE key = ?1",
params![key],
|row| row.get(0),
)
.optional()
})
}
pub fn save_cloud_cache(&self, key: &str, payload: &str) -> Result<(), String> {
self.with(|connection| {
connection.execute(
"INSERT INTO cloud_cache (key, payload, cached_at)
VALUES (?1, ?2, ?3)
ON CONFLICT(key) DO UPDATE SET
payload = excluded.payload,
cached_at = excluded.cached_at",
params![key, payload, chrono::Utc::now().to_rfc3339()],
)?;
Ok(())
})
}
}
+374 -47
View File
@@ -6,6 +6,7 @@ mod sync;
mod thumbnails;
mod workspace;
mod world;
mod ws;
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use serde::{Deserialize, Serialize};
@@ -16,7 +17,8 @@ use assets::Asset;
use db::Store;
use compiler::{CompileResult, Diagnostic};
use lsp::{LspHandle, LspState};
use sync::{Account, SpaceSummary, SyncReport};
use ws::WsState;
use sync::{Account, ProjectSummary, SyncReport};
use workspace::{
browse, is_project_dir, is_text_file, list_files, load_settings, project_file_path,
read_target_files, resolve_target, save_settings, workspace_path, BrowseEntry,
@@ -70,7 +72,7 @@ pub struct AppInfo {
fn app_info() -> AppInfo {
AppInfo {
version: env!("CARGO_PKG_VERSION").to_string(),
typst_version: "0.14.2".to_string(),
typst_version: "0.15.1".to_string(),
authors: "SirBlobby".to_string(),
license: "Apache-2.0".to_string(),
tauri_version: tauri::VERSION.to_string(),
@@ -89,7 +91,7 @@ fn update_settings(
workspace_root: Option<String>,
server_url: Option<String>,
autosave_seconds: Option<u32>,
sync_minutes: Option<u32>,
sync_seconds: Option<u32>,
) -> Result<Settings, String> {
let mut settings = load_settings(&app, &store)?;
if let Some(root) = workspace_root {
@@ -104,8 +106,8 @@ fn update_settings(
if let Some(seconds) = autosave_seconds {
settings.autosave_seconds = seconds;
}
if let Some(minutes) = sync_minutes {
settings.sync_minutes = minutes;
if let Some(seconds) = sync_seconds {
settings.sync_seconds = seconds;
}
save_settings(&store, &settings)?;
Ok(settings)
@@ -351,7 +353,7 @@ pub struct TargetInfo {
pub entrypoint: String,
pub standalone: bool,
pub is_project: bool,
pub space_id: Option<String>,
pub cloud_project_id: Option<String>,
pub files: Vec<FileEntry>,
}
@@ -368,7 +370,7 @@ fn target_info(app: AppHandle, store: State<'_, Store>, path: String) -> Result<
entrypoint: target.entrypoint.clone(),
standalone: true,
is_project: false,
space_id: None,
cloud_project_id: None,
files: vec![FileEntry {
path: target.entrypoint.clone(),
name: target.entrypoint,
@@ -385,7 +387,7 @@ fn target_info(app: AppHandle, store: State<'_, Store>, path: String) -> Result<
entrypoint: target.entrypoint,
standalone: false,
is_project: is_project_dir(&target.root),
space_id: meta.space_id,
cloud_project_id: meta.cloud_project_id,
files: list_files(&target.root)?,
})
}
@@ -498,6 +500,24 @@ fn export_target(
Ok(destination)
}
#[tauri::command]
fn render_target_png(
app: AppHandle,
store: State<'_, Store>,
path: String,
) -> Result<Vec<u8>, String> {
let target = resolve_target(&app, &store, &path)?;
let files = read_target_files(&app, &store, &target)?;
compiler::export_png(target.entrypoint, files).map_err(|diagnostics| {
diagnostics
.into_iter()
.map(|d| d.message)
.collect::<Vec<_>>()
.join("; ")
})
}
#[tauri::command]
fn thumbnail(app: AppHandle, store: State<'_, Store>, path: String) -> Result<thumbnails::Thumbnail, String> {
thumbnails::thumbnail(&app, &store, &path)
@@ -517,6 +537,16 @@ fn clear_thumbnails(store: State<'_, Store>) -> Result<(), String> {
store.clear_thumbnails()
}
#[tauri::command]
fn get_cloud_cache(store: State<'_, Store>, key: String) -> Result<Option<String>, String> {
store.cloud_cache(&key)
}
#[tauri::command]
fn save_cloud_cache(store: State<'_, Store>, key: String, payload: String) -> Result<(), String> {
store.save_cloud_cache(&key, &payload)
}
#[tauri::command]
fn list_assets(app: AppHandle, store: State<'_, Store>) -> Result<Vec<Asset>, String> {
assets::list_assets(&app, &store)
@@ -675,10 +705,17 @@ fn lsp_running(state: State<'_, LspState>) -> bool {
state.is_running()
}
#[tauri::command]
fn cloud_check_compatibility(server_url: String) -> sync::CompatibilityStatus {
let server_url = server_url.trim_end_matches('/').to_string();
sync::check_compatibility(&server_url)
}
#[tauri::command]
fn cloud_login(
app: AppHandle,
store: State<'_, Store>,
ws_state: State<'_, WsState>,
server_url: String,
email: String,
password: String,
@@ -688,12 +725,14 @@ fn cloud_login(
let response = sync::login(&server_url, &email, &password, &device_name)?;
let mut settings = load_settings(&app, &store)?;
settings.server_url = server_url;
settings.device_token = Some(response.token);
settings.server_url = server_url.clone();
settings.device_token = Some(response.token.clone());
settings.account_email = Some(response.email.clone());
settings.account_username = Some(response.username.clone());
save_settings(&store, &settings)?;
ws_state.start(app, server_url, response.token);
Ok(Account {
user_id: response.user_id,
username: response.username,
@@ -702,7 +741,11 @@ fn cloud_login(
}
#[tauri::command]
fn cloud_logout(app: AppHandle, store: State<'_, Store>) -> Result<(), String> {
fn cloud_logout(
app: AppHandle,
store: State<'_, Store>,
ws_state: State<'_, WsState>,
) -> Result<(), String> {
let mut settings = load_settings(&app, &store)?;
if let Some(token) = &settings.device_token {
let _ = sync::logout(&settings.server_url, token);
@@ -711,7 +754,31 @@ fn cloud_logout(app: AppHandle, store: State<'_, Store>) -> Result<(), String> {
settings.device_token = None;
settings.account_email = None;
settings.account_username = None;
save_settings(&store, &settings)
save_settings(&store, &settings)?;
ws_state.stop(&app);
Ok(())
}
#[tauri::command]
fn cloud_ws_start(app: AppHandle, store: State<'_, Store>, ws_state: State<'_, WsState>) -> Result<(), String> {
let settings = load_settings(&app, &store)?;
if let Some(token) = settings.device_token {
ws_state.start(app, settings.server_url, token);
}
Ok(())
}
#[tauri::command]
fn cloud_ws_stop(app: AppHandle, ws_state: State<'_, WsState>) -> Result<(), String> {
ws_state.stop(&app);
Ok(())
}
#[tauri::command]
fn cloud_ws_status(ws_state: State<'_, WsState>) -> Result<String, String> {
Ok(ws_state.status())
}
#[tauri::command]
@@ -725,9 +792,9 @@ fn cloud_account(app: AppHandle, store: State<'_, Store>) -> Result<Option<Accou
}
#[tauri::command]
fn cloud_list_spaces(app: AppHandle, store: State<'_, Store>) -> Result<Vec<SpaceSummary>, String> {
fn cloud_list_projects(app: AppHandle, store: State<'_, Store>) -> Result<Vec<ProjectSummary>, String> {
let (server_url, token) = cloud_credentials(&app, &store)?;
sync::list_spaces(&server_url, &token)
sync::list_cloud_projects(&server_url, &token)
}
#[tauri::command]
@@ -739,6 +806,71 @@ fn cloud_list_folders(
sync::list_folders(&server_url, &token)
}
#[tauri::command]
fn cloud_create_folder(
app: AppHandle,
store: State<'_, Store>,
name: String,
parent_id: Option<String>,
) -> Result<sync::CloudFolder, String> {
let (server_url, token) = cloud_credentials(&app, &store)?;
sync::create_folder(&server_url, &token, &name, parent_id.as_deref())
}
#[tauri::command]
fn cloud_rename_folder(
app: AppHandle,
store: State<'_, Store>,
folder_id: String,
name: String,
) -> Result<sync::CloudFolder, String> {
let (server_url, token) = cloud_credentials(&app, &store)?;
sync::rename_folder(&server_url, &token, &folder_id, &name)
}
#[tauri::command]
fn cloud_move_folder(
app: AppHandle,
store: State<'_, Store>,
folder_id: String,
parent_id: Option<String>,
) -> Result<sync::CloudFolder, String> {
let (server_url, token) = cloud_credentials(&app, &store)?;
sync::move_folder(&server_url, &token, &folder_id, parent_id.as_deref())
}
#[tauri::command]
fn cloud_delete_folder(
app: AppHandle,
store: State<'_, Store>,
folder_id: String,
) -> Result<(), String> {
let (server_url, token) = cloud_credentials(&app, &store)?;
sync::delete_folder(&server_url, &token, &folder_id)
}
#[tauri::command]
fn cloud_move_project(
app: AppHandle,
store: State<'_, Store>,
cloud_project_id: String,
folder_id: Option<String>,
) -> Result<ProjectSummary, String> {
let (server_url, token) = cloud_credentials(&app, &store)?;
sync::move_cloud_project(&server_url, &token, &cloud_project_id, folder_id.as_deref())
}
#[tauri::command]
fn cloud_move_document(
app: AppHandle,
store: State<'_, Store>,
document_id: String,
folder_id: Option<String>,
) -> Result<sync::CloudDocument, String> {
let (server_url, token) = cloud_credentials(&app, &store)?;
sync::move_document(&server_url, &token, &document_id, folder_id.as_deref())
}
#[tauri::command]
fn cloud_list_documents(
app: AppHandle,
@@ -781,6 +913,97 @@ fn cloud_download_file(
Ok(file.name)
}
#[tauri::command]
fn cloud_delete_file(app: AppHandle, store: State<'_, Store>, file_id: String) -> Result<(), String> {
let (server_url, token) = cloud_credentials(&app, &store)?;
sync::delete_account_file(&server_url, &token, &file_id)
}
fn guess_mime_type(name: &str) -> &'static str {
let extension = std::path::Path::new(name)
.extension()
.and_then(|value| value.to_str())
.unwrap_or("")
.to_lowercase();
match extension.as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"svg" => "image/svg+xml",
"webp" => "image/webp",
"ttf" => "font/ttf",
"otf" => "font/otf",
"ttc" | "otc" => "font/collection",
"pdf" => "application/pdf",
_ => "application/octet-stream",
}
}
#[tauri::command]
fn cloud_upload_file(
app: AppHandle,
store: State<'_, Store>,
path: String,
folder_id: Option<String>,
) -> Result<sync::CloudFile, String> {
let (server_url, token) = cloud_credentials(&app, &store)?;
let name = std::path::Path::new(&path)
.file_name()
.map(|value| value.to_string_lossy().to_string())
.ok_or_else(|| format!("'{}' has no file name", path))?;
let data = std::fs::read(&path).map_err(|e| e.to_string())?;
let mime_type = guess_mime_type(&name);
sync::upload_account_file(
&server_url,
&token,
&name,
mime_type,
&data,
folder_id.as_deref(),
)
}
#[tauri::command]
fn cloud_rename_file(
app: AppHandle,
store: State<'_, Store>,
file_id: String,
name: String,
) -> Result<sync::CloudFile, String> {
let (server_url, token) = cloud_credentials(&app, &store)?;
sync::rename_account_file(&server_url, &token, &file_id, name.trim())
}
#[tauri::command]
fn cloud_move_file(
app: AppHandle,
store: State<'_, Store>,
file_id: String,
folder_id: Option<String>,
) -> Result<sync::CloudFile, String> {
let (server_url, token) = cloud_credentials(&app, &store)?;
sync::move_account_file(&server_url, &token, &file_id, folder_id.as_deref())
}
#[tauri::command]
fn cloud_delete_document(
app: AppHandle,
store: State<'_, Store>,
document_id: String,
) -> Result<(), String> {
let (server_url, token) = cloud_credentials(&app, &store)?;
sync::delete_document(&server_url, &token, &document_id)?;
if let Ok(links) = store.all_document_links() {
if let Some((path, _, _)) = links.into_iter().find(|(_, id, _)| id == &document_id) {
let _ = store.forget_document_link(&path);
}
}
Ok(())
}
#[tauri::command]
fn cloud_list_shared(
app: AppHandle,
@@ -839,6 +1062,32 @@ fn cloud_sync_document(
sync::sync_document(&server_url, &token, &app, &store, &path)
}
#[tauri::command]
fn cloud_room_id(
app: AppHandle,
store: State<'_, Store>,
path: String,
file: String,
) -> Result<Option<String>, String> {
if let Some(link) = store.document_link(&path)? {
return Ok(Some(link.document_id));
}
let meta = store.meta(&path)?;
let Some(cloud_project_id) = meta.cloud_project_id else {
return Ok(None);
};
let (server_url, token) = cloud_credentials(&app, &store)?;
let manifest = sync::get_manifest(&server_url, &token, &cloud_project_id)?;
Ok(manifest
.files
.into_iter()
.find(|entry| entry.path == file)
.map(|entry| format!("project:{}:{}", cloud_project_id, entry.id)))
}
#[tauri::command]
fn cloud_resolve_document(
app: AppHandle,
@@ -864,6 +1113,30 @@ fn cloud_unlink_document(store: State<'_, Store>, path: String) -> Result<(), St
store.forget_document_link(&path)
}
#[tauri::command]
fn cloud_create_document(
app: AppHandle,
store: State<'_, Store>,
path: String,
title: String,
) -> Result<String, String> {
let (server_url, token) = cloud_credentials(&app, &store)?;
let full = workspace_path(&app, &store, &path)?;
let content = std::fs::read_to_string(&full).map_err(|e| e.to_string())?;
let document = sync::create_document(&server_url, &token, title.trim(), &content, None)?;
store.save_document_link(
&path,
&document.id,
&document.hash,
&document.role,
&document.content,
)?;
Ok(document.id)
}
#[derive(Serialize)]
pub struct LinkedDocument {
pub path: String,
@@ -899,21 +1172,21 @@ fn cloud_linked_documents(
}
#[derive(Serialize)]
pub struct LinkedSpace {
pub struct LinkedProject {
pub path: String,
pub space_id: String,
pub cloud_project_id: String,
pub synced_at: Option<String>,
pub sync_state: Option<String>,
}
#[tauri::command]
fn cloud_linked_spaces(
fn cloud_linked_projects(
app: AppHandle,
store: State<'_, Store>,
) -> Result<Vec<LinkedSpace>, String> {
) -> Result<Vec<LinkedProject>, String> {
let mut linked = Vec::new();
for (path, space_id, synced_at) in store.all_space_links()? {
for (path, cloud_project_id, synced_at) in store.all_cloud_project_links()? {
let Ok(full) = workspace_path(&app, &store, &path) else {
continue;
};
@@ -921,10 +1194,10 @@ fn cloud_linked_spaces(
continue;
}
linked.push(LinkedSpace {
linked.push(LinkedProject {
sync_state: workspace::project_sync_state(&full, synced_at.as_deref()),
path,
space_id,
cloud_project_id,
synced_at,
});
}
@@ -941,31 +1214,62 @@ fn cloud_document_link(
}
#[tauri::command]
fn cloud_create_space(app: AppHandle, store: State<'_, Store>, name: String) -> Result<SpaceSummary, String> {
let (server_url, token) = cloud_credentials(&app, &store)?;
sync::create_space(&server_url, &token, name.trim())
}
#[tauri::command]
fn cloud_delete_space(app: AppHandle, store: State<'_, Store>, space_id: String) -> Result<(), String> {
let (server_url, token) = cloud_credentials(&app, &store)?;
sync::delete_space(&server_url, &token, &space_id)
}
#[tauri::command]
fn cloud_clone_space(
fn cloud_create_project(
app: AppHandle,
store: State<'_, Store>,
space_id: String,
name: String,
folder_id: Option<String>,
) -> Result<ProjectSummary, String> {
let (server_url, token) = cloud_credentials(&app, &store)?;
sync::create_cloud_project(&server_url, &token, name.trim(), folder_id.as_deref())
}
#[tauri::command]
fn cloud_new_document(
app: AppHandle,
store: State<'_, Store>,
title: String,
folder_id: Option<String>,
) -> Result<sync::DocumentContent, String> {
let title = title.trim();
if title.is_empty() {
return Err("Document name cannot be empty".to_string());
}
let (server_url, token) = cloud_credentials(&app, &store)?;
let content = format!("= {}\n\nStart writing here.\n", title);
sync::create_document(&server_url, &token, title, &content, folder_id.as_deref())
}
#[tauri::command]
fn cloud_delete_project(app: AppHandle, store: State<'_, Store>, cloud_project_id: String) -> Result<(), String> {
let (server_url, token) = cloud_credentials(&app, &store)?;
sync::delete_cloud_project(&server_url, &token, &cloud_project_id)
}
#[tauri::command]
fn cloud_clone_project(
app: AppHandle,
store: State<'_, Store>,
cloud_project_id: String,
project_name: String,
parent: String,
) -> Result<SyncReport, String> {
let (server_url, token) = cloud_credentials(&app, &store)?;
let project = project_name.trim().to_string();
let project = join_path(&parent, project_name.trim());
let dir = workspace_path(&app, &store, &project)?;
if dir.exists() {
return Err(format!("A project named '{}' already exists", project_name));
}
sync::clone_space(&server_url, &token, &app, &store, &project, &dir, &space_id)
sync::clone_cloud_project(
&server_url,
&token,
&app,
&store,
&project,
&dir,
&cloud_project_id,
)
}
#[tauri::command]
@@ -973,17 +1277,17 @@ fn cloud_link_project(
app: AppHandle,
store: State<'_, Store>,
project: String,
space_id: Option<String>,
cloud_project_id: Option<String>,
) -> Result<SyncReport, String> {
let (server_url, token) = cloud_credentials(&app, &store)?;
let (dir, mut meta) = load_project(&app, &store, &project)?;
let space_id = match space_id {
let cloud_project_id = match cloud_project_id {
Some(id) if !id.trim().is_empty() => id,
_ => sync::create_space(&server_url, &token, &project)?.id,
_ => sync::create_cloud_project(&server_url, &token, &project, None)?.id,
};
meta.space_id = Some(space_id);
meta.cloud_project_id = Some(cloud_project_id);
meta.base_hashes.clear();
store.save_meta(&project, &meta)?;
@@ -993,7 +1297,7 @@ fn cloud_link_project(
#[tauri::command]
fn cloud_unlink_project(app: AppHandle, store: State<'_, Store>, project: String) -> Result<(), String> {
let (dir, mut meta) = load_project(&app, &store, &project)?;
meta.space_id = None;
meta.cloud_project_id = None;
meta.base_hashes.clear();
meta.last_synced_at = None;
let _ = dir;
@@ -1069,12 +1373,14 @@ pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_clipboard_manager::init())
.setup(|app| {
let store = Store::open(&app.handle())?;
app.manage(store);
Ok(())
})
.manage(LspState::default())
.manage(WsState::default())
.on_window_event(|window, event| {
if matches!(event, tauri::WindowEvent::Destroyed) {
if let Some(state) = window.app_handle().try_state::<LspState>() {
@@ -1102,9 +1408,12 @@ pub fn run() {
set_target_entrypoint,
compile_target,
export_target,
render_target_png,
thumbnail,
read_image,
clear_thumbnails,
get_cloud_cache,
save_cloud_cache,
list_assets,
list_resources,
list_font_families,
@@ -1116,25 +1425,43 @@ pub fn run() {
lsp_send,
lsp_stop,
lsp_running,
cloud_check_compatibility,
cloud_login,
cloud_logout,
cloud_account,
cloud_list_spaces,
cloud_ws_start,
cloud_ws_stop,
cloud_ws_status,
cloud_list_projects,
cloud_list_folders,
cloud_create_folder,
cloud_rename_folder,
cloud_move_folder,
cloud_delete_folder,
cloud_move_project,
cloud_move_document,
cloud_list_documents,
cloud_list_shared,
cloud_list_files,
cloud_download_file,
cloud_delete_file,
cloud_upload_file,
cloud_rename_file,
cloud_move_file,
cloud_download_document,
cloud_delete_document,
cloud_sync_document,
cloud_room_id,
cloud_resolve_document,
cloud_document_link,
cloud_linked_documents,
cloud_linked_spaces,
cloud_linked_projects,
cloud_unlink_document,
cloud_create_space,
cloud_delete_space,
cloud_clone_space,
cloud_create_document,
cloud_create_project,
cloud_new_document,
cloud_delete_project,
cloud_clone_project,
cloud_link_project,
cloud_unlink_project,
cloud_push,
+338 -38
View File
@@ -34,6 +34,88 @@ fn describe(error: ureq::Error) -> String {
}
}
pub const MIN_SERVER_VERSION: &str = "1.5.0";
fn parse_version(version: &str) -> (u32, u32, u32) {
let mut parts = version.trim().split('.').map(|part| part.parse::<u32>().unwrap_or(0));
(
parts.next().unwrap_or(0),
parts.next().unwrap_or(0),
parts.next().unwrap_or(0),
)
}
fn version_at_least(actual: &str, required: &str) -> bool {
parse_version(actual) >= parse_version(required)
}
#[derive(Deserialize)]
struct ServerVersionInfo {
server_version: String,
min_desktop_version: String,
}
#[derive(Serialize, Clone)]
pub struct CompatibilityStatus {
pub compatible: bool,
pub server_version: String,
pub desktop_version: String,
pub min_server_version: String,
pub min_desktop_version: String,
pub message: Option<String>,
}
pub fn check_compatibility(server_url: &str) -> CompatibilityStatus {
let desktop_version = env!("CARGO_PKG_VERSION").to_string();
let info = agent()
.get(&endpoint(server_url, "/version"))
.call()
.map_err(describe)
.and_then(|response| response.into_json::<ServerVersionInfo>().map_err(|e| e.to_string()));
match info {
Ok(info) => {
let server_too_old = !version_at_least(&info.server_version, MIN_SERVER_VERSION);
let desktop_too_old = !version_at_least(&desktop_version, &info.min_desktop_version);
let message = if server_too_old {
Some(format!(
"This app requires a TypstDrive server v{} or newer (server is running v{}). Ask the administrator to update it.",
MIN_SERVER_VERSION, info.server_version
))
} else if desktop_too_old {
Some(format!(
"This TypstDrive server requires typst-desktop v{} or newer (you have v{}). Please update the app.",
info.min_desktop_version, desktop_version
))
} else {
None
};
CompatibilityStatus {
compatible: !server_too_old && !desktop_too_old,
server_version: info.server_version,
desktop_version,
min_server_version: MIN_SERVER_VERSION.to_string(),
min_desktop_version: info.min_desktop_version,
message,
}
}
Err(_) => CompatibilityStatus {
compatible: false,
server_version: "unknown".to_string(),
desktop_version,
min_server_version: MIN_SERVER_VERSION.to_string(),
min_desktop_version: "unknown".to_string(),
message: Some(format!(
"Could not determine the server's version. It may be unreachable, or older than v{} which doesn't support version checks. Please update the server.",
MIN_SERVER_VERSION
)),
},
}
}
#[derive(Deserialize, Serialize, Clone)]
pub struct Account {
pub user_id: String,
@@ -55,12 +137,20 @@ pub fn login(
password: &str,
device_name: &str,
) -> Result<LoginResponse, String> {
let status = check_compatibility(server_url);
if !status.compatible {
return Err(status
.message
.unwrap_or_else(|| "This server is not compatible with this app.".to_string()));
}
agent()
.post(&endpoint(server_url, "/auth/login"))
.send_json(ureq::json!({
"email": email,
"password": password,
"device_name": device_name,
"client_version": env!("CARGO_PKG_VERSION"),
}))
.map_err(describe)?
.into_json::<LoginResponse>()
@@ -87,51 +177,79 @@ pub fn me(server_url: &str, token: &str) -> Result<Account, String> {
}
#[derive(Deserialize, Serialize, Clone)]
pub struct SpaceSummary {
pub struct ProjectSummary {
pub id: String,
pub name: String,
pub entrypoint: String,
pub folder_id: Option<String>,
pub role: String,
pub updated_at: String,
}
pub fn list_spaces(server_url: &str, token: &str) -> Result<Vec<SpaceSummary>, String> {
pub fn list_cloud_projects(server_url: &str, token: &str) -> Result<Vec<ProjectSummary>, String> {
agent()
.get(&endpoint(server_url, "/spaces"))
.get(&endpoint(server_url, "/projects"))
.set("Authorization", &format!("Bearer {}", token))
.call()
.map_err(describe)?
.into_json::<Vec<SpaceSummary>>()
.into_json::<Vec<ProjectSummary>>()
.map_err(|e| e.to_string())
}
pub fn create_space(server_url: &str, token: &str, name: &str) -> Result<SpaceSummary, String> {
pub fn create_cloud_project(
server_url: &str,
token: &str,
name: &str,
folder_id: Option<&str>,
) -> Result<ProjectSummary, String> {
agent()
.post(&endpoint(server_url, "/spaces"))
.post(&endpoint(server_url, "/projects"))
.set("Authorization", &format!("Bearer {}", token))
.send_json(ureq::json!({ "name": name }))
.send_json(ureq::json!({ "name": name, "folder_id": folder_id }))
.map_err(describe)?
.into_json::<SpaceSummary>()
.into_json::<ProjectSummary>()
.map_err(|e| e.to_string())
}
pub fn delete_space(server_url: &str, token: &str, space_id: &str) -> Result<(), String> {
pub fn delete_cloud_project(
server_url: &str,
token: &str,
cloud_project_id: &str,
) -> Result<(), String> {
agent()
.delete(&endpoint(server_url, &format!("/spaces/{}", space_id)))
.delete(&endpoint(server_url, &format!("/projects/{}", cloud_project_id)))
.set("Authorization", &format!("Bearer {}", token))
.call()
.map_err(describe)?;
Ok(())
}
pub fn move_cloud_project(
server_url: &str,
token: &str,
cloud_project_id: &str,
folder_id: Option<&str>,
) -> Result<ProjectSummary, String> {
agent()
.patch(&endpoint(server_url, &format!("/projects/{}", cloud_project_id)))
.set("Authorization", &format!("Bearer {}", token))
.send_json(ureq::json!({ "folder_id": folder_id }))
.map_err(describe)?
.into_json::<ProjectSummary>()
.map_err(|e| e.to_string())
}
#[derive(Deserialize)]
pub struct ManifestEntry {
pub id: String,
pub path: String,
pub hash: String,
}
#[derive(Deserialize)]
pub struct SpaceManifest {
pub struct ProjectManifest {
pub project_id: String,
pub name: String,
pub entrypoint: String,
pub files: Vec<ManifestEntry>,
}
@@ -139,17 +257,17 @@ pub struct SpaceManifest {
pub fn get_manifest(
server_url: &str,
token: &str,
space_id: &str,
) -> Result<SpaceManifest, String> {
cloud_project_id: &str,
) -> Result<ProjectManifest, String> {
agent()
.get(&endpoint(
server_url,
&format!("/spaces/{}/manifest", space_id),
&format!("/projects/{}/manifest", cloud_project_id),
))
.set("Authorization", &format!("Bearer {}", token))
.call()
.map_err(describe)?
.into_json::<SpaceManifest>()
.into_json::<ProjectManifest>()
.map_err(|e| e.to_string())
}
@@ -176,11 +294,14 @@ impl FileContent {
pub fn pull_file(
server_url: &str,
token: &str,
space_id: &str,
cloud_project_id: &str,
path: &str,
) -> Result<FileContent, String> {
agent()
.get(&endpoint(server_url, &format!("/spaces/{}/file", space_id)))
.get(&endpoint(
server_url,
&format!("/projects/{}/file", cloud_project_id),
))
.query("path", path)
.set("Authorization", &format!("Bearer {}", token))
.call()
@@ -204,7 +325,7 @@ pub enum PushResult {
pub fn push_file(
server_url: &str,
token: &str,
space_id: &str,
cloud_project_id: &str,
path: &str,
bytes: &[u8],
base_hash: Option<&str>,
@@ -216,7 +337,10 @@ pub fn push_file(
};
let response = agent()
.put(&endpoint(server_url, &format!("/spaces/{}/file", space_id)))
.put(&endpoint(
server_url,
&format!("/projects/{}/file", cloud_project_id),
))
.set("Authorization", &format!("Bearer {}", token))
.send_json(ureq::json!({
"path": path,
@@ -248,11 +372,14 @@ pub fn push_file(
pub fn delete_remote_file(
server_url: &str,
token: &str,
space_id: &str,
cloud_project_id: &str,
path: &str,
) -> Result<(), String> {
let response = agent()
.delete(&endpoint(server_url, &format!("/spaces/{}/file", space_id)))
.delete(&endpoint(
server_url,
&format!("/projects/{}/file", cloud_project_id),
))
.query("path", path)
.set("Authorization", &format!("Bearer {}", token))
.call();
@@ -306,12 +433,12 @@ pub fn pull_project(
project_dir: &Path,
meta: &mut ProjectMeta,
) -> Result<SyncReport, String> {
let space_id = meta
.space_id
let cloud_project_id = meta
.cloud_project_id
.clone()
.ok_or("Project is not linked to a cloud space")?;
.ok_or("Project is not linked to a cloud project")?;
let manifest = get_manifest(server_url, token, &space_id)?;
let manifest = get_manifest(server_url, token, &cloud_project_id)?;
let mut report = SyncReport::default();
let local_files: HashSet<String> = collect_files(project_dir)?.into_iter().collect();
@@ -327,7 +454,7 @@ pub fn pull_project(
if base.is_some() {
continue;
}
let remote = pull_file(server_url, token, &space_id, &entry.path)?;
let remote = pull_file(server_url, token, &cloud_project_id, &entry.path)?;
write_local(project_dir, &entry.path, &remote.bytes()?)?;
meta.base_hashes.insert(entry.path.clone(), remote.hash);
report.pulled.push(entry.path.clone());
@@ -346,7 +473,7 @@ pub fn pull_project(
continue;
}
let remote = pull_file(server_url, token, &space_id, &entry.path)?;
let remote = pull_file(server_url, token, &cloud_project_id, &entry.path)?;
let remote_bytes = remote.bytes()?;
if base.as_deref() == Some(local_hash.as_str()) {
@@ -428,10 +555,10 @@ pub fn push_project(
project_dir: &Path,
meta: &mut ProjectMeta,
) -> Result<SyncReport, String> {
let space_id = meta
.space_id
let cloud_project_id = meta
.cloud_project_id
.clone()
.ok_or("Project is not linked to a cloud space")?;
.ok_or("Project is not linked to a cloud project")?;
let mut report = SyncReport::default();
let local_files = collect_files(project_dir)?;
@@ -446,7 +573,14 @@ pub fn push_project(
continue;
}
match push_file(server_url, token, &space_id, path, &bytes, base.as_deref())? {
match push_file(
server_url,
token,
&cloud_project_id,
path,
&bytes,
base.as_deref(),
)? {
PushResult::Applied => {
meta.base_hashes.insert(path.clone(), hash);
report.pushed.push(path.clone());
@@ -481,7 +615,7 @@ pub fn push_project(
.collect();
for path in removed {
delete_remote_file(server_url, token, &space_id, &path)?;
delete_remote_file(server_url, token, &cloud_project_id, &path)?;
meta.base_hashes.remove(&path);
report.deleted_remote.push(path);
}
@@ -522,20 +656,20 @@ pub fn report_progress(
);
}
pub fn clone_space(
pub fn clone_cloud_project(
server_url: &str,
token: &str,
app: &tauri::AppHandle,
store: &Store,
project: &str,
project_dir: &Path,
space_id: &str,
cloud_project_id: &str,
) -> Result<SyncReport, String> {
std::fs::create_dir_all(project_dir).map_err(|e| e.to_string())?;
let manifest = get_manifest(server_url, token, space_id)?;
let manifest = get_manifest(server_url, token, cloud_project_id)?;
let mut meta = store.meta(project)?;
meta.space_id = Some(space_id.to_string());
meta.cloud_project_id = Some(cloud_project_id.to_string());
meta.entrypoint = manifest.entrypoint.clone();
let mut report = SyncReport::default();
@@ -544,7 +678,7 @@ pub fn clone_space(
for (index, entry) in manifest.files.iter().enumerate() {
report_progress(app, project, index, total, false);
let remote = pull_file(server_url, token, space_id, &entry.path)?;
let remote = pull_file(server_url, token, cloud_project_id, &entry.path)?;
write_local(project_dir, &entry.path, &remote.bytes()?)?;
meta.base_hashes.insert(entry.path.clone(), remote.hash);
report.pulled.push(entry.path.clone());
@@ -621,7 +755,7 @@ pub struct CloudDocument {
#[derive(Deserialize, Serialize)]
pub struct SharedItems {
pub documents: Vec<CloudDocument>,
pub spaces: Vec<SpaceSummary>,
pub projects: Vec<ProjectSummary>,
}
pub fn list_folders(server_url: &str, token: &str) -> Result<Vec<CloudFolder>, String> {
@@ -634,6 +768,66 @@ pub fn list_folders(server_url: &str, token: &str) -> Result<Vec<CloudFolder>, S
.map_err(|e| e.to_string())
}
pub fn create_folder(
server_url: &str,
token: &str,
name: &str,
parent_id: Option<&str>,
) -> Result<CloudFolder, String> {
agent()
.post(&endpoint(server_url, "/folders"))
.set("Authorization", &format!("Bearer {}", token))
.send_json(ureq::json!({
"name": name,
"parent_id": parent_id,
}))
.map_err(describe)?
.into_json::<CloudFolder>()
.map_err(|e| e.to_string())
}
pub fn rename_folder(
server_url: &str,
token: &str,
folder_id: &str,
name: &str,
) -> Result<CloudFolder, String> {
agent()
.patch(&endpoint(server_url, &format!("/folders/{}", folder_id)))
.set("Authorization", &format!("Bearer {}", token))
.send_json(ureq::json!({ "name": name }))
.map_err(describe)?
.into_json::<CloudFolder>()
.map_err(|e| e.to_string())
}
pub fn move_folder(
server_url: &str,
token: &str,
folder_id: &str,
parent_id: Option<&str>,
) -> Result<CloudFolder, String> {
agent()
.patch(&endpoint(
server_url,
&format!("/folders/{}/move", folder_id),
))
.set("Authorization", &format!("Bearer {}", token))
.send_json(ureq::json!({ "parent_id": parent_id }))
.map_err(describe)?
.into_json::<CloudFolder>()
.map_err(|e| e.to_string())
}
pub fn delete_folder(server_url: &str, token: &str, folder_id: &str) -> Result<(), String> {
agent()
.delete(&endpoint(server_url, &format!("/folders/{}", folder_id)))
.set("Authorization", &format!("Bearer {}", token))
.call()
.map_err(describe)?;
Ok(())
}
pub fn list_documents(
server_url: &str,
token: &str,
@@ -687,6 +881,41 @@ pub fn pull_document(
.map_err(|e| e.to_string())
}
pub fn create_document(
server_url: &str,
token: &str,
title: &str,
content: &str,
folder_id: Option<&str>,
) -> Result<DocumentContent, String> {
agent()
.post(&endpoint(server_url, "/documents"))
.set("Authorization", &format!("Bearer {}", token))
.send_json(ureq::json!({
"title": title,
"content": content,
"folder_id": folder_id,
}))
.map_err(describe)?
.into_json::<DocumentContent>()
.map_err(|e| e.to_string())
}
pub fn move_document(
server_url: &str,
token: &str,
document_id: &str,
folder_id: Option<&str>,
) -> Result<CloudDocument, String> {
agent()
.patch(&endpoint(server_url, &format!("/documents/{}", document_id)))
.set("Authorization", &format!("Bearer {}", token))
.send_json(ureq::json!({ "folder_id": folder_id }))
.map_err(describe)?
.into_json::<CloudDocument>()
.map_err(|e| e.to_string())
}
pub fn sync_document(
server_url: &str,
token: &str,
@@ -881,6 +1110,15 @@ pub fn push_document(
}
}
pub fn delete_document(server_url: &str, token: &str, document_id: &str) -> Result<(), String> {
agent()
.delete(&endpoint(server_url, &format!("/documents/{}", document_id)))
.set("Authorization", &format!("Bearer {}", token))
.call()
.map_err(describe)?;
Ok(())
}
#[derive(Deserialize, Serialize, Clone)]
pub struct CloudFile {
pub id: String,
@@ -916,6 +1154,29 @@ pub fn list_account_files(
.map_err(|e| e.to_string())
}
pub fn upload_account_file(
server_url: &str,
token: &str,
name: &str,
mime_type: &str,
data: &[u8],
folder_id: Option<&str>,
) -> Result<CloudFile, String> {
agent()
.post(&endpoint(server_url, "/files"))
.set("Authorization", &format!("Bearer {}", token))
.send_json(ureq::json!({
"name": name,
"mime_type": mime_type,
"encoding": "base64",
"content": BASE64.encode(data),
"folder_id": folder_id,
}))
.map_err(describe)?
.into_json::<CloudFile>()
.map_err(|e| e.to_string())
}
pub fn pull_account_file(
server_url: &str,
token: &str,
@@ -929,3 +1190,42 @@ pub fn pull_account_file(
.into_json::<CloudFileContent>()
.map_err(|e| e.to_string())
}
pub fn delete_account_file(server_url: &str, token: &str, file_id: &str) -> Result<(), String> {
agent()
.delete(&endpoint(server_url, &format!("/files/{}", file_id)))
.set("Authorization", &format!("Bearer {}", token))
.call()
.map_err(describe)?;
Ok(())
}
pub fn rename_account_file(
server_url: &str,
token: &str,
file_id: &str,
name: &str,
) -> Result<CloudFile, String> {
agent()
.patch(&endpoint(server_url, &format!("/files/{}", file_id)))
.set("Authorization", &format!("Bearer {}", token))
.send_json(ureq::json!({ "name": name }))
.map_err(describe)?
.into_json::<CloudFile>()
.map_err(|e| e.to_string())
}
pub fn move_account_file(
server_url: &str,
token: &str,
file_id: &str,
folder_id: Option<&str>,
) -> Result<CloudFile, String> {
agent()
.patch(&endpoint(server_url, &format!("/files/{}/move", file_id)))
.set("Authorization", &format!("Bearer {}", token))
.send_json(ureq::json!({ "folder_id": folder_id }))
.map_err(describe)?
.into_json::<CloudFile>()
.map_err(|e| e.to_string())
}
+10 -10
View File
@@ -68,7 +68,7 @@ pub struct Settings {
#[serde(default)]
pub autosave_seconds: u32,
#[serde(default)]
pub sync_minutes: u32,
pub sync_seconds: u32,
}
impl Settings {
@@ -84,7 +84,7 @@ impl Settings {
account_email: None,
account_username: None,
autosave_seconds: 5,
sync_minutes: 0,
sync_seconds: 0,
}
}
}
@@ -115,7 +115,7 @@ pub fn workspace_root(app: &AppHandle, store: &Store) -> Result<PathBuf, String>
#[derive(Serialize, Deserialize, Clone)]
pub struct ProjectMeta {
pub entrypoint: String,
pub space_id: Option<String>,
pub cloud_project_id: Option<String>,
pub last_synced_at: Option<String>,
pub base_hashes: HashMap<String, String>,
}
@@ -124,7 +124,7 @@ impl Default for ProjectMeta {
fn default() -> Self {
ProjectMeta {
entrypoint: "main.typ".to_string(),
space_id: None,
cloud_project_id: None,
last_synced_at: None,
base_hashes: HashMap::new(),
}
@@ -154,7 +154,7 @@ pub struct BrowseEntry {
pub kind: String,
pub size: u64,
pub modified: Option<String>,
pub space_id: Option<String>,
pub cloud_project_id: Option<String>,
pub last_synced_at: Option<String>,
pub child_count: usize,
pub cloud_linked: bool,
@@ -259,9 +259,9 @@ pub fn browse(app: &AppHandle, store: &Store, relative: &str) -> Result<Vec<Brow
})
.unwrap_or(0);
let space_id = meta.as_ref().and_then(|m| m.space_id.clone());
let cloud_project_id = meta.as_ref().and_then(|m| m.cloud_project_id.clone());
let last_synced_at = meta.as_ref().and_then(|m| m.last_synced_at.clone());
let sync_state = if space_id.is_some() {
let sync_state = if cloud_project_id.is_some() {
sync_state_for(newest_change(&full), last_synced_at.as_deref())
} else {
None
@@ -273,8 +273,8 @@ pub fn browse(app: &AppHandle, store: &Store, relative: &str) -> Result<Vec<Brow
kind: if project { "project" } else { "folder" }.to_string(),
size: 0,
modified: modified_at(&full),
cloud_linked: space_id.is_some(),
space_id,
cloud_linked: cloud_project_id.is_some(),
cloud_project_id,
last_synced_at,
sync_state,
child_count,
@@ -299,7 +299,7 @@ pub fn browse(app: &AppHandle, store: &Store, relative: &str) -> Result<Vec<Brow
kind: kind.to_string(),
size: std::fs::metadata(&full).map(|m| m.len()).unwrap_or(0),
modified: modified_at(&full),
space_id: None,
cloud_project_id: None,
last_synced_at: link.as_ref().and_then(|link| link.synced_at.clone()),
child_count: 0,
cloud_linked: link.is_some(),
+156
View File
@@ -0,0 +1,156 @@
use serde::{Deserialize, Serialize};
use std::net::TcpStream;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use std::time::Duration;
use tauri::{AppHandle, Emitter, Manager};
use tungstenite::client::IntoClientRequest;
use tungstenite::stream::MaybeTlsStream;
use tungstenite::Message;
pub const STATUS_EVENT: &str = "cloud://ws-status";
pub const SYNC_EVENT: &str = "cloud://sync-event";
const READ_TIMEOUT: Duration = Duration::from_secs(10);
const RECONNECT_DELAY: Duration = Duration::from_secs(5);
#[derive(Deserialize, Serialize, Clone)]
pub struct DeviceEvent {
pub kind: String,
pub project_id: Option<String>,
pub document_id: Option<String>,
}
pub struct WsState {
generation: AtomicU64,
status: Mutex<String>,
}
impl Default for WsState {
fn default() -> Self {
Self {
generation: AtomicU64::new(0),
status: Mutex::new("offline".to_string()),
}
}
}
impl WsState {
pub fn status(&self) -> String {
self.status
.lock()
.map(|slot| slot.clone())
.unwrap_or_else(|_| "offline".to_string())
}
fn set_status(&self, app: &AppHandle, status: &str) {
if let Ok(mut slot) = self.status.lock() {
*slot = status.to_string();
}
let _ = app.emit(STATUS_EVENT, status);
}
pub fn start(&self, app: AppHandle, server_url: String, token: String) {
let generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
self.set_status(&app, "connecting");
std::thread::spawn(move || run_loop(app, server_url, token, generation));
}
pub fn stop(&self, app: &AppHandle) {
self.generation.fetch_add(1, Ordering::SeqCst);
self.set_status(app, "offline");
}
}
fn still_current(app: &AppHandle, generation: u64) -> bool {
app.state::<WsState>().generation.load(Ordering::SeqCst) == generation
}
fn ws_url(server_url: &str) -> String {
let trimmed = server_url.trim_end_matches('/');
if let Some(rest) = trimmed.strip_prefix("https://") {
format!("wss://{}/api/desktop/ws", rest)
} else if let Some(rest) = trimmed.strip_prefix("http://") {
format!("ws://{}/api/desktop/ws", rest)
} else {
format!("ws://{}/api/desktop/ws", trimmed)
}
}
fn configure_read_timeout(stream: &MaybeTlsStream<TcpStream>) {
let tcp = match stream {
MaybeTlsStream::Plain(stream) => Some(stream),
MaybeTlsStream::NativeTls(stream) => Some(stream.get_ref()),
_ => None,
};
if let Some(tcp) = tcp {
let _ = tcp.set_read_timeout(Some(READ_TIMEOUT));
}
}
fn is_timeout(error: &tungstenite::Error) -> bool {
matches!(
error,
tungstenite::Error::Io(io_error)
if io_error.kind() == std::io::ErrorKind::WouldBlock
|| io_error.kind() == std::io::ErrorKind::TimedOut
)
}
fn connect_and_listen(app: &AppHandle, url: &str, token: &str, generation: u64) -> Result<(), String> {
let mut request = url
.into_client_request()
.map_err(|e| e.to_string())?;
let header_value = format!("Bearer {}", token)
.parse()
.map_err(|_| "Invalid device token".to_string())?;
request.headers_mut().insert("Authorization", header_value);
let (mut socket, _response) = tungstenite::connect(request).map_err(|e| e.to_string())?;
configure_read_timeout(socket.get_ref());
app.state::<WsState>().set_status(app, "connected");
loop {
if !still_current(app, generation) {
let _ = socket.close(None);
return Ok(());
}
match socket.read() {
Ok(Message::Text(text)) => {
if let Ok(event) = serde_json::from_str::<DeviceEvent>(text.as_ref()) {
let _ = app.emit(SYNC_EVENT, event);
}
}
Ok(Message::Ping(_)) => {
let _ = socket.flush();
}
Ok(Message::Close(_)) => return Ok(()),
Ok(_) => {}
Err(ref error) if is_timeout(error) => continue,
Err(error) => return Err(error.to_string()),
}
}
}
fn run_loop(app: AppHandle, server_url: String, token: String, generation: u64) {
let url = ws_url(&server_url);
loop {
if !still_current(&app, generation) {
return;
}
let _ = connect_and_listen(&app, &url, &token, generation);
if !still_current(&app, generation) {
return;
}
app.state::<WsState>().set_status(&app, "offline");
std::thread::sleep(RECONNECT_DELAY);
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "typst-desktop",
"version": "1.0.0",
"version": "1.1.0",
"identifier": "co.sirblob.typst-desktop",
"build": {
"beforeDevCommand": "bun run dev",
+112
View File
@@ -31,6 +31,118 @@
--color-success: #4cc47f;
}
:root[data-color-theme="slate"] {
--color-surface: #ffffff;
--color-surface-muted: #f2f5f7;
--color-surface-sunken: #e6ebef;
--color-line: #d7dee4;
--color-ink: #12181f;
--color-ink-muted: #5a6672;
--color-accent: #0f9b8e;
--color-accent-soft: #e1f5f2;
}
:root[data-color-theme="slate"][data-theme="dark"] {
--color-surface: #12181e;
--color-surface-muted: #182027;
--color-surface-sunken: #1f2830;
--color-line: #2b3640;
--color-ink: #eef2f5;
--color-ink-muted: #8b98a5;
--color-accent: #3fc2b3;
--color-accent-soft: #163330;
}
:root[data-color-theme="sunset"] {
--color-surface: #fffdf9;
--color-surface-muted: #faf3e9;
--color-surface-sunken: #f3e6d3;
--color-line: #e7d5b8;
--color-ink: #241a10;
--color-ink-muted: #7a6650;
--color-accent: #e8623f;
--color-accent-soft: #fbe4dc;
}
:root[data-color-theme="sunset"][data-theme="dark"] {
--color-surface: #1f1712;
--color-surface-muted: #261c15;
--color-surface-sunken: #2f241a;
--color-line: #3d2f22;
--color-ink: #f7ede1;
--color-ink-muted: #b9a48c;
--color-accent: #f4805c;
--color-accent-soft: #3a2419;
}
:root[data-color-theme="forest"] {
--color-surface: #fbfdfb;
--color-surface-muted: #eef5ee;
--color-surface-sunken: #dfebe0;
--color-line: #c9dccb;
--color-ink: #12201a;
--color-ink-muted: #57685c;
--color-accent: #2f9457;
--color-accent-soft: #dcf0e2;
}
:root[data-color-theme="forest"][data-theme="dark"] {
--color-surface: #121a15;
--color-surface-muted: #17211b;
--color-surface-sunken: #1e2c22;
--color-line: #2b3c30;
--color-ink: #e9f3ec;
--color-ink-muted: #8fa896;
--color-accent: #4fbf7c;
--color-accent-soft: #1a3324;
}
:root[data-color-theme="grape"] {
--color-surface: #fdfbff;
--color-surface-muted: #f4eefb;
--color-surface-sunken: #e8daf5;
--color-line: #d7c3ec;
--color-ink: #1c1526;
--color-ink-muted: #6a5d7c;
--color-accent: #8b47d6;
--color-accent-soft: #f0e2fb;
}
:root[data-color-theme="grape"][data-theme="dark"] {
--color-surface: #17121e;
--color-surface-muted: #1d1725;
--color-surface-sunken: #251d30;
--color-line: #362a42;
--color-ink: #f1eaf7;
--color-ink-muted: #a998b8;
--color-accent: #b47af0;
--color-accent-soft: #2e2140;
}
:root[data-contrast="high"] {
--color-line: #9aa0ab;
--color-ink-muted: #33363c;
}
:root[data-theme="dark"][data-contrast="high"] {
--color-line: #545b66;
--color-ink-muted: #c7cdd6;
}
[data-contrast="high"] :focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
[data-reduce-motion="true"] *,
[data-reduce-motion="true"] *::before,
[data-reduce-motion="true"] *::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
}
html,
body {
height: 100%;
+46
View File
@@ -1,5 +1,9 @@
<script lang="ts">
import { onDestroy } from "svelte";
import Icon from "@iconify/svelte";
import { EditorView } from "@codemirror/view";
import { EditorState } from "@codemirror/state";
import { MergeView } from "@codemirror/merge";
import Modal from "./Modal.svelte";
import type { Conflict, Resolution } from "$lib/ts/api";
@@ -25,6 +29,37 @@
const current = $derived(conflicts[index]);
let diffHost: HTMLDivElement | undefined = $state();
let mergeView: MergeView | null = null;
function readOnlyState(doc: string) {
return EditorState.create({
doc,
extensions: [EditorView.editable.of(false), EditorView.lineWrapping],
});
}
$effect(() => {
const conflict = current;
mergeView?.destroy();
mergeView = null;
if (!diffHost || !conflict || conflict.binary) return;
mergeView = new MergeView({
a: readOnlyState(conflict.local_text),
b: readOnlyState(conflict.remote_text),
parent: diffHost,
gutter: true,
highlightChanges: true,
collapseUnchanged: {},
});
});
onDestroy(() => {
mergeView?.destroy();
});
function choose(option: "merged" | "local" | "remote") {
mode[index] = option;
choices[index] =
@@ -97,6 +132,17 @@
</p>
</div>
{:else}
<div class="flex flex-col gap-1">
<div class="flex justify-between text-[10px] text-[var(--color-ink-muted)]">
<span>This device</span>
<span>Cloud</span>
</div>
<div
class="scroll-thin h-64 w-full overflow-auto rounded-md border border-[var(--color-line)] text-xs"
bind:this={diffHost}
></div>
</div>
<div class="flex items-center gap-1.5">
{#each [["merged", "Merged"], ["local", "This device"], ["remote", "Cloud"]] as [option, label]}
<button
+34 -18
View File
@@ -7,12 +7,7 @@
highlightActiveLine,
} from "@codemirror/view";
import { EditorState, Compartment, StateField } from "@codemirror/state";
import {
defaultKeymap,
history,
historyKeymap,
indentWithTab,
} from "@codemirror/commands";
import { defaultKeymap, history, indentWithTab } from "@codemirror/commands";
import {
bracketMatching,
indentOnInput,
@@ -28,6 +23,9 @@
} from "@codemirror/autocomplete";
import { lintGutter, setDiagnostics } from "@codemirror/lint";
import { LSPClient, languageServerExtensions } from "@codemirror/lsp-client";
import { yCollab } from "y-codemirror.next";
import type { Awareness } from "y-protocols/awareness";
import type * as Y from "yjs";
import { typstCompletions } from "$lib/ts/completions";
import { editorTheme } from "$lib/ts/editor-theme";
@@ -41,8 +39,8 @@
targetPath: string;
enableLsp?: boolean;
diagnostics?: Diagnostic[];
collab?: { text: Y.Text; awareness: Awareness } | null;
onchange: (value: string) => void;
onsave: () => void;
onlspstatus?: (status: "off" | "starting" | "on" | "unavailable") => void;
onready?: (view: EditorView | null) => void;
}
@@ -53,8 +51,8 @@
targetPath,
enableLsp = true,
diagnostics = [],
collab = null,
onchange,
onsave,
onlspstatus,
onready,
}: Props = $props();
@@ -65,6 +63,7 @@
const languageSlot = new Compartment();
const lspSlot = new Compartment();
const themeSlot = new Compartment();
const collabSlot = new Compartment();
const bridge = new LspBridge();
let client: LSPClient | null = null;
@@ -158,22 +157,14 @@
languageSlot.of(isToml ? StreamLanguage.define(toml) : []),
lspSlot.of([]),
themeSlot.of(editorTheme(app.theme === "dark")),
collabSlot.of([]),
...(isToml
? []
: [autocompletion({ override: [typstCompletions] })]),
EditorView.lineWrapping,
keymap.of([
{
key: "Mod-s",
preventDefault: true,
run: () => {
onsave();
return true;
},
},
...closeBracketsKeymap,
...defaultKeymap,
...historyKeymap,
indentWithTab,
]),
EditorView.updateListener.of((update) => {
@@ -209,9 +200,34 @@
});
}
let boundCollab: { text: Y.Text; awareness: Awareness } | null = null;
$effect(() => {
const next = collab;
if (next === boundCollab) return;
laterDispatch((current) => {
if (next) {
current.dispatch({
changes: {
from: 0,
to: current.state.doc.length,
insert: next.text.toString(),
},
effects: collabSlot.reconfigure([
yCollab(next.text, next.awareness),
]),
});
} else {
current.dispatch({ effects: collabSlot.reconfigure([]) });
}
boundCollab = next;
});
});
$effect(() => {
const next = content;
if (!view) return;
if (!view || collab) return;
if (view.state.doc.toString() === next) return;
laterDispatch((current) => {
+6 -20
View File
@@ -5,9 +5,7 @@
import {
insertText,
prefixLines,
redoEdit,
setTypstConfig,
undoEdit,
wrapSelection,
} from "$lib/ts/editor-actions";
import { app } from "$lib/ts/state.svelte";
@@ -37,10 +35,10 @@
icon: string,
label: string,
run: () => void,
size = "text-base",
size = "text-sm",
)}
<button
class="rounded p-1.5 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)] disabled:opacity-40 disabled:hover:bg-transparent"
class="rounded p-1 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)] disabled:opacity-40 disabled:hover:bg-transparent"
title={label}
aria-label={label}
{disabled}
@@ -51,17 +49,12 @@
{/snippet}
{#snippet divider()}
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--color-line)]"></div>
<div class="mx-0.5 h-3.5 w-px shrink-0 bg-[var(--color-line)]"></div>
{/snippet}
<div
class="scroll-thin flex shrink-0 items-center gap-0.5 overflow-x-auto border-b border-[var(--color-line)] bg-[var(--color-surface)] px-2 py-1"
class="scroll-thin flex shrink-0 items-center gap-px overflow-x-auto border-b border-[var(--color-line)] bg-[var(--color-surface)] px-1.5 py-0.5"
>
{@render action("ph:arrow-counter-clockwise", "Undo", () => undoEdit(view))}
{@render action("ph:arrow-clockwise", "Redo", () => redoEdit(view))}
{@render divider()}
{@render action("ph:text-h", "Heading", () => prefixLines(view, "= ", "Heading"))}
{@render action("ph:text-b", "Bold", () => wrapSelection(view, "*", "*", "bold"))}
{@render action("ph:text-italic", "Italic", () =>
@@ -103,7 +96,7 @@
{@render divider()}
<select
class="max-w-36 rounded border border-[var(--color-line)] bg-[var(--color-surface)] px-1.5 py-1 text-xs text-[var(--color-ink)] focus:border-[var(--color-accent)] focus:outline-none disabled:opacity-40"
class="max-w-24 rounded border border-[var(--color-line)] bg-[var(--color-surface)] px-1 py-0.5 text-xs text-[var(--color-ink)] focus:border-[var(--color-accent)] focus:outline-none disabled:opacity-40"
aria-label="Document font"
{disabled}
bind:value={selectedFont}
@@ -118,14 +111,7 @@
{/each}
</select>
<button
class="flex items-center gap-1.5 rounded px-2 py-1 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)] disabled:opacity-40"
{disabled}
onclick={onpagesettings}
>
<Icon icon="ph:file-text" />
Page
</button>
{@render action("ph:file-text", "Page settings", onpagesettings)}
<div class="flex-1"></div>
</div>
+114 -45
View File
@@ -1,17 +1,18 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import type { FileEntry } from "$lib/ts/api";
import { clampMenu } from "$lib/ts/menu-position";
interface Props {
files: FileEntry[];
activePath: string | null;
entrypoint: string;
selected: string | null;
selected: Set<string>;
dropTarget: string | null;
onopen: (path: string) => void;
onselect: (path: string | null, isDir: boolean) => void;
onselect: (paths: string[], primary: string | null, isDir: boolean) => void;
onrename: (path: string) => void;
ondelete: (path: string) => void;
ondelete: (paths: string[]) => void;
onduplicate: (path: string) => void;
onreveal: (path: string) => void;
onsetentry: (path: string) => void;
@@ -111,9 +112,56 @@
null,
);
let dragging = $state<string | null>(null);
let anchorPath = $state<string | null>(null);
function flattenVisible(nodes: TreeNode[]): TreeNode[] {
const result: TreeNode[] = [];
for (const node of nodes) {
result.push(node);
if (node.isDir && node.children.length > 0 && !collapsed[node.path]) {
result.push(...flattenVisible(node.children));
}
}
return result;
}
function selectRange(from: string, to: TreeNode) {
const flat = flattenVisible(tree);
const fromIndex = flat.findIndex((node) => node.path === from);
const toIndex = flat.findIndex((node) => node.path === to.path);
if (fromIndex === -1 || toIndex === -1) {
onselect([to.path], to.path, to.isDir);
return;
}
const [start, end] =
fromIndex < toIndex ? [fromIndex, toIndex] : [toIndex, fromIndex];
const paths = flat.slice(start, end + 1).map((node) => node.path);
onselect(paths, to.path, to.isDir);
}
function handleRowClick(event: MouseEvent, node: TreeNode) {
if (event.shiftKey && anchorPath) {
selectRange(anchorPath, node);
return;
}
if (event.ctrlKey || event.metaKey) {
const next = new Set(selected);
if (next.has(node.path)) {
next.delete(node.path);
} else {
next.add(node.path);
}
anchorPath = node.path;
onselect([...next], node.path, node.isDir);
return;
}
anchorPath = node.path;
onselect([node.path], node.path, node.isDir);
function activate(node: TreeNode) {
onselect(node.path, node.isDir);
if (node.isDir) {
collapsed[node.path] = !collapsed[node.path];
} else {
@@ -123,7 +171,10 @@
function openMenu(event: MouseEvent, node: TreeNode) {
event.preventDefault();
onselect(node.path, node.isDir);
if (!selected.has(node.path)) {
anchorPath = node.path;
onselect([node.path], node.path, node.isDir);
}
menu = { path: node.path, isDir: node.isDir, x: event.clientX, y: event.clientY };
}
@@ -134,7 +185,9 @@
}
if (event.key === "Delete") {
event.preventDefault();
ondelete(node.path);
const paths =
selected.has(node.path) && selected.size > 1 ? [...selected] : [node.path];
ondelete(paths);
}
}
@@ -157,6 +210,7 @@
const index = path.lastIndexOf("/");
return index === -1 ? "" : path.slice(0, index);
}
</script>
<svelte:window
@@ -175,26 +229,28 @@
data-tree-dir={node.isDir ? "true" : "false"}
role="treeitem"
tabindex="0"
aria-selected={node.path === selected}
aria-selected={selected.has(node.path)}
draggable="true"
class="group flex items-center gap-1.5 rounded px-2 py-1 text-xs transition
{node.path === activePath
? 'bg-[var(--color-accent-soft)] text-[var(--color-accent)]'
: node.path === selected
: selected.has(node.path)
? 'bg-[var(--color-surface-sunken)]'
: 'text-[var(--color-ink)] hover:bg-[var(--color-surface-sunken)]'}
{dropTarget === node.path
? 'ring-1 ring-inset ring-[var(--color-accent)]'
: ''}"
style="padding-left: {depth * 12 + 8}px"
onclick={() => activate(node)}
onclick={(event) => handleRowClick(event, node)}
oncontextmenu={(event) => openMenu(event, node)}
onkeydown={(event) => handleKey(event, node)}
ondragstart={(event) => {
dragging = node.path;
event.dataTransfer?.setData("text/plain", node.path);
}}
ondragend={() => (dragging = null)}
ondragend={() => {
dragging = null;
}}
ondragover={(event) => {
if (!dragging || !node.isDir) return;
event.preventDefault();
@@ -254,7 +310,7 @@
>
<button
class="rounded p-1 text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
onclick={() => onnewfile(selected ?? "")}
onclick={() => onnewfile(anchorPath ?? "")}
title="New file"
aria-label="New file"
>
@@ -262,7 +318,7 @@
</button>
<button
class="rounded p-1 text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
onclick={() => onnewfolder(selected ?? "")}
onclick={() => onnewfolder(anchorPath ?? "")}
title="New folder"
aria-label="New folder"
>
@@ -270,7 +326,7 @@
</button>
<button
class="rounded p-1 text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
onclick={() => onimport(selected ?? "")}
onclick={() => onimport(anchorPath ?? "")}
title="Import files"
aria-label="Import files"
>
@@ -293,10 +349,16 @@
data-tree-path=""
data-tree-dir="true"
onclick={(event) => {
if (event.target === event.currentTarget) onselect(null, true);
if (event.target === event.currentTarget) {
anchorPath = null;
onselect([], null, true);
}
}}
onkeydown={(event) => {
if (event.key === "Escape") onselect(null, true);
if (event.key === "Escape") {
anchorPath = null;
onselect([], null, true);
}
}}
ondragover={(event) => {
if (dragging) event.preventDefault();
@@ -318,11 +380,15 @@
{#if menu}
{@const target = menu}
{@const menuPaths =
selected.has(target.path) && selected.size > 1 ? [...selected] : [target.path]}
{@const single = menuPaths.length === 1}
<div
use:clampMenu
class="fixed z-50 flex w-48 flex-col rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] py-1 text-xs shadow-lg"
style="left: {target.x}px; top: {target.y}px"
>
{#if target.isDir}
{#if single && target.isDir}
<button
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
onclick={() => onnewfile(target.path)}
@@ -342,7 +408,7 @@
Import files here
</button>
<div class="my-1 h-px bg-[var(--color-line)]"></div>
{:else if target.path.endsWith(".typ")}
{:else if single && target.path.endsWith(".typ")}
<button
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
onclick={() => onsetentry(target.path)}
@@ -352,36 +418,39 @@
<div class="my-1 h-px bg-[var(--color-line)]"></div>
{/if}
<button
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
onclick={() => onrename(target.path)}
>
Rename
</button>
<button
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
onclick={() => onduplicate(target.path)}
>
Duplicate
</button>
<button
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
onclick={() => navigator.clipboard.writeText(target.path)}
>
Copy path
</button>
<button
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
onclick={() => onreveal(target.path)}
>
Reveal in file manager
</button>
<div class="my-1 h-px bg-[var(--color-line)]"></div>
{#if single}
<button
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
onclick={() => onrename(target.path)}
>
Rename
</button>
<button
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
onclick={() => onduplicate(target.path)}
>
Duplicate
</button>
<button
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
onclick={() => navigator.clipboard.writeText(target.path)}
>
Copy path
</button>
<button
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
onclick={() => onreveal(target.path)}
>
Reveal in file manager
</button>
<div class="my-1 h-px bg-[var(--color-line)]"></div>
{/if}
<button
class="px-3 py-1.5 text-left text-[var(--color-danger)] hover:bg-[var(--color-surface-sunken)]"
onclick={() => ondelete(target.path)}
onclick={() => ondelete(menuPaths)}
>
Delete
{single ? "Delete" : `Delete ${menuPaths.length} items`}
</button>
</div>
{/if}
+433 -86
View File
@@ -1,17 +1,20 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import * as api from "$lib/ts/api";
import type { BrowseEntry, EntryKind } from "$lib/ts/api";
import type { BrowseEntry, CloudFile, CloudFolder, EntryKind } from "$lib/ts/api";
import { clampMenu } from "$lib/ts/menu-position";
import {
app,
breadcrumbs,
browseTo,
cloudBreadcrumbs,
linkedDocument,
linkedSpace,
linkedProject,
openCloudFolder,
openTarget,
refreshCloud,
refreshEntries,
setError,
} from "$lib/ts/state.svelte";
interface Props {
@@ -22,13 +25,22 @@
onrename: (entry: BrowseEntry) => void;
ondelete: (entry: BrowseEntry) => void;
onlink: (entry: BrowseEntry) => void;
onsavetocloud: (entry: BrowseEntry) => void;
onviewimage: (paths: string[], index: number) => void;
ondownloaddocument: (documentId: string, title: string) => void;
onremovedownload: (path: string) => void;
ondownloadfile: (fileId: string, name: string) => void;
onclonespace: (spaceId: string, name: string) => void;
ondeletespace: (spaceId: string) => void;
onnewspace: () => void;
ondeletefile: (fileId: string) => void;
onuploadfile: () => void;
onrenamefile: (file: CloudFile) => void;
oncloneproject: (cloudProjectId: string, name: string) => void;
ondeleteproject: (cloudProjectId: string) => void;
ondeletedocument: (documentId: string) => void;
onnewcloudproject: () => void;
onnewclouddocument: () => void;
onnewcloudfolder: () => void;
onrenamecloudfolder: (folder: CloudFolder) => void;
ondeletecloudfolder: (folder: CloudFolder) => void;
onsignin: () => void;
}
@@ -40,19 +52,45 @@
onrename,
ondelete,
onlink,
onsavetocloud,
onviewimage,
ondownloaddocument,
onremovedownload,
ondownloadfile,
onclonespace,
ondeletespace,
onnewspace,
ondeletefile,
onuploadfile,
onrenamefile,
oncloneproject,
ondeleteproject,
ondeletedocument,
onnewcloudproject,
onnewclouddocument,
onnewcloudfolder,
onrenamecloudfolder,
ondeletecloudfolder,
onsignin,
}: Props = $props();
let menuFor = $state<string | null>(null);
let menuAt = $state({ x: 0, y: 0 });
function openContextMenu(event: MouseEvent, path: string) {
event.preventDefault();
event.stopPropagation();
menuAt = { x: event.clientX, y: event.clientY };
menuFor = path;
}
let cloudMenuFor = $state<string | null>(null);
let cloudMenuAt = $state({ x: 0, y: 0 });
function openCloudContextMenu(event: MouseEvent, id: string) {
event.preventDefault();
event.stopPropagation();
cloudMenuAt = { x: event.clientX, y: event.clientY };
cloudMenuFor = id;
}
const trail = $derived(breadcrumbs());
const cloudTrail = $derived(cloudBreadcrumbs());
@@ -82,7 +120,7 @@
const pending = [
...app.linkedDocuments.map((linked) => linked.path),
...app.linkedSpaces.map((linked) => linked.path),
...app.linkedProjects.map((linked) => linked.path),
];
let cancelled = false;
@@ -167,6 +205,94 @@
.map((entry) => entry.path),
);
let dragging = $state<string | null>(null);
let dropTarget = $state<string | null>(null);
async function moveTo(source: string, destination: string) {
if (source === destination) return;
const parent = source.includes("/") ? source.slice(0, source.lastIndexOf("/")) : "";
if (parent === destination) return;
try {
await api.moveEntry(source, destination);
await refreshEntries();
} catch (error) {
setError(error);
}
}
function startDrag(event: DragEvent, path: string) {
dragging = path;
event.dataTransfer?.setData("text/plain", path);
}
function endDrag() {
dragging = null;
dropTarget = null;
}
function allowDrop(event: DragEvent, path: string) {
if (!dragging) return;
event.preventDefault();
dropTarget = path;
}
function handleDrop(event: DragEvent, destination: string) {
event.preventDefault();
const source = dragging ?? event.dataTransfer?.getData("text/plain");
dragging = null;
dropTarget = null;
if (source) moveTo(source, destination);
}
type CloudDragItem = {
kind: "project" | "document" | "folder" | "file";
id: string;
};
let cloudDragging = $state<CloudDragItem | null>(null);
let cloudDropTarget = $state<string | null>(null);
async function moveCloudItem(item: CloudDragItem, folderId: string | null) {
try {
if (item.kind === "folder") {
await api.cloudMoveFolder(item.id, folderId);
} else if (item.kind === "project") {
await api.cloudMoveProject(item.id, folderId);
} else if (item.kind === "document") {
await api.cloudMoveDocument(item.id, folderId);
} else {
await api.cloudMoveFile(item.id, folderId);
}
await refreshCloud();
} catch (error) {
setError(error);
}
}
function startCloudDrag(event: DragEvent, item: CloudDragItem) {
cloudDragging = item;
event.dataTransfer?.setData("text/plain", item.id);
}
function endCloudDrag() {
cloudDragging = null;
cloudDropTarget = null;
}
function allowCloudDrop(event: DragEvent, folderId: string) {
if (!cloudDragging) return;
if (cloudDragging.kind === "folder" && cloudDragging.id === folderId) return;
event.preventDefault();
cloudDropTarget = folderId;
}
function handleCloudDrop(event: DragEvent, folderId: string) {
event.preventDefault();
const item = cloudDragging;
cloudDragging = null;
cloudDropTarget = null;
if (item) moveCloudItem(item, folderId === "" ? null : folderId);
}
function activate(entry: BrowseEntry) {
if (entry.kind === "folder") {
browseTo(entry.path);
@@ -190,7 +316,80 @@
}
</script>
{#snippet cloudMenu(
id: string,
onopen: (() => void) | null,
ondownload: () => void,
onremove: (() => void) | null,
onrename: (() => void) | null,
ondelete: (() => void) | null,
)}
{#if cloudMenuFor === id}
<div
use:clampMenu
class="fixed z-50 flex w-44 flex-col rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] py-1 text-xs shadow-lg"
style="left: {cloudMenuAt.x}px; top: {cloudMenuAt.y}px"
>
{#if onopen}
<button
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
onclick={() => {
onopen();
cloudMenuFor = null;
}}
>
Open
</button>
{:else}
<button
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
onclick={() => {
ondownload();
cloudMenuFor = null;
}}
>
Download
</button>
{/if}
{#if onremove}
<button
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
onclick={() => {
onremove();
cloudMenuFor = null;
}}
>
Remove from this device
</button>
{/if}
{#if onrename}
<button
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
onclick={() => {
onrename();
cloudMenuFor = null;
}}
>
Rename
</button>
{/if}
{#if ondelete}
<button
class="px-3 py-1.5 text-left text-[var(--color-danger)] hover:bg-[var(--color-surface-sunken)]"
onclick={() => {
ondelete();
cloudMenuFor = null;
}}
>
Delete from cloud
</button>
{/if}
</div>
{/if}
{/snippet}
{#snippet cloudCard(
id: string,
icon: string,
title: string,
meta: string,
@@ -198,10 +397,21 @@
onopen: (() => void) | null,
ondownload: () => void,
onremove: (() => void) | null,
ondelete: (() => void) | null,
)}
{@const [dragKind, dragId] = id.split(":") as [
"project" | "document",
string,
]}
<div
class="group flex flex-col overflow-hidden rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] transition hover:border-[var(--color-accent)] hover:shadow-sm"
role="group"
oncontextmenu={(event) => openCloudContextMenu(event, id)}
draggable="true"
ondragstart={(event) => startCloudDrag(event, { kind: dragKind, id: dragId })}
ondragend={endCloudDrag}
>
{@render cloudMenu(id, onopen, ondownload, onremove, null, ondelete)}
<div
class="flex h-24 items-center justify-center overflow-hidden border-b border-[var(--color-line)] bg-[var(--color-surface-muted)]"
>
@@ -276,11 +486,11 @@
</button>
{/if}
{#if onremove}
{#if onremove || ondelete}
<button
class="rounded-md border border-[var(--color-line)] px-2 py-1.5 text-[10px] text-[var(--color-ink-muted)] transition hover:border-[var(--color-danger)] hover:text-[var(--color-danger)]"
onclick={onremove}
aria-label="Remove"
onclick={onremove ?? ondelete}
aria-label={onremove ? "Remove" : "Delete"}
>
<Icon icon="ph:trash" />
</button>
@@ -329,7 +539,8 @@
{#if menuFor === entry.path}
<div
class="fixed z-50 flex w-40 -translate-x-full flex-col rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] py-1 text-xs shadow-lg"
use:clampMenu
class="fixed z-50 flex w-40 flex-col rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] py-1 text-xs shadow-lg"
style="left: {menuAt.x}px; top: {menuAt.y}px"
>
{#if entry.kind === "project" || entry.kind === "document"}
@@ -353,7 +564,7 @@
Open image
</button>
{/if}
{#if entry.kind === "project" && !entry.space_id && app.account}
{#if entry.kind === "project" && !entry.cloud_project_id && app.account}
<button
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
onclick={() => {
@@ -364,6 +575,17 @@
Upload to cloud
</button>
{/if}
{#if entry.kind === "document" && !entry.cloud_linked && app.account}
<button
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
onclick={() => {
onsavetocloud(entry);
menuFor = null;
}}
>
Save to cloud
</button>
{/if}
<button
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
onclick={() => {
@@ -391,27 +613,51 @@
if (!(event.target as HTMLElement).closest("[data-card-menu]")) {
menuFor = null;
}
cloudMenuFor = null;
}}
/>
<div class="flex h-full flex-col bg-[var(--color-surface-muted)]">
<div
class="flex h-full flex-col bg-[var(--color-surface-muted)]"
role="presentation"
oncontextmenu={(event) => event.preventDefault()}
>
<div
class="flex items-center gap-2 border-b border-[var(--color-line)] bg-[var(--color-surface)] px-4 py-2.5"
>
<div class="flex rounded-lg bg-[var(--color-surface-sunken)] p-0.5 text-xs font-medium">
{#each [["local", "Local", "ph:hard-drives"], ["cloud", "Cloud", "ph:cloud"]] as [value, label, icon]}
{#if app.scope === "local"}
<button
class="flex items-center gap-1.5 rounded px-2 py-1 text-xs transition hover:bg-[var(--color-surface-muted)]
{app.currentDir === '' ? 'font-medium' : 'text-[var(--color-ink-muted)]'}
{dropTarget === '' ? 'bg-[var(--color-accent-soft)] text-[var(--color-accent)]' : ''}"
onclick={() => browseTo("")}
ondragover={(event) => allowDrop(event, "")}
ondragleave={() => {
if (dropTarget === "") dropTarget = null;
}}
ondrop={(event) => handleDrop(event, "")}
>
<Icon icon="ph:house" />
Workspace
</button>
{#each trail as crumb, index}
<Icon icon="ph:caret-right" class="text-[10px] text-[var(--color-ink-muted)]" />
<button
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 transition
{app.scope === value
? 'bg-[var(--color-surface)] text-[var(--color-ink)] shadow-sm'
: 'text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]'}"
onclick={() => (app.scope = value as "local" | "cloud")}
class="rounded px-2 py-1 text-xs transition hover:bg-[var(--color-surface-muted)]
{index === trail.length - 1 ? 'font-medium' : 'text-[var(--color-ink-muted)]'}
{dropTarget === crumb.path ? 'bg-[var(--color-accent-soft)] text-[var(--color-accent)]' : ''}"
onclick={() => browseTo(crumb.path)}
ondragover={(event) => allowDrop(event, crumb.path)}
ondragleave={() => {
if (dropTarget === crumb.path) dropTarget = null;
}}
ondrop={(event) => handleDrop(event, crumb.path)}
>
<Icon {icon} />
{label}
{crumb.name}
</button>
{/each}
</div>
{/if}
<div class="flex-1"></div>
@@ -432,54 +678,51 @@
</button>
<button
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
onclick={onnewdocument}
onclick={onnewproject}
>
<Icon icon="ph:file-plus" />
Document
<Icon icon="ph:folder-star" />
Project
</button>
<button
class="flex items-center gap-1.5 rounded-md bg-[var(--color-accent)] px-2.5 py-1.5 text-xs font-medium text-white transition hover:opacity-90"
onclick={onnewproject}
onclick={onnewdocument}
>
<Icon icon="ph:plus" />
New project
New document
</button>
{:else if app.account}
<button
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
onclick={onuploadfile}
>
<Icon icon="ph:upload-simple" />
Upload
</button>
<button
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
onclick={onnewcloudfolder}
>
<Icon icon="ph:folder-plus" />
Folder
</button>
<button
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
onclick={onnewcloudproject}
>
<Icon icon="ph:folder-star" />
Project
</button>
<button
class="flex items-center gap-1.5 rounded-md bg-[var(--color-accent)] px-2.5 py-1.5 text-xs font-medium text-white transition hover:opacity-90"
onclick={onnewspace}
onclick={onnewclouddocument}
>
<Icon icon="ph:plus" />
New space
New document
</button>
{/if}
</div>
{#if app.scope === "local"}
<div
class="flex items-center gap-1 border-b border-[var(--color-line)] bg-[var(--color-surface)] px-4 py-2 text-xs"
>
<button
class="flex items-center gap-1.5 rounded px-2 py-1 transition hover:bg-[var(--color-surface-muted)]
{app.currentDir === '' ? 'font-medium' : 'text-[var(--color-ink-muted)]'}"
onclick={() => browseTo("")}
>
<Icon icon="ph:house" />
Workspace
</button>
{#each trail as crumb, index}
<Icon icon="ph:caret-right" class="text-[10px] text-[var(--color-ink-muted)]" />
<button
class="rounded px-2 py-1 transition hover:bg-[var(--color-surface-muted)]
{index === trail.length - 1 ? 'font-medium' : 'text-[var(--color-ink-muted)]'}"
onclick={() => browseTo(crumb.path)}
>
{crumb.name}
</button>
{/each}
</div>
<div class="scroll-thin flex-1 overflow-y-auto p-4">
{#if app.entries.length === 0}
<div
@@ -490,16 +733,16 @@
<div class="flex gap-2">
<button
class="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-white hover:opacity-90"
onclick={onnewproject}
>
New project
</button>
<button
class="rounded-md border border-[var(--color-line)] px-3 py-1.5 text-xs hover:bg-[var(--color-surface)]"
onclick={onnewdocument}
>
New document
</button>
<button
class="rounded-md border border-[var(--color-line)] px-3 py-1.5 text-xs hover:bg-[var(--color-surface)]"
onclick={onnewproject}
>
New project
</button>
</div>
</div>
{:else}
@@ -514,7 +757,20 @@
<div class="grid grid-cols-[repeat(auto-fill,minmax(210px,1fr))] gap-2">
{#each containers as entry (entry.path)}
<div
class="group relative flex items-center gap-2.5 rounded-lg border border-[var(--color-line)] bg-[var(--color-surface-sunken)] px-3 py-2.5 transition hover:border-[var(--color-accent)] hover:bg-[var(--color-surface)]"
class="group relative flex items-center gap-2.5 rounded-lg border px-3 py-2.5 transition hover:border-[var(--color-accent)] hover:bg-[var(--color-surface)]
{dropTarget === entry.path
? 'border-[var(--color-accent)] bg-[var(--color-accent-soft)]'
: 'border-[var(--color-line)] bg-[var(--color-surface-sunken)]'}"
role="group"
draggable="true"
ondragstart={(event) => startDrag(event, entry.path)}
ondragend={endDrag}
ondragover={(event) => allowDrop(event, entry.path)}
ondragleave={() => {
if (dropTarget === entry.path) dropTarget = null;
}}
ondrop={(event) => handleDrop(event, entry.path)}
oncontextmenu={(event) => openContextMenu(event, entry.path)}
>
<button
class="flex min-w-0 flex-1 items-center gap-2.5 text-left"
@@ -566,6 +822,11 @@
{#each documents as entry (entry.path)}
<div
class="group relative flex flex-col overflow-hidden rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] transition hover:border-[var(--color-accent)] hover:shadow-md"
role="group"
draggable="true"
ondragstart={(event) => startDrag(event, entry.path)}
ondragend={endDrag}
oncontextmenu={(event) => openContextMenu(event, entry.path)}
>
<button
class="flex flex-col text-left"
@@ -658,8 +919,14 @@
class="flex items-center gap-2 rounded-lg border px-3.5 py-2 text-xs font-medium transition
{app.cloudFolder !== 'shared'
? 'border-[var(--color-accent)] bg-[var(--color-accent)] text-white shadow-sm'
: 'border-[var(--color-line)] bg-[var(--color-surface)] text-[var(--color-ink-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-ink)]'}"
: 'border-[var(--color-line)] bg-[var(--color-surface)] text-[var(--color-ink-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-ink)]'}
{cloudDropTarget === '' ? 'ring-2 ring-[var(--color-accent)]' : ''}"
onclick={() => openCloudFolder(null)}
ondragover={(event) => allowCloudDrop(event, "")}
ondragleave={() => {
if (cloudDropTarget === "") cloudDropTarget = null;
}}
ondrop={(event) => handleCloudDrop(event, "")}
>
<Icon icon="ph:cloud-fill" class="text-base" />
My Drive
@@ -684,11 +951,24 @@
{/if}
</div>
{#if app.cloudOffline}
<div class="mb-3 flex items-center gap-2 rounded-lg border border-[var(--color-line)] bg-[var(--color-surface-muted)] px-3 py-2 text-xs text-[var(--color-ink-muted)]">
<Icon icon="ph:wifi-slash" class="text-base" />
Offline — showing last synced data
</div>
{/if}
{#if cloudTrail.length > 0}
<div class="mb-3 flex flex-wrap items-center gap-1 text-xs">
<button
class="rounded px-2 py-1 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface)] hover:text-[var(--color-ink)]"
class="rounded px-2 py-1 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface)] hover:text-[var(--color-ink)]
{cloudDropTarget === '' ? 'bg-[var(--color-accent-soft)] text-[var(--color-accent)]' : ''}"
onclick={() => openCloudFolder(null)}
ondragover={(event) => allowCloudDrop(event, "")}
ondragleave={() => {
if (cloudDropTarget === "") cloudDropTarget = null;
}}
ondrop={(event) => handleCloudDrop(event, "")}
>
My Drive
</button>
@@ -702,8 +982,16 @@
class="rounded px-2 py-1 transition hover:bg-[var(--color-surface)]
{index === cloudTrail.length - 1
? 'font-medium'
: 'text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]'}"
: 'text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]'}
{cloudDropTarget === folder.id
? 'bg-[var(--color-accent-soft)] text-[var(--color-accent)]'
: ''}"
onclick={() => openCloudFolder(folder.id)}
ondragover={(event) => allowCloudDrop(event, folder.id)}
ondragleave={() => {
if (cloudDropTarget === folder.id) cloudDropTarget = null;
}}
ondrop={(event) => handleCloudDrop(event, folder.id)}
>
{folder.name}
</button>
@@ -716,16 +1004,40 @@
class="mb-4 grid grid-cols-[repeat(auto-fill,minmax(210px,1fr))] gap-2"
>
{#each app.cloudFolders as folder (folder.id)}
<button
class="flex items-center gap-2.5 rounded-lg border border-[var(--color-line)] bg-[var(--color-surface-sunken)] px-3 py-2.5 text-left transition hover:border-[var(--color-accent)] hover:bg-[var(--color-surface)]"
onclick={() => openCloudFolder(folder.id)}
>
<Icon
icon="ph:folder-fill"
class="shrink-0 text-2xl text-[var(--color-ink-muted)]"
/>
<span class="truncate text-xs font-medium">{folder.name}</span>
</button>
<div class="relative">
<button
class="flex w-full items-center gap-2.5 rounded-lg border px-3 py-2.5 text-left transition hover:border-[var(--color-accent)] hover:bg-[var(--color-surface)]
{cloudDropTarget === folder.id
? 'border-[var(--color-accent)] bg-[var(--color-accent-soft)]'
: 'border-[var(--color-line)] bg-[var(--color-surface-sunken)]'}"
onclick={() => openCloudFolder(folder.id)}
oncontextmenu={(event) =>
openCloudContextMenu(event, `folder:${folder.id}`)}
draggable="true"
ondragstart={(event) =>
startCloudDrag(event, { kind: "folder", id: folder.id })}
ondragend={endCloudDrag}
ondragover={(event) => allowCloudDrop(event, folder.id)}
ondragleave={() => {
if (cloudDropTarget === folder.id) cloudDropTarget = null;
}}
ondrop={(event) => handleCloudDrop(event, folder.id)}
>
<Icon
icon="ph:folder-fill"
class="shrink-0 text-2xl text-[var(--color-ink-muted)]"
/>
<span class="truncate text-xs font-medium">{folder.name}</span>
</button>
{@render cloudMenu(
`folder:${folder.id}`,
() => openCloudFolder(folder.id),
() => {},
null,
() => onrenamecloudfolder(folder),
() => ondeletecloudfolder(folder),
)}
</div>
{/each}
</div>
{/if}
@@ -742,6 +1054,7 @@
{#each app.cloudDocuments as entry (entry.id)}
{@const linked = linkedDocument(entry.id)}
{@render cloudCard(
`document:${entry.id}`,
"ph:file-text",
entry.title,
linked
@@ -751,6 +1064,7 @@
linked ? () => openTarget(linked.path) : null,
() => ondownloaddocument(entry.id, entry.title),
linked ? () => onremovedownload(linked.path) : null,
() => ondeletedocument(entry.id),
)}
{/each}
</div>
@@ -768,7 +1082,22 @@
{#each app.cloudFiles as file (file.id)}
<div
class="flex items-center gap-2.5 rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] p-3 transition hover:border-[var(--color-accent)]"
role="group"
oncontextmenu={(event) =>
openCloudContextMenu(event, `file:${file.id}`)}
draggable="true"
ondragstart={(event) =>
startCloudDrag(event, { kind: "file", id: file.id })}
ondragend={endCloudDrag}
>
{@render cloudMenu(
`file:${file.id}`,
null,
() => ondownloadfile(file.id, file.name),
null,
() => onrenamefile(file),
() => ondeletefile(file.id),
)}
<Icon
icon={api.isImagePath(file.name)
? "ph:image"
@@ -798,35 +1127,53 @@
</div>
{/if}
{#if app.spaces.length === 0 && app.cloudDocuments.length === 0 && app.cloudFolders.length === 0 && app.cloudFiles.length === 0}
{#if app.cloudProjects.length === 0 && app.cloudDocuments.length === 0 && app.cloudFolders.length === 0 && app.cloudFiles.length === 0}
<div
class="flex flex-col items-center justify-center gap-3 py-16 text-[var(--color-ink-muted)]"
>
<Icon icon="ph:cloud" class="text-5xl" />
<p class="text-sm">Nothing here yet.</p>
{#if app.cloudFolder !== "shared"}
<div class="flex gap-2">
<button
class="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-white hover:opacity-90"
onclick={onnewclouddocument}
>
New document
</button>
<button
class="rounded-md border border-[var(--color-line)] px-3 py-1.5 text-xs hover:bg-[var(--color-surface)]"
onclick={onnewcloudproject}
>
New project
</button>
</div>
{/if}
</div>
{/if}
{#if app.spaces.length > 0}
{#if app.cloudProjects.length > 0}
<h2
class="mb-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-ink-muted)]"
>
Spaces
Cloud projects
</h2>
{/if}
<div class="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-3">
{#each app.spaces as space (space.id)}
{@const linked = linkedSpace(space.id)}
{#each app.cloudProjects as project (project.id)}
{@const linked = linkedProject(project.id)}
{@render cloudCard(
`project:${project.id}`,
"ph:folder-star",
space.name,
project.name,
linked
? `Project · ${space.role}`
: `${space.role} · ${formatDate(space.updated_at)}`,
? `Project · ${project.role}`
: `${project.role} · ${formatDate(project.updated_at)}`,
linked,
linked ? () => openTarget(linked.path) : null,
() => onclonespace(space.id, space.name),
space.role === "owner" ? () => ondeletespace(space.id) : null,
() => oncloneproject(project.id, project.name),
null,
project.role === "owner" ? () => ondeleteproject(project.id) : null,
)}
{/each}
</div>
+482 -19
View File
@@ -5,13 +5,29 @@
import { openUrl } from "@tauri-apps/plugin-opener";
import Modal from "./Modal.svelte";
import * as api from "$lib/ts/api";
import type { AppInfo } from "$lib/ts/api";
import type { AppInfo, CompatibilityStatus } from "$lib/ts/api";
import {
HOTKEY_DEFS,
comboFromEvent,
isCustomized,
keysFor,
rebindHotkey,
resetHotkey,
} from "$lib/ts/hotkeys";
import {
app,
applyTheme,
applyColorTheme,
applyAccent,
applyTextScale,
applyReduceMotion,
applyContrast,
colorThemes,
refreshEntries,
restartAutoSync,
setError,
type ThemePreference,
type TextScale,
} from "$lib/ts/state.svelte";
interface Props {
@@ -21,21 +37,181 @@
let { onclose, onsignin }: Props = $props();
type Section = "files" | "appearance" | "account" | "about";
type Section =
| "files"
| "appearance"
| "accessibility"
| "account"
| "lsp"
| "hotkeys"
| "about";
const sections: { id: Section; label: string; icon: string }[] = [
{ id: "files", label: "Files", icon: "ph:folder" },
{ id: "appearance", label: "Appearance", icon: "ph:palette" },
{ id: "accessibility", label: "Accessibility", icon: "ph:wheelchair" },
{ id: "account", label: "Account", icon: "ph:user-circle" },
{ id: "lsp", label: "Language Server", icon: "ph:plugs-connected" },
{ id: "hotkeys", label: "Hotkeys", icon: "ph:keyboard" },
{ id: "about", label: "About", icon: "ph:info" },
];
const isMac =
typeof navigator !== "undefined" &&
/mac/i.test(navigator.platform ?? navigator.userAgent);
function formatKeys(combo: string): string[] {
const variants = combo.split(",");
const preferred =
variants.find((variant) =>
isMac ? variant.includes("command") : !variant.includes("command"),
) ?? variants[0];
return preferred.split("+").map((part) => {
switch (part) {
case "command":
return "⌘";
case "ctrl":
return "Ctrl";
case "alt":
return isMac ? "⌥" : "Alt";
case "shift":
return "Shift";
case "esc":
return "Esc";
case "space":
return "Space";
case "up":
return "↑";
case "down":
return "↓";
case "left":
return "←";
case "right":
return "→";
default:
return part.length === 1 ? part.toUpperCase() : part;
}
});
}
let editingId = $state<string | null>(null);
let hotkeyVersion = $state(0);
const editableHotkeyGroups = $derived.by(() => {
hotkeyVersion;
const groups = new Map<string, typeof HOTKEY_DEFS>();
for (const def of HOTKEY_DEFS) {
if (!groups.has(def.group)) groups.set(def.group, []);
groups.get(def.group)!.push(def);
}
return Array.from(groups.entries()).map(([title, defs]) => ({
title,
items: defs.map((def) => ({
id: def.id,
label: def.label,
keys: keysFor(def.id),
customized: isCustomized(def.id),
})),
}));
});
$effect(() => {
if (!editingId) return;
const id = editingId;
function handleCapture(event: KeyboardEvent) {
event.preventDefault();
event.stopPropagation();
if (event.key === "Escape") {
editingId = null;
return;
}
const combo = comboFromEvent(event);
if (!combo) return;
rebindHotkey(id, combo);
hotkeyVersion++;
editingId = null;
}
window.addEventListener("keydown", handleCapture, true);
return () => window.removeEventListener("keydown", handleCapture, true);
});
const staticHotkeyGroups: { title: string; items: { keys: string[]; label: string }[] }[] = [
{
title: "File browser",
items: [
{ keys: ["F2"], label: "Rename selected entry" },
{ keys: ["Delete"], label: "Delete selected entries" },
{ keys: ["Esc"], label: "Cancel rename or clear selection" },
],
},
{
title: "Image viewer",
items: [
{ keys: ["←"], label: "Previous image" },
{ keys: ["→"], label: "Next image" },
{ keys: ["Esc"], label: "Close viewer" },
],
},
{
title: "General",
items: [{ keys: ["Esc"], label: "Close dialog" }],
},
];
const lspLabel: Record<string, string> = {
off: "Off",
starting: "Starting…",
on: "Running",
unavailable: "Unavailable",
};
const accentPresets = [
"#3b6cf6",
"#7c5cfc",
"#22b573",
"#f2994a",
"#ec4899",
"#14b8a6",
];
const textScaleOptions: { value: TextScale; label: string }[] = [
{ value: "small", label: "Small" },
{ value: "default", label: "Default" },
{ value: "large", label: "Large" },
{ value: "xlarge", label: "Extra large" },
];
let section = $state<Section>("files");
let workspaceRoot = $state(untrack(() => app.settings?.workspace_root ?? ""));
let serverUrl = $state(untrack(() => app.settings?.server_url ?? ""));
let compatibility = $state<CompatibilityStatus | null>(null);
let checkingCompatibility = $state(false);
let compatibilityTimer: ReturnType<typeof setTimeout> | undefined;
$effect(() => {
const url = serverUrl.trim();
clearTimeout(compatibilityTimer);
if (!url) {
compatibility = null;
return;
}
compatibilityTimer = setTimeout(async () => {
checkingCompatibility = true;
try {
compatibility = await api.cloudCheckCompatibility(url);
} catch {
compatibility = null;
} finally {
checkingCompatibility = false;
}
}, 600);
});
let autosaveSeconds = $state(untrack(() => app.settings?.autosave_seconds ?? 0));
let syncMinutes = $state(untrack(() => app.settings?.sync_minutes ?? 0));
let syncSeconds = $state(untrack(() => app.settings?.sync_seconds ?? 0));
let saving = $state(false);
let info = $state<AppInfo | null>(null);
@@ -57,9 +233,11 @@
const syncOptions = [
{ value: 0, label: "Off" },
{ value: 1, label: "1 minute" },
{ value: 2, label: "2 minutes" },
{ value: 5, label: "5 minutes" },
{ value: 15, label: "15 seconds" },
{ value: 30, label: "30 seconds" },
{ value: 60, label: "1 minute" },
{ value: 120, label: "2 minutes" },
{ value: 300, label: "5 minutes" },
];
const links = [
@@ -73,6 +251,16 @@
url: "https://typst.app/universe/",
icon: "ph:planet",
},
{
label: "GitHub Repository",
url: "https://github.com/SirBlobby/typst-desktop",
icon: "ph:github-logo",
},
{
label: "Report an Issue",
url: "https://github.com/SirBlobby/typst-desktop/issues",
icon: "ph:bug",
},
];
async function browse() {
@@ -89,7 +277,7 @@
workspaceRoot,
serverUrl,
autosaveSeconds,
syncMinutes,
syncSeconds,
});
restartAutoSync();
await refreshEntries();
@@ -105,7 +293,7 @@
try {
await api.cloudLogout();
app.account = null;
app.spaces = [];
app.cloudProjects = [];
app.settings = await api.getSettings();
} catch (error) {
setError(error);
@@ -163,7 +351,9 @@
{/each}
</select>
<span class="text-[var(--color-ink-muted)]">
Saves the file being edited after you stop typing.
Saves the file being edited after you stop typing. Cloud-linked
files always save to disk every few seconds regardless of this
setting, since they sync live and have no manual Save button.
</span>
</div>
</div>
@@ -172,13 +362,13 @@
<div class="flex flex-col gap-2 text-xs">
<span class="font-medium text-[var(--color-ink-muted)]">Theme</span>
<div class="flex gap-2">
{#each [["light", "Light", "ph:sun"], ["dark", "Dark", "ph:moon"]] as [value, label, icon]}
{#each [["light", "Light", "ph:sun"], ["dark", "Dark", "ph:moon"], ["system", "System", "ph:desktop"]] as [value, label, icon]}
<button
class="flex flex-1 items-center justify-center gap-1.5 rounded-md border px-3 py-2.5 transition
{app.theme === value
{app.themePreference === value
? 'border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-accent)]'
: 'border-[var(--color-line)] text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-muted)]'}"
onclick={() => applyTheme(value as "light" | "dark")}
onclick={() => applyTheme(value as ThemePreference)}
>
<Icon {icon} class="text-base" />
{label}
@@ -186,7 +376,135 @@
{/each}
</div>
<span class="text-[var(--color-ink-muted)]">
Applies immediately to the editor and preview.
System follows your OS setting and updates live.
</span>
</div>
<div class="flex flex-col gap-2 text-xs">
<span class="font-medium text-[var(--color-ink-muted)]">Color theme</span>
<div class="flex flex-wrap gap-2">
{#each colorThemes as entry}
<button
class="flex items-center gap-1.5 rounded-md border px-3 py-2 transition
{app.colorTheme === entry.id
? 'border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-accent)]'
: 'border-[var(--color-line)] text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-muted)]'}"
onclick={() => applyColorTheme(entry.id)}
>
<span
class="h-3 w-3 rounded-full"
style="background-color: {entry.accent}"
></span>
{entry.label}
</button>
{/each}
</div>
<span class="text-[var(--color-ink-muted)]">
Sets the surface colors and default accent for the app.
</span>
</div>
<div class="flex flex-col gap-2 text-xs">
<span class="font-medium text-[var(--color-ink-muted)]">
Accent color
</span>
<div class="flex items-center gap-2">
<button
class="flex h-7 w-7 items-center justify-center rounded-full border transition
{app.accent === null
? 'border-[var(--color-accent)] text-[var(--color-accent)]'
: 'border-[var(--color-line)] text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-muted)]'}"
title="Default"
onclick={() => applyAccent(null)}
>
<Icon icon="ph:arrow-counter-clockwise" class="text-sm" />
</button>
{#each accentPresets as preset}
<button
class="h-7 w-7 rounded-full border-2 transition
{app.accent === preset
? 'border-[var(--color-ink)]'
: 'border-transparent hover:opacity-80'}"
style="background-color: {preset}"
title={preset}
onclick={() => applyAccent(preset)}
></button>
{/each}
<input
type="color"
class="h-7 w-7 cursor-pointer rounded-full border border-[var(--color-line)] bg-transparent p-0"
value={app.accent ?? "#3b6cf6"}
title="Custom color"
oninput={(event) =>
applyAccent((event.target as HTMLInputElement).value)}
/>
</div>
<span class="text-[var(--color-ink-muted)]">
Overrides the accent color used across the app.
</span>
</div>
</div>
{:else if section === "accessibility"}
<div class="flex flex-col gap-5">
<div class="flex flex-col gap-2 text-xs">
<span class="font-medium text-[var(--color-ink-muted)]">
UI text scale
</span>
<div class="flex gap-2">
{#each textScaleOptions as option}
<button
class="flex flex-1 items-center justify-center rounded-md border px-3 py-2.5 transition
{app.textScale === option.value
? 'border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-accent)]'
: 'border-[var(--color-line)] text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-muted)]'}"
onclick={() => applyTextScale(option.value)}
>
{option.label}
</button>
{/each}
</div>
<span class="text-[var(--color-ink-muted)]">
Scales text and controls throughout the app.
</span>
</div>
<div class="flex flex-col gap-2 text-xs">
<label class="flex items-center gap-2">
<input
type="checkbox"
checked={app.reduceMotion}
onchange={(event) =>
applyReduceMotion(
(event.target as HTMLInputElement).checked,
)}
/>
<span class="font-medium text-[var(--color-ink-muted)]">
Reduce motion
</span>
</label>
<span class="text-[var(--color-ink-muted)]">
Shortens transitions and animations across the app.
</span>
</div>
<div class="flex flex-col gap-2 text-xs">
<label class="flex items-center gap-2">
<input
type="checkbox"
checked={app.contrast === "high"}
onchange={(event) =>
applyContrast(
(event.target as HTMLInputElement).checked
? "high"
: "normal",
)}
/>
<span class="font-medium text-[var(--color-ink-muted)]">
High contrast
</span>
</label>
<span class="text-[var(--color-ink-muted)]">
Increases contrast for borders, muted text, and focus outlines.
</span>
</div>
</div>
@@ -234,27 +552,166 @@
TypstDrive server
</span>
<input class={fieldClass} bind:value={serverUrl} />
{#if checkingCompatibility}
<span class="flex items-center gap-1 text-[var(--color-ink-muted)]">
<Icon icon="ph:circle-notch" class="animate-spin" />
Checking server version...
</span>
{:else if compatibility && !compatibility.compatible}
<span class="flex items-center gap-1 text-[var(--color-danger)]">
<Icon icon="ph:warning-circle" />
{compatibility.message}
</span>
{:else if compatibility && compatibility.compatible}
<span class="flex items-center gap-1 text-[var(--color-success)]">
<Icon icon="ph:check-circle" />
Compatible (server v{compatibility.server_version})
</span>
{/if}
</div>
<div class="flex flex-col gap-1 text-xs">
<span class="font-medium text-[var(--color-ink-muted)]">
Automatic sync
</span>
<select class={fieldClass} bind:value={syncMinutes}>
<select class={fieldClass} bind:value={syncSeconds}>
{#each syncOptions as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
<span class="text-[var(--color-ink-muted)]">
Pulls and pushes cloud-linked projects on a timer. Conflicts pause
syncing until they are resolved.
Pulls and pushes cloud-linked projects on a timer. Used as a
fallback when live sync (websocket) is unavailable. Conflicts
pause syncing until they are resolved.
</span>
</div>
</div>
{:else if section === "lsp"}
<div class="flex flex-col gap-5">
<div class="flex items-center gap-3 rounded-md border border-[var(--color-line)] bg-[var(--color-surface-muted)] p-3">
<span
class="h-2.5 w-2.5 rounded-full
{app.lspStatus === 'on'
? 'bg-[var(--color-success)]'
: app.lspStatus === 'starting'
? 'bg-[var(--color-accent)]'
: 'bg-[var(--color-ink-muted)]'}"
></span>
<div class="text-xs">
<p class="font-medium">{lspLabel[app.lspStatus]}</p>
<p class="text-[var(--color-ink-muted)]">
Reflects the file currently open in the editor.
</p>
</div>
</div>
<p class="text-xs text-[var(--color-ink-muted)]">
Typst Desktop uses <span class="font-medium">tinymist</span>, the
official Typst language server, to provide autocomplete, diagnostics,
and hover info while editing. It must be installed and available on
your system PATH — Typst Desktop does not bundle or install it for
you.
</p>
{#if app.lspStatus === "unavailable"}
<div
class="rounded-md border border-[var(--color-danger)]/30 bg-[var(--color-danger)]/10 p-3 text-xs text-[var(--color-danger)]"
>
tinymist could not be started. Confirm it's installed and on your
PATH, then reopen the file.
</div>
{/if}
</div>
{:else if section === "hotkeys"}
<div class="flex flex-col gap-5">
<p class="text-xs text-[var(--color-ink-muted)]">
Click the pencil next to a shortcut and press a new key combination.
Press Esc while listening to cancel.
</p>
{#each editableHotkeyGroups as group}
<div class="flex flex-col gap-1.5">
<span class="text-xs font-semibold uppercase tracking-wide text-[var(--color-ink-muted)]">
{group.title}
</span>
<div class="flex flex-col divide-y divide-[var(--color-line)] rounded-md border border-[var(--color-line)]">
{#each group.items as item}
<div class="flex items-center justify-between gap-4 px-3 py-2">
<span class="text-xs">{item.label}</span>
{#if editingId === item.id}
<span class="text-xs text-[var(--color-accent)]">
Press keys… (Esc to cancel)
</span>
{:else}
<span class="flex shrink-0 items-center gap-1">
{#each formatKeys(item.keys) as key}
<kbd
class="rounded border border-[var(--color-line)] bg-[var(--color-surface-muted)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-ink-muted)]"
>
{key}
</kbd>
{/each}
{#if item.customized}
<button
class="rounded p-1 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
title="Reset to default"
aria-label="Reset to default"
onclick={() => {
resetHotkey(item.id);
hotkeyVersion++;
}}
>
<Icon icon="ph:arrow-counter-clockwise" class="text-xs" />
</button>
{/if}
<button
class="rounded p-1 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
title="Change shortcut"
aria-label="Change shortcut"
onclick={() => (editingId = item.id)}
>
<Icon icon="ph:pencil-simple" class="text-xs" />
</button>
</span>
{/if}
</div>
{/each}
</div>
</div>
{/each}
{#each staticHotkeyGroups as group}
<div class="flex flex-col gap-1.5">
<span class="text-xs font-semibold uppercase tracking-wide text-[var(--color-ink-muted)]">
{group.title}
</span>
<div class="flex flex-col divide-y divide-[var(--color-line)] rounded-md border border-[var(--color-line)]">
{#each group.items as item}
<div class="flex items-center justify-between gap-4 px-3 py-2">
<span class="text-xs">{item.label}</span>
<span class="flex shrink-0 items-center gap-1">
{#each item.keys as key}
<kbd
class="rounded border border-[var(--color-line)] bg-[var(--color-surface-muted)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-ink-muted)]"
>
{key}
</kbd>
{/each}
</span>
</div>
{/each}
</div>
</div>
{/each}
</div>
{:else}
<div class="flex flex-col gap-4">
<div class="flex items-center gap-3">
<Icon icon="ph:file-code" class="text-3xl text-[var(--color-accent)]" />
<span
class="flex h-16 w-16 items-center justify-center rounded-lg bg-[var(--color-accent)]"
>
<img src="/favicon.png" alt="Typst Desktop" class="h-11 w-11" />
</span>
<div>
<p class="text-sm font-semibold">Typst Desktop</p>
<p class="text-xs text-[var(--color-ink-muted)]">
@@ -305,13 +762,19 @@
</div>
{#snippet footer()}
{@const draftless =
section === "about" ||
section === "appearance" ||
section === "accessibility" ||
section === "lsp" ||
section === "hotkeys"}
<button
class="rounded-md px-3 py-1.5 text-xs text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)]"
onclick={onclose}
>
{section === "about" || section === "appearance" ? "Close" : "Cancel"}
{draftless ? "Close" : "Cancel"}
</button>
{#if section !== "about" && section !== "appearance"}
{#if !draftless}
<button
class="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-white transition hover:opacity-90 disabled:opacity-50"
disabled={saving}
+121 -20
View File
@@ -7,7 +7,7 @@ export interface Settings {
account_email: string | null;
account_username: string | null;
autosave_seconds: number;
sync_minutes: number;
sync_seconds: number;
}
export interface FileEntry {
@@ -53,10 +53,11 @@ export interface Account {
email: string;
}
export interface SpaceSummary {
export interface ProjectSummary {
id: string;
name: string;
entrypoint: string;
folder_id: string | null;
role: string;
updated_at: string;
}
@@ -94,7 +95,7 @@ export interface BrowseEntry {
kind: EntryKind;
size: number;
modified: string | null;
space_id: string | null;
cloud_project_id: string | null;
last_synced_at: string | null;
child_count: number;
cloud_linked: boolean;
@@ -106,7 +107,7 @@ export interface TargetInfo {
entrypoint: string;
standalone: boolean;
is_project: boolean;
space_id: string | null;
cloud_project_id: string | null;
files: FileEntry[];
}
@@ -167,6 +168,9 @@ export const exportTarget = (
destination: string,
) => invoke<string>("export_target", { path, format, destination });
export const renderTargetPng = (path: string) =>
invoke<number[]>("render_target_png", { path });
export interface Asset {
name: string;
kind: "font" | "image" | "file";
@@ -248,9 +252,21 @@ export const updateSettings = (changes: {
workspaceRoot?: string;
serverUrl?: string;
autosaveSeconds?: number;
syncMinutes?: number;
syncSeconds?: number;
}) => invoke<Settings>("update_settings", changes);
export interface CompatibilityStatus {
compatible: boolean;
server_version: string;
desktop_version: string;
min_server_version: string;
min_desktop_version: string;
message: string | null;
}
export const cloudCheckCompatibility = (serverUrl: string) =>
invoke<CompatibilityStatus>("cloud_check_compatibility", { serverUrl });
export const cloudLogin = (
serverUrl: string,
email: string,
@@ -261,8 +277,26 @@ export const cloudLogout = () => invoke<void>("cloud_logout");
export const cloudAccount = () => invoke<Account | null>("cloud_account");
export const cloudListSpaces = () =>
invoke<SpaceSummary[]>("cloud_list_spaces");
export const cloudWsStart = () => invoke<void>("cloud_ws_start");
export const cloudWsStop = () => invoke<void>("cloud_ws_stop");
export const cloudWsStatus = () => invoke<string>("cloud_ws_status");
export interface DeviceEvent {
kind: "project" | "document" | "structure";
project_id: string | null;
document_id: string | null;
}
export const getCloudCache = (key: string) =>
invoke<string | null>("get_cloud_cache", { key });
export const saveCloudCache = (key: string, payload: string) =>
invoke<void>("save_cloud_cache", { key, payload });
export const cloudListProjects = () =>
invoke<ProjectSummary[]>("cloud_list_projects");
export interface CloudFolder {
id: string;
@@ -280,7 +314,7 @@ export interface CloudDocument {
export interface SharedItems {
documents: CloudDocument[];
spaces: SpaceSummary[];
projects: ProjectSummary[];
}
export interface DocumentLink {
@@ -293,6 +327,21 @@ export interface DocumentLink {
export const cloudListFolders = () =>
invoke<CloudFolder[]>("cloud_list_folders");
export const cloudCreateFolder = (name: string, parentId?: string | null) =>
invoke<CloudFolder>("cloud_create_folder", {
name,
parentId: parentId ?? null,
});
export const cloudRenameFolder = (folderId: string, name: string) =>
invoke<CloudFolder>("cloud_rename_folder", { folderId, name });
export const cloudMoveFolder = (folderId: string, parentId: string | null) =>
invoke<CloudFolder>("cloud_move_folder", { folderId, parentId });
export const cloudDeleteFolder = (folderId: string) =>
invoke<void>("cloud_delete_folder", { folderId });
export const cloudListDocuments = (folderId?: string | null) =>
invoke<CloudDocument[]>("cloud_list_documents", {
folderId: folderId ?? null,
@@ -314,12 +363,50 @@ export const cloudListFiles = (folderId?: string | null) =>
export const cloudDownloadFile = (fileId: string) =>
invoke<string>("cloud_download_file", { fileId });
export const cloudDeleteFile = (fileId: string) =>
invoke<void>("cloud_delete_file", { fileId });
export const cloudUploadFile = (path: string, folderId?: string | null) =>
invoke<CloudFile>("cloud_upload_file", { path, folderId: folderId ?? null });
export const cloudRenameFile = (fileId: string, name: string) =>
invoke<CloudFile>("cloud_rename_file", { fileId, name });
export const cloudMoveFile = (fileId: string, folderId: string | null) =>
invoke<CloudFile>("cloud_move_file", { fileId, folderId });
export const cloudDownloadDocument = (documentId: string, parent: string) =>
invoke<string>("cloud_download_document", { documentId, parent });
export const cloudDeleteDocument = (documentId: string) =>
invoke<void>("cloud_delete_document", { documentId });
export const cloudCreateDocument = (path: string, title: string) =>
invoke<string>("cloud_create_document", { path, title });
export const cloudMoveDocument = (documentId: string, folderId: string | null) =>
invoke<CloudDocument>("cloud_move_document", { documentId, folderId });
export interface DocumentContent {
id: string;
title: string;
role: string;
hash: string;
content: string;
}
export const cloudNewDocument = (title: string, folderId?: string | null) =>
invoke<DocumentContent>("cloud_new_document", {
title,
folderId: folderId ?? null,
});
export const cloudSyncDocument = (path: string) =>
invoke<SyncReport>("cloud_sync_document", { path });
export const cloudRoomId = (path: string, file: string) =>
invoke<string | null>("cloud_room_id", { path, file });
export const cloudResolveDocument = (
path: string,
content: string,
@@ -333,9 +420,9 @@ export interface LinkedDocument {
sync_state: "synced" | "pending" | null;
}
export interface LinkedSpace {
export interface LinkedProject {
path: string;
space_id: string;
cloud_project_id: string;
synced_at: string | null;
sync_state: "synced" | "pending" | null;
}
@@ -343,8 +430,8 @@ export interface LinkedSpace {
export const cloudLinkedDocuments = () =>
invoke<LinkedDocument[]>("cloud_linked_documents");
export const cloudLinkedSpaces = () =>
invoke<LinkedSpace[]>("cloud_linked_spaces");
export const cloudLinkedProjects = () =>
invoke<LinkedProject[]>("cloud_linked_projects");
export const cloudDocumentLink = (path: string) =>
invoke<DocumentLink | null>("cloud_document_link", { path });
@@ -352,17 +439,31 @@ export const cloudDocumentLink = (path: string) =>
export const cloudUnlinkDocument = (path: string) =>
invoke<void>("cloud_unlink_document", { path });
export const cloudCreateSpace = (name: string) =>
invoke<SpaceSummary>("cloud_create_space", { name });
export const cloudCreateProject = (name: string, folderId?: string | null) =>
invoke<ProjectSummary>("cloud_create_project", {
name,
folderId: folderId ?? null,
});
export const cloudDeleteSpace = (spaceId: string) =>
invoke<void>("cloud_delete_space", { spaceId });
export const cloudDeleteProject = (cloudProjectId: string) =>
invoke<void>("cloud_delete_project", { cloudProjectId });
export const cloudCloneSpace = (spaceId: string, projectName: string) =>
invoke<SyncReport>("cloud_clone_space", { spaceId, projectName });
export const cloudMoveProject = (
cloudProjectId: string,
folderId: string | null,
) => invoke<ProjectSummary>("cloud_move_project", { cloudProjectId, folderId });
export const cloudLinkProject = (project: string, spaceId?: string) =>
invoke<SyncReport>("cloud_link_project", { project, spaceId: spaceId ?? null });
export const cloudCloneProject = (
cloudProjectId: string,
projectName: string,
parent: string,
) => invoke<SyncReport>("cloud_clone_project", { cloudProjectId, projectName, parent });
export const cloudLinkProject = (project: string, cloudProjectId?: string) =>
invoke<SyncReport>("cloud_link_project", {
project,
cloudProjectId: cloudProjectId ?? null,
});
export const cloudUnlinkProject = (project: string) =>
invoke<void>("cloud_unlink_project", { project });
+41 -10
View File
@@ -18,17 +18,48 @@ export function wrapSelection(
placeholder = "",
) {
if (!view) return;
const selection = view.state.selection.main;
const selected = view.state.doc.sliceString(selection.from, selection.to);
const body = selected || placeholder;
const { state } = view;
const selection = state.selection.main;
const selected = state.doc.sliceString(selection.from, selection.to);
view.dispatch({
changes: { from: selection.from, to: selection.to, insert: prefix + body + suffix },
selection: {
anchor: selection.from + prefix.length,
head: selection.from + prefix.length + body.length,
},
});
const innerWrapped =
selected.length >= prefix.length + suffix.length &&
selected.startsWith(prefix) &&
selected.endsWith(suffix);
const before = state.doc.sliceString(
Math.max(0, selection.from - prefix.length),
selection.from,
);
const after = state.doc.sliceString(
selection.to,
Math.min(state.doc.length, selection.to + suffix.length),
);
const outerWrapped = before === prefix && after === suffix;
if (innerWrapped) {
const inner = selected.slice(prefix.length, selected.length - suffix.length);
view.dispatch({
changes: { from: selection.from, to: selection.to, insert: inner },
selection: { anchor: selection.from, head: selection.from + inner.length },
});
} else if (outerWrapped) {
const from = selection.from - prefix.length;
const to = selection.to + suffix.length;
view.dispatch({
changes: { from, to, insert: selected },
selection: { anchor: from, head: from + selected.length },
});
} else {
const body = selected || placeholder;
view.dispatch({
changes: { from: selection.from, to: selection.to, insert: prefix + body + suffix },
selection: {
anchor: selection.from + prefix.length,
head: selection.from + prefix.length + body.length,
},
});
}
view.focus();
}
+1 -1
View File
@@ -40,7 +40,7 @@ const palette: Record<"light" | "dark", ThemeColors> = {
background: "#16181d",
surface: "#1d2026",
text: "#eef0f4",
selection: "#2f3a52",
selection: "#3f5a91",
activeLine: "#1d2026",
cursor: "#6b93ff",
border: "#2f343d",
+154
View File
@@ -0,0 +1,154 @@
import hotkeys from "hotkeys-js";
// The default filter ignores any contenteditable target, which silently
// blocks every shortcut while the CodeMirror editor (contenteditable) has
// focus — exactly when these shortcuts are meant to fire. Only keep the
// exclusion for classic form fields (rename dialogs, prompts, etc).
hotkeys.filter = (event: KeyboardEvent) => {
const target = (event.target as HTMLElement | null) ?? null;
const tagName = target?.tagName;
return tagName !== "INPUT" && tagName !== "TEXTAREA" && tagName !== "SELECT";
};
export interface HotkeyDef {
id: string;
group: string;
label: string;
defaultKeys: string;
}
export const HOTKEY_DEFS: HotkeyDef[] = [
{ id: "save", group: "Editor", label: "Save and compile", defaultKeys: "command+s,ctrl+s" },
{ id: "undo", group: "Editor", label: "Undo", defaultKeys: "command+z,ctrl+z" },
{
id: "redo",
group: "Editor",
label: "Redo",
defaultKeys: "command+shift+z,ctrl+shift+z,ctrl+y",
},
{
id: "toggleSidebar",
group: "Editor",
label: "Toggle file sidebar",
defaultKeys: "command+shift+b,ctrl+shift+b",
},
{ id: "bold", group: "Formatting", label: "Bold (toggle)", defaultKeys: "command+b,ctrl+b" },
{ id: "italic", group: "Formatting", label: "Italic (toggle)", defaultKeys: "command+i,ctrl+i" },
{
id: "underline",
group: "Formatting",
label: "Underline (toggle)",
defaultKeys: "command+u,ctrl+u",
},
{
id: "strikethrough",
group: "Formatting",
label: "Strikethrough (toggle)",
defaultKeys: "command+shift+x,ctrl+shift+x",
},
{ id: "link", group: "Formatting", label: "Insert link", defaultKeys: "command+k,ctrl+k" },
{
id: "numberedList",
group: "Formatting",
label: "Numbered list",
defaultKeys: "command+shift+7,ctrl+shift+7",
},
{
id: "bulletedList",
group: "Formatting",
label: "Bulleted list",
defaultKeys: "command+shift+8,ctrl+shift+8",
},
...[1, 2, 3, 4, 5, 6].map((level) => ({
id: `heading${level}`,
group: "Formatting",
label: `Heading level ${level}`,
defaultKeys: `command+alt+${level},ctrl+alt+${level}`,
})),
];
const STORAGE_KEY = "hotkey-overrides";
function loadOverrides(): Record<string, string> {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}");
} catch {
return {};
}
}
function saveOverrides(overrides: Record<string, string>) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(overrides));
}
export function keysFor(id: string): string {
const def = HOTKEY_DEFS.find((entry) => entry.id === id);
const overrides = loadOverrides();
return overrides[id] ?? def?.defaultKeys ?? "";
}
export function isCustomized(id: string): boolean {
return id in loadOverrides();
}
const registered = new Map<string, { keys: string; handler: (event: KeyboardEvent) => void }>();
export function registerHotkey(id: string, handler: (event: KeyboardEvent) => void) {
const keys = keysFor(id);
if (!keys) return;
hotkeys(keys, handler);
registered.set(id, { keys, handler });
}
export function unregisterAll() {
for (const { keys } of registered.values()) hotkeys.unbind(keys);
registered.clear();
}
export function rebindHotkey(id: string, newKeys: string) {
const entry = registered.get(id);
if (!entry) return;
hotkeys.unbind(entry.keys);
hotkeys(newKeys, entry.handler);
registered.set(id, { keys: newKeys, handler: entry.handler });
const def = HOTKEY_DEFS.find((item) => item.id === id);
const overrides = loadOverrides();
if (def && def.defaultKeys === newKeys) {
delete overrides[id];
} else {
overrides[id] = newKeys;
}
saveOverrides(overrides);
}
export function resetHotkey(id: string) {
const def = HOTKEY_DEFS.find((entry) => entry.id === id);
if (def) rebindHotkey(id, def.defaultKeys);
}
const NAMED_KEYS: Record<string, string> = {
" ": "space",
Escape: "esc",
ArrowUp: "up",
ArrowDown: "down",
ArrowLeft: "left",
ArrowRight: "right",
};
export function comboFromEvent(event: KeyboardEvent): string | null {
if (["Control", "Shift", "Alt", "Meta"].includes(event.key)) return null;
const parts: string[] = [];
if (event.ctrlKey) parts.push("ctrl");
if (event.metaKey) parts.push("command");
if (event.altKey) parts.push("alt");
if (event.shiftKey) parts.push("shift");
const key = event.key;
const mainKey = key.length === 1 ? key.toLowerCase() : (NAMED_KEYS[key] ?? key.toLowerCase());
parts.push(mainKey);
return parts.join("+");
}
+20
View File
@@ -0,0 +1,20 @@
export function clampMenu(node: HTMLElement) {
const margin = 8;
const rect = node.getBoundingClientRect();
let left = rect.left;
let top = rect.top;
if (rect.right > window.innerWidth - margin) {
left = window.innerWidth - rect.width - margin;
}
if (rect.bottom > window.innerHeight - margin) {
top = window.innerHeight - rect.height - margin;
}
left = Math.max(margin, left);
top = Math.max(margin, top);
node.style.left = `${left}px`;
node.style.top = `${top}px`;
}
+423 -40
View File
@@ -1,4 +1,10 @@
import { listen } from "@tauri-apps/api/event";
import * as api from "./api";
import {
openCollabSession,
closeCollabSession,
type CollabSession,
} from "./yjs-client";
import type {
Account,
BrowseEntry,
@@ -7,18 +13,31 @@ import type {
CloudFolder,
CompileResult,
Conflict,
DeviceEvent,
Diagnostic,
DocumentLink,
LinkedDocument,
LinkedSpace,
LinkedProject,
ProjectSummary,
Settings,
SpaceSummary,
TargetInfo,
} from "./api";
export type Scope = "local" | "cloud";
export type View = "files" | "editor";
export type LspStatus = "off" | "starting" | "on" | "unavailable";
export type ThemePreference = "light" | "dark" | "system";
export type TextScale = "small" | "default" | "large" | "xlarge";
export type ContrastLevel = "normal" | "high";
export type ColorTheme = "default" | "slate" | "sunset" | "forest" | "grape";
export const colorThemes: { id: ColorTheme; label: string; accent: string }[] = [
{ id: "default", label: "Default", accent: "#3b6cf6" },
{ id: "slate", label: "Slate", accent: "#0f9b8e" },
{ id: "sunset", label: "Sunset", accent: "#e8623f" },
{ id: "forest", label: "Forest", accent: "#2f9457" },
{ id: "grape", label: "Grape", accent: "#8b47d6" },
];
interface AppState {
view: View;
@@ -28,15 +47,17 @@ interface AppState {
currentDir: string;
entries: BrowseEntry[];
spaces: SpaceSummary[];
cloudProjects: ProjectSummary[];
cloudFolder: string | null | "shared";
cloudFolders: CloudFolder[];
cloudFolderTree: CloudFolder[];
cloudDocuments: CloudDocument[];
cloudFiles: CloudFile[];
cloudLoading: boolean;
cloudOffline: boolean;
wsStatus: string;
linkedDocuments: LinkedDocument[];
linkedSpaces: LinkedSpace[];
linkedProjects: LinkedProject[];
documentLink: DocumentLink | null;
target: TargetInfo | null;
@@ -51,9 +72,19 @@ interface AppState {
download: DownloadProgress | null;
syncing: boolean;
conflicts: Conflict[];
collab: CollabSession | null;
collabIntent: boolean;
collabStatus: "connecting" | "connected" | "offline" | null;
collabConflict: Conflict | null;
status: string;
error: string;
theme: "light" | "dark";
themePreference: ThemePreference;
colorTheme: ColorTheme;
accent: string | null;
textScale: TextScale;
reduceMotion: boolean;
contrast: ContrastLevel;
}
export const app = $state<AppState>({
@@ -64,15 +95,17 @@ export const app = $state<AppState>({
currentDir: "",
entries: [],
spaces: [],
cloudProjects: [],
cloudFolder: null,
cloudFolders: [],
cloudFolderTree: [],
cloudDocuments: [],
cloudFiles: [],
cloudLoading: false,
cloudOffline: false,
wsStatus: "offline",
linkedDocuments: [],
linkedSpaces: [],
linkedProjects: [],
documentLink: null,
target: null,
@@ -87,9 +120,19 @@ export const app = $state<AppState>({
download: null,
syncing: false,
conflicts: [],
collab: null,
collabIntent: false,
collabStatus: null,
collabConflict: null,
status: "",
error: "",
theme: "light",
themePreference: "light",
colorTheme: "default",
accent: null,
textScale: "default",
reduceMotion: false,
contrast: "normal",
});
export interface DownloadProgress {
@@ -132,10 +175,146 @@ export function clearMessages() {
app.error = "";
}
export function applyTheme(theme: "light" | "dark") {
app.theme = theme;
document.documentElement.dataset.theme = theme;
localStorage.setItem("typst-desktop-theme", theme);
const THEME_KEY = "typst-desktop-theme";
const COLOR_THEME_KEY = "typst-desktop-color-theme";
const ACCENT_KEY = "typst-desktop-accent";
const TEXT_SCALE_KEY = "typst-desktop-text-scale";
const REDUCE_MOTION_KEY = "typst-desktop-reduce-motion";
const CONTRAST_KEY = "typst-desktop-contrast";
const TEXT_SCALE_PX: Record<TextScale, number> = {
small: 14,
default: 16,
large: 18,
xlarge: 20,
};
let systemThemeQuery: MediaQueryList | null = null;
function resolveTheme(preference: ThemePreference): "light" | "dark" {
if (preference !== "system") return preference;
if (!systemThemeQuery) {
systemThemeQuery = window.matchMedia("(prefers-color-scheme: dark)");
systemThemeQuery.addEventListener("change", () => {
if (app.themePreference === "system") applyTheme("system");
});
}
return systemThemeQuery.matches ? "dark" : "light";
}
export function applyTheme(preference: ThemePreference) {
app.themePreference = preference;
app.theme = resolveTheme(preference);
document.documentElement.dataset.theme = app.theme;
localStorage.setItem(THEME_KEY, preference);
if (app.accent) applyAccent(app.accent);
}
export function applyColorTheme(theme: ColorTheme) {
app.colorTheme = theme;
document.documentElement.dataset.colorTheme = theme;
localStorage.setItem(COLOR_THEME_KEY, theme);
applyAccent(null);
}
function hexToRgb(hex: string): [number, number, number] {
const value = parseInt(hex.replace("#", ""), 16);
return [(value >> 16) & 255, (value >> 8) & 255, value & 255];
}
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
let h = 0;
let s = 0;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0);
break;
case g:
h = (b - r) / d + 2;
break;
default:
h = (r - g) / d + 4;
}
h /= 6;
}
return [h * 360, s * 100, l * 100];
}
function hslToHex(h: number, s: number, l: number): string {
s /= 100;
l /= 100;
const k = (n: number) => (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
const f = (n: number) =>
l - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1));
const toHex = (n: number) =>
Math.round(n * 255)
.toString(16)
.padStart(2, "0");
return `#${toHex(f(0))}${toHex(f(8))}${toHex(f(4))}`;
}
function accentSoft(hex: string, dark: boolean): string {
const [r, g, b] = hexToRgb(hex);
const [h, s] = rgbToHsl(r, g, b);
return dark
? hslToHex(h, Math.min(s, 55), 18)
: hslToHex(h, Math.min(s, 70), 92);
}
export function applyAccent(color: string | null) {
app.accent = color;
const root = document.documentElement.style;
if (color) {
root.setProperty("--color-accent", color);
root.setProperty("--color-accent-soft", accentSoft(color, app.theme === "dark"));
localStorage.setItem(ACCENT_KEY, color);
} else {
root.removeProperty("--color-accent");
root.removeProperty("--color-accent-soft");
localStorage.removeItem(ACCENT_KEY);
}
}
export function applyTextScale(scale: TextScale) {
app.textScale = scale;
document.documentElement.style.fontSize = `${TEXT_SCALE_PX[scale]}px`;
localStorage.setItem(TEXT_SCALE_KEY, scale);
}
export function applyReduceMotion(enabled: boolean) {
app.reduceMotion = enabled;
if (enabled) {
document.documentElement.dataset.reduceMotion = "true";
} else {
delete document.documentElement.dataset.reduceMotion;
}
localStorage.setItem(REDUCE_MOTION_KEY, String(enabled));
}
export function applyContrast(level: ContrastLevel) {
app.contrast = level;
if (level === "high") {
document.documentElement.dataset.contrast = "high";
} else {
delete document.documentElement.dataset.contrast;
}
localStorage.setItem(CONTRAST_KEY, level);
}
export function breadcrumbs(): { name: string; path: string }[] {
@@ -148,12 +327,42 @@ export function breadcrumbs(): { name: string; path: string }[] {
}
export async function bootstrap() {
const stored = localStorage.getItem("typst-desktop-theme");
applyTheme(stored === "dark" ? "dark" : "light");
const storedTheme = localStorage.getItem(THEME_KEY);
applyTheme(
storedTheme === "dark" || storedTheme === "light" || storedTheme === "system"
? storedTheme
: "light",
);
const storedColorTheme = localStorage.getItem(COLOR_THEME_KEY);
const validColorTheme = colorThemes.some((entry) => entry.id === storedColorTheme);
document.documentElement.dataset.colorTheme = validColorTheme
? (storedColorTheme as ColorTheme)
: "default";
app.colorTheme = validColorTheme ? (storedColorTheme as ColorTheme) : "default";
const storedAccent = localStorage.getItem(ACCENT_KEY);
if (storedAccent) applyAccent(storedAccent);
const storedTextScale = localStorage.getItem(TEXT_SCALE_KEY);
applyTextScale(
storedTextScale === "small" ||
storedTextScale === "large" ||
storedTextScale === "xlarge"
? storedTextScale
: "default",
);
applyReduceMotion(localStorage.getItem(REDUCE_MOTION_KEY) === "true");
applyContrast(localStorage.getItem(CONTRAST_KEY) === "high" ? "high" : "normal");
try {
app.settings = await api.getSettings();
restartAutoSync();
await initWsSync();
if (app.settings?.device_token) {
api.cloudWsStart().catch(() => {});
}
await browseTo("");
await refreshAccount();
} catch (error) {
@@ -161,6 +370,38 @@ export async function bootstrap() {
}
}
async function initWsSync() {
await listen<string>("cloud://ws-status", (event) => {
app.wsStatus = event.payload;
});
await listen<DeviceEvent>("cloud://sync-event", (event) => {
handleDeviceEvent(event.payload);
});
app.wsStatus = await api.cloudWsStatus().catch(() => "offline");
}
function handleDeviceEvent(event: DeviceEvent) {
const linkedProject = app.target?.cloud_project_id;
const linkedDocument = app.documentLink?.document_id;
const matchesOpenTarget =
(event.kind === "project" &&
event.project_id &&
event.project_id === linkedProject) ||
(event.kind === "document" &&
event.document_id &&
event.document_id === linkedDocument);
if (matchesOpenTarget) {
if (app.collabIntent) return;
autoSync();
} else if (app.scope === "cloud") {
refreshCloud();
}
}
export async function browseTo(path: string) {
try {
app.entries = await api.browseWorkspace(path);
@@ -179,54 +420,90 @@ export async function refreshAccount() {
try {
app.account = await api.cloudAccount();
if (app.account) {
await refreshSpaces();
await refreshCloudProjects();
} else {
app.spaces = [];
app.cloudProjects = [];
}
} catch {
app.account = null;
}
}
export async function refreshSpaces() {
export async function refreshCloudProjects() {
try {
app.spaces = await api.cloudListSpaces();
app.cloudProjects = await api.cloudListProjects();
} catch (error) {
setError(error);
}
}
interface CloudSnapshot {
folders: CloudFolder[];
documents: CloudDocument[];
projects: ProjectSummary[];
files: CloudFile[];
}
function cloudCacheKey() {
return `cloud:${app.cloudFolder ?? "root"}`;
}
function applyCloudSnapshot(snapshot: CloudSnapshot) {
if (app.cloudFolder === "shared") {
app.cloudDocuments = snapshot.documents;
app.cloudProjects = snapshot.projects;
app.cloudFolders = [];
app.cloudFiles = [];
} else {
app.cloudFolderTree = snapshot.folders;
app.cloudFolders = snapshot.folders.filter(
(folder) => (folder.parent_id ?? null) === app.cloudFolder,
);
app.cloudDocuments = snapshot.documents;
app.cloudProjects = snapshot.projects.filter(
(project) =>
project.role !== "owner" ||
(project.folder_id ?? null) === app.cloudFolder,
);
app.cloudFiles = snapshot.files;
}
}
export async function refreshCloud() {
if (!app.account) return;
app.cloudLoading = true;
const cacheKey = cloudCacheKey();
try {
app.linkedDocuments = await api.cloudLinkedDocuments().catch(() => []);
app.linkedSpaces = await api.cloudLinkedSpaces().catch(() => []);
app.linkedProjects = await api.cloudLinkedProjects().catch(() => []);
let snapshot: CloudSnapshot;
if (app.cloudFolder === "shared") {
const shared = await api.cloudListShared();
app.cloudDocuments = shared.documents;
app.spaces = shared.spaces;
app.cloudFolders = [];
app.cloudFiles = [];
snapshot = { folders: [], documents: shared.documents, projects: shared.projects, files: [] };
} else {
const [folders, documents, spaces, files] = await Promise.all([
const [folders, documents, projects, files] = await Promise.all([
api.cloudListFolders(),
api.cloudListDocuments(app.cloudFolder),
api.cloudListSpaces(),
api.cloudListProjects(),
api.cloudListFiles(app.cloudFolder),
]);
app.cloudFolderTree = folders;
app.cloudFolders = folders.filter(
(folder) => (folder.parent_id ?? null) === app.cloudFolder,
);
app.cloudDocuments = documents;
app.spaces = spaces;
app.cloudFiles = files;
snapshot = { folders, documents, projects, files };
}
applyCloudSnapshot(snapshot);
app.cloudOffline = false;
api.saveCloudCache(cacheKey, JSON.stringify(snapshot)).catch(() => {});
} catch (error) {
setError(error);
const cached = await api.getCloudCache(cacheKey).catch(() => null);
if (cached) {
applyCloudSnapshot(JSON.parse(cached));
app.cloudOffline = true;
} else {
setError(error);
}
} finally {
app.cloudLoading = false;
}
@@ -254,7 +531,7 @@ export async function openCloudFolder(id: string | null | "shared") {
export async function downloadDocument(documentId: string, title: string) {
try {
const path = await api.cloudDownloadDocument(documentId, "");
const path = await api.cloudDownloadDocument(documentId, app.currentDir);
await refreshCloud();
setStatus(`Downloaded '${title}' to this device`);
return path;
@@ -279,8 +556,10 @@ export async function downloadCloudFile(fileId: string, name: string) {
}
}
export function linkedSpace(spaceId: string) {
return app.linkedSpaces.find((linked) => linked.space_id === spaceId);
export function linkedProject(cloudProjectId: string) {
return app.linkedProjects.find(
(linked) => linked.cloud_project_id === cloudProjectId,
);
}
export async function removeDownloadedDocument(path: string) {
@@ -322,10 +601,19 @@ export async function openTarget(path: string) {
}
}
function stopCollab() {
closeCollabSession(app.collab);
app.collab = null;
app.collabIntent = false;
app.collabStatus = null;
app.collabConflict = null;
}
export async function closeTarget() {
cancelScheduledCompile();
cancelAutosave();
if (app.dirty) await saveActiveFile();
stopCollab();
app.view = "files";
app.target = null;
app.activePath = null;
@@ -352,6 +640,7 @@ export async function openFile(file: string) {
cancelAutosave();
if (app.dirty && app.activePath) await saveActiveFile();
stopCollab();
try {
const payload = await api.readTargetFile(app.target.path, file);
@@ -359,11 +648,100 @@ export async function openFile(file: string) {
app.editorContent = payload.is_text ? payload.content : "";
app.dirty = false;
if (payload.is_text) await compile();
if (payload.is_text) await tryOpenCollab(file, app.editorContent);
} catch (error) {
setError(error);
}
}
function bindCollabSession(session: CollabSession) {
app.collab = session;
app.collabStatus = session.provider.wsconnected ? "connected" : "connecting";
session.provider.on("status", (event: { status: string }) => {
app.collabStatus =
event.status === "connected"
? "connected"
: event.status === "connecting"
? "connecting"
: "offline";
});
}
async function tryOpenCollab(file: string, diskContent: string) {
if (!app.target || !app.settings?.device_token) return;
let roomId: string | null;
try {
roomId = await api.cloudRoomId(app.target.path, file);
} catch {
roomId = null;
}
if (!roomId) return;
app.collabIntent = true;
app.collabStatus = "connecting";
const session = openCollabSession(
app.settings.server_url,
app.settings.device_token,
roomId,
);
const synced = await new Promise<boolean>((resolve) => {
session.provider.once("synced", (isSynced: boolean) => resolve(isSynced));
}).catch(() => false);
if (app.activePath !== file) {
closeCollabSession(session);
return;
}
if (!synced) {
bindCollabSession(session);
return;
}
const remoteText = session.text.toString();
if (remoteText !== diskContent) {
app.collabConflict = {
path: file,
local_text: diskContent,
remote_text: remoteText,
merged_text: remoteText,
server_hash: "",
auto_merged: false,
binary: false,
};
pendingCollabSession = session;
return;
}
bindCollabSession(session);
}
let pendingCollabSession: CollabSession | null = null;
export function resolveCollabConflict(content: string) {
const session = pendingCollabSession;
pendingCollabSession = null;
app.collabConflict = null;
if (!session) return;
const ytext = session.text;
ytext.doc?.transact(() => {
ytext.delete(0, ytext.length);
ytext.insert(0, content);
});
bindCollabSession(session);
}
export function cancelCollabConflict() {
closeCollabSession(pendingCollabSession);
pendingCollabSession = null;
app.collabConflict = null;
}
export async function saveActiveFile() {
if (!app.target || !app.activePath) return;
try {
@@ -446,9 +824,14 @@ export function cancelScheduledCompile() {
let autosaveTimer: ReturnType<typeof setTimeout> | null = null;
const COLLAB_AUTOSAVE_SECONDS = 5;
export function scheduleAutosave() {
const seconds = app.settings?.autosave_seconds ?? 0;
if (autosaveTimer) clearTimeout(autosaveTimer);
const seconds = app.collabIntent
? Math.min(app.settings?.autosave_seconds || COLLAB_AUTOSAVE_SECONDS, COLLAB_AUTOSAVE_SECONDS)
: (app.settings?.autosave_seconds ?? 0);
if (seconds <= 0) return;
autosaveTimer = setTimeout(() => {
@@ -472,19 +855,19 @@ export function restartAutoSync() {
syncTimer = null;
}
const minutes = app.settings?.sync_minutes ?? 0;
if (minutes <= 0) return;
const seconds = app.settings?.sync_seconds ?? 0;
if (seconds <= 0) return;
syncTimer = setInterval(() => {
autoSync();
}, minutes * 60 * 1000);
if (app.wsStatus !== "connected") autoSync();
}, seconds * 1000);
}
async function autoSync() {
if (!app.account || app.syncing) return;
if (app.conflicts.length > 0) return;
const linked = app.target?.space_id || app.documentLink;
const linked = app.target?.cloud_project_id || app.documentLink;
const project = linked ? app.target?.path : null;
if (!project) return;
+38
View File
@@ -0,0 +1,38 @@
import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";
export interface CollabSession {
doc: Y.Doc;
text: Y.Text;
provider: WebsocketProvider;
}
function wsUrl(serverUrl: string) {
return serverUrl
.replace(/^https:/, "wss:")
.replace(/^http:/, "ws:")
.replace(/\/$/, "");
}
export function openCollabSession(
serverUrl: string,
deviceToken: string,
roomId: string,
): CollabSession {
const doc = new Y.Doc();
const text = doc.getText("typst");
const provider = new WebsocketProvider(`${wsUrl(serverUrl)}/yjs`, roomId, doc, {
params: { token: deviceToken },
disableBc: true,
});
return { doc, text, provider };
}
export function closeCollabSession(session: CollabSession | null) {
if (!session) return;
session.provider.disconnect();
session.provider.destroy();
session.doc.destroy();
}
+499 -90
View File
@@ -1,7 +1,10 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { onMount } from "svelte";
import { registerHotkey, unregisterAll } from "$lib/ts/hotkeys";
import { save } from "@tauri-apps/plugin-dialog";
import { writeImage, writeText } from "@tauri-apps/plugin-clipboard-manager";
import { Image } from "@tauri-apps/api/image";
import { revealItemInDir } from "@tauri-apps/plugin-opener";
import { getCurrentWebview } from "@tauri-apps/api/webview";
import { listen } from "@tauri-apps/api/event";
@@ -22,10 +25,16 @@
import PageSettingsModal from "$lib/components/PageSettingsModal.svelte";
import type { EditorView } from "@codemirror/view";
import { insertText } from "$lib/ts/editor-actions";
import {
insertText,
prefixLines,
redoEdit,
undoEdit,
wrapSelection,
} from "$lib/ts/editor-actions";
import * as api from "$lib/ts/api";
import type { BrowseEntry } from "$lib/ts/api";
import type { BrowseEntry, CloudFile, CloudFolder } from "$lib/ts/api";
import { pickFiles } from "$lib/ts/import";
import {
app,
@@ -38,10 +47,12 @@
openFile,
openTarget,
refreshAccount,
refreshCloud,
refreshEntries,
refreshSpaces,
refreshTarget,
removeDownloadedDocument,
resolveCollabConflict,
cancelCollabConflict,
runSync,
saveAndCompile,
scheduleAutosave,
@@ -60,13 +71,21 @@
| { kind: "rename-entry"; entry: BrowseEntry }
| { kind: "delete-entry"; entry: BrowseEntry }
| { kind: "link-entry"; entry: BrowseEntry }
| { kind: "new-space" }
| { kind: "delete-space"; id: string }
| { kind: "clone-space"; id: string; name: string }
| { kind: "save-document-to-cloud"; entry: BrowseEntry }
| { kind: "new-cloud-project" }
| { kind: "new-cloud-document" }
| { kind: "new-cloud-folder" }
| { kind: "rename-cloud-folder"; folder: CloudFolder }
| { kind: "delete-cloud-folder"; folder: CloudFolder }
| { kind: "delete-cloud-project"; id: string }
| { kind: "delete-cloud-document"; id: string }
| { kind: "delete-cloud-file"; id: string }
| { kind: "rename-cloud-file"; file: CloudFile }
| { kind: "clone-cloud-project"; id: string; name: string }
| { kind: "new-file"; parent: string }
| { kind: "new-subfolder"; parent: string }
| { kind: "rename-file"; path: string }
| { kind: "delete-file"; path: string }
| { kind: "delete-file"; paths: string[] }
| { kind: "login" }
| { kind: "settings" }
| { kind: "assets" }
@@ -76,10 +95,21 @@
let dialog = $state<Dialog>({ kind: "none" });
let editorView = $state<EditorView | null>(null);
let imageViewer = $state<{ paths: string[]; index: number } | null>(null);
let selectedEntries = $state<Set<string>>(new Set());
let selectedEntry = $state<string | null>(null);
let selectedIsDir = $state(false);
let treeDropTarget = $state<string | null>(null);
const SIDEBAR_COLLAPSED_KEY = "editor-sidebar-collapsed";
let sidebarCollapsed = $state(
localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "true",
);
function toggleSidebar() {
sidebarCollapsed = !sidebarCollapsed;
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(sidebarCollapsed));
}
const selectedFolder = $derived(
!selectedEntry
? ""
@@ -148,31 +178,97 @@
setStatus(`Deleted '${entry.name}'`);
});
const currentCloudFolder = () =>
app.cloudFolder === "shared" ? null : app.cloudFolder;
const linkEntry = (entry: BrowseEntry) =>
guard(async () => {
const report = await api.cloudLinkProject(entry.path);
await refreshEntries();
await refreshSpaces();
setStatus(`Uploaded ${report.pushed.length} files to a new cloud space`);
await refreshCloud();
setStatus(`Uploaded ${report.pushed.length} files to a new cloud project`);
});
const createSpace = (name: string) =>
const saveDocumentToCloud = (entry: BrowseEntry, title: string) =>
guard(async () => {
await api.cloudCreateSpace(name);
await refreshSpaces();
await api.cloudCreateDocument(entry.path, title);
await refreshEntries();
setStatus(`Saved '${entry.name}' to the cloud`);
});
const deleteSpace = (id: string) =>
const createCloudProject = (name: string) =>
guard(async () => {
await api.cloudDeleteSpace(id);
await refreshSpaces();
await api.cloudCreateProject(name, currentCloudFolder());
await refreshCloud();
});
const cloneSpace = (id: string, name: string) =>
const createCloudDocument = (title: string) =>
guard(async () => {
await api.cloudCloneSpace(id, name);
await api.cloudNewDocument(title, currentCloudFolder());
await refreshCloud();
});
const createCloudFolder = (name: string) =>
guard(async () => {
await api.cloudCreateFolder(name, currentCloudFolder());
await refreshCloud();
});
const renameCloudFolder = (folder: CloudFolder, name: string) =>
guard(async () => {
await api.cloudRenameFolder(folder.id, name);
await refreshCloud();
});
const deleteCloudFolder = (folder: CloudFolder) =>
guard(async () => {
await api.cloudDeleteFolder(folder.id);
await refreshCloud();
});
const deleteCloudProject = (id: string) =>
guard(async () => {
await api.cloudDeleteProject(id);
await refreshCloud();
});
const deleteCloudDocument = (id: string) =>
guard(async () => {
await api.cloudDeleteDocument(id);
await refreshCloud();
});
const deleteCloudFile = (id: string) =>
guard(async () => {
await api.cloudDeleteFile(id);
await refreshCloud();
});
const uploadCloudFiles = () =>
guard(async () => {
const sources = await pickFiles("assets");
if (sources.length === 0) return;
const folderId = currentCloudFolder();
for (const source of sources) {
await api.cloudUploadFile(source, folderId);
}
await refreshCloud();
setStatus(`Uploaded ${sources.length} file(s) to TypstDrive`);
});
const renameCloudFile = (file: CloudFile, name: string) =>
guard(async () => {
await api.cloudRenameFile(file.id, name);
await refreshCloud();
});
const cloneCloudProject = (id: string, name: string) =>
guard(async () => {
const parent = app.currentDir;
await api.cloudCloneProject(id, name, parent);
app.scope = "local";
await browseTo("");
await browseTo(parent);
setStatus(`Downloaded '${name}' to this device`);
});
@@ -250,13 +346,17 @@
if (app.activePath === path) await openFile(next);
});
const deleteFile = (path: string) =>
const deleteFile = (paths: string[]) =>
guard(async () => {
await api.deleteEntry(`${app.target!.path}/${path}`);
if (app.activePath === path) {
app.activePath = null;
app.editorContent = "";
for (const path of paths) {
await api.deleteEntry(`${app.target!.path}/${path}`);
if (app.activePath === path) {
app.activePath = null;
app.editorContent = "";
}
}
selectedEntries = new Set();
selectedEntry = null;
await refreshTarget();
await compile();
});
@@ -291,6 +391,42 @@
}
}
async function pngBytesToRgba(bytes: Uint8Array) {
const blob = new Blob([bytes], { type: "image/png" });
const bitmap = await createImageBitmap(blob);
const canvas = document.createElement("canvas");
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const context = canvas.getContext("2d");
if (!context) throw new Error("Could not create canvas context");
context.drawImage(bitmap, 0, 0);
const { data } = context.getImageData(0, 0, canvas.width, canvas.height);
return { rgba: new Uint8Array(data.buffer), width: canvas.width, height: canvas.height };
}
async function copyAs(format: string) {
if (!app.target) return;
try {
if (format === "png") {
const bytes = await api.renderTargetPng(app.target.path);
const { rgba, width, height } = await pngBytesToRgba(new Uint8Array(bytes));
const image = await Image.new(rgba, width, height);
await writeImage(image);
} else if (format === "svg") {
const svg = app.compiled?.pages[0];
if (!svg) {
setStatus("Nothing to copy yet");
return;
}
await writeText(svg);
}
setStatus(`Copied ${format.toUpperCase()} to clipboard`);
} catch (error) {
setError(error);
}
}
const resolveDocumentConflicts = (resolutions: api.Resolution[]) =>
guard(async () => {
for (const resolution of resolutions) {
@@ -317,13 +453,6 @@
setStatus("Conflicts resolved and uploaded");
});
function handleKeydown(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key === "s") {
event.preventDefault();
if (app.view === "editor") saveAndCompile();
}
}
let dropActive = $state(false);
function dropDestination():
@@ -399,21 +528,100 @@
}
});
registerHotkey("save", (event) => {
event.preventDefault();
if (app.view === "editor") saveAndCompile();
});
registerHotkey("undo", (event) => {
event.preventDefault();
if (app.view === "editor") undoEdit(editorView);
});
registerHotkey("redo", (event) => {
event.preventDefault();
if (app.view === "editor") redoEdit(editorView);
});
registerHotkey("toggleSidebar", (event) => {
event.preventDefault();
if (app.view === "editor" && !app.target?.standalone) toggleSidebar();
});
registerHotkey("bold", (event) => {
event.preventDefault();
if (app.view === "editor") wrapSelection(editorView, "*", "*", "bold");
});
registerHotkey("italic", (event) => {
event.preventDefault();
if (app.view === "editor") wrapSelection(editorView, "_", "_", "italic");
});
registerHotkey("underline", (event) => {
event.preventDefault();
if (app.view === "editor")
wrapSelection(editorView, "#underline[", "]", "underlined");
});
registerHotkey("strikethrough", (event) => {
event.preventDefault();
if (app.view === "editor")
wrapSelection(editorView, "#strike[", "]", "struck through");
});
registerHotkey("link", (event) => {
event.preventDefault();
if (app.view === "editor") insertText(editorView, '#link("https://")[text]');
});
registerHotkey("numberedList", (event) => {
event.preventDefault();
if (app.view === "editor") prefixLines(editorView, "+ ", "Numbered item");
});
registerHotkey("bulletedList", (event) => {
event.preventDefault();
if (app.view === "editor") prefixLines(editorView, "- ", "List item");
});
for (const level of [1, 2, 3, 4, 5, 6]) {
registerHotkey(`heading${level}`, (event) => {
event.preventDefault();
if (app.view === "editor")
prefixLines(editorView, "=".repeat(level) + " ", "Heading");
});
}
return () => {
pending.then((unlisten) => unlisten());
downloads.then((unlisten) => unlisten());
unregisterAll();
};
});
const lspLabel: Record<string, string> = {
off: "LSP off",
starting: "LSP starting",
on: "LSP ready",
unavailable: "LSP unavailable",
const wsLabel: Record<string, string> = {
connected: "Live sync",
connecting: "Connecting…",
offline: "Offline (polling)",
};
const collabLabel: Record<string, string> = {
connected: "Live",
connecting: "Connecting…",
offline: "Offline — editing locally",
};
</script>
<svelte:window on:keydown={handleKeydown} />
{#snippet statusBadge(
icon: string,
label: string,
tone: "success" | "accent" | "muted",
spin = false,
)}
<span
class="flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium
{tone === 'success'
? 'bg-[var(--color-success)]/10 text-[var(--color-success)]'
: tone === 'accent'
? 'bg-[var(--color-accent)]/10 text-[var(--color-accent)]'
: 'bg-[var(--color-surface-sunken)] text-[var(--color-ink-muted)]'}"
>
<Icon {icon} class="text-xs {spin ? 'animate-spin' : ''}" />
{label}
</span>
{/snippet}
<div class="flex h-screen flex-col">
<header
@@ -428,21 +636,41 @@
<Icon icon="ph:arrow-left" />
Files
</button>
{#if editorView}
<button
class="rounded-md p-1.5 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
title="Undo"
aria-label="Undo"
onclick={() => undoEdit(editorView)}
>
<Icon icon="ph:arrow-counter-clockwise" class="text-sm" />
</button>
<button
class="rounded-md p-1.5 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
title="Redo"
aria-label="Redo"
onclick={() => redoEdit(editorView)}
>
<Icon icon="ph:arrow-clockwise" class="text-sm" />
</button>
{/if}
<span data-tauri-drag-region class="text-sm font-medium">
{app.target?.path.split("/").pop()}
</span>
{#if app.target?.standalone}
<span
class="rounded bg-[var(--color-surface-sunken)] px-1.5 py-0.5 text-[10px] text-[var(--color-ink-muted)]"
>
single file
</span>
{@render statusBadge("ph:file", "Single file", "muted")}
{/if}
{#if app.dirty}
<span class="h-1.5 w-1.5 rounded-full bg-[var(--color-accent)]"></span>
{/if}
{:else}
<Icon icon="ph:file-code" class="text-lg text-[var(--color-accent)]" />
<span
class="flex h-6 w-6 items-center justify-center rounded-md bg-[var(--color-accent)]"
>
<img src="/favicon.png" alt="Typst Desktop" class="h-6 w-6" />
</span>
<span data-tauri-drag-region class="text-sm font-semibold">
Typst Desktop
</span>
@@ -450,29 +678,71 @@
<div data-tauri-drag-region class="h-full flex-1"></div>
{#if app.view === "editor"}
<span
class="flex items-center gap-1 text-[10px] text-[var(--color-ink-muted)]"
title="Typst language server (tinymist)"
>
<span
class="h-1.5 w-1.5 rounded-full
{app.lspStatus === 'on'
? 'bg-[var(--color-success)]'
: app.lspStatus === 'starting'
? 'bg-[var(--color-accent)]'
: 'bg-[var(--color-ink-muted)]'}"
></span>
{lspLabel[app.lspStatus]}
</span>
{#if app.view !== "editor"}
<div class="flex rounded-lg bg-[var(--color-surface-sunken)] p-0.5 text-xs font-medium">
{#each [["local", "Local", "ph:hard-drives"], ["cloud", "Cloud", "ph:cloud"]] as [value, label, icon]}
<button
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 transition
{app.scope === value
? 'bg-[var(--color-surface)] text-[var(--color-ink)] shadow-sm'
: 'text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]'}"
onclick={() => (app.scope = value as "local" | "cloud")}
>
<Icon {icon} />
{label}
</button>
{/each}
</div>
{/if}
<button
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
onclick={saveAndCompile}
>
<Icon icon="ph:floppy-disk" />
Save
</button>
{#if app.view === "editor"}
{#if app.account && (app.target?.cloud_project_id || app.documentLink) && !app.collabIntent}
<span title="Cloud sync connection">
{@render statusBadge(
app.wsStatus === "connected"
? "ph:cloud-check"
: app.wsStatus === "connecting"
? "ph:circle-notch"
: "ph:cloud-slash",
wsLabel[app.wsStatus] ?? "Offline (polling)",
app.wsStatus === "connected"
? "success"
: app.wsStatus === "connecting"
? "accent"
: "muted",
app.wsStatus === "connecting",
)}
</span>
{/if}
{#if app.collabIntent}
<span title="Realtime cloud sync">
{@render statusBadge(
app.collabStatus === "connected"
? "ph:broadcast"
: app.collabStatus === "connecting"
? "ph:circle-notch"
: "ph:wifi-slash",
collabLabel[app.collabStatus ?? "offline"],
app.collabStatus === "connected"
? "success"
: app.collabStatus === "connecting"
? "accent"
: "muted",
app.collabStatus === "connecting",
)}
</span>
{/if}
{#if !app.collabIntent}
<button
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
onclick={saveAndCompile}
>
<Icon icon="ph:floppy-disk" />
Save
</button>
{/if}
<div class="group relative">
<button
@@ -495,7 +765,28 @@
</div>
</div>
{#if app.target?.space_id || app.documentLink}
<div class="group relative">
<button
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
>
<Icon icon="ph:copy" />
Copy
</button>
<div
class="invisible absolute right-0 top-full z-20 flex w-32 flex-col rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] py-1 text-xs shadow-lg group-hover:visible"
>
{#each ["png", "svg"] as format}
<button
class="px-3 py-1.5 text-left uppercase hover:bg-[var(--color-surface-sunken)]"
onclick={() => copyAs(format)}
>
{format}
</button>
{/each}
</div>
</div>
{#if (app.target?.cloud_project_id || app.documentLink) && !app.collabIntent}
<button
class="flex items-center gap-1.5 rounded-md bg-[var(--color-accent)] px-2.5 py-1.5 text-xs font-medium text-white transition hover:opacity-90 disabled:opacity-50"
disabled={app.syncing}
@@ -598,19 +889,45 @@
onrename={(entry) => (dialog = { kind: "rename-entry", entry })}
ondelete={(entry) => (dialog = { kind: "delete-entry", entry })}
onlink={(entry) => (dialog = { kind: "link-entry", entry })}
onsavetocloud={(entry) =>
(dialog = { kind: "save-document-to-cloud", entry })}
onviewimage={(paths, index) => (imageViewer = { paths, index })}
ondownloaddocument={(documentId, title) =>
downloadDocument(documentId, title)}
onremovedownload={removeDownloadedDocument}
ondownloadfile={downloadCloudFile}
onnewspace={() => (dialog = { kind: "new-space" })}
onclonespace={(id, name) => (dialog = { kind: "clone-space", id, name })}
ondeletespace={(id) => (dialog = { kind: "delete-space", id })}
ondeletefile={(id) => (dialog = { kind: "delete-cloud-file", id })}
onuploadfile={uploadCloudFiles}
onrenamefile={(file) => (dialog = { kind: "rename-cloud-file", file })}
onnewcloudproject={() => (dialog = { kind: "new-cloud-project" })}
onnewclouddocument={() => (dialog = { kind: "new-cloud-document" })}
onnewcloudfolder={() => (dialog = { kind: "new-cloud-folder" })}
onrenamecloudfolder={(folder) =>
(dialog = { kind: "rename-cloud-folder", folder })}
ondeletecloudfolder={(folder) =>
(dialog = { kind: "delete-cloud-folder", folder })}
oncloneproject={(id, name) =>
(dialog = { kind: "clone-cloud-project", id, name })}
ondeleteproject={(id) => (dialog = { kind: "delete-cloud-project", id })}
ondeletedocument={(id) => (dialog = { kind: "delete-cloud-document", id })}
onsignin={() => (dialog = { kind: "login" })}
/>
</div>
{:else}
{#if !app.target?.standalone}
{#if !app.target?.standalone && sidebarCollapsed}
<div
class="flex w-8 shrink-0 flex-col items-center border-r border-[var(--color-line)] bg-[var(--color-surface)] py-1.5"
>
<button
class="rounded p-1 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
title="Show files"
aria-label="Show files"
onclick={toggleSidebar}
>
<Icon icon="ph:sidebar-simple" class="text-base" />
</button>
</div>
{:else if !app.target?.standalone}
<div
class="flex w-56 shrink-0 flex-col border-r border-[var(--color-line)] bg-[var(--color-surface)]"
>
@@ -622,21 +939,30 @@
>
{selectedFolder ? selectedFolder : "Files"}
</span>
<button
class="shrink-0 rounded p-1 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
title="Hide files"
aria-label="Hide files"
onclick={toggleSidebar}
>
<Icon icon="ph:sidebar-simple" class="text-sm" />
</button>
</div>
<FileTree
files={app.target?.files ?? []}
activePath={app.activePath}
entrypoint={app.target?.entrypoint ?? "main.typ"}
selected={selectedEntry}
selected={selectedEntries}
dropTarget={treeDropTarget}
onopen={openFile}
onselect={(path, isDir) => {
selectedEntry = path;
onselect={(paths, primary, isDir) => {
selectedEntries = new Set(paths);
selectedEntry = primary;
selectedIsDir = isDir;
}}
onrename={(path) => (dialog = { kind: "rename-file", path })}
ondelete={(path) => (dialog = { kind: "delete-file", path })}
ondelete={(paths) => (dialog = { kind: "delete-file", paths })}
onduplicate={duplicateInTarget}
onreveal={revealInTarget}
onsetentry={setEntrypoint}
@@ -675,13 +1001,15 @@
filePath={app.activePath}
targetPath={app.target?.path ?? ""}
diagnostics={app.diagnostics}
collab={app.collab
? { text: app.collab.text, awareness: app.collab.provider.awareness }
: null}
onchange={(value) => {
app.editorContent = value;
app.dirty = true;
scheduleCompile();
scheduleAutosave();
}}
onsave={saveAndCompile}
onlspstatus={(status) => (app.lspStatus = status)}
onready={(view) => (editorView = view)}
/>
@@ -792,36 +1120,107 @@
{@const target = dialog}
<ConfirmModal
title="Upload to cloud"
message="A new cloud space will be created for '{target.entry.name}' and its files uploaded."
message="A new cloud project will be created for '{target.entry.name}' and its files uploaded."
confirmLabel="Upload"
onconfirm={() => linkEntry(target.entry)}
onclose={close}
/>
{:else if dialog.kind === "new-space"}
{:else if dialog.kind === "save-document-to-cloud"}
{@const target = dialog}
<PromptModal
title="New cloud space"
label="Space name"
icon="ph:cloud-plus"
onsubmit={createSpace}
title="Save to cloud"
label="Document title"
icon="ph:cloud-arrow-up"
value={target.entry.name.replace(/\.typ$/i, "")}
confirmLabel="Save"
onsubmit={(title) => saveDocumentToCloud(target.entry, title)}
onclose={close}
/>
{:else if dialog.kind === "delete-space"}
{:else if dialog.kind === "new-cloud-project"}
<PromptModal
title="New cloud project"
label="Project name"
icon="ph:cloud-plus"
onsubmit={createCloudProject}
onclose={close}
/>
{:else if dialog.kind === "new-cloud-document"}
<PromptModal
title="New cloud document"
label="Document title"
icon="ph:cloud-plus"
onsubmit={createCloudDocument}
onclose={close}
/>
{:else if dialog.kind === "new-cloud-folder"}
<PromptModal
title="New cloud folder"
label="Folder name"
icon="ph:folder-plus"
onsubmit={createCloudFolder}
onclose={close}
/>
{:else if dialog.kind === "rename-cloud-folder"}
{@const target = dialog}
<PromptModal
title="Rename folder"
label="New name"
value={target.folder.name}
confirmLabel="Rename"
onsubmit={(name) => renameCloudFolder(target.folder, name)}
onclose={close}
/>
{:else if dialog.kind === "delete-cloud-folder"}
{@const target = dialog}
<ConfirmModal
title="Delete cloud space"
message="This permanently deletes the space and its files from TypstDrive. Local copies are kept."
onconfirm={() => deleteSpace(target.id)}
title="Delete cloud folder"
message="'{target.folder.name}' will be permanently removed from TypstDrive. It must be empty first."
onconfirm={() => deleteCloudFolder(target.folder)}
onclose={close}
/>
{:else if dialog.kind === "clone-space"}
{:else if dialog.kind === "delete-cloud-project"}
{@const target = dialog}
<ConfirmModal
title="Delete cloud project"
message="This permanently deletes the project and its files from TypstDrive. Local copies are kept."
onconfirm={() => deleteCloudProject(target.id)}
onclose={close}
/>
{:else if dialog.kind === "delete-cloud-document"}
{@const target = dialog}
<ConfirmModal
title="Delete cloud document"
message="This permanently deletes the document from TypstDrive. A local copy, if downloaded, is kept."
onconfirm={() => deleteCloudDocument(target.id)}
onclose={close}
/>
{:else if dialog.kind === "delete-cloud-file"}
{@const target = dialog}
<ConfirmModal
title="Delete cloud file"
message="This permanently deletes the file from TypstDrive."
onconfirm={() => deleteCloudFile(target.id)}
onclose={close}
/>
{:else if dialog.kind === "rename-cloud-file"}
{@const target = dialog}
<PromptModal
title="Download space"
title="Rename file"
label="New name"
value={target.file.name}
confirmLabel="Rename"
onsubmit={(name) => renameCloudFile(target.file, name)}
onclose={close}
/>
{:else if dialog.kind === "clone-cloud-project"}
{@const target = dialog}
<PromptModal
title="Download project"
label="Save as project"
icon="ph:download-simple"
value={target.name}
confirmLabel="Download"
onsubmit={(name) => cloneSpace(target.id, name)}
onsubmit={(name) => cloneCloudProject(target.id, name)}
onclose={close}
/>
{:else if dialog.kind === "new-file"}
@@ -857,9 +1256,11 @@
{:else if dialog.kind === "delete-file"}
{@const target = dialog}
<ConfirmModal
title="Delete file"
message="'{target.path}' will be permanently deleted."
onconfirm={() => deleteFile(target.path)}
title={target.paths.length > 1 ? "Delete files" : "Delete file"}
message={target.paths.length > 1
? `${target.paths.length} items will be permanently deleted.`
: `'${target.paths[0]}' will be permanently deleted.`}
onconfirm={() => deleteFile(target.paths)}
onclose={close}
/>
{:else if dialog.kind === "login"}
@@ -895,3 +1296,11 @@
onclose={close}
/>
{/if}
{#if app.collabConflict}
<ConflictModal
conflicts={[app.collabConflict]}
onresolve={(resolutions) => resolveCollabConflict(resolutions[0]?.content ?? "")}
onclose={cancelCollabConflict}
/>
{/if}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 34 KiB