25 Commits
Author SHA1 Message Date
SirBlob 2cabc3e303 Harden Typst wasm parser fallback against panics
CI / frontend (push) Successful in 20s
CI / backend (push) Successful in 1m27s
2026-07-22 18:07:19 -04:00
SirBlob 0e69f5a179 Guard conflict choice lookup before it is populated 2026-07-22 18:07:19 -04:00
SirBlob 7b0cc2a807 Fix incorrect y-websocket event name in sync wait 2026-07-22 18:07:19 -04:00
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 4172 additions and 897 deletions
+46 -40
View File
@@ -7,13 +7,13 @@ on:
workflow_dispatch: workflow_dispatch:
env: env:
TYPST_COMMIT: 44b3f78ed37fedea75e911dde2269ef86c45316f TYPST_COMMIT: 9dfd3a08500b7896045f907433cf7b4b02434fad
APPIMAGE_EXTRACT_AND_RUN: 1 APPIMAGE_EXTRACT_AND_RUN: 1
NO_STRIP: 1 NO_STRIP: 1
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-22.04
steps: steps:
- name: Checkout repository - name: Checkout repository
@@ -23,8 +23,8 @@ jobs:
- name: Clone Typst compiler - name: Clone Typst compiler
run: | run: |
git clone https://github.com/typst/typst.git typstdrive/typst git clone https://github.com/typst/typst.git typst-desktop/typst
git -C typstdrive/typst checkout "$TYPST_COMMIT" git -C typst-desktop/typst checkout "$TYPST_COMMIT"
- name: Install build dependencies - name: Install build dependencies
run: | run: |
@@ -41,7 +41,8 @@ jobs:
file \ file \
curl \ curl \
wget \ wget \
jq jq \
xdg-utils
- name: Set up Bun - name: Set up Bun
uses: oven-sh/setup-bun@v2 uses: oven-sh/setup-bun@v2
@@ -78,49 +79,54 @@ jobs:
ls -la packages ls -la packages
- name: Upload packages - name: Upload packages
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v3
with: with:
name: typst-desktop-linux-x86_64 name: typst-desktop-linux-x86_64
path: packages/* path: packages/*
if-no-files-found: error if-no-files-found: error
- name: Publish to GitHub release build-arch:
if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-22.04
env: container:
TOKEN: ${{ secrets.RELEASE_TOKEN }} image: archlinux:base-devel
REPOSITORY: sirblobby/typst-desktop
TAG: ${{ github.ref_name }} steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
path: typst-desktop
- name: Update pacman and install build dependencies
run: | 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" - name: Create unprivileged build user
auth="Authorization: Bearer $TOKEN" run: |
json="Content-Type: application/json" 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 - name: Build package
release_id=$existing working-directory: typst-desktop/packaging/arch
else run: runuser -u builder -- env TYPST_COMMIT="$TYPST_COMMIT" makepkg --noconfirm
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
if [ -z "$release_id" ] || [ "$release_id" = "null" ]; then - name: Collect package
echo "Could not create or find the release" run: |
exit 1 mkdir -p packages
fi find typst-desktop/packaging/arch -maxdepth 1 -name "*.pkg.tar.zst" -exec cp {} packages/ \;
ls -la packages
for package in packages/*; do - name: Upload package
name=$(basename "$package") uses: actions/upload-artifact@v3
echo "Uploading $name" with:
curl -sS --fail -X POST -H "$auth" \ name: typst-desktop-arch-x86_64
-H "Content-Type: application/octet-stream" \ path: packages/*
--data-binary "@$package" \ if-no-files-found: error
"https://uploads.github.com/repos/$REPOSITORY/releases/$release_id/assets?name=$name" \
> /dev/null
done
echo "Uploaded to the draft release for $TAG"
+3 -3
View File
@@ -8,7 +8,7 @@ on:
workflow_dispatch: workflow_dispatch:
env: env:
TYPST_COMMIT: 44b3f78ed37fedea75e911dde2269ef86c45316f TYPST_COMMIT: 9dfd3a08500b7896045f907433cf7b4b02434fad
jobs: jobs:
frontend: frontend:
@@ -41,8 +41,8 @@ jobs:
- name: Clone Typst compiler - name: Clone Typst compiler
run: | run: |
git clone https://github.com/typst/typst.git typstdrive/typst git clone https://github.com/typst/typst.git typst-desktop/typst
git -C typstdrive/typst checkout "$TYPST_COMMIT" git -C typst-desktop/typst checkout "$TYPST_COMMIT"
- name: Install build dependencies - name: Install build dependencies
run: | run: |
+2
View File
@@ -1,5 +1,6 @@
.DS_Store .DS_Store
node_modules node_modules
/typst
/build /build
/.svelte-kit /.svelte-kit
/package /package
@@ -8,3 +9,4 @@ node_modules
!.env.example !.env.example
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.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 # 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) [![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/) [![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/) [![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/) [![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: Packages are attached to each [release](https://github.com/sirblobby/typst-desktop/releases). Set the version you want first:
```bash ```bash
VERSION=1.0.0 VERSION=1.1.0
BASE=https://github.com/sirblobby/typst-desktop/releases/download/v$VERSION BASE=https://github.com/sirblobby/typst-desktop/releases/download/v$VERSION
``` ```
@@ -44,24 +46,16 @@ Remove it with `sudo dnf remove typst-desktop`.
### Arch ### Arch
There is no package in the AUR. Use the AppImage, which needs FUSE:
```bash ```bash
sudo pacman -S fuse2 wget "$BASE/typst-desktop-${VERSION}-1-x86_64.pkg.tar.zst"
wget "$BASE/typst-desktop_${VERSION}_amd64.AppImage" sudo pacman -U "typst-desktop-${VERSION}-1-x86_64.pkg.tar.zst"
chmod +x "typst-desktop_${VERSION}_amd64.AppImage"
./typst-desktop_${VERSION}_amd64.AppImage
``` ```
To keep it on your `PATH`: Remove it with `sudo pacman -R typst-desktop`.
```bash 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.
sudo install -Dm755 "typst-desktop_${VERSION}_amd64.AppImage" /usr/local/bin/typst-desktop
```
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 — and on Arch too, but may hit the WebKitGTK/EGL crash described above there. 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. Building from source is covered under [Development](#development).
## Features ## Features
@@ -169,10 +163,10 @@ The editor header shows the language server status. Without `tinymist` the edito
## Development ## 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 ```bash
git clone https://github.com/typst/typst.git ../typstdrive/typst git clone https://github.com/typst/typst.git typst
``` ```
Then: 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). 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 ### 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. `.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 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 ```bash
git clone https://github.com/typst/typst.git ../typstdrive/typst git clone https://github.com/typst/typst.git typst
git -C ../typstdrive/typst checkout 44b3f78ed37fedea75e911dde2269ef86c45316f git -C typst checkout 9dfd3a08500b7896045f907433cf7b4b02434fad
``` ```
Linux builds also need the WebKit and GTK development packages: 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 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 ```bash
bun run build:linux 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 ## License
Apache License 2.0. See [LICENSE](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", "name": "typst-desktop",
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.20.2", "@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.10.3", "@codemirror/commands": "^6.10.4",
"@codemirror/language": "^6.10.8", "@codemirror/language": "^6.12.4",
"@codemirror/legacy-modes": "^6.5.1", "@codemirror/legacy-modes": "^6.5.3",
"@codemirror/lint": "^6.9.6", "@codemirror/lint": "^6.9.7",
"@codemirror/lsp-client": "^6.2.4", "@codemirror/lsp-client": "^6.2.5",
"@codemirror/state": "^6.6.0", "@codemirror/merge": "^6.12.2",
"@codemirror/view": "^6.43.0", "@codemirror/state": "^6.7.1",
"@iconify/svelte": "^5.2.1", "@codemirror/view": "^6.43.6",
"@lezer/highlight": "^1.2.1", "@iconify/svelte": "^5.2.2",
"@lezer/highlight": "^1.2.3",
"@tauri-apps/api": "^2.11.1", "@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", "@tauri-apps/plugin-opener": "^2.5.4",
"codemirror": "^6.0.2", "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": { "devDependencies": {
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.70.0", "@sveltejs/kit": "^2.70.1",
"@sveltejs/vite-plugin-svelte": "^5.1.1", "@sveltejs/vite-plugin-svelte": "^5.1.1",
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.3",
"@tauri-apps/cli": "^2.11.4", "@tauri-apps/cli": "^2.11.4",
"svelte": "^5.56.6", "svelte": "^5.56.7",
"svelte-check": "^4.7.3", "svelte-check": "^4.7.3",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.3",
"typescript": "~5.6.3", "typescript": "~5.6.3",
"vite": "^6.4.3", "vite": "^6.4.3",
"vite-plugin-top-level-await": "^1.6.0", "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/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/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=="], "@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/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=="], "@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/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=="], "@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=="], "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=="], "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=="], "jiti": ["[email protected]", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
"kleur": ["[email protected]", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], "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": ["[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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "@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", "name": "typst-desktop",
"version": "1.0.0", "version": "1.1.0",
"description": "", "description": "",
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -14,29 +14,35 @@
}, },
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.20.2", "@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.10.3", "@codemirror/commands": "^6.10.4",
"@codemirror/language": "^6.10.8", "@codemirror/language": "^6.12.4",
"@codemirror/legacy-modes": "^6.5.1", "@codemirror/legacy-modes": "^6.5.3",
"@codemirror/lint": "^6.9.6", "@codemirror/lint": "^6.9.7",
"@codemirror/lsp-client": "^6.2.4", "@codemirror/lsp-client": "^6.2.5",
"@codemirror/state": "^6.6.0", "@codemirror/merge": "^6.12.2",
"@codemirror/view": "^6.43.0", "@codemirror/state": "^6.7.1",
"@iconify/svelte": "^5.2.1", "@codemirror/view": "^6.43.6",
"@lezer/highlight": "^1.2.1", "@iconify/svelte": "^5.2.2",
"@lezer/highlight": "^1.2.3",
"@tauri-apps/api": "^2.11.1", "@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", "@tauri-apps/plugin-opener": "^2.5.4",
"codemirror": "^6.0.2", "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": { "devDependencies": {
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.3",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.3",
"@sveltejs/kit": "^2.70.0", "@sveltejs/kit": "^2.70.1",
"@sveltejs/vite-plugin-svelte": "^5.1.1", "@sveltejs/vite-plugin-svelte": "^5.1.1",
"svelte": "^5.56.6", "svelte": "^5.56.7",
"svelte-check": "^4.7.3", "svelte-check": "^4.7.3",
"typescript": "~5.6.3", "typescript": "~5.6.3",
"vite": "^6.4.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] [package]
name = "typst-desktop" name = "typst-desktop"
version = "1.0.0" version = "1.1.0"
description = "A Tauri App" description = "A Tauri App"
authors = ["SirBlobby"] authors = ["SirBlobby"]
license = "Apache-2.0" license = "Apache-2.0"
@@ -22,18 +22,19 @@ tauri-build = { version = "2", features = [] }
tauri = { version = "2", features = [] } tauri = { version = "2", features = [] }
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
tauri-plugin-clipboard-manager = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
rusqlite = { version = "0.32", features = ["bundled"] } rusqlite = { version = "0.32", features = ["bundled"] }
typst = { version = "0.14.2", path = "../../typstdrive/typst/crates/typst" } typst = { version = "0.15.1", path = "../typst/crates/typst" }
typst-kit = { path = "../../typstdrive/typst/crates/typst-kit", features = ["system-downloader", "system-packages", "embedded-fonts"] } typst-kit = { path = "../typst/crates/typst-kit", features = ["system-downloader", "system-packages", "embedded-fonts"] }
typst-pdf = { path = "../../typstdrive/typst/crates/typst-pdf" } typst-pdf = { path = "../typst/crates/typst-pdf" }
typst-render = { path = "../../typstdrive/typst/crates/typst-render" } typst-render = { path = "../typst/crates/typst-render" }
typst-svg = { path = "../../typstdrive/typst/crates/typst-svg" } typst-svg = { path = "../typst/crates/typst-svg" }
typst-html = { path = "../../typstdrive/typst/crates/typst-html" } typst-html = { path = "../typst/crates/typst-html" }
typst-layout = { path = "../../typstdrive/typst/crates/typst-layout" } typst-layout = { path = "../typst/crates/typst-layout" }
typst-assets = { version = "0.14.2", features = ["fonts"] } typst-assets = { version = "0.15.1", features = ["fonts"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
sha2 = "0.10" sha2 = "0.10"
@@ -41,4 +42,5 @@ base64 = "0.22"
diffy = "0.4" diffy = "0.4"
walkdir = "2" walkdir = "2"
ureq = { version = "2.12", features = ["json"] } 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", "opener:allow-reveal-item-in-dir",
"dialog:default", "dialog:default",
"dialog:allow-open", "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 std::collections::HashMap;
use typst::diag::Warned; use typst::diag::Warned;
use typst::layout::{Frame, FrameItem}; use typst::layout::{Frame, FrameItem};
use typst::utils::Scalar;
use typst::WorldExt; use typst::WorldExt;
use typst_html::HtmlDocument; use typst_html::{HtmlDocument, HtmlOptions};
use typst_layout::PagedDocument; use typst_layout::PagedDocument;
use typst_pdf::{pdf, PdfOptions}; use typst_pdf::{pdf, PdfOptions};
use typst_render::{render, RenderOptions}; use typst_render::{render, RenderOptions};
@@ -174,7 +175,7 @@ pub fn export_png(
warnings: _, warnings: _,
} => { } => {
let options = RenderOptions { let options = RenderOptions {
pixel_per_pt: 2.0, pixel_per_pt: Scalar::new(2.0),
..RenderOptions::default() ..RenderOptions::default()
}; };
match document.pages().first() { match document.pages().first() {
@@ -207,7 +208,7 @@ pub fn export_html(
} => return Err(collect_diagnostics(&world, &source_text, errors)), } => return Err(collect_diagnostics(&world, &source_text, errors)),
}; };
typst_html::html(&document) typst_html::html(&document, &HtmlOptions::default())
.map(|html| html.into_bytes()) .map(|html| html.into_bytes())
.map_err(|errors| collect_diagnostics(&world, &source_text, errors)) .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>, pub synced_at: Option<String>,
} }
const SCHEMA: [&str; 5] = [ const SCHEMA: [&str; 6] = [
"CREATE TABLE IF NOT EXISTS settings ( "CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT NOT NULL value TEXT NOT NULL
@@ -27,7 +27,7 @@ const SCHEMA: [&str; 5] = [
"CREATE TABLE IF NOT EXISTS projects ( "CREATE TABLE IF NOT EXISTS projects (
path TEXT PRIMARY KEY, path TEXT PRIMARY KEY,
entrypoint TEXT NOT NULL DEFAULT 'main.typ', entrypoint TEXT NOT NULL DEFAULT 'main.typ',
space_id TEXT, cloud_project_id TEXT,
last_synced_at TEXT last_synced_at TEXT
)", )",
"CREATE TABLE IF NOT EXISTS base_files ( "CREATE TABLE IF NOT EXISTS base_files (
@@ -51,10 +51,17 @@ const SCHEMA: [&str; 5] = [
data TEXT NOT NULL, data TEXT NOT NULL,
source_modified INTEGER 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] = const MIGRATIONS: [&str; 2] = [
["ALTER TABLE document_links ADD COLUMN synced_at TEXT"]; "ALTER TABLE document_links ADD COLUMN synced_at TEXT",
"ALTER TABLE projects RENAME COLUMN space_id TO cloud_project_id",
];
impl Store { impl Store {
pub fn open(app: &AppHandle) -> Result<Self, String> { 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| { let row: Option<(String, Option<String>, Option<String>)> = self.with(|connection| {
connection connection
.query_row( .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], params![project],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
) )
.optional() .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()); return Ok(ProjectMeta::default());
}; };
@@ -158,7 +165,7 @@ impl Store {
Ok(ProjectMeta { Ok(ProjectMeta {
entrypoint, entrypoint,
space_id, cloud_project_id,
last_synced_at, last_synced_at,
base_hashes, base_hashes,
}) })
@@ -180,16 +187,16 @@ impl Store {
pub fn save_meta(&self, project: &str, meta: &ProjectMeta) -> Result<(), String> { pub fn save_meta(&self, project: &str, meta: &ProjectMeta) -> Result<(), String> {
self.with(|connection| { self.with(|connection| {
connection.execute( 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) VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(path) DO UPDATE SET ON CONFLICT(path) DO UPDATE SET
entrypoint = excluded.entrypoint, entrypoint = excluded.entrypoint,
space_id = excluded.space_id, cloud_project_id = excluded.cloud_project_id,
last_synced_at = excluded.last_synced_at", last_synced_at = excluded.last_synced_at",
params![ params![
project, project,
meta.entrypoint, meta.entrypoint,
meta.space_id, meta.cloud_project_id,
meta.last_synced_at 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| { self.with(|connection| {
let mut statement = connection.prepare( let mut statement = connection.prepare(
"SELECT path, space_id, last_synced_at FROM projects "SELECT path, cloud_project_id, last_synced_at FROM projects
WHERE space_id IS NOT NULL", WHERE cloud_project_id IS NOT NULL",
)?; )?;
let rows = statement.query_map([], |row| { let rows = statement.query_map([], |row| {
Ok(( Ok((
@@ -414,4 +421,30 @@ impl Store {
Ok(()) 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 thumbnails;
mod workspace; mod workspace;
mod world; mod world;
mod ws;
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -16,7 +17,8 @@ use assets::Asset;
use db::Store; use db::Store;
use compiler::{CompileResult, Diagnostic}; use compiler::{CompileResult, Diagnostic};
use lsp::{LspHandle, LspState}; use lsp::{LspHandle, LspState};
use sync::{Account, SpaceSummary, SyncReport}; use ws::WsState;
use sync::{Account, ProjectSummary, SyncReport};
use workspace::{ use workspace::{
browse, is_project_dir, is_text_file, list_files, load_settings, project_file_path, browse, is_project_dir, is_text_file, list_files, load_settings, project_file_path,
read_target_files, resolve_target, save_settings, workspace_path, BrowseEntry, read_target_files, resolve_target, save_settings, workspace_path, BrowseEntry,
@@ -70,7 +72,7 @@ pub struct AppInfo {
fn app_info() -> AppInfo { fn app_info() -> AppInfo {
AppInfo { AppInfo {
version: env!("CARGO_PKG_VERSION").to_string(), 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(), authors: "SirBlobby".to_string(),
license: "Apache-2.0".to_string(), license: "Apache-2.0".to_string(),
tauri_version: tauri::VERSION.to_string(), tauri_version: tauri::VERSION.to_string(),
@@ -89,7 +91,7 @@ fn update_settings(
workspace_root: Option<String>, workspace_root: Option<String>,
server_url: Option<String>, server_url: Option<String>,
autosave_seconds: Option<u32>, autosave_seconds: Option<u32>,
sync_minutes: Option<u32>, sync_seconds: Option<u32>,
) -> Result<Settings, String> { ) -> Result<Settings, String> {
let mut settings = load_settings(&app, &store)?; let mut settings = load_settings(&app, &store)?;
if let Some(root) = workspace_root { if let Some(root) = workspace_root {
@@ -104,8 +106,8 @@ fn update_settings(
if let Some(seconds) = autosave_seconds { if let Some(seconds) = autosave_seconds {
settings.autosave_seconds = seconds; settings.autosave_seconds = seconds;
} }
if let Some(minutes) = sync_minutes { if let Some(seconds) = sync_seconds {
settings.sync_minutes = minutes; settings.sync_seconds = seconds;
} }
save_settings(&store, &settings)?; save_settings(&store, &settings)?;
Ok(settings) Ok(settings)
@@ -351,7 +353,7 @@ pub struct TargetInfo {
pub entrypoint: String, pub entrypoint: String,
pub standalone: bool, pub standalone: bool,
pub is_project: bool, pub is_project: bool,
pub space_id: Option<String>, pub cloud_project_id: Option<String>,
pub files: Vec<FileEntry>, pub files: Vec<FileEntry>,
} }
@@ -368,7 +370,7 @@ fn target_info(app: AppHandle, store: State<'_, Store>, path: String) -> Result<
entrypoint: target.entrypoint.clone(), entrypoint: target.entrypoint.clone(),
standalone: true, standalone: true,
is_project: false, is_project: false,
space_id: None, cloud_project_id: None,
files: vec![FileEntry { files: vec![FileEntry {
path: target.entrypoint.clone(), path: target.entrypoint.clone(),
name: target.entrypoint, name: target.entrypoint,
@@ -385,7 +387,7 @@ fn target_info(app: AppHandle, store: State<'_, Store>, path: String) -> Result<
entrypoint: target.entrypoint, entrypoint: target.entrypoint,
standalone: false, standalone: false,
is_project: is_project_dir(&target.root), is_project: is_project_dir(&target.root),
space_id: meta.space_id, cloud_project_id: meta.cloud_project_id,
files: list_files(&target.root)?, files: list_files(&target.root)?,
}) })
} }
@@ -498,6 +500,24 @@ fn export_target(
Ok(destination) 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] #[tauri::command]
fn thumbnail(app: AppHandle, store: State<'_, Store>, path: String) -> Result<thumbnails::Thumbnail, String> { fn thumbnail(app: AppHandle, store: State<'_, Store>, path: String) -> Result<thumbnails::Thumbnail, String> {
thumbnails::thumbnail(&app, &store, &path) thumbnails::thumbnail(&app, &store, &path)
@@ -517,6 +537,16 @@ fn clear_thumbnails(store: State<'_, Store>) -> Result<(), String> {
store.clear_thumbnails() 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] #[tauri::command]
fn list_assets(app: AppHandle, store: State<'_, Store>) -> Result<Vec<Asset>, String> { fn list_assets(app: AppHandle, store: State<'_, Store>) -> Result<Vec<Asset>, String> {
assets::list_assets(&app, &store) assets::list_assets(&app, &store)
@@ -675,10 +705,17 @@ fn lsp_running(state: State<'_, LspState>) -> bool {
state.is_running() 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] #[tauri::command]
fn cloud_login( fn cloud_login(
app: AppHandle, app: AppHandle,
store: State<'_, Store>, store: State<'_, Store>,
ws_state: State<'_, WsState>,
server_url: String, server_url: String,
email: String, email: String,
password: String, password: String,
@@ -688,12 +725,14 @@ fn cloud_login(
let response = sync::login(&server_url, &email, &password, &device_name)?; let response = sync::login(&server_url, &email, &password, &device_name)?;
let mut settings = load_settings(&app, &store)?; let mut settings = load_settings(&app, &store)?;
settings.server_url = server_url; settings.server_url = server_url.clone();
settings.device_token = Some(response.token); settings.device_token = Some(response.token.clone());
settings.account_email = Some(response.email.clone()); settings.account_email = Some(response.email.clone());
settings.account_username = Some(response.username.clone()); settings.account_username = Some(response.username.clone());
save_settings(&store, &settings)?; save_settings(&store, &settings)?;
ws_state.start(app, server_url, response.token);
Ok(Account { Ok(Account {
user_id: response.user_id, user_id: response.user_id,
username: response.username, username: response.username,
@@ -702,7 +741,11 @@ fn cloud_login(
} }
#[tauri::command] #[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)?; let mut settings = load_settings(&app, &store)?;
if let Some(token) = &settings.device_token { if let Some(token) = &settings.device_token {
let _ = sync::logout(&settings.server_url, 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.device_token = None;
settings.account_email = None; settings.account_email = None;
settings.account_username = 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] #[tauri::command]
@@ -725,9 +792,9 @@ fn cloud_account(app: AppHandle, store: State<'_, Store>) -> Result<Option<Accou
} }
#[tauri::command] #[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)?; let (server_url, token) = cloud_credentials(&app, &store)?;
sync::list_spaces(&server_url, &token) sync::list_cloud_projects(&server_url, &token)
} }
#[tauri::command] #[tauri::command]
@@ -739,6 +806,71 @@ fn cloud_list_folders(
sync::list_folders(&server_url, &token) 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] #[tauri::command]
fn cloud_list_documents( fn cloud_list_documents(
app: AppHandle, app: AppHandle,
@@ -781,6 +913,97 @@ fn cloud_download_file(
Ok(file.name) 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] #[tauri::command]
fn cloud_list_shared( fn cloud_list_shared(
app: AppHandle, app: AppHandle,
@@ -839,6 +1062,32 @@ fn cloud_sync_document(
sync::sync_document(&server_url, &token, &app, &store, &path) 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] #[tauri::command]
fn cloud_resolve_document( fn cloud_resolve_document(
app: AppHandle, app: AppHandle,
@@ -864,6 +1113,30 @@ fn cloud_unlink_document(store: State<'_, Store>, path: String) -> Result<(), St
store.forget_document_link(&path) 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)] #[derive(Serialize)]
pub struct LinkedDocument { pub struct LinkedDocument {
pub path: String, pub path: String,
@@ -899,21 +1172,21 @@ fn cloud_linked_documents(
} }
#[derive(Serialize)] #[derive(Serialize)]
pub struct LinkedSpace { pub struct LinkedProject {
pub path: String, pub path: String,
pub space_id: String, pub cloud_project_id: String,
pub synced_at: Option<String>, pub synced_at: Option<String>,
pub sync_state: Option<String>, pub sync_state: Option<String>,
} }
#[tauri::command] #[tauri::command]
fn cloud_linked_spaces( fn cloud_linked_projects(
app: AppHandle, app: AppHandle,
store: State<'_, Store>, store: State<'_, Store>,
) -> Result<Vec<LinkedSpace>, String> { ) -> Result<Vec<LinkedProject>, String> {
let mut linked = Vec::new(); 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 { let Ok(full) = workspace_path(&app, &store, &path) else {
continue; continue;
}; };
@@ -921,10 +1194,10 @@ fn cloud_linked_spaces(
continue; continue;
} }
linked.push(LinkedSpace { linked.push(LinkedProject {
sync_state: workspace::project_sync_state(&full, synced_at.as_deref()), sync_state: workspace::project_sync_state(&full, synced_at.as_deref()),
path, path,
space_id, cloud_project_id,
synced_at, synced_at,
}); });
} }
@@ -941,31 +1214,62 @@ fn cloud_document_link(
} }
#[tauri::command] #[tauri::command]
fn cloud_create_space(app: AppHandle, store: State<'_, Store>, name: String) -> Result<SpaceSummary, String> { fn cloud_create_project(
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(
app: AppHandle, app: AppHandle,
store: State<'_, Store>, 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, project_name: String,
parent: String,
) -> Result<SyncReport, String> { ) -> Result<SyncReport, String> {
let (server_url, token) = cloud_credentials(&app, &store)?; 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)?; let dir = workspace_path(&app, &store, &project)?;
if dir.exists() { if dir.exists() {
return Err(format!("A project named '{}' already exists", project_name)); 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] #[tauri::command]
@@ -973,17 +1277,17 @@ fn cloud_link_project(
app: AppHandle, app: AppHandle,
store: State<'_, Store>, store: State<'_, Store>,
project: String, project: String,
space_id: Option<String>, cloud_project_id: Option<String>,
) -> Result<SyncReport, String> { ) -> Result<SyncReport, String> {
let (server_url, token) = cloud_credentials(&app, &store)?; let (server_url, token) = cloud_credentials(&app, &store)?;
let (dir, mut meta) = load_project(&app, &store, &project)?; 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, 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(); meta.base_hashes.clear();
store.save_meta(&project, &meta)?; store.save_meta(&project, &meta)?;
@@ -993,7 +1297,7 @@ fn cloud_link_project(
#[tauri::command] #[tauri::command]
fn cloud_unlink_project(app: AppHandle, store: State<'_, Store>, project: String) -> Result<(), String> { fn cloud_unlink_project(app: AppHandle, store: State<'_, Store>, project: String) -> Result<(), String> {
let (dir, mut meta) = load_project(&app, &store, &project)?; let (dir, mut meta) = load_project(&app, &store, &project)?;
meta.space_id = None; meta.cloud_project_id = None;
meta.base_hashes.clear(); meta.base_hashes.clear();
meta.last_synced_at = None; meta.last_synced_at = None;
let _ = dir; let _ = dir;
@@ -1069,12 +1373,14 @@ pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_clipboard_manager::init())
.setup(|app| { .setup(|app| {
let store = Store::open(&app.handle())?; let store = Store::open(&app.handle())?;
app.manage(store); app.manage(store);
Ok(()) Ok(())
}) })
.manage(LspState::default()) .manage(LspState::default())
.manage(WsState::default())
.on_window_event(|window, event| { .on_window_event(|window, event| {
if matches!(event, tauri::WindowEvent::Destroyed) { if matches!(event, tauri::WindowEvent::Destroyed) {
if let Some(state) = window.app_handle().try_state::<LspState>() { if let Some(state) = window.app_handle().try_state::<LspState>() {
@@ -1102,9 +1408,12 @@ pub fn run() {
set_target_entrypoint, set_target_entrypoint,
compile_target, compile_target,
export_target, export_target,
render_target_png,
thumbnail, thumbnail,
read_image, read_image,
clear_thumbnails, clear_thumbnails,
get_cloud_cache,
save_cloud_cache,
list_assets, list_assets,
list_resources, list_resources,
list_font_families, list_font_families,
@@ -1116,25 +1425,43 @@ pub fn run() {
lsp_send, lsp_send,
lsp_stop, lsp_stop,
lsp_running, lsp_running,
cloud_check_compatibility,
cloud_login, cloud_login,
cloud_logout, cloud_logout,
cloud_account, cloud_account,
cloud_list_spaces, cloud_ws_start,
cloud_ws_stop,
cloud_ws_status,
cloud_list_projects,
cloud_list_folders, 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_documents,
cloud_list_shared, cloud_list_shared,
cloud_list_files, cloud_list_files,
cloud_download_file, cloud_download_file,
cloud_delete_file,
cloud_upload_file,
cloud_rename_file,
cloud_move_file,
cloud_download_document, cloud_download_document,
cloud_delete_document,
cloud_sync_document, cloud_sync_document,
cloud_room_id,
cloud_resolve_document, cloud_resolve_document,
cloud_document_link, cloud_document_link,
cloud_linked_documents, cloud_linked_documents,
cloud_linked_spaces, cloud_linked_projects,
cloud_unlink_document, cloud_unlink_document,
cloud_create_space, cloud_create_document,
cloud_delete_space, cloud_create_project,
cloud_clone_space, cloud_new_document,
cloud_delete_project,
cloud_clone_project,
cloud_link_project, cloud_link_project,
cloud_unlink_project, cloud_unlink_project,
cloud_push, 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)] #[derive(Deserialize, Serialize, Clone)]
pub struct Account { pub struct Account {
pub user_id: String, pub user_id: String,
@@ -55,12 +137,20 @@ pub fn login(
password: &str, password: &str,
device_name: &str, device_name: &str,
) -> Result<LoginResponse, String> { ) -> 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() agent()
.post(&endpoint(server_url, "/auth/login")) .post(&endpoint(server_url, "/auth/login"))
.send_json(ureq::json!({ .send_json(ureq::json!({
"email": email, "email": email,
"password": password, "password": password,
"device_name": device_name, "device_name": device_name,
"client_version": env!("CARGO_PKG_VERSION"),
})) }))
.map_err(describe)? .map_err(describe)?
.into_json::<LoginResponse>() .into_json::<LoginResponse>()
@@ -87,51 +177,79 @@ pub fn me(server_url: &str, token: &str) -> Result<Account, String> {
} }
#[derive(Deserialize, Serialize, Clone)] #[derive(Deserialize, Serialize, Clone)]
pub struct SpaceSummary { pub struct ProjectSummary {
pub id: String, pub id: String,
pub name: String, pub name: String,
pub entrypoint: String, pub entrypoint: String,
pub folder_id: Option<String>,
pub role: String, pub role: String,
pub updated_at: 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() agent()
.get(&endpoint(server_url, "/spaces")) .get(&endpoint(server_url, "/projects"))
.set("Authorization", &format!("Bearer {}", token)) .set("Authorization", &format!("Bearer {}", token))
.call() .call()
.map_err(describe)? .map_err(describe)?
.into_json::<Vec<SpaceSummary>>() .into_json::<Vec<ProjectSummary>>()
.map_err(|e| e.to_string()) .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() agent()
.post(&endpoint(server_url, "/spaces")) .post(&endpoint(server_url, "/projects"))
.set("Authorization", &format!("Bearer {}", token)) .set("Authorization", &format!("Bearer {}", token))
.send_json(ureq::json!({ "name": name })) .send_json(ureq::json!({ "name": name, "folder_id": folder_id }))
.map_err(describe)? .map_err(describe)?
.into_json::<SpaceSummary>() .into_json::<ProjectSummary>()
.map_err(|e| e.to_string()) .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() agent()
.delete(&endpoint(server_url, &format!("/spaces/{}", space_id))) .delete(&endpoint(server_url, &format!("/projects/{}", cloud_project_id)))
.set("Authorization", &format!("Bearer {}", token)) .set("Authorization", &format!("Bearer {}", token))
.call() .call()
.map_err(describe)?; .map_err(describe)?;
Ok(()) 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)] #[derive(Deserialize)]
pub struct ManifestEntry { pub struct ManifestEntry {
pub id: String,
pub path: String, pub path: String,
pub hash: String, pub hash: String,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct SpaceManifest { pub struct ProjectManifest {
pub project_id: String,
pub name: String,
pub entrypoint: String, pub entrypoint: String,
pub files: Vec<ManifestEntry>, pub files: Vec<ManifestEntry>,
} }
@@ -139,17 +257,17 @@ pub struct SpaceManifest {
pub fn get_manifest( pub fn get_manifest(
server_url: &str, server_url: &str,
token: &str, token: &str,
space_id: &str, cloud_project_id: &str,
) -> Result<SpaceManifest, String> { ) -> Result<ProjectManifest, String> {
agent() agent()
.get(&endpoint( .get(&endpoint(
server_url, server_url,
&format!("/spaces/{}/manifest", space_id), &format!("/projects/{}/manifest", cloud_project_id),
)) ))
.set("Authorization", &format!("Bearer {}", token)) .set("Authorization", &format!("Bearer {}", token))
.call() .call()
.map_err(describe)? .map_err(describe)?
.into_json::<SpaceManifest>() .into_json::<ProjectManifest>()
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
@@ -176,11 +294,14 @@ impl FileContent {
pub fn pull_file( pub fn pull_file(
server_url: &str, server_url: &str,
token: &str, token: &str,
space_id: &str, cloud_project_id: &str,
path: &str, path: &str,
) -> Result<FileContent, String> { ) -> Result<FileContent, String> {
agent() agent()
.get(&endpoint(server_url, &format!("/spaces/{}/file", space_id))) .get(&endpoint(
server_url,
&format!("/projects/{}/file", cloud_project_id),
))
.query("path", path) .query("path", path)
.set("Authorization", &format!("Bearer {}", token)) .set("Authorization", &format!("Bearer {}", token))
.call() .call()
@@ -204,7 +325,7 @@ pub enum PushResult {
pub fn push_file( pub fn push_file(
server_url: &str, server_url: &str,
token: &str, token: &str,
space_id: &str, cloud_project_id: &str,
path: &str, path: &str,
bytes: &[u8], bytes: &[u8],
base_hash: Option<&str>, base_hash: Option<&str>,
@@ -216,7 +337,10 @@ pub fn push_file(
}; };
let response = agent() 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)) .set("Authorization", &format!("Bearer {}", token))
.send_json(ureq::json!({ .send_json(ureq::json!({
"path": path, "path": path,
@@ -248,11 +372,14 @@ pub fn push_file(
pub fn delete_remote_file( pub fn delete_remote_file(
server_url: &str, server_url: &str,
token: &str, token: &str,
space_id: &str, cloud_project_id: &str,
path: &str, path: &str,
) -> Result<(), String> { ) -> Result<(), String> {
let response = agent() 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) .query("path", path)
.set("Authorization", &format!("Bearer {}", token)) .set("Authorization", &format!("Bearer {}", token))
.call(); .call();
@@ -306,12 +433,12 @@ pub fn pull_project(
project_dir: &Path, project_dir: &Path,
meta: &mut ProjectMeta, meta: &mut ProjectMeta,
) -> Result<SyncReport, String> { ) -> Result<SyncReport, String> {
let space_id = meta let cloud_project_id = meta
.space_id .cloud_project_id
.clone() .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 mut report = SyncReport::default();
let local_files: HashSet<String> = collect_files(project_dir)?.into_iter().collect(); let local_files: HashSet<String> = collect_files(project_dir)?.into_iter().collect();
@@ -327,7 +454,7 @@ pub fn pull_project(
if base.is_some() { if base.is_some() {
continue; 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()?)?; write_local(project_dir, &entry.path, &remote.bytes()?)?;
meta.base_hashes.insert(entry.path.clone(), remote.hash); meta.base_hashes.insert(entry.path.clone(), remote.hash);
report.pulled.push(entry.path.clone()); report.pulled.push(entry.path.clone());
@@ -346,7 +473,7 @@ pub fn pull_project(
continue; 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()?; let remote_bytes = remote.bytes()?;
if base.as_deref() == Some(local_hash.as_str()) { if base.as_deref() == Some(local_hash.as_str()) {
@@ -428,10 +555,10 @@ pub fn push_project(
project_dir: &Path, project_dir: &Path,
meta: &mut ProjectMeta, meta: &mut ProjectMeta,
) -> Result<SyncReport, String> { ) -> Result<SyncReport, String> {
let space_id = meta let cloud_project_id = meta
.space_id .cloud_project_id
.clone() .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 mut report = SyncReport::default();
let local_files = collect_files(project_dir)?; let local_files = collect_files(project_dir)?;
@@ -446,7 +573,14 @@ pub fn push_project(
continue; 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 => { PushResult::Applied => {
meta.base_hashes.insert(path.clone(), hash); meta.base_hashes.insert(path.clone(), hash);
report.pushed.push(path.clone()); report.pushed.push(path.clone());
@@ -481,7 +615,7 @@ pub fn push_project(
.collect(); .collect();
for path in removed { 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); meta.base_hashes.remove(&path);
report.deleted_remote.push(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, server_url: &str,
token: &str, token: &str,
app: &tauri::AppHandle, app: &tauri::AppHandle,
store: &Store, store: &Store,
project: &str, project: &str,
project_dir: &Path, project_dir: &Path,
space_id: &str, cloud_project_id: &str,
) -> Result<SyncReport, String> { ) -> Result<SyncReport, String> {
std::fs::create_dir_all(project_dir).map_err(|e| e.to_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)?; 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(); meta.entrypoint = manifest.entrypoint.clone();
let mut report = SyncReport::default(); let mut report = SyncReport::default();
@@ -544,7 +678,7 @@ pub fn clone_space(
for (index, entry) in manifest.files.iter().enumerate() { for (index, entry) in manifest.files.iter().enumerate() {
report_progress(app, project, index, total, false); 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()?)?; write_local(project_dir, &entry.path, &remote.bytes()?)?;
meta.base_hashes.insert(entry.path.clone(), remote.hash); meta.base_hashes.insert(entry.path.clone(), remote.hash);
report.pulled.push(entry.path.clone()); report.pulled.push(entry.path.clone());
@@ -621,7 +755,7 @@ pub struct CloudDocument {
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize)]
pub struct SharedItems { pub struct SharedItems {
pub documents: Vec<CloudDocument>, 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> { 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()) .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( pub fn list_documents(
server_url: &str, server_url: &str,
token: &str, token: &str,
@@ -687,6 +881,41 @@ pub fn pull_document(
.map_err(|e| e.to_string()) .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( pub fn sync_document(
server_url: &str, server_url: &str,
token: &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)] #[derive(Deserialize, Serialize, Clone)]
pub struct CloudFile { pub struct CloudFile {
pub id: String, pub id: String,
@@ -916,6 +1154,29 @@ pub fn list_account_files(
.map_err(|e| e.to_string()) .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( pub fn pull_account_file(
server_url: &str, server_url: &str,
token: &str, token: &str,
@@ -929,3 +1190,42 @@ pub fn pull_account_file(
.into_json::<CloudFileContent>() .into_json::<CloudFileContent>()
.map_err(|e| e.to_string()) .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)] #[serde(default)]
pub autosave_seconds: u32, pub autosave_seconds: u32,
#[serde(default)] #[serde(default)]
pub sync_minutes: u32, pub sync_seconds: u32,
} }
impl Settings { impl Settings {
@@ -84,7 +84,7 @@ impl Settings {
account_email: None, account_email: None,
account_username: None, account_username: None,
autosave_seconds: 5, 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)] #[derive(Serialize, Deserialize, Clone)]
pub struct ProjectMeta { pub struct ProjectMeta {
pub entrypoint: String, pub entrypoint: String,
pub space_id: Option<String>, pub cloud_project_id: Option<String>,
pub last_synced_at: Option<String>, pub last_synced_at: Option<String>,
pub base_hashes: HashMap<String, String>, pub base_hashes: HashMap<String, String>,
} }
@@ -124,7 +124,7 @@ impl Default for ProjectMeta {
fn default() -> Self { fn default() -> Self {
ProjectMeta { ProjectMeta {
entrypoint: "main.typ".to_string(), entrypoint: "main.typ".to_string(),
space_id: None, cloud_project_id: None,
last_synced_at: None, last_synced_at: None,
base_hashes: HashMap::new(), base_hashes: HashMap::new(),
} }
@@ -154,7 +154,7 @@ pub struct BrowseEntry {
pub kind: String, pub kind: String,
pub size: u64, pub size: u64,
pub modified: Option<String>, pub modified: Option<String>,
pub space_id: Option<String>, pub cloud_project_id: Option<String>,
pub last_synced_at: Option<String>, pub last_synced_at: Option<String>,
pub child_count: usize, pub child_count: usize,
pub cloud_linked: bool, pub cloud_linked: bool,
@@ -259,9 +259,9 @@ pub fn browse(app: &AppHandle, store: &Store, relative: &str) -> Result<Vec<Brow
}) })
.unwrap_or(0); .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 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()) sync_state_for(newest_change(&full), last_synced_at.as_deref())
} else { } else {
None 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(), kind: if project { "project" } else { "folder" }.to_string(),
size: 0, size: 0,
modified: modified_at(&full), modified: modified_at(&full),
cloud_linked: space_id.is_some(), cloud_linked: cloud_project_id.is_some(),
space_id, cloud_project_id,
last_synced_at, last_synced_at,
sync_state, sync_state,
child_count, child_count,
@@ -299,7 +299,7 @@ pub fn browse(app: &AppHandle, store: &Store, relative: &str) -> Result<Vec<Brow
kind: kind.to_string(), kind: kind.to_string(),
size: std::fs::metadata(&full).map(|m| m.len()).unwrap_or(0), size: std::fs::metadata(&full).map(|m| m.len()).unwrap_or(0),
modified: modified_at(&full), modified: modified_at(&full),
space_id: None, cloud_project_id: None,
last_synced_at: link.as_ref().and_then(|link| link.synced_at.clone()), last_synced_at: link.as_ref().and_then(|link| link.synced_at.clone()),
child_count: 0, child_count: 0,
cloud_linked: link.is_some(), 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", "$schema": "https://schema.tauri.app/config/2",
"productName": "typst-desktop", "productName": "typst-desktop",
"version": "1.0.0", "version": "1.1.0",
"identifier": "co.sirblob.typst-desktop", "identifier": "co.sirblob.typst-desktop",
"build": { "build": {
"beforeDevCommand": "bun run dev", "beforeDevCommand": "bun run dev",
+112
View File
@@ -31,6 +31,118 @@
--color-success: #4cc47f; --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, html,
body { body {
height: 100%; height: 100%;
+47 -1
View File
@@ -1,5 +1,9 @@
<script lang="ts"> <script lang="ts">
import { onDestroy } from "svelte";
import Icon from "@iconify/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 Modal from "./Modal.svelte";
import type { Conflict, Resolution } from "$lib/ts/api"; import type { Conflict, Resolution } from "$lib/ts/api";
@@ -25,6 +29,37 @@
const current = $derived(conflicts[index]); 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") { function choose(option: "merged" | "local" | "remote") {
mode[index] = option; mode[index] = option;
choices[index] = choices[index] =
@@ -75,7 +110,7 @@
: 'border-[var(--color-line)] text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-muted)]'}" : 'border-[var(--color-line)] text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-muted)]'}"
onclick={() => (index = position)} onclick={() => (index = position)}
> >
{#if choices[position].includes("<<<<<<<")} {#if choices[position]?.includes("<<<<<<<")}
<Icon icon="ph:warning" class="text-[var(--color-danger)]" /> <Icon icon="ph:warning" class="text-[var(--color-danger)]" />
{:else} {:else}
<Icon icon="ph:check-circle" class="text-[var(--color-success)]" /> <Icon icon="ph:check-circle" class="text-[var(--color-success)]" />
@@ -97,6 +132,17 @@
</p> </p>
</div> </div>
{:else} {: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"> <div class="flex items-center gap-1.5">
{#each [["merged", "Merged"], ["local", "This device"], ["remote", "Cloud"]] as [option, label]} {#each [["merged", "Merged"], ["local", "This device"], ["remote", "Cloud"]] as [option, label]}
<button <button
+52 -20
View File
@@ -7,12 +7,7 @@
highlightActiveLine, highlightActiveLine,
} from "@codemirror/view"; } from "@codemirror/view";
import { EditorState, Compartment, StateField } from "@codemirror/state"; import { EditorState, Compartment, StateField } from "@codemirror/state";
import { import { defaultKeymap, history, indentWithTab } from "@codemirror/commands";
defaultKeymap,
history,
historyKeymap,
indentWithTab,
} from "@codemirror/commands";
import { import {
bracketMatching, bracketMatching,
indentOnInput, indentOnInput,
@@ -28,6 +23,9 @@
} from "@codemirror/autocomplete"; } from "@codemirror/autocomplete";
import { lintGutter, setDiagnostics } from "@codemirror/lint"; import { lintGutter, setDiagnostics } from "@codemirror/lint";
import { LSPClient, languageServerExtensions } from "@codemirror/lsp-client"; 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 { typstCompletions } from "$lib/ts/completions";
import { editorTheme } from "$lib/ts/editor-theme"; import { editorTheme } from "$lib/ts/editor-theme";
@@ -41,8 +39,8 @@
targetPath: string; targetPath: string;
enableLsp?: boolean; enableLsp?: boolean;
diagnostics?: Diagnostic[]; diagnostics?: Diagnostic[];
collab?: { text: Y.Text; awareness: Awareness } | null;
onchange: (value: string) => void; onchange: (value: string) => void;
onsave: () => void;
onlspstatus?: (status: "off" | "starting" | "on" | "unavailable") => void; onlspstatus?: (status: "off" | "starting" | "on" | "unavailable") => void;
onready?: (view: EditorView | null) => void; onready?: (view: EditorView | null) => void;
} }
@@ -53,8 +51,8 @@
targetPath, targetPath,
enableLsp = true, enableLsp = true,
diagnostics = [], diagnostics = [],
collab = null,
onchange, onchange,
onsave,
onlspstatus, onlspstatus,
onready, onready,
}: Props = $props(); }: Props = $props();
@@ -65,12 +63,19 @@
const languageSlot = new Compartment(); const languageSlot = new Compartment();
const lspSlot = new Compartment(); const lspSlot = new Compartment();
const themeSlot = new Compartment(); const themeSlot = new Compartment();
const collabSlot = new Compartment();
const bridge = new LspBridge(); const bridge = new LspBridge();
let client: LSPClient | null = null; let client: LSPClient | null = null;
const isToml = $derived(filePath.toLowerCase().endsWith(".toml")); const isToml = $derived(filePath.toLowerCase().endsWith(".toml"));
function disableTypstLanguage() {
queueMicrotask(() => {
view?.dispatch({ effects: languageSlot.reconfigure([]) });
});
}
function resilientSync(parser: any) { function resilientSync(parser: any) {
return StateField.define<null>({ return StateField.define<null>({
create: () => null, create: () => null,
@@ -79,7 +84,11 @@
transaction.startState.facet(language) !== transaction.startState.facet(language) !==
transaction.state.facet(language) transaction.state.facet(language)
) { ) {
parser.clearParser(); try {
parser.clearParser();
} catch {
disableTypstLanguage();
}
return null; return null;
} }
@@ -95,7 +104,13 @@
for (const edit of edits.edits) parser.applyTreeEdit(edit); for (const edit of edits.edits) parser.applyTreeEdit(edit);
}); });
} catch { } catch {
parser.clearParser(); try {
parser.clearParser();
} catch {
// The wasm parser instance panicked and is no longer usable.
// Drop syntax highlighting rather than crash on every keystroke.
disableTypstLanguage();
}
} }
return null; return null;
@@ -158,22 +173,14 @@
languageSlot.of(isToml ? StreamLanguage.define(toml) : []), languageSlot.of(isToml ? StreamLanguage.define(toml) : []),
lspSlot.of([]), lspSlot.of([]),
themeSlot.of(editorTheme(app.theme === "dark")), themeSlot.of(editorTheme(app.theme === "dark")),
collabSlot.of([]),
...(isToml ...(isToml
? [] ? []
: [autocompletion({ override: [typstCompletions] })]), : [autocompletion({ override: [typstCompletions] })]),
EditorView.lineWrapping, EditorView.lineWrapping,
keymap.of([ keymap.of([
{
key: "Mod-s",
preventDefault: true,
run: () => {
onsave();
return true;
},
},
...closeBracketsKeymap, ...closeBracketsKeymap,
...defaultKeymap, ...defaultKeymap,
...historyKeymap,
indentWithTab, indentWithTab,
]), ]),
EditorView.updateListener.of((update) => { EditorView.updateListener.of((update) => {
@@ -209,9 +216,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(() => { $effect(() => {
const next = content; const next = content;
if (!view) return; if (!view || collab) return;
if (view.state.doc.toString() === next) return; if (view.state.doc.toString() === next) return;
laterDispatch((current) => { laterDispatch((current) => {
+6 -20
View File
@@ -5,9 +5,7 @@
import { import {
insertText, insertText,
prefixLines, prefixLines,
redoEdit,
setTypstConfig, setTypstConfig,
undoEdit,
wrapSelection, wrapSelection,
} from "$lib/ts/editor-actions"; } from "$lib/ts/editor-actions";
import { app } from "$lib/ts/state.svelte"; import { app } from "$lib/ts/state.svelte";
@@ -37,10 +35,10 @@
icon: string, icon: string,
label: string, label: string,
run: () => void, run: () => void,
size = "text-base", size = "text-sm",
)} )}
<button <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} title={label}
aria-label={label} aria-label={label}
{disabled} {disabled}
@@ -51,17 +49,12 @@
{/snippet} {/snippet}
{#snippet divider()} {#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} {/snippet}
<div <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-h", "Heading", () => prefixLines(view, "= ", "Heading"))}
{@render action("ph:text-b", "Bold", () => wrapSelection(view, "*", "*", "bold"))} {@render action("ph:text-b", "Bold", () => wrapSelection(view, "*", "*", "bold"))}
{@render action("ph:text-italic", "Italic", () => {@render action("ph:text-italic", "Italic", () =>
@@ -103,7 +96,7 @@
{@render divider()} {@render divider()}
<select <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" aria-label="Document font"
{disabled} {disabled}
bind:value={selectedFont} bind:value={selectedFont}
@@ -118,14 +111,7 @@
{/each} {/each}
</select> </select>
<button {@render action("ph:file-text", "Page settings", onpagesettings)}
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>
<div class="flex-1"></div> <div class="flex-1"></div>
</div> </div>
+114 -45
View File
@@ -1,17 +1,18 @@
<script lang="ts"> <script lang="ts">
import Icon from "@iconify/svelte"; import Icon from "@iconify/svelte";
import type { FileEntry } from "$lib/ts/api"; import type { FileEntry } from "$lib/ts/api";
import { clampMenu } from "$lib/ts/menu-position";
interface Props { interface Props {
files: FileEntry[]; files: FileEntry[];
activePath: string | null; activePath: string | null;
entrypoint: string; entrypoint: string;
selected: string | null; selected: Set<string>;
dropTarget: string | null; dropTarget: string | null;
onopen: (path: string) => void; onopen: (path: string) => void;
onselect: (path: string | null, isDir: boolean) => void; onselect: (paths: string[], primary: string | null, isDir: boolean) => void;
onrename: (path: string) => void; onrename: (path: string) => void;
ondelete: (path: string) => void; ondelete: (paths: string[]) => void;
onduplicate: (path: string) => void; onduplicate: (path: string) => void;
onreveal: (path: string) => void; onreveal: (path: string) => void;
onsetentry: (path: string) => void; onsetentry: (path: string) => void;
@@ -111,9 +112,56 @@
null, null,
); );
let dragging = $state<string | null>(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) { if (node.isDir) {
collapsed[node.path] = !collapsed[node.path]; collapsed[node.path] = !collapsed[node.path];
} else { } else {
@@ -123,7 +171,10 @@
function openMenu(event: MouseEvent, node: TreeNode) { function openMenu(event: MouseEvent, node: TreeNode) {
event.preventDefault(); 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 }; menu = { path: node.path, isDir: node.isDir, x: event.clientX, y: event.clientY };
} }
@@ -134,7 +185,9 @@
} }
if (event.key === "Delete") { if (event.key === "Delete") {
event.preventDefault(); 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("/"); const index = path.lastIndexOf("/");
return index === -1 ? "" : path.slice(0, index); return index === -1 ? "" : path.slice(0, index);
} }
</script> </script>
<svelte:window <svelte:window
@@ -175,26 +229,28 @@
data-tree-dir={node.isDir ? "true" : "false"} data-tree-dir={node.isDir ? "true" : "false"}
role="treeitem" role="treeitem"
tabindex="0" tabindex="0"
aria-selected={node.path === selected} aria-selected={selected.has(node.path)}
draggable="true" draggable="true"
class="group flex items-center gap-1.5 rounded px-2 py-1 text-xs transition class="group flex items-center gap-1.5 rounded px-2 py-1 text-xs transition
{node.path === activePath {node.path === activePath
? 'bg-[var(--color-accent-soft)] text-[var(--color-accent)]' ? 'bg-[var(--color-accent-soft)] text-[var(--color-accent)]'
: node.path === selected : selected.has(node.path)
? 'bg-[var(--color-surface-sunken)]' ? 'bg-[var(--color-surface-sunken)]'
: 'text-[var(--color-ink)] hover:bg-[var(--color-surface-sunken)]'} : 'text-[var(--color-ink)] hover:bg-[var(--color-surface-sunken)]'}
{dropTarget === node.path {dropTarget === node.path
? 'ring-1 ring-inset ring-[var(--color-accent)]' ? 'ring-1 ring-inset ring-[var(--color-accent)]'
: ''}" : ''}"
style="padding-left: {depth * 12 + 8}px" style="padding-left: {depth * 12 + 8}px"
onclick={() => activate(node)} onclick={(event) => handleRowClick(event, node)}
oncontextmenu={(event) => openMenu(event, node)} oncontextmenu={(event) => openMenu(event, node)}
onkeydown={(event) => handleKey(event, node)} onkeydown={(event) => handleKey(event, node)}
ondragstart={(event) => { ondragstart={(event) => {
dragging = node.path; dragging = node.path;
event.dataTransfer?.setData("text/plain", node.path); event.dataTransfer?.setData("text/plain", node.path);
}} }}
ondragend={() => (dragging = null)} ondragend={() => {
dragging = null;
}}
ondragover={(event) => { ondragover={(event) => {
if (!dragging || !node.isDir) return; if (!dragging || !node.isDir) return;
event.preventDefault(); event.preventDefault();
@@ -254,7 +310,7 @@
> >
<button <button
class="rounded p-1 text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]" 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" title="New file"
aria-label="New file" aria-label="New file"
> >
@@ -262,7 +318,7 @@
</button> </button>
<button <button
class="rounded p-1 text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]" 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" title="New folder"
aria-label="New folder" aria-label="New folder"
> >
@@ -270,7 +326,7 @@
</button> </button>
<button <button
class="rounded p-1 text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]" 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" title="Import files"
aria-label="Import files" aria-label="Import files"
> >
@@ -293,10 +349,16 @@
data-tree-path="" data-tree-path=""
data-tree-dir="true" data-tree-dir="true"
onclick={(event) => { onclick={(event) => {
if (event.target === event.currentTarget) onselect(null, true); if (event.target === event.currentTarget) {
anchorPath = null;
onselect([], null, true);
}
}} }}
onkeydown={(event) => { onkeydown={(event) => {
if (event.key === "Escape") onselect(null, true); if (event.key === "Escape") {
anchorPath = null;
onselect([], null, true);
}
}} }}
ondragover={(event) => { ondragover={(event) => {
if (dragging) event.preventDefault(); if (dragging) event.preventDefault();
@@ -318,11 +380,15 @@
{#if menu} {#if menu}
{@const target = menu} {@const target = menu}
{@const menuPaths =
selected.has(target.path) && selected.size > 1 ? [...selected] : [target.path]}
{@const single = menuPaths.length === 1}
<div <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" 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" style="left: {target.x}px; top: {target.y}px"
> >
{#if target.isDir} {#if single && target.isDir}
<button <button
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]" class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
onclick={() => onnewfile(target.path)} onclick={() => onnewfile(target.path)}
@@ -342,7 +408,7 @@
Import files here Import files here
</button> </button>
<div class="my-1 h-px bg-[var(--color-line)]"></div> <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 <button
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]" class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
onclick={() => onsetentry(target.path)} onclick={() => onsetentry(target.path)}
@@ -352,36 +418,39 @@
<div class="my-1 h-px bg-[var(--color-line)]"></div> <div class="my-1 h-px bg-[var(--color-line)]"></div>
{/if} {/if}
<button {#if single}
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]" <button
onclick={() => onrename(target.path)} class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
> onclick={() => onrename(target.path)}
Rename >
</button> Rename
<button </button>
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]" <button
onclick={() => onduplicate(target.path)} class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
> onclick={() => onduplicate(target.path)}
Duplicate >
</button> Duplicate
<button </button>
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]" <button
onclick={() => navigator.clipboard.writeText(target.path)} class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
> onclick={() => navigator.clipboard.writeText(target.path)}
Copy path >
</button> Copy path
<button </button>
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]" <button
onclick={() => onreveal(target.path)} class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
> onclick={() => onreveal(target.path)}
Reveal in file manager >
</button> Reveal in file manager
<div class="my-1 h-px bg-[var(--color-line)]"></div> </button>
<div class="my-1 h-px bg-[var(--color-line)]"></div>
{/if}
<button <button
class="px-3 py-1.5 text-left text-[var(--color-danger)] hover:bg-[var(--color-surface-sunken)]" 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> </button>
</div> </div>
{/if} {/if}
+433 -86
View File
@@ -1,17 +1,20 @@
<script lang="ts"> <script lang="ts">
import Icon from "@iconify/svelte"; import Icon from "@iconify/svelte";
import * as api from "$lib/ts/api"; 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 { import {
app, app,
breadcrumbs, breadcrumbs,
browseTo, browseTo,
cloudBreadcrumbs, cloudBreadcrumbs,
linkedDocument, linkedDocument,
linkedSpace, linkedProject,
openCloudFolder, openCloudFolder,
openTarget, openTarget,
refreshCloud, refreshCloud,
refreshEntries,
setError,
} from "$lib/ts/state.svelte"; } from "$lib/ts/state.svelte";
interface Props { interface Props {
@@ -22,13 +25,22 @@
onrename: (entry: BrowseEntry) => void; onrename: (entry: BrowseEntry) => void;
ondelete: (entry: BrowseEntry) => void; ondelete: (entry: BrowseEntry) => void;
onlink: (entry: BrowseEntry) => void; onlink: (entry: BrowseEntry) => void;
onsavetocloud: (entry: BrowseEntry) => void;
onviewimage: (paths: string[], index: number) => void; onviewimage: (paths: string[], index: number) => void;
ondownloaddocument: (documentId: string, title: string) => void; ondownloaddocument: (documentId: string, title: string) => void;
onremovedownload: (path: string) => void; onremovedownload: (path: string) => void;
ondownloadfile: (fileId: string, name: string) => void; ondownloadfile: (fileId: string, name: string) => void;
onclonespace: (spaceId: string, name: string) => void; ondeletefile: (fileId: string) => void;
ondeletespace: (spaceId: string) => void; onuploadfile: () => void;
onnewspace: () => 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; onsignin: () => void;
} }
@@ -40,19 +52,45 @@
onrename, onrename,
ondelete, ondelete,
onlink, onlink,
onsavetocloud,
onviewimage, onviewimage,
ondownloaddocument, ondownloaddocument,
onremovedownload, onremovedownload,
ondownloadfile, ondownloadfile,
onclonespace, ondeletefile,
ondeletespace, onuploadfile,
onnewspace, onrenamefile,
oncloneproject,
ondeleteproject,
ondeletedocument,
onnewcloudproject,
onnewclouddocument,
onnewcloudfolder,
onrenamecloudfolder,
ondeletecloudfolder,
onsignin, onsignin,
}: Props = $props(); }: Props = $props();
let menuFor = $state<string | null>(null); let menuFor = $state<string | null>(null);
let menuAt = $state({ x: 0, y: 0 }); 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 trail = $derived(breadcrumbs());
const cloudTrail = $derived(cloudBreadcrumbs()); const cloudTrail = $derived(cloudBreadcrumbs());
@@ -82,7 +120,7 @@
const pending = [ const pending = [
...app.linkedDocuments.map((linked) => linked.path), ...app.linkedDocuments.map((linked) => linked.path),
...app.linkedSpaces.map((linked) => linked.path), ...app.linkedProjects.map((linked) => linked.path),
]; ];
let cancelled = false; let cancelled = false;
@@ -167,6 +205,94 @@
.map((entry) => entry.path), .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) { function activate(entry: BrowseEntry) {
if (entry.kind === "folder") { if (entry.kind === "folder") {
browseTo(entry.path); browseTo(entry.path);
@@ -190,7 +316,80 @@
} }
</script> </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( {#snippet cloudCard(
id: string,
icon: string, icon: string,
title: string, title: string,
meta: string, meta: string,
@@ -198,10 +397,21 @@
onopen: (() => void) | null, onopen: (() => void) | null,
ondownload: () => void, ondownload: () => void,
onremove: (() => void) | null, onremove: (() => void) | null,
ondelete: (() => void) | null,
)} )}
{@const [dragKind, dragId] = id.split(":") as [
"project" | "document",
string,
]}
<div <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" 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 <div
class="flex h-24 items-center justify-center overflow-hidden border-b border-[var(--color-line)] bg-[var(--color-surface-muted)]" 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> </button>
{/if} {/if}
{#if onremove} {#if onremove || ondelete}
<button <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)]" 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} onclick={onremove ?? ondelete}
aria-label="Remove" aria-label={onremove ? "Remove" : "Delete"}
> >
<Icon icon="ph:trash" /> <Icon icon="ph:trash" />
</button> </button>
@@ -329,7 +539,8 @@
{#if menuFor === entry.path} {#if menuFor === entry.path}
<div <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" style="left: {menuAt.x}px; top: {menuAt.y}px"
> >
{#if entry.kind === "project" || entry.kind === "document"} {#if entry.kind === "project" || entry.kind === "document"}
@@ -353,7 +564,7 @@
Open image Open image
</button> </button>
{/if} {/if}
{#if entry.kind === "project" && !entry.space_id && app.account} {#if entry.kind === "project" && !entry.cloud_project_id && app.account}
<button <button
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]" class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
onclick={() => { onclick={() => {
@@ -364,6 +575,17 @@
Upload to cloud Upload to cloud
</button> </button>
{/if} {/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 <button
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]" class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
onclick={() => { onclick={() => {
@@ -391,27 +613,51 @@
if (!(event.target as HTMLElement).closest("[data-card-menu]")) { if (!(event.target as HTMLElement).closest("[data-card-menu]")) {
menuFor = null; 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 <div
class="flex items-center gap-2 border-b border-[var(--color-line)] bg-[var(--color-surface)] px-4 py-2.5" 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"> {#if app.scope === "local"}
{#each [["local", "Local", "ph:hard-drives"], ["cloud", "Cloud", "ph:cloud"]] as [value, label, icon]} <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 <button
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 transition class="rounded px-2 py-1 text-xs transition hover:bg-[var(--color-surface-muted)]
{app.scope === value {index === trail.length - 1 ? 'font-medium' : 'text-[var(--color-ink-muted)]'}
? 'bg-[var(--color-surface)] text-[var(--color-ink)] shadow-sm' {dropTarget === crumb.path ? 'bg-[var(--color-accent-soft)] text-[var(--color-accent)]' : ''}"
: 'text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]'}" onclick={() => browseTo(crumb.path)}
onclick={() => (app.scope = value as "local" | "cloud")} ondragover={(event) => allowDrop(event, crumb.path)}
ondragleave={() => {
if (dropTarget === crumb.path) dropTarget = null;
}}
ondrop={(event) => handleDrop(event, crumb.path)}
> >
<Icon {icon} /> {crumb.name}
{label}
</button> </button>
{/each} {/each}
</div> {/if}
<div class="flex-1"></div> <div class="flex-1"></div>
@@ -432,54 +678,51 @@
</button> </button>
<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)]" 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" /> <Icon icon="ph:folder-star" />
Document Project
</button> </button>
<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" 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" /> <Icon icon="ph:plus" />
New project New document
</button> </button>
{:else if app.account} {: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 <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" 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" /> <Icon icon="ph:plus" />
New space New document
</button> </button>
{/if} {/if}
</div> </div>
{#if app.scope === "local"} {#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"> <div class="scroll-thin flex-1 overflow-y-auto p-4">
{#if app.entries.length === 0} {#if app.entries.length === 0}
<div <div
@@ -490,16 +733,16 @@
<div class="flex gap-2"> <div class="flex gap-2">
<button <button
class="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-white hover:opacity-90" 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} onclick={onnewdocument}
> >
New document New document
</button> </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>
</div> </div>
{:else} {:else}
@@ -514,7 +757,20 @@
<div class="grid grid-cols-[repeat(auto-fill,minmax(210px,1fr))] gap-2"> <div class="grid grid-cols-[repeat(auto-fill,minmax(210px,1fr))] gap-2">
{#each containers as entry (entry.path)} {#each containers as entry (entry.path)}
<div <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 <button
class="flex min-w-0 flex-1 items-center gap-2.5 text-left" class="flex min-w-0 flex-1 items-center gap-2.5 text-left"
@@ -566,6 +822,11 @@
{#each documents as entry (entry.path)} {#each documents as entry (entry.path)}
<div <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" 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 <button
class="flex flex-col text-left" 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 class="flex items-center gap-2 rounded-lg border px-3.5 py-2 text-xs font-medium transition
{app.cloudFolder !== 'shared' {app.cloudFolder !== 'shared'
? 'border-[var(--color-accent)] bg-[var(--color-accent)] text-white shadow-sm' ? '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)} onclick={() => openCloudFolder(null)}
ondragover={(event) => allowCloudDrop(event, "")}
ondragleave={() => {
if (cloudDropTarget === "") cloudDropTarget = null;
}}
ondrop={(event) => handleCloudDrop(event, "")}
> >
<Icon icon="ph:cloud-fill" class="text-base" /> <Icon icon="ph:cloud-fill" class="text-base" />
My Drive My Drive
@@ -684,11 +951,24 @@
{/if} {/if}
</div> </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} {#if cloudTrail.length > 0}
<div class="mb-3 flex flex-wrap items-center gap-1 text-xs"> <div class="mb-3 flex flex-wrap items-center gap-1 text-xs">
<button <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)} onclick={() => openCloudFolder(null)}
ondragover={(event) => allowCloudDrop(event, "")}
ondragleave={() => {
if (cloudDropTarget === "") cloudDropTarget = null;
}}
ondrop={(event) => handleCloudDrop(event, "")}
> >
My Drive My Drive
</button> </button>
@@ -702,8 +982,16 @@
class="rounded px-2 py-1 transition hover:bg-[var(--color-surface)] class="rounded px-2 py-1 transition hover:bg-[var(--color-surface)]
{index === cloudTrail.length - 1 {index === cloudTrail.length - 1
? 'font-medium' ? '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)} 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} {folder.name}
</button> </button>
@@ -716,16 +1004,40 @@
class="mb-4 grid grid-cols-[repeat(auto-fill,minmax(210px,1fr))] gap-2" class="mb-4 grid grid-cols-[repeat(auto-fill,minmax(210px,1fr))] gap-2"
> >
{#each app.cloudFolders as folder (folder.id)} {#each app.cloudFolders as folder (folder.id)}
<button <div class="relative">
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)]" <button
onclick={() => openCloudFolder(folder.id)} 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
<Icon ? 'border-[var(--color-accent)] bg-[var(--color-accent-soft)]'
icon="ph:folder-fill" : 'border-[var(--color-line)] bg-[var(--color-surface-sunken)]'}"
class="shrink-0 text-2xl text-[var(--color-ink-muted)]" onclick={() => openCloudFolder(folder.id)}
/> oncontextmenu={(event) =>
<span class="truncate text-xs font-medium">{folder.name}</span> openCloudContextMenu(event, `folder:${folder.id}`)}
</button> 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} {/each}
</div> </div>
{/if} {/if}
@@ -742,6 +1054,7 @@
{#each app.cloudDocuments as entry (entry.id)} {#each app.cloudDocuments as entry (entry.id)}
{@const linked = linkedDocument(entry.id)} {@const linked = linkedDocument(entry.id)}
{@render cloudCard( {@render cloudCard(
`document:${entry.id}`,
"ph:file-text", "ph:file-text",
entry.title, entry.title,
linked linked
@@ -751,6 +1064,7 @@
linked ? () => openTarget(linked.path) : null, linked ? () => openTarget(linked.path) : null,
() => ondownloaddocument(entry.id, entry.title), () => ondownloaddocument(entry.id, entry.title),
linked ? () => onremovedownload(linked.path) : null, linked ? () => onremovedownload(linked.path) : null,
() => ondeletedocument(entry.id),
)} )}
{/each} {/each}
</div> </div>
@@ -768,7 +1082,22 @@
{#each app.cloudFiles as file (file.id)} {#each app.cloudFiles as file (file.id)}
<div <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)]" 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
icon={api.isImagePath(file.name) icon={api.isImagePath(file.name)
? "ph:image" ? "ph:image"
@@ -798,35 +1127,53 @@
</div> </div>
{/if} {/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 <div
class="flex flex-col items-center justify-center gap-3 py-16 text-[var(--color-ink-muted)]" class="flex flex-col items-center justify-center gap-3 py-16 text-[var(--color-ink-muted)]"
> >
<Icon icon="ph:cloud" class="text-5xl" /> <Icon icon="ph:cloud" class="text-5xl" />
<p class="text-sm">Nothing here yet.</p> <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> </div>
{/if} {/if}
{#if app.spaces.length > 0} {#if app.cloudProjects.length > 0}
<h2 <h2
class="mb-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-ink-muted)]" class="mb-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-ink-muted)]"
> >
Spaces Cloud projects
</h2> </h2>
{/if} {/if}
<div class="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-3"> <div class="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-3">
{#each app.spaces as space (space.id)} {#each app.cloudProjects as project (project.id)}
{@const linked = linkedSpace(space.id)} {@const linked = linkedProject(project.id)}
{@render cloudCard( {@render cloudCard(
`project:${project.id}`,
"ph:folder-star", "ph:folder-star",
space.name, project.name,
linked linked
? `Project · ${space.role}` ? `Project · ${project.role}`
: `${space.role} · ${formatDate(space.updated_at)}`, : `${project.role} · ${formatDate(project.updated_at)}`,
linked, linked,
linked ? () => openTarget(linked.path) : null, linked ? () => openTarget(linked.path) : null,
() => onclonespace(space.id, space.name), () => oncloneproject(project.id, project.name),
space.role === "owner" ? () => ondeletespace(space.id) : null, null,
project.role === "owner" ? () => ondeleteproject(project.id) : null,
)} )}
{/each} {/each}
</div> </div>
+482 -19
View File
@@ -5,13 +5,29 @@
import { openUrl } from "@tauri-apps/plugin-opener"; import { openUrl } from "@tauri-apps/plugin-opener";
import Modal from "./Modal.svelte"; import Modal from "./Modal.svelte";
import * as api from "$lib/ts/api"; 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 { import {
app, app,
applyTheme, applyTheme,
applyColorTheme,
applyAccent,
applyTextScale,
applyReduceMotion,
applyContrast,
colorThemes,
refreshEntries, refreshEntries,
restartAutoSync, restartAutoSync,
setError, setError,
type ThemePreference,
type TextScale,
} from "$lib/ts/state.svelte"; } from "$lib/ts/state.svelte";
interface Props { interface Props {
@@ -21,21 +37,181 @@
let { onclose, onsignin }: Props = $props(); 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 }[] = [ const sections: { id: Section; label: string; icon: string }[] = [
{ id: "files", label: "Files", icon: "ph:folder" }, { id: "files", label: "Files", icon: "ph:folder" },
{ id: "appearance", label: "Appearance", icon: "ph:palette" }, { id: "appearance", label: "Appearance", icon: "ph:palette" },
{ id: "accessibility", label: "Accessibility", icon: "ph:wheelchair" },
{ id: "account", label: "Account", icon: "ph:user-circle" }, { 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" }, { 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 section = $state<Section>("files");
let workspaceRoot = $state(untrack(() => app.settings?.workspace_root ?? "")); let workspaceRoot = $state(untrack(() => app.settings?.workspace_root ?? ""));
let serverUrl = $state(untrack(() => app.settings?.server_url ?? "")); 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 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 saving = $state(false);
let info = $state<AppInfo | null>(null); let info = $state<AppInfo | null>(null);
@@ -57,9 +233,11 @@
const syncOptions = [ const syncOptions = [
{ value: 0, label: "Off" }, { value: 0, label: "Off" },
{ value: 1, label: "1 minute" }, { value: 15, label: "15 seconds" },
{ value: 2, label: "2 minutes" }, { value: 30, label: "30 seconds" },
{ value: 5, label: "5 minutes" }, { value: 60, label: "1 minute" },
{ value: 120, label: "2 minutes" },
{ value: 300, label: "5 minutes" },
]; ];
const links = [ const links = [
@@ -73,6 +251,16 @@
url: "https://typst.app/universe/", url: "https://typst.app/universe/",
icon: "ph:planet", 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() { async function browse() {
@@ -89,7 +277,7 @@
workspaceRoot, workspaceRoot,
serverUrl, serverUrl,
autosaveSeconds, autosaveSeconds,
syncMinutes, syncSeconds,
}); });
restartAutoSync(); restartAutoSync();
await refreshEntries(); await refreshEntries();
@@ -105,7 +293,7 @@
try { try {
await api.cloudLogout(); await api.cloudLogout();
app.account = null; app.account = null;
app.spaces = []; app.cloudProjects = [];
app.settings = await api.getSettings(); app.settings = await api.getSettings();
} catch (error) { } catch (error) {
setError(error); setError(error);
@@ -163,7 +351,9 @@
{/each} {/each}
</select> </select>
<span class="text-[var(--color-ink-muted)]"> <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> </span>
</div> </div>
</div> </div>
@@ -172,13 +362,13 @@
<div class="flex flex-col gap-2 text-xs"> <div class="flex flex-col gap-2 text-xs">
<span class="font-medium text-[var(--color-ink-muted)]">Theme</span> <span class="font-medium text-[var(--color-ink-muted)]">Theme</span>
<div class="flex gap-2"> <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 <button
class="flex flex-1 items-center justify-center gap-1.5 rounded-md border px-3 py-2.5 transition 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-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)]'}" : '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" /> <Icon {icon} class="text-base" />
{label} {label}
@@ -186,7 +376,135 @@
{/each} {/each}
</div> </div>
<span class="text-[var(--color-ink-muted)]"> <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> </span>
</div> </div>
</div> </div>
@@ -234,27 +552,166 @@
TypstDrive server TypstDrive server
</span> </span>
<input class={fieldClass} bind:value={serverUrl} /> <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>
<div class="flex flex-col gap-1 text-xs"> <div class="flex flex-col gap-1 text-xs">
<span class="font-medium text-[var(--color-ink-muted)]"> <span class="font-medium text-[var(--color-ink-muted)]">
Automatic sync Automatic sync
</span> </span>
<select class={fieldClass} bind:value={syncMinutes}> <select class={fieldClass} bind:value={syncSeconds}>
{#each syncOptions as option} {#each syncOptions as option}
<option value={option.value}>{option.label}</option> <option value={option.value}>{option.label}</option>
{/each} {/each}
</select> </select>
<span class="text-[var(--color-ink-muted)]"> <span class="text-[var(--color-ink-muted)]">
Pulls and pushes cloud-linked projects on a timer. Conflicts pause Pulls and pushes cloud-linked projects on a timer. Used as a
syncing until they are resolved. fallback when live sync (websocket) is unavailable. Conflicts
pause syncing until they are resolved.
</span> </span>
</div> </div>
</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} {:else}
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div class="flex items-center gap-3"> <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> <div>
<p class="text-sm font-semibold">Typst Desktop</p> <p class="text-sm font-semibold">Typst Desktop</p>
<p class="text-xs text-[var(--color-ink-muted)]"> <p class="text-xs text-[var(--color-ink-muted)]">
@@ -305,13 +762,19 @@
</div> </div>
{#snippet footer()} {#snippet footer()}
{@const draftless =
section === "about" ||
section === "appearance" ||
section === "accessibility" ||
section === "lsp" ||
section === "hotkeys"}
<button <button
class="rounded-md px-3 py-1.5 text-xs text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)]" class="rounded-md px-3 py-1.5 text-xs text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)]"
onclick={onclose} onclick={onclose}
> >
{section === "about" || section === "appearance" ? "Close" : "Cancel"} {draftless ? "Close" : "Cancel"}
</button> </button>
{#if section !== "about" && section !== "appearance"} {#if !draftless}
<button <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" 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} disabled={saving}
+121 -20
View File
@@ -7,7 +7,7 @@ export interface Settings {
account_email: string | null; account_email: string | null;
account_username: string | null; account_username: string | null;
autosave_seconds: number; autosave_seconds: number;
sync_minutes: number; sync_seconds: number;
} }
export interface FileEntry { export interface FileEntry {
@@ -53,10 +53,11 @@ export interface Account {
email: string; email: string;
} }
export interface SpaceSummary { export interface ProjectSummary {
id: string; id: string;
name: string; name: string;
entrypoint: string; entrypoint: string;
folder_id: string | null;
role: string; role: string;
updated_at: string; updated_at: string;
} }
@@ -94,7 +95,7 @@ export interface BrowseEntry {
kind: EntryKind; kind: EntryKind;
size: number; size: number;
modified: string | null; modified: string | null;
space_id: string | null; cloud_project_id: string | null;
last_synced_at: string | null; last_synced_at: string | null;
child_count: number; child_count: number;
cloud_linked: boolean; cloud_linked: boolean;
@@ -106,7 +107,7 @@ export interface TargetInfo {
entrypoint: string; entrypoint: string;
standalone: boolean; standalone: boolean;
is_project: boolean; is_project: boolean;
space_id: string | null; cloud_project_id: string | null;
files: FileEntry[]; files: FileEntry[];
} }
@@ -167,6 +168,9 @@ export const exportTarget = (
destination: string, destination: string,
) => invoke<string>("export_target", { path, format, destination }); ) => invoke<string>("export_target", { path, format, destination });
export const renderTargetPng = (path: string) =>
invoke<number[]>("render_target_png", { path });
export interface Asset { export interface Asset {
name: string; name: string;
kind: "font" | "image" | "file"; kind: "font" | "image" | "file";
@@ -248,9 +252,21 @@ export const updateSettings = (changes: {
workspaceRoot?: string; workspaceRoot?: string;
serverUrl?: string; serverUrl?: string;
autosaveSeconds?: number; autosaveSeconds?: number;
syncMinutes?: number; syncSeconds?: number;
}) => invoke<Settings>("update_settings", changes); }) => 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 = ( export const cloudLogin = (
serverUrl: string, serverUrl: string,
email: string, email: string,
@@ -261,8 +277,26 @@ export const cloudLogout = () => invoke<void>("cloud_logout");
export const cloudAccount = () => invoke<Account | null>("cloud_account"); export const cloudAccount = () => invoke<Account | null>("cloud_account");
export const cloudListSpaces = () => export const cloudWsStart = () => invoke<void>("cloud_ws_start");
invoke<SpaceSummary[]>("cloud_list_spaces");
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 { export interface CloudFolder {
id: string; id: string;
@@ -280,7 +314,7 @@ export interface CloudDocument {
export interface SharedItems { export interface SharedItems {
documents: CloudDocument[]; documents: CloudDocument[];
spaces: SpaceSummary[]; projects: ProjectSummary[];
} }
export interface DocumentLink { export interface DocumentLink {
@@ -293,6 +327,21 @@ export interface DocumentLink {
export const cloudListFolders = () => export const cloudListFolders = () =>
invoke<CloudFolder[]>("cloud_list_folders"); 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) => export const cloudListDocuments = (folderId?: string | null) =>
invoke<CloudDocument[]>("cloud_list_documents", { invoke<CloudDocument[]>("cloud_list_documents", {
folderId: folderId ?? null, folderId: folderId ?? null,
@@ -314,12 +363,50 @@ export const cloudListFiles = (folderId?: string | null) =>
export const cloudDownloadFile = (fileId: string) => export const cloudDownloadFile = (fileId: string) =>
invoke<string>("cloud_download_file", { fileId }); 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) => export const cloudDownloadDocument = (documentId: string, parent: string) =>
invoke<string>("cloud_download_document", { documentId, parent }); 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) => export const cloudSyncDocument = (path: string) =>
invoke<SyncReport>("cloud_sync_document", { path }); invoke<SyncReport>("cloud_sync_document", { path });
export const cloudRoomId = (path: string, file: string) =>
invoke<string | null>("cloud_room_id", { path, file });
export const cloudResolveDocument = ( export const cloudResolveDocument = (
path: string, path: string,
content: string, content: string,
@@ -333,9 +420,9 @@ export interface LinkedDocument {
sync_state: "synced" | "pending" | null; sync_state: "synced" | "pending" | null;
} }
export interface LinkedSpace { export interface LinkedProject {
path: string; path: string;
space_id: string; cloud_project_id: string;
synced_at: string | null; synced_at: string | null;
sync_state: "synced" | "pending" | null; sync_state: "synced" | "pending" | null;
} }
@@ -343,8 +430,8 @@ export interface LinkedSpace {
export const cloudLinkedDocuments = () => export const cloudLinkedDocuments = () =>
invoke<LinkedDocument[]>("cloud_linked_documents"); invoke<LinkedDocument[]>("cloud_linked_documents");
export const cloudLinkedSpaces = () => export const cloudLinkedProjects = () =>
invoke<LinkedSpace[]>("cloud_linked_spaces"); invoke<LinkedProject[]>("cloud_linked_projects");
export const cloudDocumentLink = (path: string) => export const cloudDocumentLink = (path: string) =>
invoke<DocumentLink | null>("cloud_document_link", { path }); invoke<DocumentLink | null>("cloud_document_link", { path });
@@ -352,17 +439,31 @@ export const cloudDocumentLink = (path: string) =>
export const cloudUnlinkDocument = (path: string) => export const cloudUnlinkDocument = (path: string) =>
invoke<void>("cloud_unlink_document", { path }); invoke<void>("cloud_unlink_document", { path });
export const cloudCreateSpace = (name: string) => export const cloudCreateProject = (name: string, folderId?: string | null) =>
invoke<SpaceSummary>("cloud_create_space", { name }); invoke<ProjectSummary>("cloud_create_project", {
name,
folderId: folderId ?? null,
});
export const cloudDeleteSpace = (spaceId: string) => export const cloudDeleteProject = (cloudProjectId: string) =>
invoke<void>("cloud_delete_space", { spaceId }); invoke<void>("cloud_delete_project", { cloudProjectId });
export const cloudCloneSpace = (spaceId: string, projectName: string) => export const cloudMoveProject = (
invoke<SyncReport>("cloud_clone_space", { spaceId, projectName }); cloudProjectId: string,
folderId: string | null,
) => invoke<ProjectSummary>("cloud_move_project", { cloudProjectId, folderId });
export const cloudLinkProject = (project: string, spaceId?: string) => export const cloudCloneProject = (
invoke<SyncReport>("cloud_link_project", { project, spaceId: spaceId ?? null }); 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) => export const cloudUnlinkProject = (project: string) =>
invoke<void>("cloud_unlink_project", { project }); invoke<void>("cloud_unlink_project", { project });
+41 -10
View File
@@ -18,17 +18,48 @@ export function wrapSelection(
placeholder = "", placeholder = "",
) { ) {
if (!view) return; if (!view) return;
const selection = view.state.selection.main; const { state } = view;
const selected = view.state.doc.sliceString(selection.from, selection.to); const selection = state.selection.main;
const body = selected || placeholder; const selected = state.doc.sliceString(selection.from, selection.to);
view.dispatch({ const innerWrapped =
changes: { from: selection.from, to: selection.to, insert: prefix + body + suffix }, selected.length >= prefix.length + suffix.length &&
selection: { selected.startsWith(prefix) &&
anchor: selection.from + prefix.length, selected.endsWith(suffix);
head: selection.from + prefix.length + body.length,
}, 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(); view.focus();
} }
+1 -1
View File
@@ -40,7 +40,7 @@ const palette: Record<"light" | "dark", ThemeColors> = {
background: "#16181d", background: "#16181d",
surface: "#1d2026", surface: "#1d2026",
text: "#eef0f4", text: "#eef0f4",
selection: "#2f3a52", selection: "#3f5a91",
activeLine: "#1d2026", activeLine: "#1d2026",
cursor: "#6b93ff", cursor: "#6b93ff",
border: "#2f343d", 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 * as api from "./api";
import {
openCollabSession,
closeCollabSession,
type CollabSession,
} from "./yjs-client";
import type { import type {
Account, Account,
BrowseEntry, BrowseEntry,
@@ -7,18 +13,31 @@ import type {
CloudFolder, CloudFolder,
CompileResult, CompileResult,
Conflict, Conflict,
DeviceEvent,
Diagnostic, Diagnostic,
DocumentLink, DocumentLink,
LinkedDocument, LinkedDocument,
LinkedSpace, LinkedProject,
ProjectSummary,
Settings, Settings,
SpaceSummary,
TargetInfo, TargetInfo,
} from "./api"; } from "./api";
export type Scope = "local" | "cloud"; export type Scope = "local" | "cloud";
export type View = "files" | "editor"; export type View = "files" | "editor";
export type LspStatus = "off" | "starting" | "on" | "unavailable"; 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 { interface AppState {
view: View; view: View;
@@ -28,15 +47,17 @@ interface AppState {
currentDir: string; currentDir: string;
entries: BrowseEntry[]; entries: BrowseEntry[];
spaces: SpaceSummary[]; cloudProjects: ProjectSummary[];
cloudFolder: string | null | "shared"; cloudFolder: string | null | "shared";
cloudFolders: CloudFolder[]; cloudFolders: CloudFolder[];
cloudFolderTree: CloudFolder[]; cloudFolderTree: CloudFolder[];
cloudDocuments: CloudDocument[]; cloudDocuments: CloudDocument[];
cloudFiles: CloudFile[]; cloudFiles: CloudFile[];
cloudLoading: boolean; cloudLoading: boolean;
cloudOffline: boolean;
wsStatus: string;
linkedDocuments: LinkedDocument[]; linkedDocuments: LinkedDocument[];
linkedSpaces: LinkedSpace[]; linkedProjects: LinkedProject[];
documentLink: DocumentLink | null; documentLink: DocumentLink | null;
target: TargetInfo | null; target: TargetInfo | null;
@@ -51,9 +72,19 @@ interface AppState {
download: DownloadProgress | null; download: DownloadProgress | null;
syncing: boolean; syncing: boolean;
conflicts: Conflict[]; conflicts: Conflict[];
collab: CollabSession | null;
collabIntent: boolean;
collabStatus: "connecting" | "connected" | "offline" | null;
collabConflict: Conflict | null;
status: string; status: string;
error: string; error: string;
theme: "light" | "dark"; theme: "light" | "dark";
themePreference: ThemePreference;
colorTheme: ColorTheme;
accent: string | null;
textScale: TextScale;
reduceMotion: boolean;
contrast: ContrastLevel;
} }
export const app = $state<AppState>({ export const app = $state<AppState>({
@@ -64,15 +95,17 @@ export const app = $state<AppState>({
currentDir: "", currentDir: "",
entries: [], entries: [],
spaces: [], cloudProjects: [],
cloudFolder: null, cloudFolder: null,
cloudFolders: [], cloudFolders: [],
cloudFolderTree: [], cloudFolderTree: [],
cloudDocuments: [], cloudDocuments: [],
cloudFiles: [], cloudFiles: [],
cloudLoading: false, cloudLoading: false,
cloudOffline: false,
wsStatus: "offline",
linkedDocuments: [], linkedDocuments: [],
linkedSpaces: [], linkedProjects: [],
documentLink: null, documentLink: null,
target: null, target: null,
@@ -87,9 +120,19 @@ export const app = $state<AppState>({
download: null, download: null,
syncing: false, syncing: false,
conflicts: [], conflicts: [],
collab: null,
collabIntent: false,
collabStatus: null,
collabConflict: null,
status: "", status: "",
error: "", error: "",
theme: "light", theme: "light",
themePreference: "light",
colorTheme: "default",
accent: null,
textScale: "default",
reduceMotion: false,
contrast: "normal",
}); });
export interface DownloadProgress { export interface DownloadProgress {
@@ -132,10 +175,146 @@ export function clearMessages() {
app.error = ""; app.error = "";
} }
export function applyTheme(theme: "light" | "dark") { const THEME_KEY = "typst-desktop-theme";
app.theme = theme; const COLOR_THEME_KEY = "typst-desktop-color-theme";
document.documentElement.dataset.theme = theme; const ACCENT_KEY = "typst-desktop-accent";
localStorage.setItem("typst-desktop-theme", theme); 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 }[] { export function breadcrumbs(): { name: string; path: string }[] {
@@ -148,12 +327,42 @@ export function breadcrumbs(): { name: string; path: string }[] {
} }
export async function bootstrap() { export async function bootstrap() {
const stored = localStorage.getItem("typst-desktop-theme"); const storedTheme = localStorage.getItem(THEME_KEY);
applyTheme(stored === "dark" ? "dark" : "light"); 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 { try {
app.settings = await api.getSettings(); app.settings = await api.getSettings();
restartAutoSync(); restartAutoSync();
await initWsSync();
if (app.settings?.device_token) {
api.cloudWsStart().catch(() => {});
}
await browseTo(""); await browseTo("");
await refreshAccount(); await refreshAccount();
} catch (error) { } 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) { export async function browseTo(path: string) {
try { try {
app.entries = await api.browseWorkspace(path); app.entries = await api.browseWorkspace(path);
@@ -179,54 +420,90 @@ export async function refreshAccount() {
try { try {
app.account = await api.cloudAccount(); app.account = await api.cloudAccount();
if (app.account) { if (app.account) {
await refreshSpaces(); await refreshCloudProjects();
} else { } else {
app.spaces = []; app.cloudProjects = [];
} }
} catch { } catch {
app.account = null; app.account = null;
} }
} }
export async function refreshSpaces() { export async function refreshCloudProjects() {
try { try {
app.spaces = await api.cloudListSpaces(); app.cloudProjects = await api.cloudListProjects();
} catch (error) { } catch (error) {
setError(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() { export async function refreshCloud() {
if (!app.account) return; if (!app.account) return;
app.cloudLoading = true; app.cloudLoading = true;
const cacheKey = cloudCacheKey();
try { try {
app.linkedDocuments = await api.cloudLinkedDocuments().catch(() => []); 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") { if (app.cloudFolder === "shared") {
const shared = await api.cloudListShared(); const shared = await api.cloudListShared();
app.cloudDocuments = shared.documents; snapshot = { folders: [], documents: shared.documents, projects: shared.projects, files: [] };
app.spaces = shared.spaces;
app.cloudFolders = [];
app.cloudFiles = [];
} else { } else {
const [folders, documents, spaces, files] = await Promise.all([ const [folders, documents, projects, files] = await Promise.all([
api.cloudListFolders(), api.cloudListFolders(),
api.cloudListDocuments(app.cloudFolder), api.cloudListDocuments(app.cloudFolder),
api.cloudListSpaces(), api.cloudListProjects(),
api.cloudListFiles(app.cloudFolder), api.cloudListFiles(app.cloudFolder),
]); ]);
app.cloudFolderTree = folders; snapshot = { folders, documents, projects, files };
app.cloudFolders = folders.filter(
(folder) => (folder.parent_id ?? null) === app.cloudFolder,
);
app.cloudDocuments = documents;
app.spaces = spaces;
app.cloudFiles = files;
} }
applyCloudSnapshot(snapshot);
app.cloudOffline = false;
api.saveCloudCache(cacheKey, JSON.stringify(snapshot)).catch(() => {});
} catch (error) { } 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 { } finally {
app.cloudLoading = false; app.cloudLoading = false;
} }
@@ -254,7 +531,7 @@ export async function openCloudFolder(id: string | null | "shared") {
export async function downloadDocument(documentId: string, title: string) { export async function downloadDocument(documentId: string, title: string) {
try { try {
const path = await api.cloudDownloadDocument(documentId, ""); const path = await api.cloudDownloadDocument(documentId, app.currentDir);
await refreshCloud(); await refreshCloud();
setStatus(`Downloaded '${title}' to this device`); setStatus(`Downloaded '${title}' to this device`);
return path; return path;
@@ -279,8 +556,10 @@ export async function downloadCloudFile(fileId: string, name: string) {
} }
} }
export function linkedSpace(spaceId: string) { export function linkedProject(cloudProjectId: string) {
return app.linkedSpaces.find((linked) => linked.space_id === spaceId); return app.linkedProjects.find(
(linked) => linked.cloud_project_id === cloudProjectId,
);
} }
export async function removeDownloadedDocument(path: string) { 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() { export async function closeTarget() {
cancelScheduledCompile(); cancelScheduledCompile();
cancelAutosave(); cancelAutosave();
if (app.dirty) await saveActiveFile(); if (app.dirty) await saveActiveFile();
stopCollab();
app.view = "files"; app.view = "files";
app.target = null; app.target = null;
app.activePath = null; app.activePath = null;
@@ -352,6 +640,7 @@ export async function openFile(file: string) {
cancelAutosave(); cancelAutosave();
if (app.dirty && app.activePath) await saveActiveFile(); if (app.dirty && app.activePath) await saveActiveFile();
stopCollab();
try { try {
const payload = await api.readTargetFile(app.target.path, file); 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.editorContent = payload.is_text ? payload.content : "";
app.dirty = false; app.dirty = false;
if (payload.is_text) await compile(); if (payload.is_text) await compile();
if (payload.is_text) await tryOpenCollab(file, app.editorContent);
} catch (error) { } catch (error) {
setError(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("sync", (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() { export async function saveActiveFile() {
if (!app.target || !app.activePath) return; if (!app.target || !app.activePath) return;
try { try {
@@ -446,9 +824,14 @@ export function cancelScheduledCompile() {
let autosaveTimer: ReturnType<typeof setTimeout> | null = null; let autosaveTimer: ReturnType<typeof setTimeout> | null = null;
const COLLAB_AUTOSAVE_SECONDS = 5;
export function scheduleAutosave() { export function scheduleAutosave() {
const seconds = app.settings?.autosave_seconds ?? 0;
if (autosaveTimer) clearTimeout(autosaveTimer); 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; if (seconds <= 0) return;
autosaveTimer = setTimeout(() => { autosaveTimer = setTimeout(() => {
@@ -472,19 +855,19 @@ export function restartAutoSync() {
syncTimer = null; syncTimer = null;
} }
const minutes = app.settings?.sync_minutes ?? 0; const seconds = app.settings?.sync_seconds ?? 0;
if (minutes <= 0) return; if (seconds <= 0) return;
syncTimer = setInterval(() => { syncTimer = setInterval(() => {
autoSync(); if (app.wsStatus !== "connected") autoSync();
}, minutes * 60 * 1000); }, seconds * 1000);
} }
async function autoSync() { async function autoSync() {
if (!app.account || app.syncing) return; if (!app.account || app.syncing) return;
if (app.conflicts.length > 0) 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; const project = linked ? app.target?.path : null;
if (!project) return; 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"> <script lang="ts">
import Icon from "@iconify/svelte"; import Icon from "@iconify/svelte";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { registerHotkey, unregisterAll } from "$lib/ts/hotkeys";
import { save } from "@tauri-apps/plugin-dialog"; 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 { revealItemInDir } from "@tauri-apps/plugin-opener";
import { getCurrentWebview } from "@tauri-apps/api/webview"; import { getCurrentWebview } from "@tauri-apps/api/webview";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
@@ -22,10 +25,16 @@
import PageSettingsModal from "$lib/components/PageSettingsModal.svelte"; import PageSettingsModal from "$lib/components/PageSettingsModal.svelte";
import type { EditorView } from "@codemirror/view"; 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 * 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 { pickFiles } from "$lib/ts/import";
import { import {
app, app,
@@ -38,10 +47,12 @@
openFile, openFile,
openTarget, openTarget,
refreshAccount, refreshAccount,
refreshCloud,
refreshEntries, refreshEntries,
refreshSpaces,
refreshTarget, refreshTarget,
removeDownloadedDocument, removeDownloadedDocument,
resolveCollabConflict,
cancelCollabConflict,
runSync, runSync,
saveAndCompile, saveAndCompile,
scheduleAutosave, scheduleAutosave,
@@ -60,13 +71,21 @@
| { kind: "rename-entry"; entry: BrowseEntry } | { kind: "rename-entry"; entry: BrowseEntry }
| { kind: "delete-entry"; entry: BrowseEntry } | { kind: "delete-entry"; entry: BrowseEntry }
| { kind: "link-entry"; entry: BrowseEntry } | { kind: "link-entry"; entry: BrowseEntry }
| { kind: "new-space" } | { kind: "save-document-to-cloud"; entry: BrowseEntry }
| { kind: "delete-space"; id: string } | { kind: "new-cloud-project" }
| { kind: "clone-space"; id: string; name: string } | { 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-file"; parent: string }
| { kind: "new-subfolder"; parent: string } | { kind: "new-subfolder"; parent: string }
| { kind: "rename-file"; path: string } | { kind: "rename-file"; path: string }
| { kind: "delete-file"; path: string } | { kind: "delete-file"; paths: string[] }
| { kind: "login" } | { kind: "login" }
| { kind: "settings" } | { kind: "settings" }
| { kind: "assets" } | { kind: "assets" }
@@ -76,10 +95,21 @@
let dialog = $state<Dialog>({ kind: "none" }); let dialog = $state<Dialog>({ kind: "none" });
let editorView = $state<EditorView | null>(null); let editorView = $state<EditorView | null>(null);
let imageViewer = $state<{ paths: string[]; index: number } | 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 selectedEntry = $state<string | null>(null);
let selectedIsDir = $state(false); let selectedIsDir = $state(false);
let treeDropTarget = $state<string | null>(null); 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( const selectedFolder = $derived(
!selectedEntry !selectedEntry
? "" ? ""
@@ -148,31 +178,97 @@
setStatus(`Deleted '${entry.name}'`); setStatus(`Deleted '${entry.name}'`);
}); });
const currentCloudFolder = () =>
app.cloudFolder === "shared" ? null : app.cloudFolder;
const linkEntry = (entry: BrowseEntry) => const linkEntry = (entry: BrowseEntry) =>
guard(async () => { guard(async () => {
const report = await api.cloudLinkProject(entry.path); const report = await api.cloudLinkProject(entry.path);
await refreshEntries(); await refreshEntries();
await refreshSpaces(); await refreshCloud();
setStatus(`Uploaded ${report.pushed.length} files to a new cloud space`); setStatus(`Uploaded ${report.pushed.length} files to a new cloud project`);
}); });
const createSpace = (name: string) => const saveDocumentToCloud = (entry: BrowseEntry, title: string) =>
guard(async () => { guard(async () => {
await api.cloudCreateSpace(name); await api.cloudCreateDocument(entry.path, title);
await refreshSpaces(); await refreshEntries();
setStatus(`Saved '${entry.name}' to the cloud`);
}); });
const deleteSpace = (id: string) => const createCloudProject = (name: string) =>
guard(async () => { guard(async () => {
await api.cloudDeleteSpace(id); await api.cloudCreateProject(name, currentCloudFolder());
await refreshSpaces(); await refreshCloud();
}); });
const cloneSpace = (id: string, name: string) => const createCloudDocument = (title: string) =>
guard(async () => { 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"; app.scope = "local";
await browseTo(""); await browseTo(parent);
setStatus(`Downloaded '${name}' to this device`); setStatus(`Downloaded '${name}' to this device`);
}); });
@@ -250,13 +346,17 @@
if (app.activePath === path) await openFile(next); if (app.activePath === path) await openFile(next);
}); });
const deleteFile = (path: string) => const deleteFile = (paths: string[]) =>
guard(async () => { guard(async () => {
await api.deleteEntry(`${app.target!.path}/${path}`); for (const path of paths) {
if (app.activePath === path) { await api.deleteEntry(`${app.target!.path}/${path}`);
app.activePath = null; if (app.activePath === path) {
app.editorContent = ""; app.activePath = null;
app.editorContent = "";
}
} }
selectedEntries = new Set();
selectedEntry = null;
await refreshTarget(); await refreshTarget();
await compile(); 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[]) => const resolveDocumentConflicts = (resolutions: api.Resolution[]) =>
guard(async () => { guard(async () => {
for (const resolution of resolutions) { for (const resolution of resolutions) {
@@ -317,13 +453,6 @@
setStatus("Conflicts resolved and uploaded"); 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); let dropActive = $state(false);
function dropDestination(): 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 () => { return () => {
pending.then((unlisten) => unlisten()); pending.then((unlisten) => unlisten());
downloads.then((unlisten) => unlisten()); downloads.then((unlisten) => unlisten());
unregisterAll();
}; };
}); });
const lspLabel: Record<string, string> = {
off: "LSP off", const wsLabel: Record<string, string> = {
starting: "LSP starting", connected: "Live sync",
on: "LSP ready", connecting: "Connecting…",
unavailable: "LSP unavailable", offline: "Offline (polling)",
};
const collabLabel: Record<string, string> = {
connected: "Live",
connecting: "Connecting…",
offline: "Offline — editing locally",
}; };
</script> </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"> <div class="flex h-screen flex-col">
<header <header
@@ -428,21 +636,41 @@
<Icon icon="ph:arrow-left" /> <Icon icon="ph:arrow-left" />
Files Files
</button> </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"> <span data-tauri-drag-region class="text-sm font-medium">
{app.target?.path.split("/").pop()} {app.target?.path.split("/").pop()}
</span> </span>
{#if app.target?.standalone} {#if app.target?.standalone}
<span {@render statusBadge("ph:file", "Single file", "muted")}
class="rounded bg-[var(--color-surface-sunken)] px-1.5 py-0.5 text-[10px] text-[var(--color-ink-muted)]"
>
single file
</span>
{/if} {/if}
{#if app.dirty} {#if app.dirty}
<span class="h-1.5 w-1.5 rounded-full bg-[var(--color-accent)]"></span> <span class="h-1.5 w-1.5 rounded-full bg-[var(--color-accent)]"></span>
{/if} {/if}
{:else} {: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"> <span data-tauri-drag-region class="text-sm font-semibold">
Typst Desktop Typst Desktop
</span> </span>
@@ -450,29 +678,71 @@
<div data-tauri-drag-region class="h-full flex-1"></div> <div data-tauri-drag-region class="h-full flex-1"></div>
{#if app.view === "editor"} {#if app.view !== "editor"}
<span <div class="flex rounded-lg bg-[var(--color-surface-sunken)] p-0.5 text-xs font-medium">
class="flex items-center gap-1 text-[10px] text-[var(--color-ink-muted)]" {#each [["local", "Local", "ph:hard-drives"], ["cloud", "Cloud", "ph:cloud"]] as [value, label, icon]}
title="Typst language server (tinymist)" <button
> class="flex items-center gap-1.5 rounded-md px-3 py-1.5 transition
<span {app.scope === value
class="h-1.5 w-1.5 rounded-full ? 'bg-[var(--color-surface)] text-[var(--color-ink)] shadow-sm'
{app.lspStatus === 'on' : 'text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]'}"
? 'bg-[var(--color-success)]' onclick={() => (app.scope = value as "local" | "cloud")}
: app.lspStatus === 'starting' >
? 'bg-[var(--color-accent)]' <Icon {icon} />
: 'bg-[var(--color-ink-muted)]'}" {label}
></span> </button>
{lspLabel[app.lspStatus]} {/each}
</span> </div>
{/if}
<button {#if app.view === "editor"}
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)]" {#if app.account && (app.target?.cloud_project_id || app.documentLink) && !app.collabIntent}
onclick={saveAndCompile} <span title="Cloud sync connection">
> {@render statusBadge(
<Icon icon="ph:floppy-disk" /> app.wsStatus === "connected"
Save ? "ph:cloud-check"
</button> : 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"> <div class="group relative">
<button <button
@@ -495,7 +765,28 @@
</div> </div>
</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 <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" 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} disabled={app.syncing}
@@ -598,19 +889,45 @@
onrename={(entry) => (dialog = { kind: "rename-entry", entry })} onrename={(entry) => (dialog = { kind: "rename-entry", entry })}
ondelete={(entry) => (dialog = { kind: "delete-entry", entry })} ondelete={(entry) => (dialog = { kind: "delete-entry", entry })}
onlink={(entry) => (dialog = { kind: "link-entry", entry })} onlink={(entry) => (dialog = { kind: "link-entry", entry })}
onsavetocloud={(entry) =>
(dialog = { kind: "save-document-to-cloud", entry })}
onviewimage={(paths, index) => (imageViewer = { paths, index })} onviewimage={(paths, index) => (imageViewer = { paths, index })}
ondownloaddocument={(documentId, title) => ondownloaddocument={(documentId, title) =>
downloadDocument(documentId, title)} downloadDocument(documentId, title)}
onremovedownload={removeDownloadedDocument} onremovedownload={removeDownloadedDocument}
ondownloadfile={downloadCloudFile} ondownloadfile={downloadCloudFile}
onnewspace={() => (dialog = { kind: "new-space" })} ondeletefile={(id) => (dialog = { kind: "delete-cloud-file", id })}
onclonespace={(id, name) => (dialog = { kind: "clone-space", id, name })} onuploadfile={uploadCloudFiles}
ondeletespace={(id) => (dialog = { kind: "delete-space", id })} 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" })} onsignin={() => (dialog = { kind: "login" })}
/> />
</div> </div>
{:else} {: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 <div
class="flex w-56 shrink-0 flex-col border-r border-[var(--color-line)] bg-[var(--color-surface)]" 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"} {selectedFolder ? selectedFolder : "Files"}
</span> </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> </div>
<FileTree <FileTree
files={app.target?.files ?? []} files={app.target?.files ?? []}
activePath={app.activePath} activePath={app.activePath}
entrypoint={app.target?.entrypoint ?? "main.typ"} entrypoint={app.target?.entrypoint ?? "main.typ"}
selected={selectedEntry} selected={selectedEntries}
dropTarget={treeDropTarget} dropTarget={treeDropTarget}
onopen={openFile} onopen={openFile}
onselect={(path, isDir) => { onselect={(paths, primary, isDir) => {
selectedEntry = path; selectedEntries = new Set(paths);
selectedEntry = primary;
selectedIsDir = isDir; selectedIsDir = isDir;
}} }}
onrename={(path) => (dialog = { kind: "rename-file", path })} onrename={(path) => (dialog = { kind: "rename-file", path })}
ondelete={(path) => (dialog = { kind: "delete-file", path })} ondelete={(paths) => (dialog = { kind: "delete-file", paths })}
onduplicate={duplicateInTarget} onduplicate={duplicateInTarget}
onreveal={revealInTarget} onreveal={revealInTarget}
onsetentry={setEntrypoint} onsetentry={setEntrypoint}
@@ -675,13 +1001,15 @@
filePath={app.activePath} filePath={app.activePath}
targetPath={app.target?.path ?? ""} targetPath={app.target?.path ?? ""}
diagnostics={app.diagnostics} diagnostics={app.diagnostics}
collab={app.collab
? { text: app.collab.text, awareness: app.collab.provider.awareness }
: null}
onchange={(value) => { onchange={(value) => {
app.editorContent = value; app.editorContent = value;
app.dirty = true; app.dirty = true;
scheduleCompile(); scheduleCompile();
scheduleAutosave(); scheduleAutosave();
}} }}
onsave={saveAndCompile}
onlspstatus={(status) => (app.lspStatus = status)} onlspstatus={(status) => (app.lspStatus = status)}
onready={(view) => (editorView = view)} onready={(view) => (editorView = view)}
/> />
@@ -792,36 +1120,107 @@
{@const target = dialog} {@const target = dialog}
<ConfirmModal <ConfirmModal
title="Upload to cloud" 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" confirmLabel="Upload"
onconfirm={() => linkEntry(target.entry)} onconfirm={() => linkEntry(target.entry)}
onclose={close} onclose={close}
/> />
{:else if dialog.kind === "new-space"} {:else if dialog.kind === "save-document-to-cloud"}
{@const target = dialog}
<PromptModal <PromptModal
title="New cloud space" title="Save to cloud"
label="Space name" label="Document title"
icon="ph:cloud-plus" icon="ph:cloud-arrow-up"
onsubmit={createSpace} value={target.entry.name.replace(/\.typ$/i, "")}
confirmLabel="Save"
onsubmit={(title) => saveDocumentToCloud(target.entry, title)}
onclose={close} 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} {@const target = dialog}
<ConfirmModal <ConfirmModal
title="Delete cloud space" title="Delete cloud folder"
message="This permanently deletes the space and its files from TypstDrive. Local copies are kept." message="'{target.folder.name}' will be permanently removed from TypstDrive. It must be empty first."
onconfirm={() => deleteSpace(target.id)} onconfirm={() => deleteCloudFolder(target.folder)}
onclose={close} 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} {@const target = dialog}
<PromptModal <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" label="Save as project"
icon="ph:download-simple" icon="ph:download-simple"
value={target.name} value={target.name}
confirmLabel="Download" confirmLabel="Download"
onsubmit={(name) => cloneSpace(target.id, name)} onsubmit={(name) => cloneCloudProject(target.id, name)}
onclose={close} onclose={close}
/> />
{:else if dialog.kind === "new-file"} {:else if dialog.kind === "new-file"}
@@ -857,9 +1256,11 @@
{:else if dialog.kind === "delete-file"} {:else if dialog.kind === "delete-file"}
{@const target = dialog} {@const target = dialog}
<ConfirmModal <ConfirmModal
title="Delete file" title={target.paths.length > 1 ? "Delete files" : "Delete file"}
message="'{target.path}' will be permanently deleted." message={target.paths.length > 1
onconfirm={() => deleteFile(target.path)} ? `${target.paths.length} items will be permanently deleted.`
: `'${target.paths[0]}' will be permanently deleted.`}
onconfirm={() => deleteFile(target.paths)}
onclose={close} onclose={close}
/> />
{:else if dialog.kind === "login"} {:else if dialog.kind === "login"}
@@ -895,3 +1296,11 @@
onclose={close} onclose={close}
/> />
{/if} {/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