Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cabc3e303 | ||
|
|
0e69f5a179 | ||
|
|
7b0cc2a807 | ||
|
|
43c8cfd85a | ||
|
|
e7cca246ed | ||
|
|
cc9c9f940d | ||
|
|
65b041b6c9 | ||
|
|
30bfa0d7a8 | ||
|
|
d678de28e6 | ||
|
|
ea53f92f62 | ||
|
|
5f6b2b3883 | ||
|
|
a16edeb876 | ||
|
|
566bf1a6cb | ||
|
|
d7b113c59a | ||
|
|
62f2f8483f | ||
|
|
46e2de2af9 | ||
|
|
117f5b499c | ||
|
|
4b685311a0 |
@@ -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: |
|
||||||
@@ -79,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"
|
|
||||||
|
|||||||
@@ -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: |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
<img src="src-tauri/icons/icon.png" width="96" alt="Typst Desktop icon" />
|
||||||
|
|
||||||
# Typst Desktop
|
# Typst Desktop
|
||||||
|
|
||||||
[](https://github.com/sirblobby/typst-desktop)
|
[](https://github.com/sirblobby/typst-desktop)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://typst.app/)
|
[](https://typst.app/)
|
||||||
[](https://www.rust-lang.org/)
|
[](https://www.rust-lang.org/)
|
||||||
@@ -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:
|
||||||
@@ -200,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:
|
||||||
@@ -217,12 +211,16 @@ sudo apt-get install libwebkit2gtk-4.1-dev libgtk-3-dev librsvg2-dev patchelf
|
|||||||
bun run build:linux
|
bun run build:linux
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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 width="1908" height="1038" alt="image" src="https://github.com/user-attachments/assets/86f84d9f-7b76-453b-aefa-c15531e49600" />
|
<img src="images/preview1.png" alt="Typst Desktop editor preview" />
|
||||||
<img width="1908" height="1038" alt="image" src="https://github.com/user-attachments/assets/272a0178-7b5c-48a2-8af1-691ca3f100ef" />
|
<img src="images/preview2.png" alt="Typst Desktop project editor preview" />
|
||||||
<img width="1908" height="1038" alt="image" src="https://github.com/user-attachments/assets/5f3f982e-29d0-4b3b-b7eb-fa6e888fe01a" />
|
<img src="images/preview3.png" alt="Typst Desktop editor preview" />
|
||||||
|
|
||||||
|
|||||||
@@ -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=="],
|
||||||
@@ -286,6 +294,8 @@
|
|||||||
|
|
||||||
"@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-clipboard-manager": ["@tauri-apps/[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-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=="],
|
||||||
|
|||||||
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 101 KiB |
@@ -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": {
|
||||||
@@ -20,15 +20,21 @@
|
|||||||
"@codemirror/legacy-modes": "^6.5.3",
|
"@codemirror/legacy-modes": "^6.5.3",
|
||||||
"@codemirror/lint": "^6.9.7",
|
"@codemirror/lint": "^6.9.7",
|
||||||
"@codemirror/lsp-client": "^6.2.5",
|
"@codemirror/lsp-client": "^6.2.5",
|
||||||
|
"@codemirror/merge": "^6.12.2",
|
||||||
"@codemirror/state": "^6.7.1",
|
"@codemirror/state": "^6.7.1",
|
||||||
"@codemirror/view": "^6.43.6",
|
"@codemirror/view": "^6.43.6",
|
||||||
"@iconify/svelte": "^5.2.2",
|
"@iconify/svelte": "^5.2.2",
|
||||||
"@lezer/highlight": "^1.2.3",
|
"@lezer/highlight": "^1.2.3",
|
||||||
"@tauri-apps/api": "^2.11.1",
|
"@tauri-apps/api": "^2.11.1",
|
||||||
|
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||||
"@tauri-apps/plugin-dialog": "^2.7.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",
|
||||||
@@ -36,7 +42,7 @@
|
|||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
"@sveltejs/kit": "^2.70.1",
|
"@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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -77,6 +77,27 @@ dependencies = [
|
|||||||
"object",
|
"object",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arboard"
|
||||||
|
version = "3.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
|
||||||
|
dependencies = [
|
||||||
|
"clipboard-win",
|
||||||
|
"image",
|
||||||
|
"log",
|
||||||
|
"objc2",
|
||||||
|
"objc2-app-kit",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-core-graphics",
|
||||||
|
"objc2-foundation",
|
||||||
|
"parking_lot",
|
||||||
|
"percent-encoding",
|
||||||
|
"windows-sys 0.60.2",
|
||||||
|
"wl-clipboard-rs",
|
||||||
|
"x11rb",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arrayref"
|
name = "arrayref"
|
||||||
version = "0.3.9"
|
version = "0.3.9"
|
||||||
@@ -620,6 +641,15 @@ dependencies = [
|
|||||||
"serde_path_to_error",
|
"serde_path_to_error",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clipboard-win"
|
||||||
|
version = "5.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
|
||||||
|
dependencies = [
|
||||||
|
"error-code",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cobs"
|
name = "cobs"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -917,6 +947,12 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "data-encoding"
|
||||||
|
version = "2.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "data-url"
|
name = "data-url"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -1058,13 +1094,19 @@ checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bit-set",
|
"bit-set",
|
||||||
"cssparser",
|
"cssparser",
|
||||||
"foldhash",
|
"foldhash 0.2.0",
|
||||||
"html5ever",
|
"html5ever",
|
||||||
"precomputed-hash",
|
"precomputed-hash",
|
||||||
"selectors",
|
"selectors",
|
||||||
"tendril",
|
"tendril",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "downcast-rs"
|
||||||
|
version = "1.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dpi"
|
name = "dpi"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@@ -1235,6 +1277,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "error-code"
|
||||||
|
version = "3.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "euclid"
|
name = "euclid"
|
||||||
version = "0.22.14"
|
version = "0.22.14"
|
||||||
@@ -1300,6 +1348,12 @@ version = "2.4.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fax"
|
||||||
|
version = "0.2.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fdeflate"
|
name = "fdeflate"
|
||||||
version = "0.3.7"
|
version = "0.3.7"
|
||||||
@@ -1341,6 +1395,12 @@ version = "0.1.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fixedbitset"
|
||||||
|
version = "0.5.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flate2"
|
name = "flate2"
|
||||||
version = "1.1.9"
|
version = "1.1.9"
|
||||||
@@ -1367,6 +1427,12 @@ version = "1.0.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foldhash"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foldhash"
|
name = "foldhash"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -1649,6 +1715,16 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gethostname"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
|
||||||
|
dependencies = [
|
||||||
|
"rustix",
|
||||||
|
"windows-link 0.2.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -1785,7 +1861,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e"
|
checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"foldhash",
|
"foldhash 0.2.0",
|
||||||
"hashbrown 0.17.1",
|
"hashbrown 0.17.1",
|
||||||
"log",
|
"log",
|
||||||
"peniko",
|
"peniko",
|
||||||
@@ -1899,13 +1975,22 @@ dependencies = [
|
|||||||
"ahash",
|
"ahash",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.15.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||||
|
dependencies = [
|
||||||
|
"foldhash 0.1.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.17.1"
|
version = "0.17.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"foldhash",
|
"foldhash 0.2.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2425,6 +2510,7 @@ dependencies = [
|
|||||||
"moxcms",
|
"moxcms",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"png 0.18.1",
|
"png 0.18.1",
|
||||||
|
"tiff",
|
||||||
"zune-core",
|
"zune-core",
|
||||||
"zune-jpeg",
|
"zune-jpeg",
|
||||||
]
|
]
|
||||||
@@ -2961,6 +3047,15 @@ version = "1.0.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nom"
|
||||||
|
version = "8.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
@@ -3046,6 +3141,7 @@ dependencies = [
|
|||||||
"block2",
|
"block2",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
|
"objc2-core-graphics",
|
||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3307,6 +3403,16 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "os_pipe"
|
||||||
|
version = "1.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "palette"
|
name = "palette"
|
||||||
version = "0.7.6"
|
version = "0.7.6"
|
||||||
@@ -3422,6 +3528,17 @@ version = "2.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "petgraph"
|
||||||
|
version = "0.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
|
||||||
|
dependencies = [
|
||||||
|
"fixedbitset",
|
||||||
|
"hashbrown 0.15.5",
|
||||||
|
"indexmap 2.14.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "phf"
|
name = "phf"
|
||||||
version = "0.13.1"
|
version = "0.13.1"
|
||||||
@@ -3733,6 +3850,15 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quick-xml"
|
||||||
|
version = "0.39.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.41.0"
|
version = "0.41.0"
|
||||||
@@ -3769,6 +3895,8 @@ version = "0.8.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
|
checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"rand_chacha",
|
||||||
"rand_core",
|
"rand_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3787,6 +3915,9 @@ name = "rand_core"
|
|||||||
version = "0.6.4"
|
version = "0.6.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "raw-window-handle"
|
name = "raw-window-handle"
|
||||||
@@ -4438,6 +4569,17 @@ dependencies = [
|
|||||||
"stable_deref_trait",
|
"stable_deref_trait",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha1"
|
||||||
|
version = "0.10.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.9"
|
version = "0.10.9"
|
||||||
@@ -4971,6 +5113,21 @@ dependencies = [
|
|||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-clipboard-manager"
|
||||||
|
version = "2.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "206dc20af4ed210748ba945c2774e60fd0acd52b9a73a028402caf809e9b6ecf"
|
||||||
|
dependencies = [
|
||||||
|
"arboard",
|
||||||
|
"log",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-dialog"
|
name = "tauri-plugin-dialog"
|
||||||
version = "2.7.1"
|
version = "2.7.1"
|
||||||
@@ -5203,6 +5360,20 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tiff"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
|
||||||
|
dependencies = [
|
||||||
|
"fax",
|
||||||
|
"flate2",
|
||||||
|
"half",
|
||||||
|
"quick-error",
|
||||||
|
"weezl",
|
||||||
|
"zune-jpeg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "time"
|
name = "time"
|
||||||
version = "0.3.53"
|
version = "0.3.53"
|
||||||
@@ -5530,6 +5701,17 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tree_magic_mini"
|
||||||
|
version = "3.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
"nom",
|
||||||
|
"petgraph",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "try-lock"
|
name = "try-lock"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
@@ -5545,6 +5727,25 @@ dependencies = [
|
|||||||
"core_maths",
|
"core_maths",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tungstenite"
|
||||||
|
version = "0.24.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
"bytes",
|
||||||
|
"data-encoding",
|
||||||
|
"http",
|
||||||
|
"httparse",
|
||||||
|
"log",
|
||||||
|
"native-tls",
|
||||||
|
"rand",
|
||||||
|
"sha1",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"utf-8",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "two-face"
|
name = "two-face"
|
||||||
version = "0.4.5"
|
version = "0.4.5"
|
||||||
@@ -5604,7 +5805,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typst-desktop"
|
name = "typst-desktop"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -5615,8 +5816,10 @@ dependencies = [
|
|||||||
"sha2",
|
"sha2",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
|
"tauri-plugin-clipboard-manager",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
|
"tungstenite",
|
||||||
"typst",
|
"typst",
|
||||||
"typst-assets",
|
"typst-assets",
|
||||||
"typst-html",
|
"typst-html",
|
||||||
@@ -6184,6 +6387,12 @@ dependencies = [
|
|||||||
"xmlwriter",
|
"xmlwriter",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf-8"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf16_iter"
|
name = "utf16_iter"
|
||||||
version = "1.0.5"
|
version = "1.0.5"
|
||||||
@@ -6441,6 +6650,76 @@ dependencies = [
|
|||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wayland-backend"
|
||||||
|
version = "0.3.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"downcast-rs",
|
||||||
|
"rustix",
|
||||||
|
"smallvec",
|
||||||
|
"wayland-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wayland-client"
|
||||||
|
version = "0.31.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.1",
|
||||||
|
"rustix",
|
||||||
|
"wayland-backend",
|
||||||
|
"wayland-scanner",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wayland-protocols"
|
||||||
|
version = "0.32.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.1",
|
||||||
|
"wayland-backend",
|
||||||
|
"wayland-client",
|
||||||
|
"wayland-scanner",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wayland-protocols-wlr"
|
||||||
|
version = "0.3.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.1",
|
||||||
|
"wayland-backend",
|
||||||
|
"wayland-client",
|
||||||
|
"wayland-protocols",
|
||||||
|
"wayland-scanner",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wayland-scanner"
|
||||||
|
version = "0.31.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quick-xml 0.39.4",
|
||||||
|
"quote",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wayland-sys"
|
||||||
|
version = "0.31.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
|
||||||
|
dependencies = [
|
||||||
|
"pkg-config",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web-sys"
|
name = "web-sys"
|
||||||
version = "0.3.103"
|
version = "0.3.103"
|
||||||
@@ -7041,6 +7320,24 @@ version = "0.57.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wl-clipboard-rs"
|
||||||
|
version = "0.9.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"os_pipe",
|
||||||
|
"rustix",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tree_magic_mini",
|
||||||
|
"wayland-backend",
|
||||||
|
"wayland-client",
|
||||||
|
"wayland-protocols",
|
||||||
|
"wayland-protocols-wlr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "write-fonts"
|
name = "write-fonts"
|
||||||
version = "0.48.1"
|
version = "0.48.1"
|
||||||
@@ -7131,6 +7428,23 @@ dependencies = [
|
|||||||
"pkg-config",
|
"pkg-config",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "x11rb"
|
||||||
|
version = "0.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
|
||||||
|
dependencies = [
|
||||||
|
"gethostname",
|
||||||
|
"rustix",
|
||||||
|
"x11rb-protocol",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "x11rb-protocol"
|
||||||
|
version = "0.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "xattr"
|
name = "xattr"
|
||||||
version = "1.6.1"
|
version = "1.6.1"
|
||||||
|
|||||||
@@ -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,17 +22,18 @@ 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.15.1", 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.15.1", features = ["fonts"] }
|
typst-assets = { version = "0.15.1", features = ["fonts"] }
|
||||||
|
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
@@ -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"] }
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 974 B After Width: | Height: | Size: 825 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 828 B |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 12 KiB |
@@ -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(())
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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("+");
|
||||||
|
}
|
||||||
@@ -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`;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 34 KiB |