Add Typst Desktop app
@@ -0,0 +1,10 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"svelte.svelte-vscode",
|
||||
"tauri-apps.tauri-vscode",
|
||||
"rust-lang.rust-analyzer"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"svelte.enable-ts-plugin": true
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
# Typst Desktop
|
||||
|
||||
[](https://typst.app/)
|
||||
[](https://www.rust-lang.org/)
|
||||
[](https://tauri.app/)
|
||||
[](https://kit.svelte.dev/)
|
||||
[](https://tailwindcss.com/)
|
||||
[](https://www.sqlite.org/)
|
||||
|
||||
A desktop editor for Typst. Your documents stay as plain files on your own drive, and can optionally sync to a [TypstDrive](../typstdrive) server so the same notes open on any device.
|
||||
|
||||
The Typst compiler is built into the app — there is nothing extra to install to write and export documents.
|
||||
|
||||
## Features
|
||||
|
||||
- **Local first**: Every document is an ordinary file in a folder you choose. Nothing is locked in a database, and any other editor can open the same files.
|
||||
- **Two views**: A file viewer for browsing folders, projects, and documents, and an editor view for writing.
|
||||
- **Single files or projects**: Open a lone `.typ` file, or a project folder with its own `typst.toml`, chapters, bibliography, and assets.
|
||||
- **Live preview**: The preview recompiles as you type. Saving is not required to see changes.
|
||||
- **Editor**: Typst syntax highlighting, snippet autocompletion, a formatting toolbar, inline diagnostics, and optional `tinymist` language server support.
|
||||
- **Cloud sync**: Link a project to a TypstDrive Space to push and pull changes, with three-way merge and a conflict resolver for files edited in both places.
|
||||
- **Images and fonts**: Import files from anywhere on disk, or drag and drop them in. A shared asset library makes images and fonts available to every project.
|
||||
- **Thumbnails**: Documents show their first compiled page; images show a preview.
|
||||
- **Image viewer**: Open images full screen with zoom and folder navigation.
|
||||
|
||||
## Storage
|
||||
|
||||
By default the workspace lives at `~/typst` (for example, `/home/blob/typst`). Change it in Settings.
|
||||
|
||||
Inside the workspace:
|
||||
|
||||
| Path | Contents |
|
||||
|---|---|
|
||||
| `<workspace>/` | Your folders, projects, and documents. |
|
||||
| `<workspace>/.assets/` | Shared images and fonts available to every project. |
|
||||
|
||||
Application data — settings, sync state, and the thumbnail cache — is kept in a SQLite database in the platform's app-data directory. Documents themselves are never stored there.
|
||||
|
||||
## Fonts and Images
|
||||
|
||||
Fonts and images work the same way as in TypstDrive. A file is available to the compiler by its name.
|
||||
|
||||
### Fonts
|
||||
|
||||
Import `.ttf`, `.otf`, `.ttc`, or `.otc` files through **Assets**, or place them in a folder beside your document. Typst Desktop reads the family name embedded in the file and registers every weight and style under it.
|
||||
|
||||
```typst
|
||||
#set text(font: "JetBrains Mono")
|
||||
```
|
||||
|
||||
The toolbar's font dropdown lists every family available to the open document, including the fonts bundled with the app. If a family is not listed, it has not been picked up, and the name in your document will not resolve.
|
||||
|
||||
### Images
|
||||
|
||||
Reference an image by its file name:
|
||||
|
||||
```typst
|
||||
#image("logo.png", width: 50%)
|
||||
```
|
||||
|
||||
Images in the shared asset library are available to every project. Images inside a project folder are available to that project and override an asset of the same name.
|
||||
|
||||
## Cloud Sync
|
||||
|
||||
Sync is optional. Without it the app is entirely local.
|
||||
|
||||
1. Open **Settings**, enter your TypstDrive server URL, and sign in.
|
||||
2. In the file viewer, choose **Upload to cloud** on a project to create a Space from it.
|
||||
3. Use **Sync** in the editor to pull remote changes and push local ones.
|
||||
|
||||
A project already in the cloud can be brought to another device from the **Cloud** tab in the file viewer.
|
||||
|
||||
### Conflicts
|
||||
|
||||
Sync pulls before it pushes. When a file changed both locally and in the cloud, a three-way merge is attempted against the version last synced. If the merge cannot be resolved automatically, a conflict resolver opens where you can keep either version or edit the merged result. Binary files cannot be merged and keep the cloud version.
|
||||
|
||||
## Language Server
|
||||
|
||||
Typst Desktop can use [`tinymist`](https://github.com/Myriad-Dreamin/tinymist) for hover documentation, go-to-definition, and richer diagnostics. It is not bundled — install it and make sure it is on your `PATH`:
|
||||
|
||||
```bash
|
||||
curl -L -o ~/.cargo/bin/tinymist \
|
||||
https://github.com/Myriad-Dreamin/tinymist/releases/latest/download/tinymist-linux-x64 \
|
||||
&& chmod +x ~/.cargo/bin/tinymist
|
||||
```
|
||||
|
||||
The editor header shows the language server status. Without `tinymist` the editor still has syntax highlighting, autocompletion, and compiler diagnostics.
|
||||
|
||||
## Development
|
||||
|
||||
The app compiles Typst from source, so clone the compiler into TypstDrive's `typst/` folder first — both projects share it:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/typst/typst.git ../typstdrive/typst
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run tauri dev
|
||||
```
|
||||
|
||||
To build a release bundle:
|
||||
|
||||
```bash
|
||||
bun run tauri build
|
||||
```
|
||||
|
||||
### Layout
|
||||
|
||||
| Path | Contents |
|
||||
|---|---|
|
||||
| `src/` | SvelteKit frontend. |
|
||||
| `src/lib/components/` | Views, editor, modals. |
|
||||
| `src/lib/ts/` | Tauri command bindings and app state. |
|
||||
| `src-tauri/src/` | Rust backend. |
|
||||
| `src-tauri/src/workspace.rs` | Workspace browsing and file access. |
|
||||
| `src-tauri/src/compiler.rs` | Typst compilation and export. |
|
||||
| `src-tauri/src/sync.rs` | TypstDrive sync and merging. |
|
||||
| `src-tauri/src/db.rs` | Local SQLite storage. |
|
||||
@@ -0,0 +1,449 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "typst-desktop",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.20.2",
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
"@codemirror/language": "^6.10.8",
|
||||
"@codemirror/legacy-modes": "^6.5.1",
|
||||
"@codemirror/lint": "^6.9.6",
|
||||
"@codemirror/lsp-client": "^6.2.4",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/view": "^6.43.0",
|
||||
"@iconify/svelte": "^5.2.1",
|
||||
"@lezer/highlight": "^1.2.1",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.4.1",
|
||||
"@tauri-apps/plugin-opener": "^2.5.4",
|
||||
"codemirror": "^6.0.2",
|
||||
"codemirror-lang-typst": "^0.4.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.70.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.1.1",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"svelte": "^5.56.6",
|
||||
"svelte-check": "^4.7.3",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "~5.6.3",
|
||||
"vite": "^6.4.3",
|
||||
"vite-plugin-top-level-await": "^1.6.0",
|
||||
"vite-plugin-wasm": "^3.6.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@codemirror/autocomplete": ["@codemirror/[email protected]", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g=="],
|
||||
|
||||
"@codemirror/commands": ["@codemirror/[email protected]", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.7.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg=="],
|
||||
|
||||
"@codemirror/language": ["@codemirror/[email protected]", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A=="],
|
||||
|
||||
"@codemirror/legacy-modes": ["@codemirror/[email protected]", "", { "dependencies": { "@codemirror/language": "^6.0.0" } }, "sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg=="],
|
||||
|
||||
"@codemirror/lint": ["@codemirror/[email protected]", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.42.0", "crelt": "^1.0.5" } }, "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg=="],
|
||||
|
||||
"@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/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/view": ["@codemirror/[email protected]", "", { "dependencies": { "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/[email protected]", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||
|
||||
"@iconify/svelte": ["@iconify/[email protected]", "", { "dependencies": { "@iconify/types": "^2.0.0" }, "peerDependencies": { "svelte": ">5.0.0" } }, "sha512-XMXxD3nzH7yB68C3K4sNwzrJ1TBscODR0ZqCeNf0KGuEMCTSuCo0jHNDZ0o0iRXTylO41NSz/DWam/4atLG1yw=="],
|
||||
|
||||
"@iconify/types": ["@iconify/[email protected]", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/[email protected]", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/[email protected]", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@lezer/common": ["@lezer/[email protected]", "", {}, "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ=="],
|
||||
|
||||
"@lezer/highlight": ["@lezer/[email protected]", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="],
|
||||
|
||||
"@lezer/lr": ["@lezer/[email protected]", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A=="],
|
||||
|
||||
"@marijn/find-cluster-break": ["@marijn/[email protected]", "", {}, "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA=="],
|
||||
|
||||
"@polka/url": ["@polka/[email protected]", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
|
||||
|
||||
"@rollup/plugin-virtual": ["@rollup/[email protected]", "", { "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-10monEYsBp3scM4/ND4LNH5Rxvh3e/cVeL3jWTgZ2SrQ+BmUoQcopVQvnaMcOnykb1VkxUFuDAN+0FnpTFRy2A=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@sveltejs/acorn-typescript": ["@sveltejs/[email protected]", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw=="],
|
||||
|
||||
"@sveltejs/adapter-static": ["@sveltejs/[email protected]", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew=="],
|
||||
|
||||
"@sveltejs/kit": ["@sveltejs/[email protected]", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.9", "@types/cookie": "^0.6.0", "acorn": "^8.16.0", "cookie": "^0.6.0", "devalue": "^5.8.1", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-5pBnJwdNzxbrxp1TLK1NPMFF0Cx57iZUDKInznKcfifYR9m9poWfZI2Tfhw6BZIjYor5dXvcibt4EQgar3k6ww=="],
|
||||
|
||||
"@sveltejs/load-config": ["@sveltejs/[email protected]", "", {}, "sha512-1LgZ/qUqSoq+QorD83lk2hka79Px0wXNW2q5V1nZlxGhQgw1jrsIbVz5YiCeucVLo4XvFLjXukUaQjIiqowkcg=="],
|
||||
|
||||
"@sveltejs/vite-plugin-svelte": ["@sveltejs/[email protected]", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.1", "deepmerge": "^4.3.1", "kleur": "^4.1.5", "magic-string": "^0.30.17", "vitefu": "^1.0.6" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ=="],
|
||||
|
||||
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/[email protected]", "", { "dependencies": { "debug": "^4.3.7" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^5.0.0", "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw=="],
|
||||
|
||||
"@swc/core": ["@swc/[email protected]", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.27" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.15.43", "@swc/core-darwin-x64": "1.15.43", "@swc/core-linux-arm-gnueabihf": "1.15.43", "@swc/core-linux-arm64-gnu": "1.15.43", "@swc/core-linux-arm64-musl": "1.15.43", "@swc/core-linux-ppc64-gnu": "1.15.43", "@swc/core-linux-s390x-gnu": "1.15.43", "@swc/core-linux-x64-gnu": "1.15.43", "@swc/core-linux-x64-musl": "1.15.43", "@swc/core-win32-arm64-msvc": "1.15.43", "@swc/core-win32-ia32-msvc": "1.15.43", "@swc/core-win32-x64-msvc": "1.15.43" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-1CuKjFkPxIgGdeHVuNbkxmBxkcbdc08u0aiI43pFq6yY1tTVKmXT9hFEooyyKs/sJ3xf1GPHyEwTtk9Xl8dvQw=="],
|
||||
|
||||
"@swc/core-darwin-arm64": ["@swc/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v1aVuvXdo/BHxJzco9V2xpHrvwWmhfS8t6gziY5wJxd+Z2h8AeJRnAwPD8itCDaGXVBwJ/CaKfxEzTkG0Va0OA=="],
|
||||
|
||||
"@swc/core-darwin-x64": ["@swc/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-lp3d4Lamc8dt5huYdGLSR+9hLxmfr1jb0l+4XXG2zPqZwYWRN9R0U2qYoTrggiU2RWW0oV9VbWM3kBnqIc2kdQ=="],
|
||||
|
||||
"@swc/core-linux-arm-gnueabihf": ["@swc/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-JWTQQELtsG5GgphDrr/XqqmM2pDN3cZqbMS0Mrg+iTiXL3F74sn/S2IyYE/5u4h2KLkTf9qQ7dXyxsbx7YzkeA=="],
|
||||
|
||||
"@swc/core-linux-arm64-gnu": ["@swc/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-B4otJRdPWIsmiSBf0uG7Z/+vMWmkufjz5MmYxubwKuZazDW14Zd3symga1N62QR4RT+kEFeHEgsXfZGyn/w0hw=="],
|
||||
|
||||
"@swc/core-linux-arm64-musl": ["@swc/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-6zB6OnpViBxYy4tgY3v2i6AZY9fwkcHZ032UOwtwUuW1d19sdT07qF0kZe6/3UR1tUaK6jjg2rmVcUIBCEYVjQ=="],
|
||||
|
||||
"@swc/core-linux-ppc64-gnu": ["@swc/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-coxE1ZWdB3uSDVNoEtYNrRi/1epvckZx9cTJ8ICUxTMTxGk+yvQ/Twacp3ruZSaMPGCriUjP86C37VhaT6nyRg=="],
|
||||
|
||||
"@swc/core-linux-s390x-gnu": ["@swc/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-lXfLhs+LpBsD5inuYx+YDH5WsPPBQ95KPUiy8P5wq9ob9xKDZFqwNfU2QW6bGO8NqRO/H9JQomTSt5Yyh+FGfA=="],
|
||||
|
||||
"@swc/core-linux-x64-gnu": ["@swc/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-07XnKwTmKy8TGOZG3D9fRnLWGynxPjwQnZLVmBFbo6F+7vHYzBIOuwXEhemrChBWb6yDNZsVCcMWCPX6FDD2xg=="],
|
||||
|
||||
"@swc/core-linux-x64-musl": ["@swc/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-TJc+bsSIaBh+hZvZ5GRtW/K1bw66TJ9vsUwvVIsZdiWxU5ObLwZvfcnZ3UpgVfMnFibRes9uriJrQNBHEEogRQ=="],
|
||||
|
||||
"@swc/core-win32-arm64-msvc": ["@swc/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-jfd7s2/bUQYkOHLs+LWQNKZdmDa8+sufKLllhpWAhVQ2GDCwsHe3vR/j+OSiItZNtkzFuaawa3+SAKz9y5gYfw=="],
|
||||
|
||||
"@swc/core-win32-ia32-msvc": ["@swc/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-rLAE8JvucqEW1ZGohxPQrQWPBQeJG4+ypKbWfdlU/qmKScvCkxf9/Jxnzki1dkUQCQ7P5Enp13RlvqOlvx/32g=="],
|
||||
|
||||
"@swc/core-win32-x64-msvc": ["@swc/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-h8MLDHZcfIukwQWj03rIJZx1I0E81AYj2X7J/nGErG4nz+QAv6G1Z+peotvinL3lqpbo32tLYSMFo32/ySzxKg=="],
|
||||
|
||||
"@swc/counter": ["@swc/[email protected]", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="],
|
||||
|
||||
"@swc/types": ["@swc/[email protected]", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg=="],
|
||||
|
||||
"@swc/wasm": ["@swc/[email protected]", "", {}, "sha512-jYqeckrzZGAU+9OSfmL15MWfhkKWRCC8QMssL/MZu/MaIP76mU3VHjzqxlwKIz9DOSR/9jCqi1+QlWE0ILNehA=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/[email protected]", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/[email protected]", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="],
|
||||
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="],
|
||||
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/[email protected]", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="],
|
||||
|
||||
"@tailwindcss/vite": ["@tailwindcss/[email protected]", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="],
|
||||
|
||||
"@tauri-apps/api": ["@tauri-apps/[email protected]", "", {}, "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA=="],
|
||||
|
||||
"@tauri-apps/cli": ["@tauri-apps/[email protected]", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.11.4", "@tauri-apps/cli-darwin-x64": "2.11.4", "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4", "@tauri-apps/cli-linux-arm64-gnu": "2.11.4", "@tauri-apps/cli-linux-arm64-musl": "2.11.4", "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4", "@tauri-apps/cli-linux-x64-gnu": "2.11.4", "@tauri-apps/cli-linux-x64-musl": "2.11.4", "@tauri-apps/cli-win32-arm64-msvc": "2.11.4", "@tauri-apps/cli-win32-ia32-msvc": "2.11.4", "@tauri-apps/cli-win32-x64-msvc": "2.11.4" }, "bin": { "tauri": "tauri.js" } }, "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ=="],
|
||||
|
||||
"@tauri-apps/cli-darwin-arm64": ["@tauri-apps/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ=="],
|
||||
|
||||
"@tauri-apps/cli-darwin-x64": ["@tauri-apps/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A=="],
|
||||
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ=="],
|
||||
|
||||
"@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA=="],
|
||||
|
||||
"@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw=="],
|
||||
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ=="],
|
||||
|
||||
"@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ=="],
|
||||
|
||||
"@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A=="],
|
||||
|
||||
"@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ=="],
|
||||
|
||||
"@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA=="],
|
||||
|
||||
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw=="],
|
||||
|
||||
"@tauri-apps/plugin-dialog": ["@tauri-apps/[email protected]", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ=="],
|
||||
|
||||
"@tauri-apps/plugin-opener": ["@tauri-apps/[email protected]", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ=="],
|
||||
|
||||
"@types/cookie": ["@types/[email protected]", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
|
||||
|
||||
"@types/estree": ["@types/[email protected]", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
|
||||
"@types/trusted-types": ["@types/[email protected]", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||
|
||||
"acorn": ["[email protected]", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
|
||||
|
||||
"aria-query": ["[email protected]", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
|
||||
|
||||
"axobject-query": ["[email protected]", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
|
||||
|
||||
"chokidar": ["[email protected]", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||
|
||||
"clsx": ["[email protected]", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"codemirror": ["[email protected]", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw=="],
|
||||
|
||||
"codemirror-lang-typst": ["[email protected]", "", { "dependencies": { "@codemirror/language": "^6.11.2", "@codemirror/state": "^6.5.2", "@codemirror/view": "^6.38.1", "@lezer/common": "^1.2.3", "@lezer/highlight": "^1.2.1" } }, "sha512-jpHz5qQRC3LE48JH+C24qZJAEAhjqRzWA4MFodPaxriz7UwLVuTLCQ672rdDx9ziObBQ2BvHaRIm5/Upz4KoBQ=="],
|
||||
|
||||
"cookie": ["[email protected]", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
|
||||
|
||||
"crelt": ["[email protected]", "", {}, "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA=="],
|
||||
|
||||
"debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"deepmerge": ["[email protected]", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
||||
|
||||
"detect-libc": ["[email protected]", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"devalue": ["[email protected]", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="],
|
||||
|
||||
"enhanced-resolve": ["[email protected]", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw=="],
|
||||
|
||||
"esbuild": ["[email protected]", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||
|
||||
"esm-env": ["[email protected]", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
|
||||
|
||||
"esrap": ["[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA=="],
|
||||
|
||||
"fdir": ["[email protected]", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"graceful-fs": ["[email protected]", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"is-reference": ["[email protected]", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
|
||||
|
||||
"jiti": ["[email protected]", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
||||
|
||||
"kleur": ["[email protected]", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
|
||||
"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-darwin-arm64": ["[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||
|
||||
"locate-character": ["[email protected]", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
|
||||
|
||||
"magic-string": ["[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"marked": ["[email protected]", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
|
||||
|
||||
"mri": ["[email protected]", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
||||
|
||||
"mrmime": ["[email protected]", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
|
||||
|
||||
"ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["[email protected]", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="],
|
||||
|
||||
"picocolors": ["[email protected]", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["[email protected]", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
|
||||
|
||||
"postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ=="],
|
||||
|
||||
"readdirp": ["[email protected]", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||
|
||||
"rollup": ["[email protected]", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="],
|
||||
|
||||
"sade": ["[email protected]", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
|
||||
|
||||
"set-cookie-parser": ["[email protected]", "", {}, "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw=="],
|
||||
|
||||
"sirv": ["[email protected]", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
|
||||
|
||||
"source-map-js": ["[email protected]", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"style-mod": ["[email protected]", "", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="],
|
||||
|
||||
"svelte": ["[email protected]", "", { "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-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=="],
|
||||
|
||||
"tailwindcss": ["[email protected]", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
|
||||
|
||||
"tapable": ["[email protected]", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||
|
||||
"tinyglobby": ["[email protected]", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
|
||||
"totalist": ["[email protected]", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
|
||||
|
||||
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
|
||||
|
||||
"uuid": ["[email protected]", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="],
|
||||
|
||||
"vite": ["[email protected]", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],
|
||||
|
||||
"vite-plugin-top-level-await": ["[email protected]", "", { "dependencies": { "@rollup/plugin-virtual": "^3.0.2", "@swc/core": "^1.12.14", "@swc/wasm": "^1.12.14", "uuid": "10.0.0" }, "peerDependencies": { "vite": ">=2.8" } }, "sha512-bNhUreLamTIkoulCR9aDXbTbhLk6n1YE8NJUTTxl5RYskNRtzOR0ASzSjBVRtNdjIfngDXo11qOsybGLNsrdww=="],
|
||||
|
||||
"vite-plugin-wasm": ["[email protected]", "", { "peerDependencies": { "vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7 || ^8" } }, "sha512-mL/QPziiIA4RAA6DkaZZzOstdwbW5jO4Vz7Zenj0wieKWBlNvIvX5L5ljum9lcUX0ShNfBgCNLKTjNkRVVqcsw=="],
|
||||
|
||||
"vitefu": ["[email protected]", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
|
||||
|
||||
"vscode-jsonrpc": ["[email protected]", "", {}, "sha512-rfuA6T75H6m5EkbhtEPzre9pT0HPcDI2MMy4+nPFIBks5J8JBAUHD4tRYSgaBOijIEC7SRkC1kKyXTLqbmh9jw=="],
|
||||
|
||||
"vscode-languageserver-protocol": ["[email protected]", "", { "dependencies": { "vscode-jsonrpc": "9.0.1", "vscode-languageserver-types": "3.18.0" } }, "sha512-XRyDbT0Pp3sSNti3JmxVEUMySWCSi1hhM+/KUlCy1hV1zmrqpM1OwO12EAki8blhmLuIMpaJrYbo0OzGVfK2Qg=="],
|
||||
|
||||
"vscode-languageserver-types": ["[email protected]", "", {}, "sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g=="],
|
||||
|
||||
"w3c-keyname": ["[email protected]", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
|
||||
|
||||
"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/runtime": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/[email protected]", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["[email protected]", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "typst-desktop",
|
||||
"version": "0.1.0",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.20.2",
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
"@codemirror/language": "^6.10.8",
|
||||
"@codemirror/legacy-modes": "^6.5.1",
|
||||
"@codemirror/lint": "^6.9.6",
|
||||
"@codemirror/lsp-client": "^6.2.4",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/view": "^6.43.0",
|
||||
"@iconify/svelte": "^5.2.1",
|
||||
"@lezer/highlight": "^1.2.1",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.4.1",
|
||||
"@tauri-apps/plugin-opener": "^2.5.4",
|
||||
"codemirror": "^6.0.2",
|
||||
"codemirror-lang-typst": "^0.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"@sveltejs/kit": "^2.70.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.1.1",
|
||||
"svelte": "^5.56.6",
|
||||
"svelte-check": "^4.7.3",
|
||||
"typescript": "~5.6.3",
|
||||
"vite": "^6.4.3",
|
||||
"vite-plugin-wasm": "^3.6.0",
|
||||
"vite-plugin-top-level-await": "^1.6.0",
|
||||
"@tauri-apps/cli": "^2.11.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
@@ -0,0 +1,43 @@
|
||||
[package]
|
||||
name = "typst-desktop"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "typst_desktop_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
|
||||
typst = { version = "0.14.2", path = "../../typstdrive/typst/crates/typst" }
|
||||
typst-kit = { path = "../../typstdrive/typst/crates/typst-kit", features = ["system-downloader", "system-packages", "embedded-fonts"] }
|
||||
typst-pdf = { path = "../../typstdrive/typst/crates/typst-pdf" }
|
||||
typst-render = { path = "../../typstdrive/typst/crates/typst-render" }
|
||||
typst-svg = { path = "../../typstdrive/typst/crates/typst-svg" }
|
||||
typst-html = { path = "../../typstdrive/typst/crates/typst-html" }
|
||||
typst-layout = { path = "../../typstdrive/typst/crates/typst-layout" }
|
||||
typst-assets = { version = "0.14.2", features = ["fonts"] }
|
||||
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
sha2 = "0.10"
|
||||
base64 = "0.22"
|
||||
diffy = "0.4"
|
||||
walkdir = "2"
|
||||
ureq = { version = "2.12", features = ["json"] }
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-maximize",
|
||||
"core:window:allow-unmaximize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-internal-toggle-maximize",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-close",
|
||||
"opener:default",
|
||||
"dialog:default",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 974 B |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 903 B |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,254 @@
|
||||
use serde::Serialize;
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::db::Store;
|
||||
use crate::workspace::workspace_root;
|
||||
|
||||
pub const ASSETS_DIR: &str = ".assets";
|
||||
|
||||
const FONT_EXTENSIONS: [&str; 4] = ["ttf", "otf", "ttc", "otc"];
|
||||
const IMAGE_EXTENSIONS: [&str; 6] = ["png", "jpg", "jpeg", "gif", "svg", "webp"];
|
||||
|
||||
fn extension_of(name: &str) -> String {
|
||||
Path::new(name)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or("")
|
||||
.to_lowercase()
|
||||
}
|
||||
|
||||
pub fn is_font(name: &str) -> bool {
|
||||
FONT_EXTENSIONS.contains(&extension_of(name).as_str())
|
||||
}
|
||||
|
||||
pub fn is_image(name: &str) -> bool {
|
||||
IMAGE_EXTENSIONS.contains(&extension_of(name).as_str())
|
||||
}
|
||||
|
||||
pub fn assets_dir(app: &AppHandle, store: &Store) -> Result<PathBuf, String> {
|
||||
let dir = workspace_root(app, store)?.join(ASSETS_DIR);
|
||||
std::fs::create_dir_all(&dir)
|
||||
.map_err(|e| format!("Cannot create assets folder: {}", e))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
fn families_in(data: &[u8]) -> Vec<String> {
|
||||
let mut families = BTreeSet::new();
|
||||
for font in typst::text::Font::iter(typst::foundations::Bytes::new(data.to_vec())) {
|
||||
families.insert(font.info().family.clone());
|
||||
}
|
||||
families.into_iter().collect()
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Asset {
|
||||
pub name: String,
|
||||
pub kind: String,
|
||||
pub size: u64,
|
||||
pub font_families: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn list_assets(app: &AppHandle, store: &Store) -> Result<Vec<Asset>, String> {
|
||||
let dir = assets_dir(app, store)?;
|
||||
let mut assets = Vec::new();
|
||||
|
||||
for entry in std::fs::read_dir(&dir).map_err(|e| e.to_string())? {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
if !entry.path().is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
let font_families = if is_font(&name) {
|
||||
std::fs::read(entry.path())
|
||||
.map(|data| families_in(&data))
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
assets.push(Asset {
|
||||
kind: if is_font(&name) {
|
||||
"font"
|
||||
} else if is_image(&name) {
|
||||
"image"
|
||||
} else {
|
||||
"file"
|
||||
}
|
||||
.to_string(),
|
||||
name,
|
||||
size,
|
||||
font_families,
|
||||
});
|
||||
}
|
||||
|
||||
assets.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
Ok(assets)
|
||||
}
|
||||
|
||||
pub fn font_families(files: &HashMap<String, Vec<u8>>) -> Vec<String> {
|
||||
let mut families = BTreeSet::new();
|
||||
|
||||
for data in typst_assets::fonts() {
|
||||
for font in typst::text::Font::iter(typst::foundations::Bytes::new(data)) {
|
||||
families.insert(font.info().family.clone());
|
||||
}
|
||||
}
|
||||
|
||||
for (name, data) in files {
|
||||
if is_font(name) {
|
||||
for family in families_in(data) {
|
||||
families.insert(family);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
families.into_iter().collect()
|
||||
}
|
||||
|
||||
fn unique_destination(dir: &Path, name: &str) -> PathBuf {
|
||||
let candidate = dir.join(name);
|
||||
if !candidate.exists() {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
let stem = Path::new(name)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("file")
|
||||
.to_string();
|
||||
let extension = Path::new(name)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| format!(".{}", e))
|
||||
.unwrap_or_default();
|
||||
|
||||
for index in 2..1000 {
|
||||
let candidate = dir.join(format!("{}-{}{}", stem, index, extension));
|
||||
if !candidate.exists() {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
dir.join(name)
|
||||
}
|
||||
|
||||
fn copy_tree(source: &Path, destination: &Path) -> Result<(), String> {
|
||||
std::fs::create_dir_all(destination).map_err(|e| e.to_string())?;
|
||||
|
||||
for entry in std::fs::read_dir(source).map_err(|e| e.to_string())? {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
let name = entry.file_name();
|
||||
let from = entry.path();
|
||||
let to = destination.join(&name);
|
||||
|
||||
if from.is_dir() {
|
||||
copy_tree(&from, &to)?;
|
||||
} else {
|
||||
std::fs::copy(&from, &to).map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn import_paths(sources: &[String], destination: &Path) -> Result<Vec<String>, String> {
|
||||
std::fs::create_dir_all(destination).map_err(|e| e.to_string())?;
|
||||
|
||||
let mut imported = Vec::new();
|
||||
|
||||
for source in sources {
|
||||
let source_path = Path::new(source);
|
||||
let name = source_path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.ok_or_else(|| format!("'{}' has no name", source))?;
|
||||
|
||||
if source_path.is_dir() {
|
||||
let target = unique_destination(destination, &name);
|
||||
copy_tree(source_path, &target)?;
|
||||
} else if source_path.is_file() {
|
||||
let target = unique_destination(destination, &name);
|
||||
std::fs::copy(source_path, &target)
|
||||
.map_err(|e| format!("Could not import '{}': {}", name, e))?;
|
||||
} else {
|
||||
return Err(format!("'{}' could not be read", source));
|
||||
}
|
||||
|
||||
imported.push(name);
|
||||
}
|
||||
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
pub fn import_files(sources: &[String], destination: &Path) -> Result<Vec<String>, String> {
|
||||
std::fs::create_dir_all(destination).map_err(|e| e.to_string())?;
|
||||
|
||||
let mut imported = Vec::new();
|
||||
|
||||
for source in sources {
|
||||
let source_path = Path::new(source);
|
||||
if !source_path.is_file() {
|
||||
return Err(format!("'{}' is not a file", source));
|
||||
}
|
||||
|
||||
let name = source_path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.ok_or_else(|| format!("'{}' has no file name", source))?;
|
||||
|
||||
let target = unique_destination(destination, &name);
|
||||
std::fs::copy(source_path, &target)
|
||||
.map_err(|e| format!("Could not import '{}': {}", name, e))?;
|
||||
|
||||
imported.push(
|
||||
target
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or(name),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
pub fn delete_asset(app: &AppHandle, store: &Store, name: &str) -> Result<(), String> {
|
||||
if name.contains('/') || name.contains('\\') || name.contains("..") {
|
||||
return Err("Invalid asset name".to_string());
|
||||
}
|
||||
let path = assets_dir(app, store)?.join(name);
|
||||
std::fs::remove_file(path).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn asset_files(app: &AppHandle, store: &Store) -> HashMap<String, Vec<u8>> {
|
||||
let Ok(dir) = assets_dir(app, store) else {
|
||||
return HashMap::new();
|
||||
};
|
||||
|
||||
let mut files = HashMap::new();
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
return files;
|
||||
};
|
||||
|
||||
for entry in entries.filter_map(|entry| entry.ok()) {
|
||||
if !entry.path().is_file() {
|
||||
continue;
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
if let Ok(data) = std::fs::read(entry.path()) {
|
||||
files.insert(name, data);
|
||||
}
|
||||
}
|
||||
|
||||
files
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use typst::diag::Warned;
|
||||
use typst::layout::{Frame, FrameItem};
|
||||
use typst::WorldExt;
|
||||
use typst_html::HtmlDocument;
|
||||
use typst_layout::PagedDocument;
|
||||
use typst_pdf::{pdf, PdfOptions};
|
||||
use typst_render::{render, RenderOptions};
|
||||
use typst_svg::SvgOptions;
|
||||
|
||||
use crate::world::ProjectWorld;
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct DocumentStats {
|
||||
pub pages: usize,
|
||||
pub words: usize,
|
||||
pub characters: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct Diagnostic {
|
||||
pub message: String,
|
||||
pub severity: String,
|
||||
pub line: Option<usize>,
|
||||
pub column: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CompileResult {
|
||||
pub pages: Vec<String>,
|
||||
pub stats: DocumentStats,
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
fn extract_frame_text(frame: &Frame, text: &mut String) {
|
||||
for (_, item) in frame.items() {
|
||||
match item {
|
||||
FrameItem::Text(text_item) => {
|
||||
text.push_str(&text_item.text);
|
||||
text.push(' ');
|
||||
}
|
||||
FrameItem::Group(group) => extract_frame_text(&group.frame, text),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_stats(document: &PagedDocument) -> DocumentStats {
|
||||
let mut text = String::new();
|
||||
for page in document.pages() {
|
||||
extract_frame_text(&page.frame, &mut text);
|
||||
}
|
||||
|
||||
DocumentStats {
|
||||
pages: document.pages().len(),
|
||||
words: text.split_whitespace().count(),
|
||||
characters: text.chars().filter(|c| !c.is_whitespace()).count(),
|
||||
}
|
||||
}
|
||||
|
||||
fn line_and_column(source: &str, offset: usize) -> (usize, usize) {
|
||||
let mut line = 1;
|
||||
let mut column = 1;
|
||||
for (index, character) in source.char_indices() {
|
||||
if index >= offset {
|
||||
break;
|
||||
}
|
||||
if character == '\n' {
|
||||
line += 1;
|
||||
column = 1;
|
||||
} else {
|
||||
column += 1;
|
||||
}
|
||||
}
|
||||
(line, column)
|
||||
}
|
||||
|
||||
fn collect_diagnostics(
|
||||
world: &ProjectWorld,
|
||||
entrypoint_source: &str,
|
||||
errors: impl IntoIterator<Item = typst::diag::SourceDiagnostic>,
|
||||
) -> Vec<Diagnostic> {
|
||||
errors
|
||||
.into_iter()
|
||||
.map(|diagnostic| {
|
||||
let (line, column) = match world.range(diagnostic.span) {
|
||||
Some(range) => {
|
||||
let (line, column) = line_and_column(entrypoint_source, range.start);
|
||||
(Some(line), Some(column))
|
||||
}
|
||||
None => (None, None),
|
||||
};
|
||||
Diagnostic {
|
||||
message: diagnostic.message.to_string(),
|
||||
severity: format!("{:?}", diagnostic.severity).to_lowercase(),
|
||||
line,
|
||||
column,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn entrypoint_text(files: &HashMap<String, Vec<u8>>, entrypoint: &str) -> String {
|
||||
files
|
||||
.get(entrypoint)
|
||||
.map(|bytes| String::from_utf8_lossy(bytes).to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn compile_to_svg(
|
||||
entrypoint: String,
|
||||
files: HashMap<String, Vec<u8>>,
|
||||
) -> Result<CompileResult, Vec<Diagnostic>> {
|
||||
let source_text = entrypoint_text(&files, &entrypoint);
|
||||
let world = ProjectWorld::new(entrypoint, files, false);
|
||||
|
||||
match typst::compile::<PagedDocument>(&world) {
|
||||
Warned {
|
||||
output: Ok(document),
|
||||
warnings,
|
||||
} => {
|
||||
let options = SvgOptions::default();
|
||||
let pages = document
|
||||
.pages()
|
||||
.iter()
|
||||
.map(|page| typst_svg::svg(page, &options))
|
||||
.collect();
|
||||
|
||||
Ok(CompileResult {
|
||||
pages,
|
||||
stats: extract_stats(&document),
|
||||
diagnostics: collect_diagnostics(&world, &source_text, warnings),
|
||||
})
|
||||
}
|
||||
Warned {
|
||||
output: Err(errors),
|
||||
warnings: _,
|
||||
} => Err(collect_diagnostics(&world, &source_text, errors)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn export_pdf(
|
||||
entrypoint: String,
|
||||
files: HashMap<String, Vec<u8>>,
|
||||
) -> Result<Vec<u8>, Vec<Diagnostic>> {
|
||||
let source_text = entrypoint_text(&files, &entrypoint);
|
||||
let world = ProjectWorld::new(entrypoint, files, false);
|
||||
|
||||
match typst::compile::<PagedDocument>(&world) {
|
||||
Warned {
|
||||
output: Ok(document),
|
||||
warnings: _,
|
||||
} => pdf(&document, &PdfOptions::default()).map_err(|errors| {
|
||||
collect_diagnostics(&world, &source_text, errors)
|
||||
}),
|
||||
Warned {
|
||||
output: Err(errors),
|
||||
warnings: _,
|
||||
} => Err(collect_diagnostics(&world, &source_text, errors)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn export_png(
|
||||
entrypoint: String,
|
||||
files: HashMap<String, Vec<u8>>,
|
||||
) -> Result<Vec<u8>, Vec<Diagnostic>> {
|
||||
let source_text = entrypoint_text(&files, &entrypoint);
|
||||
let world = ProjectWorld::new(entrypoint, files, false);
|
||||
|
||||
match typst::compile::<PagedDocument>(&world) {
|
||||
Warned {
|
||||
output: Ok(document),
|
||||
warnings: _,
|
||||
} => {
|
||||
let options = RenderOptions {
|
||||
pixel_per_pt: 2.0,
|
||||
..RenderOptions::default()
|
||||
};
|
||||
match document.pages().first() {
|
||||
Some(page) => Ok(render(page, &options).encode_png().unwrap_or_default()),
|
||||
None => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
Warned {
|
||||
output: Err(errors),
|
||||
warnings: _,
|
||||
} => Err(collect_diagnostics(&world, &source_text, errors)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn export_html(
|
||||
entrypoint: String,
|
||||
files: HashMap<String, Vec<u8>>,
|
||||
) -> Result<Vec<u8>, Vec<Diagnostic>> {
|
||||
let source_text = entrypoint_text(&files, &entrypoint);
|
||||
let world = ProjectWorld::new(entrypoint, files, true);
|
||||
|
||||
let document = match typst::compile::<HtmlDocument>(&world) {
|
||||
Warned {
|
||||
output: Ok(document),
|
||||
warnings: _,
|
||||
} => document,
|
||||
Warned {
|
||||
output: Err(errors),
|
||||
warnings: _,
|
||||
} => return Err(collect_diagnostics(&world, &source_text, errors)),
|
||||
};
|
||||
|
||||
typst_html::html(&document)
|
||||
.map(|html| html.into_bytes())
|
||||
.map_err(|errors| collect_diagnostics(&world, &source_text, errors))
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use crate::workspace::{ProjectMeta, Settings};
|
||||
|
||||
pub struct Store {
|
||||
connection: Mutex<Connection>,
|
||||
}
|
||||
|
||||
const SCHEMA: [&str; 4] = [
|
||||
"CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS projects (
|
||||
path TEXT PRIMARY KEY,
|
||||
entrypoint TEXT NOT NULL DEFAULT 'main.typ',
|
||||
space_id TEXT,
|
||||
last_synced_at TEXT
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS base_files (
|
||||
project_path TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
hash TEXT NOT NULL,
|
||||
content BLOB,
|
||||
PRIMARY KEY (project_path, file_path)
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS thumbnails (
|
||||
path TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
source_modified INTEGER NOT NULL
|
||||
)",
|
||||
];
|
||||
|
||||
impl Store {
|
||||
pub fn open(app: &AppHandle) -> Result<Self, String> {
|
||||
let dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| format!("Cannot resolve data directory: {}", e))?;
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
|
||||
let connection =
|
||||
Connection::open(dir.join("typst-desktop.db")).map_err(|e| e.to_string())?;
|
||||
|
||||
connection
|
||||
.pragma_update(None, "journal_mode", "WAL")
|
||||
.map_err(|e| e.to_string())?;
|
||||
connection
|
||||
.pragma_update(None, "foreign_keys", "ON")
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
for statement in SCHEMA {
|
||||
connection.execute(statement, []).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
Ok(Store {
|
||||
connection: Mutex::new(connection),
|
||||
})
|
||||
}
|
||||
|
||||
fn with<T>(&self, run: impl FnOnce(&Connection) -> rusqlite::Result<T>) -> Result<T, String> {
|
||||
let connection = self
|
||||
.connection
|
||||
.lock()
|
||||
.map_err(|_| "Local database lock poisoned".to_string())?;
|
||||
run(&connection).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn settings(&self) -> Result<Option<Settings>, String> {
|
||||
let raw: Option<String> = self.with(|connection| {
|
||||
connection
|
||||
.query_row(
|
||||
"SELECT value FROM settings WHERE key = 'settings'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
})?;
|
||||
|
||||
match raw {
|
||||
Some(raw) => serde_json::from_str(&raw)
|
||||
.map(Some)
|
||||
.map_err(|e| format!("Stored settings are invalid: {}", e)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_settings(&self, settings: &Settings) -> Result<(), String> {
|
||||
let raw = serde_json::to_string(settings).map_err(|e| e.to_string())?;
|
||||
self.with(|connection| {
|
||||
connection.execute(
|
||||
"INSERT INTO settings (key, value) VALUES ('settings', ?1)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
params![raw],
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn meta(&self, project: &str) -> Result<ProjectMeta, String> {
|
||||
let row: Option<(String, Option<String>, Option<String>)> = self.with(|connection| {
|
||||
connection
|
||||
.query_row(
|
||||
"SELECT entrypoint, space_id, last_synced_at FROM projects WHERE path = ?1",
|
||||
params![project],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)
|
||||
.optional()
|
||||
})?;
|
||||
|
||||
let Some((entrypoint, space_id, last_synced_at)) = row else {
|
||||
return Ok(ProjectMeta::default());
|
||||
};
|
||||
|
||||
let base_hashes = self.with(|connection| {
|
||||
let mut statement = connection
|
||||
.prepare("SELECT file_path, hash FROM base_files WHERE project_path = ?1")?;
|
||||
let rows = statement.query_map(params![project], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})?;
|
||||
|
||||
let mut map = HashMap::new();
|
||||
for row in rows {
|
||||
let (path, hash) = row?;
|
||||
map.insert(path, hash);
|
||||
}
|
||||
Ok(map)
|
||||
})?;
|
||||
|
||||
Ok(ProjectMeta {
|
||||
entrypoint,
|
||||
space_id,
|
||||
last_synced_at,
|
||||
base_hashes,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn has_project(&self, project: &str) -> Result<bool, String> {
|
||||
let found: Option<i64> = self.with(|connection| {
|
||||
connection
|
||||
.query_row(
|
||||
"SELECT 1 FROM projects WHERE path = ?1",
|
||||
params![project],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
})?;
|
||||
Ok(found.is_some())
|
||||
}
|
||||
|
||||
pub fn save_meta(&self, project: &str, meta: &ProjectMeta) -> Result<(), String> {
|
||||
self.with(|connection| {
|
||||
connection.execute(
|
||||
"INSERT INTO projects (path, entrypoint, space_id, last_synced_at)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(path) DO UPDATE SET
|
||||
entrypoint = excluded.entrypoint,
|
||||
space_id = excluded.space_id,
|
||||
last_synced_at = excluded.last_synced_at",
|
||||
params![
|
||||
project,
|
||||
meta.entrypoint,
|
||||
meta.space_id,
|
||||
meta.last_synced_at
|
||||
],
|
||||
)?;
|
||||
|
||||
let mut keep: Vec<String> = Vec::new();
|
||||
for (file, hash) in &meta.base_hashes {
|
||||
connection.execute(
|
||||
"INSERT INTO base_files (project_path, file_path, hash)
|
||||
VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(project_path, file_path) DO UPDATE SET hash = excluded.hash",
|
||||
params![project, file, hash],
|
||||
)?;
|
||||
keep.push(file.clone());
|
||||
}
|
||||
|
||||
let mut statement = connection
|
||||
.prepare("SELECT file_path FROM base_files WHERE project_path = ?1")?;
|
||||
let existing: Vec<String> = statement
|
||||
.query_map(params![project], |row| row.get::<_, String>(0))?
|
||||
.collect::<rusqlite::Result<Vec<String>>>()?;
|
||||
|
||||
for file in existing {
|
||||
if !keep.contains(&file) {
|
||||
connection.execute(
|
||||
"DELETE FROM base_files WHERE project_path = ?1 AND file_path = ?2",
|
||||
params![project, file],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn forget_project(&self, project: &str) -> Result<(), String> {
|
||||
self.with(|connection| {
|
||||
connection.execute(
|
||||
"DELETE FROM base_files WHERE project_path = ?1",
|
||||
params![project],
|
||||
)?;
|
||||
connection.execute("DELETE FROM projects WHERE path = ?1", params![project])?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn rename_project(&self, from: &str, to: &str) -> Result<(), String> {
|
||||
self.with(|connection| {
|
||||
connection.execute(
|
||||
"UPDATE projects SET path = ?2 WHERE path = ?1",
|
||||
params![from, to],
|
||||
)?;
|
||||
connection.execute(
|
||||
"UPDATE base_files SET project_path = ?2 WHERE project_path = ?1",
|
||||
params![from, to],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn base_snapshot(&self, project: &str, file: &str) -> Result<Option<Vec<u8>>, String> {
|
||||
self.with(|connection| {
|
||||
connection
|
||||
.query_row(
|
||||
"SELECT content FROM base_files WHERE project_path = ?1 AND file_path = ?2",
|
||||
params![project, file],
|
||||
|row| row.get::<_, Option<Vec<u8>>>(0),
|
||||
)
|
||||
.optional()
|
||||
.map(|value| value.flatten())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn save_base_snapshot(
|
||||
&self,
|
||||
project: &str,
|
||||
file: &str,
|
||||
hash: &str,
|
||||
content: &[u8],
|
||||
) -> Result<(), String> {
|
||||
self.with(|connection| {
|
||||
connection.execute(
|
||||
"INSERT INTO base_files (project_path, file_path, hash, content)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(project_path, file_path) DO UPDATE SET
|
||||
hash = excluded.hash,
|
||||
content = excluded.content",
|
||||
params![project, file, hash, content],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn thumbnail(&self, path: &str, modified: i64) -> Result<Option<(String, String)>, String> {
|
||||
self.with(|connection| {
|
||||
connection
|
||||
.query_row(
|
||||
"SELECT kind, data FROM thumbnails
|
||||
WHERE path = ?1 AND source_modified >= ?2",
|
||||
params![path, modified],
|
||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
|
||||
)
|
||||
.optional()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn save_thumbnail(
|
||||
&self,
|
||||
path: &str,
|
||||
kind: &str,
|
||||
data: &str,
|
||||
modified: i64,
|
||||
) -> Result<(), String> {
|
||||
self.with(|connection| {
|
||||
connection.execute(
|
||||
"INSERT INTO thumbnails (path, kind, data, source_modified)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(path) DO UPDATE SET
|
||||
kind = excluded.kind,
|
||||
data = excluded.data,
|
||||
source_modified = excluded.source_modified",
|
||||
params![path, kind, data, modified],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn clear_thumbnails(&self) -> Result<(), String> {
|
||||
self.with(|connection| {
|
||||
connection.execute("DELETE FROM thumbnails", [])?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,714 @@
|
||||
mod assets;
|
||||
mod compiler;
|
||||
mod db;
|
||||
mod lsp;
|
||||
mod sync;
|
||||
mod thumbnails;
|
||||
mod workspace;
|
||||
mod world;
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
|
||||
use assets::Asset;
|
||||
use db::Store;
|
||||
use compiler::{CompileResult, Diagnostic};
|
||||
use lsp::{LspHandle, LspState};
|
||||
use sync::{Account, SpaceSummary, SyncReport};
|
||||
use workspace::{
|
||||
browse, is_project_dir, is_text_file, list_files, load_settings, project_file_path,
|
||||
read_target_files, resolve_target, save_settings, workspace_path, BrowseEntry,
|
||||
FileEntry, ProjectMeta, Settings, NEW_PROJECT_MAIN,
|
||||
};
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CompileFailure {
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
fn failure(message: String) -> CompileFailure {
|
||||
CompileFailure {
|
||||
diagnostics: vec![Diagnostic {
|
||||
message,
|
||||
severity: "error".to_string(),
|
||||
line: None,
|
||||
column: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn cloud_credentials(app: &AppHandle, store: &Store) -> Result<(String, String), String> {
|
||||
let settings = load_settings(app, store)?;
|
||||
let token = settings.device_token.ok_or("Not signed in to TypstDrive")?;
|
||||
Ok((settings.server_url, token))
|
||||
}
|
||||
|
||||
fn load_project(
|
||||
app: &AppHandle,
|
||||
store: &Store,
|
||||
project: &str,
|
||||
) -> Result<(PathBuf, ProjectMeta), String> {
|
||||
let dir = workspace_path(app, store, project)?;
|
||||
if !dir.is_dir() {
|
||||
return Err(format!("Project '{}' not found", project));
|
||||
}
|
||||
Ok((dir, store.meta(project)?))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_settings(app: AppHandle, store: State<'_, Store>) -> Result<Settings, String> {
|
||||
load_settings(&app, &store)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn update_settings(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
workspace_root: Option<String>,
|
||||
server_url: Option<String>,
|
||||
) -> Result<Settings, String> {
|
||||
let mut settings = load_settings(&app, &store)?;
|
||||
if let Some(root) = workspace_root {
|
||||
if root.trim().is_empty() {
|
||||
return Err("Workspace folder cannot be empty".to_string());
|
||||
}
|
||||
settings.workspace_root = root;
|
||||
}
|
||||
if let Some(url) = server_url {
|
||||
settings.server_url = url.trim_end_matches('/').to_string();
|
||||
}
|
||||
save_settings(&store, &settings)?;
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn browse_workspace(app: AppHandle, store: State<'_, Store>, path: String) -> Result<Vec<BrowseEntry>, String> {
|
||||
browse(&app, &store, &path)
|
||||
}
|
||||
|
||||
fn parent_of(path: &str) -> String {
|
||||
match path.rsplit_once('/') {
|
||||
Some((parent, _)) => parent.to_string(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn join_path(parent: &str, name: &str) -> String {
|
||||
if parent.is_empty() {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("{}/{}", parent.trim_end_matches('/'), name)
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn create_folder_entry(app: AppHandle, store: State<'_, Store>, parent: String, name: String) -> Result<String, String> {
|
||||
let path = join_path(&parent, name.trim());
|
||||
let full = workspace_path(&app, &store, &path)?;
|
||||
if full.exists() {
|
||||
return Err(format!("'{}' already exists", name));
|
||||
}
|
||||
std::fs::create_dir_all(&full).map_err(|e| e.to_string())?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn create_document_entry(app: AppHandle, store: State<'_, Store>, parent: String, name: String) -> Result<String, String> {
|
||||
let mut name = name.trim().to_string();
|
||||
if name.is_empty() {
|
||||
return Err("Document name cannot be empty".to_string());
|
||||
}
|
||||
if !name.to_lowercase().ends_with(".typ") {
|
||||
name.push_str(".typ");
|
||||
}
|
||||
|
||||
let path = join_path(&parent, &name);
|
||||
let full = workspace_path(&app, &store, &path)?;
|
||||
if full.exists() {
|
||||
return Err(format!("'{}' already exists", name));
|
||||
}
|
||||
if let Some(dir) = full.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let title = name.trim_end_matches(".typ").trim_end_matches(".TYP");
|
||||
std::fs::write(&full, format!("= {}\n\nStart writing here.\n", title))
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn create_project_entry(app: AppHandle, store: State<'_, Store>, parent: String, name: String) -> Result<String, String> {
|
||||
let name = name.trim().to_string();
|
||||
if name.is_empty() {
|
||||
return Err("Project name cannot be empty".to_string());
|
||||
}
|
||||
|
||||
let path = join_path(&parent, &name);
|
||||
let full = workspace_path(&app, &store, &path)?;
|
||||
if full.exists() {
|
||||
return Err(format!("'{}' already exists", name));
|
||||
}
|
||||
|
||||
std::fs::create_dir_all(&full).map_err(|e| e.to_string())?;
|
||||
std::fs::write(full.join("main.typ"), NEW_PROJECT_MAIN).map_err(|e| e.to_string())?;
|
||||
std::fs::write(full.join("typst.toml"), workspace::manifest_for(&name))
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
store.save_meta(
|
||||
&path,
|
||||
&ProjectMeta {
|
||||
entrypoint: "main.typ".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn rename_entry(app: AppHandle, store: State<'_, Store>, path: String, new_name: String) -> Result<String, String> {
|
||||
let new_name = new_name.trim();
|
||||
if new_name.is_empty() || new_name.contains('/') || new_name.contains('\\') {
|
||||
return Err("Invalid name".to_string());
|
||||
}
|
||||
|
||||
let from = workspace_path(&app, &store, &path)?;
|
||||
let target_path = join_path(&parent_of(&path), new_name);
|
||||
let to = workspace_path(&app, &store, &target_path)?;
|
||||
|
||||
if to.exists() {
|
||||
return Err(format!("'{}' already exists", new_name));
|
||||
}
|
||||
std::fs::rename(&from, &to).map_err(|e| e.to_string())?;
|
||||
store.rename_project(&path, &target_path)?;
|
||||
Ok(target_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn delete_entry(app: AppHandle, store: State<'_, Store>, path: String) -> Result<(), String> {
|
||||
let full = workspace_path(&app, &store, &path)?;
|
||||
if full.is_dir() {
|
||||
std::fs::remove_dir_all(&full).map_err(|e| e.to_string())?;
|
||||
store.forget_project(&path)?;
|
||||
Ok(())
|
||||
} else {
|
||||
std::fs::remove_file(&full).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn upload_entry(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
parent: String,
|
||||
name: String,
|
||||
base64_content: String,
|
||||
) -> Result<String, String> {
|
||||
let path = join_path(&parent, &name);
|
||||
let full = workspace_path(&app, &store, &path)?;
|
||||
let bytes = BASE64
|
||||
.decode(base64_content.as_bytes())
|
||||
.map_err(|e| format!("Invalid file data: {}", e))?;
|
||||
if let Some(dir) = full.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
std::fs::write(&full, bytes).map_err(|e| e.to_string())?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct FilePayload {
|
||||
pub path: String,
|
||||
pub is_text: bool,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TargetInfo {
|
||||
pub path: String,
|
||||
pub entrypoint: String,
|
||||
pub standalone: bool,
|
||||
pub is_project: bool,
|
||||
pub space_id: Option<String>,
|
||||
pub files: Vec<FileEntry>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn target_info(app: AppHandle, store: State<'_, Store>, path: String) -> Result<TargetInfo, String> {
|
||||
let target = resolve_target(&app, &store, &path)?;
|
||||
|
||||
if target.standalone {
|
||||
let size = std::fs::metadata(target.root.join(&target.entrypoint))
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0);
|
||||
return Ok(TargetInfo {
|
||||
path,
|
||||
entrypoint: target.entrypoint.clone(),
|
||||
standalone: true,
|
||||
is_project: false,
|
||||
space_id: None,
|
||||
files: vec![FileEntry {
|
||||
path: target.entrypoint.clone(),
|
||||
name: target.entrypoint,
|
||||
is_text: true,
|
||||
size,
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
let meta = store.meta(&path)?;
|
||||
Ok(TargetInfo {
|
||||
path,
|
||||
entrypoint: target.entrypoint,
|
||||
standalone: false,
|
||||
is_project: is_project_dir(&target.root),
|
||||
space_id: meta.space_id,
|
||||
files: list_files(&target.root)?,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn read_target_file(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
path: String,
|
||||
file: String,
|
||||
) -> Result<FilePayload, String> {
|
||||
let target = resolve_target(&app, &store, &path)?;
|
||||
let full = project_file_path(&target.root, &file)?;
|
||||
let bytes = std::fs::read(&full).map_err(|e| e.to_string())?;
|
||||
|
||||
let is_text = is_text_file(&file);
|
||||
Ok(FilePayload {
|
||||
path: file,
|
||||
is_text,
|
||||
content: if is_text {
|
||||
String::from_utf8_lossy(&bytes).to_string()
|
||||
} else {
|
||||
BASE64.encode(&bytes)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn write_target_file(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
path: String,
|
||||
file: String,
|
||||
content: String,
|
||||
) -> Result<(), String> {
|
||||
let target = resolve_target(&app, &store, &path)?;
|
||||
let full = project_file_path(&target.root, &file)?;
|
||||
if let Some(dir) = full.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
std::fs::write(&full, content).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_target_entrypoint(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
path: String,
|
||||
entrypoint: String,
|
||||
) -> Result<(), String> {
|
||||
let target = resolve_target(&app, &store, &path)?;
|
||||
if target.standalone {
|
||||
return Err("A standalone document is its own entrypoint".to_string());
|
||||
}
|
||||
let mut meta = store.meta(&path)?;
|
||||
meta.entrypoint = entrypoint;
|
||||
store.save_meta(&path, &meta)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn compile_target(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
path: String,
|
||||
overrides: Option<std::collections::HashMap<String, String>>,
|
||||
) -> Result<CompileResult, CompileFailure> {
|
||||
let target = resolve_target(&app, &store, &path).map_err(failure)?;
|
||||
let mut files = read_target_files(&app, &store, &target).map_err(failure)?;
|
||||
|
||||
for (file, content) in overrides.unwrap_or_default() {
|
||||
files.insert(file, content.into_bytes());
|
||||
}
|
||||
|
||||
compiler::compile_to_svg(target.entrypoint, files)
|
||||
.map_err(|diagnostics| CompileFailure { diagnostics })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn export_target(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
path: String,
|
||||
format: String,
|
||||
destination: String,
|
||||
) -> Result<String, String> {
|
||||
let target = resolve_target(&app, &store, &path)?;
|
||||
let files = read_target_files(&app, &store, &target)?;
|
||||
|
||||
let bytes = match format.as_str() {
|
||||
"pdf" => compiler::export_pdf(target.entrypoint, files),
|
||||
"png" => compiler::export_png(target.entrypoint, files),
|
||||
"html" => compiler::export_html(target.entrypoint, files),
|
||||
other => return Err(format!("Unsupported export format '{}'", other)),
|
||||
}
|
||||
.map_err(|diagnostics| {
|
||||
diagnostics
|
||||
.into_iter()
|
||||
.map(|d| d.message)
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
})?;
|
||||
|
||||
std::fs::write(&destination, bytes).map_err(|e| e.to_string())?;
|
||||
Ok(destination)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn thumbnail(app: AppHandle, store: State<'_, Store>, path: String) -> Result<thumbnails::Thumbnail, String> {
|
||||
thumbnails::thumbnail(&app, &store, &path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn read_image(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
path: String,
|
||||
) -> Result<thumbnails::ImageData, String> {
|
||||
thumbnails::read_image(&app, &store, &path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn clear_thumbnails(store: State<'_, Store>) -> Result<(), String> {
|
||||
store.clear_thumbnails()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn list_assets(app: AppHandle, store: State<'_, Store>) -> Result<Vec<Asset>, String> {
|
||||
assets::list_assets(&app, &store)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn list_font_families(app: AppHandle, store: State<'_, Store>, path: Option<String>) -> Result<Vec<String>, String> {
|
||||
let files = match path {
|
||||
Some(path) if !path.is_empty() => {
|
||||
let target = resolve_target(&app, &store, &path)?;
|
||||
read_target_files(&app, &store, &target)?
|
||||
}
|
||||
_ => assets::asset_files(&app, &store),
|
||||
};
|
||||
|
||||
Ok(assets::font_families(&files))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn import_assets(app: AppHandle, store: State<'_, Store>, sources: Vec<String>) -> Result<Vec<String>, String> {
|
||||
let destination = assets::assets_dir(&app, &store)?;
|
||||
assets::import_files(&sources, &destination)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn delete_asset(app: AppHandle, store: State<'_, Store>, name: String) -> Result<(), String> {
|
||||
assets::delete_asset(&app, &store, &name)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn import_into_target(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
path: String,
|
||||
sources: Vec<String>,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let target = resolve_target(&app, &store, &path)?;
|
||||
assets::import_paths(&sources, &target.root)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn import_into_folder(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
parent: String,
|
||||
sources: Vec<String>,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let destination = workspace_path(&app, &store, &parent)?;
|
||||
assets::import_paths(&sources, &destination)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn lsp_start(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
state: State<'_, LspState>,
|
||||
path: String,
|
||||
) -> Result<LspHandle, String> {
|
||||
let target = resolve_target(&app, &store, &path)?;
|
||||
state.start(&app, &target.root, &target.entrypoint)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn lsp_send(state: State<'_, LspState>, message: String) -> Result<(), String> {
|
||||
state.send(&message)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn lsp_stop(state: State<'_, LspState>) {
|
||||
state.stop();
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn lsp_running(state: State<'_, LspState>) -> bool {
|
||||
state.is_running()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cloud_login(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
server_url: String,
|
||||
email: String,
|
||||
password: String,
|
||||
) -> Result<Account, String> {
|
||||
let server_url = server_url.trim_end_matches('/').to_string();
|
||||
let device_name = format!("Typst Desktop ({})", std::env::consts::OS);
|
||||
let response = sync::login(&server_url, &email, &password, &device_name)?;
|
||||
|
||||
let mut settings = load_settings(&app, &store)?;
|
||||
settings.server_url = server_url;
|
||||
settings.device_token = Some(response.token);
|
||||
settings.account_email = Some(response.email.clone());
|
||||
settings.account_username = Some(response.username.clone());
|
||||
save_settings(&store, &settings)?;
|
||||
|
||||
Ok(Account {
|
||||
user_id: response.user_id,
|
||||
username: response.username,
|
||||
email: response.email,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cloud_logout(app: AppHandle, store: State<'_, Store>) -> Result<(), String> {
|
||||
let mut settings = load_settings(&app, &store)?;
|
||||
if let Some(token) = &settings.device_token {
|
||||
let _ = sync::logout(&settings.server_url, token);
|
||||
}
|
||||
|
||||
settings.device_token = None;
|
||||
settings.account_email = None;
|
||||
settings.account_username = None;
|
||||
save_settings(&store, &settings)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cloud_account(app: AppHandle, store: State<'_, Store>) -> Result<Option<Account>, String> {
|
||||
let settings = load_settings(&app, &store)?;
|
||||
let Some(token) = settings.device_token else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(sync::me(&settings.server_url, &token).ok())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cloud_list_spaces(app: AppHandle, store: State<'_, Store>) -> Result<Vec<SpaceSummary>, String> {
|
||||
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||
sync::list_spaces(&server_url, &token)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cloud_create_space(app: AppHandle, store: State<'_, Store>, name: String) -> Result<SpaceSummary, String> {
|
||||
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||
sync::create_space(&server_url, &token, name.trim())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cloud_delete_space(app: AppHandle, store: State<'_, Store>, space_id: String) -> Result<(), String> {
|
||||
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||
sync::delete_space(&server_url, &token, &space_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cloud_clone_space(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
space_id: String,
|
||||
project_name: String,
|
||||
) -> Result<SyncReport, String> {
|
||||
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||
let project = project_name.trim().to_string();
|
||||
let dir = workspace_path(&app, &store, &project)?;
|
||||
if dir.exists() {
|
||||
return Err(format!("A project named '{}' already exists", project_name));
|
||||
}
|
||||
sync::clone_space(&server_url, &token, &store, &project, &dir, &space_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cloud_link_project(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
project: String,
|
||||
space_id: Option<String>,
|
||||
) -> Result<SyncReport, String> {
|
||||
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||
let (dir, mut meta) = load_project(&app, &store, &project)?;
|
||||
|
||||
let space_id = match space_id {
|
||||
Some(id) if !id.trim().is_empty() => id,
|
||||
_ => sync::create_space(&server_url, &token, &project)?.id,
|
||||
};
|
||||
|
||||
meta.space_id = Some(space_id);
|
||||
meta.base_hashes.clear();
|
||||
store.save_meta(&project, &meta)?;
|
||||
|
||||
sync::push_project(&server_url, &token, &store, &project, &dir, &mut meta)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cloud_unlink_project(app: AppHandle, store: State<'_, Store>, project: String) -> Result<(), String> {
|
||||
let (dir, mut meta) = load_project(&app, &store, &project)?;
|
||||
meta.space_id = None;
|
||||
meta.base_hashes.clear();
|
||||
meta.last_synced_at = None;
|
||||
let _ = dir;
|
||||
store.forget_project(&project)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cloud_push(app: AppHandle, store: State<'_, Store>, project: String) -> Result<SyncReport, String> {
|
||||
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||
let (dir, mut meta) = load_project(&app, &store, &project)?;
|
||||
sync::push_project(&server_url, &token, &store, &project, &dir, &mut meta)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cloud_pull(app: AppHandle, store: State<'_, Store>, project: String) -> Result<SyncReport, String> {
|
||||
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||
let (dir, mut meta) = load_project(&app, &store, &project)?;
|
||||
sync::pull_project(&server_url, &token, &store, &project, &dir, &mut meta)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cloud_sync(app: AppHandle, store: State<'_, Store>, project: String) -> Result<SyncReport, String> {
|
||||
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||
let (dir, mut meta) = load_project(&app, &store, &project)?;
|
||||
|
||||
let mut report = sync::pull_project(&server_url, &token, &store, &project, &dir, &mut meta)?;
|
||||
if !report.conflicts.is_empty() {
|
||||
return Ok(report);
|
||||
}
|
||||
|
||||
let pushed = sync::push_project(&server_url, &token, &store, &project, &dir, &mut meta)?;
|
||||
report.pushed = pushed.pushed;
|
||||
report.deleted_remote = pushed.deleted_remote;
|
||||
report.conflicts = pushed.conflicts;
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ResolutionRequest {
|
||||
pub path: String,
|
||||
pub content: String,
|
||||
pub server_hash: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cloud_resolve_conflicts(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
project: String,
|
||||
resolutions: Vec<ResolutionRequest>,
|
||||
) -> Result<SyncReport, String> {
|
||||
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||
let (dir, mut meta) = load_project(&app, &store, &project)?;
|
||||
|
||||
for resolution in &resolutions {
|
||||
sync::resolve_conflict(
|
||||
&store,
|
||||
&project,
|
||||
&dir,
|
||||
&mut meta,
|
||||
&resolution.path,
|
||||
&resolution.content,
|
||||
&resolution.server_hash,
|
||||
)?;
|
||||
}
|
||||
|
||||
sync::push_project(&server_url, &token, &store, &project, &dir, &mut meta)
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.setup(|app| {
|
||||
let store = Store::open(&app.handle())?;
|
||||
app.manage(store);
|
||||
Ok(())
|
||||
})
|
||||
.manage(LspState::default())
|
||||
.on_window_event(|window, event| {
|
||||
if matches!(event, tauri::WindowEvent::Destroyed) {
|
||||
if let Some(state) = window.app_handle().try_state::<LspState>() {
|
||||
state.stop();
|
||||
}
|
||||
}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_settings,
|
||||
update_settings,
|
||||
browse_workspace,
|
||||
create_folder_entry,
|
||||
create_document_entry,
|
||||
create_project_entry,
|
||||
rename_entry,
|
||||
delete_entry,
|
||||
upload_entry,
|
||||
target_info,
|
||||
read_target_file,
|
||||
write_target_file,
|
||||
set_target_entrypoint,
|
||||
compile_target,
|
||||
export_target,
|
||||
thumbnail,
|
||||
read_image,
|
||||
clear_thumbnails,
|
||||
list_assets,
|
||||
list_font_families,
|
||||
import_assets,
|
||||
delete_asset,
|
||||
import_into_target,
|
||||
import_into_folder,
|
||||
lsp_start,
|
||||
lsp_send,
|
||||
lsp_stop,
|
||||
lsp_running,
|
||||
cloud_login,
|
||||
cloud_logout,
|
||||
cloud_account,
|
||||
cloud_list_spaces,
|
||||
cloud_create_space,
|
||||
cloud_delete_space,
|
||||
cloud_clone_space,
|
||||
cloud_link_project,
|
||||
cloud_unlink_project,
|
||||
cloud_push,
|
||||
cloud_pull,
|
||||
cloud_sync,
|
||||
cloud_resolve_conflicts,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
use serde::Serialize;
|
||||
use std::io::{BufReader, Read, Write};
|
||||
use std::path::Path;
|
||||
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||
use std::sync::Mutex;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
pub const MESSAGE_EVENT: &str = "lsp://message";
|
||||
pub const CLOSED_EVENT: &str = "lsp://closed";
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct LspState {
|
||||
inner: Mutex<Option<Session>>,
|
||||
}
|
||||
|
||||
struct Session {
|
||||
child: Child,
|
||||
stdin: ChildStdin,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct LspHandle {
|
||||
pub root_uri: String,
|
||||
pub document_uri: String,
|
||||
}
|
||||
|
||||
fn file_uri(path: &Path) -> String {
|
||||
let text = path.to_string_lossy().replace('\\', "/");
|
||||
if text.starts_with('/') {
|
||||
format!("file://{}", text)
|
||||
} else {
|
||||
format!("file:///{}", text)
|
||||
}
|
||||
}
|
||||
|
||||
impl LspState {
|
||||
pub fn is_running(&self) -> bool {
|
||||
self.inner.lock().map(|slot| slot.is_some()).unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn start(
|
||||
&self,
|
||||
app: &AppHandle,
|
||||
root: &Path,
|
||||
entrypoint: &str,
|
||||
) -> Result<LspHandle, String> {
|
||||
self.stop();
|
||||
|
||||
let mut child = Command::new("tinymist")
|
||||
.arg("lsp")
|
||||
.current_dir(root)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"Could not start the Typst language server (tinymist): {}. \
|
||||
Install tinymist and make sure it is on your PATH.",
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
let stdin = child.stdin.take().ok_or("Language server has no stdin")?;
|
||||
let stdout = child.stdout.take().ok_or("Language server has no stdout")?;
|
||||
|
||||
let emitter = app.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut reader = BufReader::new(stdout);
|
||||
loop {
|
||||
match read_message(&mut reader) {
|
||||
Some(message) => {
|
||||
let _ = emitter.emit(MESSAGE_EVENT, message);
|
||||
}
|
||||
None => {
|
||||
let _ = emitter.emit(CLOSED_EVENT, ());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let handle = LspHandle {
|
||||
root_uri: file_uri(root),
|
||||
document_uri: file_uri(&root.join(entrypoint)),
|
||||
};
|
||||
|
||||
let mut slot = self.inner.lock().map_err(|_| "Language server lock poisoned")?;
|
||||
*slot = Some(Session { child, stdin });
|
||||
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
pub fn send(&self, message: &str) -> Result<(), String> {
|
||||
let mut slot = self.inner.lock().map_err(|_| "Language server lock poisoned")?;
|
||||
let session = slot.as_mut().ok_or("Language server is not running")?;
|
||||
|
||||
session
|
||||
.stdin
|
||||
.write_all(format!("Content-Length: {}\r\n\r\n", message.len()).as_bytes())
|
||||
.map_err(|e| e.to_string())?;
|
||||
session
|
||||
.stdin
|
||||
.write_all(message.as_bytes())
|
||||
.map_err(|e| e.to_string())?;
|
||||
session.stdin.flush().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn stop(&self) {
|
||||
if let Ok(mut slot) = self.inner.lock() {
|
||||
if let Some(mut session) = slot.take() {
|
||||
let _ = session.child.kill();
|
||||
let _ = session.child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_message(reader: &mut BufReader<std::process::ChildStdout>) -> Option<String> {
|
||||
let mut header = String::new();
|
||||
|
||||
loop {
|
||||
let mut byte = [0u8; 1];
|
||||
if reader.read_exact(&mut byte).is_err() {
|
||||
return None;
|
||||
}
|
||||
header.push(byte[0] as char);
|
||||
if header.ends_with("\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
if header.len() > 8192 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
let mut content_length = 0usize;
|
||||
for line in header.split("\r\n") {
|
||||
if let Some(value) = line.strip_prefix("Content-Length: ") {
|
||||
content_length = value.trim().parse().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
|
||||
if content_length == 0 {
|
||||
return Some(String::new());
|
||||
}
|
||||
|
||||
let mut body = vec![0u8; content_length];
|
||||
if reader.read_exact(&mut body).is_err() {
|
||||
return None;
|
||||
}
|
||||
|
||||
String::from_utf8(body).ok()
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
typst_desktop_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,572 @@
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::workspace::{
|
||||
collect_files, content_hash, is_text_file, project_file_path, ProjectMeta,
|
||||
};
|
||||
use crate::db::Store;
|
||||
|
||||
const REQUEST_TIMEOUT_SECS: u64 = 30;
|
||||
|
||||
fn agent() -> ureq::Agent {
|
||||
ureq::AgentBuilder::new()
|
||||
.timeout(std::time::Duration::from_secs(REQUEST_TIMEOUT_SECS))
|
||||
.build()
|
||||
}
|
||||
|
||||
fn endpoint(server_url: &str, path: &str) -> String {
|
||||
format!("{}/api/desktop{}", server_url.trim_end_matches('/'), path)
|
||||
}
|
||||
|
||||
fn describe(error: ureq::Error) -> String {
|
||||
match error {
|
||||
ureq::Error::Status(code, response) => {
|
||||
let body = response.into_string().unwrap_or_default();
|
||||
if body.is_empty() {
|
||||
format!("Server returned {}", code)
|
||||
} else {
|
||||
body
|
||||
}
|
||||
}
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone)]
|
||||
pub struct Account {
|
||||
pub user_id: String,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginResponse {
|
||||
pub token: String,
|
||||
pub user_id: String,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
pub fn login(
|
||||
server_url: &str,
|
||||
email: &str,
|
||||
password: &str,
|
||||
device_name: &str,
|
||||
) -> Result<LoginResponse, String> {
|
||||
agent()
|
||||
.post(&endpoint(server_url, "/auth/login"))
|
||||
.send_json(ureq::json!({
|
||||
"email": email,
|
||||
"password": password,
|
||||
"device_name": device_name,
|
||||
}))
|
||||
.map_err(describe)?
|
||||
.into_json::<LoginResponse>()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn logout(server_url: &str, token: &str) -> Result<(), String> {
|
||||
agent()
|
||||
.post(&endpoint(server_url, "/auth/logout"))
|
||||
.set("Authorization", &format!("Bearer {}", token))
|
||||
.call()
|
||||
.map_err(describe)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn me(server_url: &str, token: &str) -> Result<Account, String> {
|
||||
agent()
|
||||
.get(&endpoint(server_url, "/auth/me"))
|
||||
.set("Authorization", &format!("Bearer {}", token))
|
||||
.call()
|
||||
.map_err(describe)?
|
||||
.into_json::<Account>()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone)]
|
||||
pub struct SpaceSummary {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub entrypoint: String,
|
||||
pub role: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
pub fn list_spaces(server_url: &str, token: &str) -> Result<Vec<SpaceSummary>, String> {
|
||||
agent()
|
||||
.get(&endpoint(server_url, "/spaces"))
|
||||
.set("Authorization", &format!("Bearer {}", token))
|
||||
.call()
|
||||
.map_err(describe)?
|
||||
.into_json::<Vec<SpaceSummary>>()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn create_space(server_url: &str, token: &str, name: &str) -> Result<SpaceSummary, String> {
|
||||
agent()
|
||||
.post(&endpoint(server_url, "/spaces"))
|
||||
.set("Authorization", &format!("Bearer {}", token))
|
||||
.send_json(ureq::json!({ "name": name }))
|
||||
.map_err(describe)?
|
||||
.into_json::<SpaceSummary>()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn delete_space(server_url: &str, token: &str, space_id: &str) -> Result<(), String> {
|
||||
agent()
|
||||
.delete(&endpoint(server_url, &format!("/spaces/{}", space_id)))
|
||||
.set("Authorization", &format!("Bearer {}", token))
|
||||
.call()
|
||||
.map_err(describe)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ManifestEntry {
|
||||
pub path: String,
|
||||
pub kind: String,
|
||||
pub hash: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SpaceManifest {
|
||||
pub space_id: String,
|
||||
pub name: String,
|
||||
pub entrypoint: String,
|
||||
pub files: Vec<ManifestEntry>,
|
||||
}
|
||||
|
||||
pub fn get_manifest(
|
||||
server_url: &str,
|
||||
token: &str,
|
||||
space_id: &str,
|
||||
) -> Result<SpaceManifest, String> {
|
||||
agent()
|
||||
.get(&endpoint(
|
||||
server_url,
|
||||
&format!("/spaces/{}/manifest", space_id),
|
||||
))
|
||||
.set("Authorization", &format!("Bearer {}", token))
|
||||
.call()
|
||||
.map_err(describe)?
|
||||
.into_json::<SpaceManifest>()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct FileContent {
|
||||
pub path: String,
|
||||
pub kind: String,
|
||||
pub hash: String,
|
||||
pub encoding: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
impl FileContent {
|
||||
pub fn bytes(&self) -> Result<Vec<u8>, String> {
|
||||
if self.encoding == "base64" {
|
||||
BASE64
|
||||
.decode(self.content.as_bytes())
|
||||
.map_err(|e| format!("Invalid base64 from server: {}", e))
|
||||
} else {
|
||||
Ok(self.content.clone().into_bytes())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pull_file(
|
||||
server_url: &str,
|
||||
token: &str,
|
||||
space_id: &str,
|
||||
path: &str,
|
||||
) -> Result<FileContent, String> {
|
||||
agent()
|
||||
.get(&endpoint(server_url, &format!("/spaces/{}/file", space_id)))
|
||||
.query("path", path)
|
||||
.set("Authorization", &format!("Bearer {}", token))
|
||||
.call()
|
||||
.map_err(describe)?
|
||||
.into_json::<FileContent>()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ConflictBody {
|
||||
server_hash: String,
|
||||
encoding: String,
|
||||
server_content: String,
|
||||
}
|
||||
|
||||
pub enum PushResult {
|
||||
Applied,
|
||||
Conflict { server_hash: String, server_text: String },
|
||||
}
|
||||
|
||||
pub fn push_file(
|
||||
server_url: &str,
|
||||
token: &str,
|
||||
space_id: &str,
|
||||
path: &str,
|
||||
bytes: &[u8],
|
||||
base_hash: Option<&str>,
|
||||
) -> Result<PushResult, String> {
|
||||
let (encoding, content) = if is_text_file(path) {
|
||||
("utf8", String::from_utf8_lossy(bytes).to_string())
|
||||
} else {
|
||||
("base64", BASE64.encode(bytes))
|
||||
};
|
||||
|
||||
let response = agent()
|
||||
.put(&endpoint(server_url, &format!("/spaces/{}/file", space_id)))
|
||||
.set("Authorization", &format!("Bearer {}", token))
|
||||
.send_json(ureq::json!({
|
||||
"path": path,
|
||||
"content": content,
|
||||
"encoding": encoding,
|
||||
"base_hash": base_hash,
|
||||
}));
|
||||
|
||||
match response {
|
||||
Ok(_) => Ok(PushResult::Applied),
|
||||
Err(ureq::Error::Status(409, body)) => {
|
||||
let conflict = body
|
||||
.into_json::<ConflictBody>()
|
||||
.map_err(|e| format!("Malformed conflict response: {}", e))?;
|
||||
let server_text = if conflict.encoding == "base64" {
|
||||
String::new()
|
||||
} else {
|
||||
conflict.server_content
|
||||
};
|
||||
Ok(PushResult::Conflict {
|
||||
server_hash: conflict.server_hash,
|
||||
server_text,
|
||||
})
|
||||
}
|
||||
Err(other) => Err(describe(other)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_remote_file(
|
||||
server_url: &str,
|
||||
token: &str,
|
||||
space_id: &str,
|
||||
path: &str,
|
||||
) -> Result<(), String> {
|
||||
let response = agent()
|
||||
.delete(&endpoint(server_url, &format!("/spaces/{}/file", space_id)))
|
||||
.query("path", path)
|
||||
.set("Authorization", &format!("Bearer {}", token))
|
||||
.call();
|
||||
|
||||
match response {
|
||||
Ok(_) => Ok(()),
|
||||
Err(ureq::Error::Status(404, _)) => Ok(()),
|
||||
Err(other) => Err(describe(other)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct Conflict {
|
||||
pub path: String,
|
||||
pub local_text: String,
|
||||
pub remote_text: String,
|
||||
pub merged_text: String,
|
||||
pub server_hash: String,
|
||||
pub auto_merged: bool,
|
||||
pub binary: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Default)]
|
||||
pub struct SyncReport {
|
||||
pub pushed: Vec<String>,
|
||||
pub pulled: Vec<String>,
|
||||
pub deleted_local: Vec<String>,
|
||||
pub deleted_remote: Vec<String>,
|
||||
pub merged: Vec<String>,
|
||||
pub conflicts: Vec<Conflict>,
|
||||
}
|
||||
|
||||
fn read_local(project_dir: &Path, relative: &str) -> Result<Vec<u8>, String> {
|
||||
let full = project_file_path(project_dir, relative)?;
|
||||
std::fs::read(&full).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn write_local(project_dir: &Path, relative: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let full = project_file_path(project_dir, relative)?;
|
||||
if let Some(parent) = full.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
std::fs::write(&full, bytes).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn pull_project(
|
||||
server_url: &str,
|
||||
token: &str,
|
||||
store: &Store,
|
||||
project: &str,
|
||||
project_dir: &Path,
|
||||
meta: &mut ProjectMeta,
|
||||
) -> Result<SyncReport, String> {
|
||||
let space_id = meta
|
||||
.space_id
|
||||
.clone()
|
||||
.ok_or("Project is not linked to a cloud space")?;
|
||||
|
||||
let manifest = get_manifest(server_url, token, &space_id)?;
|
||||
let mut report = SyncReport::default();
|
||||
|
||||
let local_files: HashSet<String> = collect_files(project_dir)?.into_iter().collect();
|
||||
let mut remote_paths = HashSet::new();
|
||||
|
||||
for entry in &manifest.files {
|
||||
remote_paths.insert(entry.path.clone());
|
||||
|
||||
let base = meta.base_hashes.get(&entry.path).cloned();
|
||||
let local_exists = local_files.contains(&entry.path);
|
||||
|
||||
if !local_exists {
|
||||
if base.is_some() {
|
||||
continue;
|
||||
}
|
||||
let remote = pull_file(server_url, token, &space_id, &entry.path)?;
|
||||
write_local(project_dir, &entry.path, &remote.bytes()?)?;
|
||||
meta.base_hashes.insert(entry.path.clone(), remote.hash);
|
||||
report.pulled.push(entry.path.clone());
|
||||
continue;
|
||||
}
|
||||
|
||||
let local_bytes = read_local(project_dir, &entry.path)?;
|
||||
let local_hash = content_hash(&local_bytes);
|
||||
|
||||
if local_hash == entry.hash {
|
||||
meta.base_hashes.insert(entry.path.clone(), entry.hash.clone());
|
||||
continue;
|
||||
}
|
||||
|
||||
if base.as_deref() == Some(entry.hash.as_str()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let remote = pull_file(server_url, token, &space_id, &entry.path)?;
|
||||
let remote_bytes = remote.bytes()?;
|
||||
|
||||
if base.as_deref() == Some(local_hash.as_str()) {
|
||||
write_local(project_dir, &entry.path, &remote_bytes)?;
|
||||
meta.base_hashes.insert(entry.path.clone(), remote.hash);
|
||||
report.pulled.push(entry.path.clone());
|
||||
continue;
|
||||
}
|
||||
|
||||
if !is_text_file(&entry.path) || remote.kind == "binary" {
|
||||
report.conflicts.push(Conflict {
|
||||
path: entry.path.clone(),
|
||||
local_text: String::new(),
|
||||
remote_text: String::new(),
|
||||
merged_text: String::new(),
|
||||
server_hash: remote.hash,
|
||||
auto_merged: false,
|
||||
binary: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let local_text = String::from_utf8_lossy(&local_bytes).to_string();
|
||||
let remote_text = String::from_utf8_lossy(&remote_bytes).to_string();
|
||||
let base_text = read_base_snapshot(store, project, &entry.path);
|
||||
|
||||
match diffy::merge(&base_text, &local_text, &remote_text) {
|
||||
Ok(merged) => {
|
||||
write_local(project_dir, &entry.path, merged.as_bytes())?;
|
||||
meta.base_hashes
|
||||
.insert(entry.path.clone(), content_hash(merged.as_bytes()));
|
||||
report.merged.push(entry.path.clone());
|
||||
}
|
||||
Err(conflicted) => {
|
||||
report.conflicts.push(Conflict {
|
||||
path: entry.path.clone(),
|
||||
local_text,
|
||||
remote_text,
|
||||
merged_text: conflicted,
|
||||
server_hash: remote.hash,
|
||||
auto_merged: false,
|
||||
binary: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let vanished: Vec<String> = meta
|
||||
.base_hashes
|
||||
.keys()
|
||||
.filter(|path| !remote_paths.contains(*path) && local_files.contains(*path))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
for path in vanished {
|
||||
let local_bytes = read_local(project_dir, &path)?;
|
||||
let base = meta.base_hashes.get(&path).cloned().unwrap_or_default();
|
||||
if content_hash(&local_bytes) == base {
|
||||
let full = project_file_path(project_dir, &path)?;
|
||||
let _ = std::fs::remove_file(full);
|
||||
meta.base_hashes.remove(&path);
|
||||
report.deleted_local.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
meta.entrypoint = manifest.entrypoint;
|
||||
meta.last_synced_at = Some(chrono::Utc::now().to_rfc3339());
|
||||
store.save_meta(project, meta)?;
|
||||
save_base_snapshots(store, project, project_dir, meta)?;
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
pub fn push_project(
|
||||
server_url: &str,
|
||||
token: &str,
|
||||
store: &Store,
|
||||
project: &str,
|
||||
project_dir: &Path,
|
||||
meta: &mut ProjectMeta,
|
||||
) -> Result<SyncReport, String> {
|
||||
let space_id = meta
|
||||
.space_id
|
||||
.clone()
|
||||
.ok_or("Project is not linked to a cloud space")?;
|
||||
|
||||
let mut report = SyncReport::default();
|
||||
let local_files = collect_files(project_dir)?;
|
||||
let local_set: HashSet<String> = local_files.iter().cloned().collect();
|
||||
|
||||
for path in &local_files {
|
||||
let bytes = read_local(project_dir, path)?;
|
||||
let hash = content_hash(&bytes);
|
||||
let base = meta.base_hashes.get(path).cloned();
|
||||
|
||||
if base.as_deref() == Some(hash.as_str()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
match push_file(server_url, token, &space_id, path, &bytes, base.as_deref())? {
|
||||
PushResult::Applied => {
|
||||
meta.base_hashes.insert(path.clone(), hash);
|
||||
report.pushed.push(path.clone());
|
||||
}
|
||||
PushResult::Conflict {
|
||||
server_hash,
|
||||
server_text,
|
||||
} => {
|
||||
let binary = !is_text_file(path);
|
||||
report.conflicts.push(Conflict {
|
||||
path: path.clone(),
|
||||
local_text: if binary {
|
||||
String::new()
|
||||
} else {
|
||||
String::from_utf8_lossy(&bytes).to_string()
|
||||
},
|
||||
remote_text: server_text.clone(),
|
||||
merged_text: server_text,
|
||||
server_hash,
|
||||
auto_merged: false,
|
||||
binary,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let removed: Vec<String> = meta
|
||||
.base_hashes
|
||||
.keys()
|
||||
.filter(|path| !local_set.contains(*path))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
for path in removed {
|
||||
delete_remote_file(server_url, token, &space_id, &path)?;
|
||||
meta.base_hashes.remove(&path);
|
||||
report.deleted_remote.push(path);
|
||||
}
|
||||
|
||||
meta.last_synced_at = Some(chrono::Utc::now().to_rfc3339());
|
||||
store.save_meta(project, meta)?;
|
||||
save_base_snapshots(store, project, project_dir, meta)?;
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
pub fn clone_space(
|
||||
server_url: &str,
|
||||
token: &str,
|
||||
store: &Store,
|
||||
project: &str,
|
||||
project_dir: &Path,
|
||||
space_id: &str,
|
||||
) -> Result<SyncReport, String> {
|
||||
std::fs::create_dir_all(project_dir).map_err(|e| e.to_string())?;
|
||||
|
||||
let manifest = get_manifest(server_url, token, space_id)?;
|
||||
let mut meta = store.meta(project)?;
|
||||
meta.space_id = Some(space_id.to_string());
|
||||
meta.entrypoint = manifest.entrypoint.clone();
|
||||
|
||||
let mut report = SyncReport::default();
|
||||
|
||||
for entry in &manifest.files {
|
||||
let remote = pull_file(server_url, token, space_id, &entry.path)?;
|
||||
write_local(project_dir, &entry.path, &remote.bytes()?)?;
|
||||
meta.base_hashes.insert(entry.path.clone(), remote.hash);
|
||||
report.pulled.push(entry.path.clone());
|
||||
}
|
||||
|
||||
meta.last_synced_at = Some(chrono::Utc::now().to_rfc3339());
|
||||
store.save_meta(project, &meta)?;
|
||||
save_base_snapshots(store, project, project_dir, &meta)?;
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
pub fn save_base_snapshots(
|
||||
store: &Store,
|
||||
project: &str,
|
||||
project_dir: &Path,
|
||||
meta: &ProjectMeta,
|
||||
) -> Result<(), String> {
|
||||
for (path, base) in &meta.base_hashes {
|
||||
let full = project_file_path(project_dir, path)?;
|
||||
let Ok(bytes) = std::fs::read(&full) else {
|
||||
continue;
|
||||
};
|
||||
if content_hash(&bytes) == *base {
|
||||
store.save_base_snapshot(project, path, base, &bytes)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_base_snapshot(store: &Store, project: &str, relative: &str) -> String {
|
||||
store
|
||||
.base_snapshot(project, relative)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|bytes| String::from_utf8_lossy(&bytes).to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn resolve_conflict(
|
||||
store: &Store,
|
||||
project: &str,
|
||||
project_dir: &Path,
|
||||
meta: &mut ProjectMeta,
|
||||
path: &str,
|
||||
content: &str,
|
||||
server_hash: &str,
|
||||
) -> Result<(), String> {
|
||||
write_local(project_dir, path, content.as_bytes())?;
|
||||
meta.base_hashes
|
||||
.insert(path.to_string(), server_hash.to_string());
|
||||
store.save_meta(project, meta)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use std::time::UNIX_EPOCH;
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::assets::is_image;
|
||||
use crate::compiler;
|
||||
use crate::db::Store;
|
||||
use crate::workspace::{read_target_files, resolve_target, workspace_path};
|
||||
|
||||
const MAX_IMAGE_BYTES: u64 = 8 * 1024 * 1024;
|
||||
const MAX_VIEWER_BYTES: u64 = 64 * 1024 * 1024;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Thumbnail {
|
||||
pub kind: String,
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
fn modified_seconds(path: &Path) -> i64 {
|
||||
std::fs::metadata(path)
|
||||
.and_then(|meta| meta.modified())
|
||||
.ok()
|
||||
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|elapsed| elapsed.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn mime_for(name: &str) -> &'static str {
|
||||
let lower = name.to_lowercase();
|
||||
if lower.ends_with(".png") {
|
||||
"image/png"
|
||||
} else if lower.ends_with(".gif") {
|
||||
"image/gif"
|
||||
} else if lower.ends_with(".svg") {
|
||||
"image/svg+xml"
|
||||
} else if lower.ends_with(".webp") {
|
||||
"image/webp"
|
||||
} else {
|
||||
"image/jpeg"
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ImageData {
|
||||
pub name: String,
|
||||
pub data: String,
|
||||
pub size: u64,
|
||||
pub width: Option<u32>,
|
||||
pub height: Option<u32>,
|
||||
}
|
||||
|
||||
pub fn read_image(app: &AppHandle, store: &Store, path: &str) -> Result<ImageData, String> {
|
||||
let full = workspace_path(app, store, path)?;
|
||||
let name = full
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
if !is_image(&name) {
|
||||
return Err("Not an image file".to_string());
|
||||
}
|
||||
|
||||
let size = std::fs::metadata(&full).map(|m| m.len()).unwrap_or(0);
|
||||
if size > MAX_VIEWER_BYTES {
|
||||
return Err("Image is too large to open".to_string());
|
||||
}
|
||||
|
||||
let bytes = std::fs::read(&full).map_err(|e| e.to_string())?;
|
||||
let (width, height) = image_dimensions(&bytes);
|
||||
|
||||
Ok(ImageData {
|
||||
data: format!("data:{};base64,{}", mime_for(&name), BASE64.encode(&bytes)),
|
||||
name,
|
||||
size,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
|
||||
fn image_dimensions(bytes: &[u8]) -> (Option<u32>, Option<u32>) {
|
||||
if bytes.len() > 24 && bytes.starts_with(&[0x89, b'P', b'N', b'G']) {
|
||||
let width = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]);
|
||||
let height = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
|
||||
return (Some(width), Some(height));
|
||||
}
|
||||
|
||||
if bytes.len() > 10 && bytes.starts_with(&[0xFF, 0xD8]) {
|
||||
let mut index = 2usize;
|
||||
while index + 9 < bytes.len() {
|
||||
if bytes[index] != 0xFF {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
let marker = bytes[index + 1];
|
||||
if (0xC0..=0xCF).contains(&marker) && marker != 0xC4 && marker != 0xC8 && marker != 0xCC
|
||||
{
|
||||
let height = u16::from_be_bytes([bytes[index + 5], bytes[index + 6]]) as u32;
|
||||
let width = u16::from_be_bytes([bytes[index + 7], bytes[index + 8]]) as u32;
|
||||
return (Some(width), Some(height));
|
||||
}
|
||||
let length = u16::from_be_bytes([bytes[index + 2], bytes[index + 3]]) as usize;
|
||||
index += 2 + length;
|
||||
}
|
||||
}
|
||||
|
||||
(None, None)
|
||||
}
|
||||
|
||||
pub fn thumbnail(app: &AppHandle, store: &Store, path: &str) -> Result<Thumbnail, String> {
|
||||
let full = workspace_path(app, store, path)?;
|
||||
let name = full
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let image = is_image(&name);
|
||||
if !image && !name.to_lowercase().ends_with(".typ") {
|
||||
return Err("No preview available".to_string());
|
||||
}
|
||||
|
||||
let modified = modified_seconds(&full);
|
||||
|
||||
if let Some((kind, data)) = store.thumbnail(path, modified)? {
|
||||
return Ok(Thumbnail { kind, data });
|
||||
}
|
||||
|
||||
let thumbnail = if image {
|
||||
let size = std::fs::metadata(&full).map(|m| m.len()).unwrap_or(0);
|
||||
if size > MAX_IMAGE_BYTES {
|
||||
return Err("Image is too large to preview".to_string());
|
||||
}
|
||||
let bytes = std::fs::read(&full).map_err(|e| e.to_string())?;
|
||||
Thumbnail {
|
||||
kind: "image".to_string(),
|
||||
data: format!("data:{};base64,{}", mime_for(&name), BASE64.encode(&bytes)),
|
||||
}
|
||||
} else {
|
||||
let target = resolve_target(app, store, path)?;
|
||||
let files = read_target_files(app, store, &target)?;
|
||||
|
||||
let result = compiler::compile_to_svg(target.entrypoint, files)
|
||||
.map_err(|_| "Document does not compile".to_string())?;
|
||||
|
||||
let svg = result
|
||||
.pages
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or("Document has no pages")?;
|
||||
|
||||
Thumbnail {
|
||||
kind: "svg".to_string(),
|
||||
data: svg,
|
||||
}
|
||||
};
|
||||
|
||||
store.save_thumbnail(path, &thumbnail.kind, &thumbnail.data, modified)?;
|
||||
Ok(thumbnail)
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use crate::db::Store;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
pub const PROJECT_META_FILE: &str = ".typst-desktop.json";
|
||||
|
||||
const TEXT_EXTENSIONS: [&str; 10] = [
|
||||
"typ", "toml", "bib", "csl", "yml", "yaml", "json", "md", "txt", "csv",
|
||||
];
|
||||
|
||||
pub fn is_text_file(path: &str) -> bool {
|
||||
Path::new(path)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| TEXT_EXTENSIONS.contains(&ext.to_lowercase().as_str()))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn content_hash(bytes: &[u8]) -> String {
|
||||
format!("{:x}", Sha256::digest(bytes))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct Settings {
|
||||
pub workspace_root: String,
|
||||
pub server_url: String,
|
||||
#[serde(default)]
|
||||
pub device_token: Option<String>,
|
||||
#[serde(default)]
|
||||
pub account_email: Option<String>,
|
||||
#[serde(default)]
|
||||
pub account_username: Option<String>,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
pub fn fallback(app: &AppHandle) -> Self {
|
||||
let home = app
|
||||
.path()
|
||||
.home_dir()
|
||||
.unwrap_or_else(|_| PathBuf::from("."));
|
||||
Settings {
|
||||
workspace_root: home.join("typst").to_string_lossy().to_string(),
|
||||
server_url: "http://localhost:3000".to_string(),
|
||||
device_token: None,
|
||||
account_email: None,
|
||||
account_username: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_settings(app: &AppHandle, store: &Store) -> Result<Settings, String> {
|
||||
match store.settings()? {
|
||||
Some(settings) => Ok(settings),
|
||||
None => {
|
||||
let settings = Settings::fallback(app);
|
||||
store.save_settings(&settings)?;
|
||||
Ok(settings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_settings(store: &Store, settings: &Settings) -> Result<(), String> {
|
||||
store.save_settings(settings)
|
||||
}
|
||||
|
||||
pub fn workspace_root(app: &AppHandle, store: &Store) -> Result<PathBuf, String> {
|
||||
let settings = load_settings(app, store)?;
|
||||
let root = PathBuf::from(&settings.workspace_root);
|
||||
std::fs::create_dir_all(&root)
|
||||
.map_err(|e| format!("Cannot create workspace directory: {}", e))?;
|
||||
Ok(root)
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct ProjectMeta {
|
||||
pub entrypoint: String,
|
||||
pub space_id: Option<String>,
|
||||
pub last_synced_at: Option<String>,
|
||||
pub base_hashes: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Default for ProjectMeta {
|
||||
fn default() -> Self {
|
||||
ProjectMeta {
|
||||
entrypoint: "main.typ".to_string(),
|
||||
space_id: None,
|
||||
last_synced_at: None,
|
||||
base_hashes: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn workspace_path(app: &AppHandle, store: &Store, relative: &str) -> Result<PathBuf, String> {
|
||||
let root = workspace_root(app, store)?;
|
||||
if relative.is_empty() {
|
||||
return Ok(root);
|
||||
}
|
||||
project_file_path(&root, relative)
|
||||
}
|
||||
|
||||
pub fn is_project_dir(path: &Path) -> bool {
|
||||
path.join(PROJECT_META_FILE).exists() || path.join("typst.toml").exists()
|
||||
}
|
||||
|
||||
pub fn is_typst_file(path: &str) -> bool {
|
||||
path.to_lowercase().ends_with(".typ")
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct BrowseEntry {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub kind: String,
|
||||
pub size: u64,
|
||||
pub modified: Option<String>,
|
||||
pub space_id: Option<String>,
|
||||
pub last_synced_at: Option<String>,
|
||||
pub child_count: usize,
|
||||
}
|
||||
|
||||
fn modified_at(path: &Path) -> Option<String> {
|
||||
let modified = std::fs::metadata(path).ok()?.modified().ok()?;
|
||||
let datetime: chrono::DateTime<chrono::Utc> = modified.into();
|
||||
Some(datetime.to_rfc3339())
|
||||
}
|
||||
|
||||
pub fn browse(app: &AppHandle, store: &Store, relative: &str) -> Result<Vec<BrowseEntry>, String> {
|
||||
let dir = workspace_path(app, store, relative)?;
|
||||
if !dir.is_dir() {
|
||||
return Err(format!("'{}' is not a folder", relative));
|
||||
}
|
||||
|
||||
let prefix = if relative.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("{}/", relative.trim_end_matches('/'))
|
||||
};
|
||||
|
||||
let mut entries = Vec::new();
|
||||
|
||||
for entry in std::fs::read_dir(&dir).map_err(|e| e.to_string())? {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = format!("{}{}", prefix, name);
|
||||
let full = entry.path();
|
||||
|
||||
if full.is_dir() {
|
||||
let project = is_project_dir(&full) || store.has_project(&path)?;
|
||||
let meta = if project {
|
||||
Some(store.meta(&path)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let child_count = std::fs::read_dir(&full)
|
||||
.map(|children| {
|
||||
children
|
||||
.filter_map(|child| child.ok())
|
||||
.filter(|child| {
|
||||
!child.file_name().to_string_lossy().starts_with('.')
|
||||
})
|
||||
.count()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
entries.push(BrowseEntry {
|
||||
name,
|
||||
path,
|
||||
kind: if project { "project" } else { "folder" }.to_string(),
|
||||
size: 0,
|
||||
modified: modified_at(&full),
|
||||
space_id: meta.as_ref().and_then(|m| m.space_id.clone()),
|
||||
last_synced_at: meta.as_ref().and_then(|m| m.last_synced_at.clone()),
|
||||
child_count,
|
||||
});
|
||||
} else {
|
||||
let kind = if is_typst_file(&name) { "document" } else { "file" };
|
||||
entries.push(BrowseEntry {
|
||||
name,
|
||||
path,
|
||||
kind: kind.to_string(),
|
||||
size: std::fs::metadata(&full).map(|m| m.len()).unwrap_or(0),
|
||||
modified: modified_at(&full),
|
||||
space_id: None,
|
||||
last_synced_at: None,
|
||||
child_count: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
entries.sort_by(|a, b| {
|
||||
let rank = |kind: &str| match kind {
|
||||
"project" => 0,
|
||||
"folder" => 1,
|
||||
"document" => 2,
|
||||
_ => 3,
|
||||
};
|
||||
rank(&a.kind)
|
||||
.cmp(&rank(&b.kind))
|
||||
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||
});
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
pub struct Target {
|
||||
pub root: PathBuf,
|
||||
pub entrypoint: String,
|
||||
pub standalone: bool,
|
||||
}
|
||||
|
||||
pub fn resolve_target(app: &AppHandle, store: &Store, path: &str) -> Result<Target, String> {
|
||||
let full = workspace_path(app, store, path)?;
|
||||
|
||||
if full.is_dir() {
|
||||
let meta = store.meta(path)?;
|
||||
return Ok(Target {
|
||||
root: full,
|
||||
entrypoint: meta.entrypoint,
|
||||
standalone: false,
|
||||
});
|
||||
}
|
||||
|
||||
if !full.is_file() {
|
||||
return Err(format!("'{}' does not exist", path));
|
||||
}
|
||||
|
||||
let parent = full
|
||||
.parent()
|
||||
.ok_or("File has no parent folder")?
|
||||
.to_path_buf();
|
||||
let name = full
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.ok_or("File has no name")?;
|
||||
|
||||
Ok(Target {
|
||||
root: parent,
|
||||
entrypoint: name,
|
||||
standalone: true,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read_target_files(
|
||||
app: &AppHandle,
|
||||
store: &Store,
|
||||
target: &Target,
|
||||
) -> Result<HashMap<String, Vec<u8>>, String> {
|
||||
let mut map = crate::assets::asset_files(app, store);
|
||||
|
||||
if !target.standalone {
|
||||
for (path, bytes) in read_all_files(&target.root)? {
|
||||
map.insert(path, bytes);
|
||||
}
|
||||
return Ok(map);
|
||||
}
|
||||
|
||||
collect_loose_files(&target.root, "", &mut map)?;
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
fn collect_loose_files(
|
||||
dir: &Path,
|
||||
prefix: &str,
|
||||
map: &mut HashMap<String, Vec<u8>>,
|
||||
) -> Result<(), String> {
|
||||
for entry in std::fs::read_dir(dir).map_err(|e| e.to_string())? {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = entry.path();
|
||||
let key = if prefix.is_empty() {
|
||||
name
|
||||
} else {
|
||||
format!("{}/{}", prefix, name)
|
||||
};
|
||||
|
||||
if path.is_dir() {
|
||||
if is_project_dir(&path) {
|
||||
continue;
|
||||
}
|
||||
collect_loose_files(&path, &key, map)?;
|
||||
} else if let Ok(bytes) = std::fs::read(&path) {
|
||||
map.insert(key, bytes);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn project_file_path(project_dir: &Path, relative: &str) -> Result<PathBuf, String> {
|
||||
if relative.is_empty() {
|
||||
return Err("Path cannot be empty".to_string());
|
||||
}
|
||||
|
||||
let mut resolved = project_dir.to_path_buf();
|
||||
for component in relative.replace('\\', "/").split('/') {
|
||||
if component.is_empty() || component == "." {
|
||||
continue;
|
||||
}
|
||||
if component == ".." {
|
||||
return Err("Path cannot escape the project".to_string());
|
||||
}
|
||||
resolved.push(component);
|
||||
}
|
||||
|
||||
if !resolved.starts_with(project_dir) {
|
||||
return Err("Path cannot escape the project".to_string());
|
||||
}
|
||||
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
pub fn relative_path(project_dir: &Path, path: &Path) -> Option<String> {
|
||||
path.strip_prefix(project_dir)
|
||||
.ok()
|
||||
.map(|rest| rest.to_string_lossy().replace('\\', "/"))
|
||||
}
|
||||
|
||||
pub fn collect_files(project_dir: &Path) -> Result<Vec<String>, String> {
|
||||
let mut files = Vec::new();
|
||||
|
||||
for entry in WalkDir::new(project_dir).into_iter().filter_map(|e| e.ok()) {
|
||||
if !entry.file_type().is_file() {
|
||||
continue;
|
||||
}
|
||||
let Some(relative) = relative_path(project_dir, entry.path()) else {
|
||||
continue;
|
||||
};
|
||||
if relative == PROJECT_META_FILE || relative.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
files.push(relative);
|
||||
}
|
||||
|
||||
files.sort();
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
pub fn read_all_files(project_dir: &Path) -> Result<HashMap<String, Vec<u8>>, String> {
|
||||
let mut map = HashMap::new();
|
||||
for relative in collect_files(project_dir)? {
|
||||
let full = project_file_path(project_dir, &relative)?;
|
||||
let bytes = std::fs::read(&full).map_err(|e| e.to_string())?;
|
||||
map.insert(relative, bytes);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct FileEntry {
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
pub is_text: bool,
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
pub fn list_files(project_dir: &Path) -> Result<Vec<FileEntry>, String> {
|
||||
let mut entries = Vec::new();
|
||||
for relative in collect_files(project_dir)? {
|
||||
let full = project_file_path(project_dir, &relative)?;
|
||||
let size = std::fs::metadata(&full).map(|m| m.len()).unwrap_or(0);
|
||||
let name = relative
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or(&relative)
|
||||
.to_string();
|
||||
entries.push(FileEntry {
|
||||
is_text: is_text_file(&relative),
|
||||
path: relative,
|
||||
name,
|
||||
size,
|
||||
});
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
pub const NEW_PROJECT_MAIN: &str = "= New Project\n\nStart writing here.\n";
|
||||
|
||||
pub fn manifest_for(name: &str) -> String {
|
||||
let slug: String = name
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
|
||||
.collect();
|
||||
let slug = slug.trim_matches('-').to_string();
|
||||
let slug = if slug.is_empty() {
|
||||
"my-project".to_string()
|
||||
} else {
|
||||
slug
|
||||
};
|
||||
|
||||
format!(
|
||||
"[package]\nname = \"{slug}\"\nversion = \"0.1.0\"\nentrypoint = \"main.typ\"\nauthors = [\"Anonymous\"]\nlicense = \"MIT\"\ndescription = \"\"\n"
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
use chrono::Datelike;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use typst::diag::{FileError, FileResult};
|
||||
use typst::foundations::{Bytes, Datetime, Duration};
|
||||
use typst::syntax::{FileId, RootedPath, Source, VirtualPath, VirtualRoot};
|
||||
use typst::text::{Font, FontBook};
|
||||
use typst::World;
|
||||
use typst::{Library, LibraryExt};
|
||||
use typst_kit::downloader::SystemDownloader;
|
||||
use typst_kit::packages::SystemPackages;
|
||||
|
||||
pub struct ProjectWorld {
|
||||
library: typst::utils::LazyHash<Library>,
|
||||
main: FileId,
|
||||
files: HashMap<String, Vec<u8>>,
|
||||
book: typst::utils::LazyHash<FontBook>,
|
||||
fonts: Vec<Font>,
|
||||
packages: SystemPackages,
|
||||
}
|
||||
|
||||
fn normalize_path(path: &str) -> String {
|
||||
path.trim_start_matches('/').replace('\\', "/")
|
||||
}
|
||||
|
||||
impl ProjectWorld {
|
||||
pub fn new(entrypoint: String, files: HashMap<String, Vec<u8>>, enable_html: bool) -> Self {
|
||||
let main = FileId::new(RootedPath::new(
|
||||
VirtualRoot::Project,
|
||||
VirtualPath::new(&entrypoint).unwrap_or_else(|_| VirtualPath::new("main.typ").unwrap()),
|
||||
));
|
||||
|
||||
let downloader = SystemDownloader::new("TypstDesktop (typst-kit)");
|
||||
let packages = SystemPackages::new(downloader);
|
||||
|
||||
let mut book = FontBook::new();
|
||||
let mut fonts = Vec::new();
|
||||
|
||||
for data in typst_assets::fonts() {
|
||||
let buffer = Bytes::new(data);
|
||||
for font in Font::iter(buffer) {
|
||||
book.push(font.info().clone());
|
||||
fonts.push(font);
|
||||
}
|
||||
}
|
||||
|
||||
for (name, data) in &files {
|
||||
let lower = name.to_lowercase();
|
||||
if [".ttf", ".otf", ".ttc", ".otc"]
|
||||
.iter()
|
||||
.any(|ext| lower.ends_with(ext))
|
||||
{
|
||||
for font in Font::iter(Bytes::new(data.clone())) {
|
||||
book.push(font.info().clone());
|
||||
fonts.push(font);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let library = if enable_html {
|
||||
Library::builder()
|
||||
.with_features([typst::Feature::Html].into_iter().collect())
|
||||
.build()
|
||||
} else {
|
||||
Library::builder().build()
|
||||
};
|
||||
|
||||
Self {
|
||||
library: typst::utils::LazyHash::new(library),
|
||||
main,
|
||||
files,
|
||||
book: typst::utils::LazyHash::new(book),
|
||||
fonts,
|
||||
packages,
|
||||
}
|
||||
}
|
||||
|
||||
fn load_bytes(&self, id: FileId) -> FileResult<Vec<u8>> {
|
||||
let path = normalize_path(id.vpath().get_without_slash());
|
||||
|
||||
if let VirtualRoot::Package(package) = id.root() {
|
||||
let root = self
|
||||
.packages
|
||||
.obtain(package)
|
||||
.map_err(|e| FileError::Other(Some(e.to_string().into())))?;
|
||||
return root.load(id.vpath()).map(|bytes| bytes.to_vec());
|
||||
}
|
||||
|
||||
self.files
|
||||
.get(&path)
|
||||
.cloned()
|
||||
.ok_or_else(|| FileError::NotFound(path.into()))
|
||||
}
|
||||
}
|
||||
|
||||
impl World for ProjectWorld {
|
||||
fn library(&self) -> &typst::utils::LazyHash<Library> {
|
||||
&self.library
|
||||
}
|
||||
|
||||
fn book(&self) -> &typst::utils::LazyHash<FontBook> {
|
||||
&self.book
|
||||
}
|
||||
|
||||
fn main(&self) -> FileId {
|
||||
self.main
|
||||
}
|
||||
|
||||
fn source(&self, id: FileId) -> FileResult<Source> {
|
||||
let data = self.load_bytes(id)?;
|
||||
let text = std::str::from_utf8(&data)
|
||||
.map_err(|_| FileError::InvalidUtf8)?
|
||||
.to_owned();
|
||||
Ok(Source::new(id, text))
|
||||
}
|
||||
|
||||
fn file(&self, id: FileId) -> FileResult<Bytes> {
|
||||
let data = self.load_bytes(id)?;
|
||||
Ok(Bytes::new(data))
|
||||
}
|
||||
|
||||
fn font(&self, index: usize) -> Option<Font> {
|
||||
self.fonts.get(index).cloned()
|
||||
}
|
||||
|
||||
fn today(&self, offset: Option<Duration>) -> Option<Datetime> {
|
||||
let now = chrono::Local::now();
|
||||
let date = if let Some(offset) = offset {
|
||||
let offset_secs = offset.hours() as i32 * 3600;
|
||||
let offset_chrono = chrono::FixedOffset::east_opt(offset_secs)?;
|
||||
now.with_timezone(&offset_chrono).date_naive()
|
||||
} else {
|
||||
now.date_naive()
|
||||
};
|
||||
|
||||
Datetime::from_ymd(date.year(), date.month() as u8, date.day() as u8)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "typst-desktop",
|
||||
"version": "0.1.0",
|
||||
"identifier": "co.sirblob.typst-desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "bun run build",
|
||||
"frontendDist": "../build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Typst Desktop",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"minWidth": 900,
|
||||
"minHeight": 600,
|
||||
"decorations": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/[email protected]",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-muted: #f6f7f9;
|
||||
--color-surface-sunken: #eceef1;
|
||||
--color-line: #dfe2e7;
|
||||
--color-ink: #14161a;
|
||||
--color-ink-muted: #5f6672;
|
||||
--color-accent: #3b6cf6;
|
||||
--color-accent-soft: #e8eefe;
|
||||
--color-danger: #d5382f;
|
||||
--color-success: #1f8a4c;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--color-surface: #16181d;
|
||||
--color-surface-muted: #1d2026;
|
||||
--color-surface-sunken: #24282f;
|
||||
--color-line: #2f343d;
|
||||
--color-ink: #eef0f4;
|
||||
--color-ink-muted: #969ead;
|
||||
--color-accent: #6b93ff;
|
||||
--color-accent-soft: #1e2a45;
|
||||
--color-danger: #f4736a;
|
||||
--color-success: #4cc47f;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-surface-muted);
|
||||
color: var(--color-ink);
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.scroll-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-line) transparent;
|
||||
}
|
||||
|
||||
.cm-editor {
|
||||
height: 100%;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.cm-editor .cm-scroller {
|
||||
font-family: ui-monospace, "SF Mono", "JetBrains Mono", monospace;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.cm-editor.cm-focused {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.preview-page svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Typst Desktop</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,177 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import Modal from "./Modal.svelte";
|
||||
import * as api from "$lib/ts/api";
|
||||
import type { Asset } from "$lib/ts/api";
|
||||
import { pickFiles } from "$lib/ts/import";
|
||||
|
||||
interface Props {
|
||||
oninsert?: (snippet: string) => void;
|
||||
onchanged?: () => void;
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let { oninsert, onchanged, onclose }: Props = $props();
|
||||
|
||||
let assets = $state<Asset[]>([]);
|
||||
let busy = $state(false);
|
||||
let error = $state("");
|
||||
let copied = $state<string | null>(null);
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
assets = await api.listAssets();
|
||||
} catch (caught) {
|
||||
error = api.errorMessage(caught);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
refresh();
|
||||
});
|
||||
|
||||
async function importFiles() {
|
||||
const sources = await pickFiles("assets");
|
||||
if (sources.length === 0) return;
|
||||
|
||||
busy = true;
|
||||
error = "";
|
||||
try {
|
||||
await api.importAssets(sources);
|
||||
await refresh();
|
||||
onchanged?.();
|
||||
} catch (caught) {
|
||||
error = api.errorMessage(caught);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(asset: Asset) {
|
||||
try {
|
||||
await api.deleteAsset(asset.name);
|
||||
await refresh();
|
||||
onchanged?.();
|
||||
} catch (caught) {
|
||||
error = api.errorMessage(caught);
|
||||
}
|
||||
}
|
||||
|
||||
function insert(asset: Asset) {
|
||||
if (asset.kind === "image") {
|
||||
oninsert?.(`#image("${asset.name}")`);
|
||||
} else if (asset.font_families.length > 0) {
|
||||
oninsert?.(`#set text(font: "${asset.font_families[0]}")`);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyFamily(family: string) {
|
||||
await navigator.clipboard.writeText(family);
|
||||
copied = family;
|
||||
setTimeout(() => (copied = null), 1200);
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const iconFor: Record<Asset["kind"], string> = {
|
||||
image: "ph:image",
|
||||
font: "ph:text-aa",
|
||||
file: "ph:file",
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal title="Images and fonts" icon="ph:images" width="max-w-2xl" {onclose}>
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-xs text-[var(--color-ink-muted)]">
|
||||
Files imported here are available to every project. Reference an image by
|
||||
its file name, and a font by its family name.
|
||||
</p>
|
||||
|
||||
{#if error}
|
||||
<p class="text-xs text-[var(--color-danger)]">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if assets.length === 0}
|
||||
<div
|
||||
class="flex flex-col items-center gap-3 rounded-lg border border-dashed border-[var(--color-line)] px-4 py-10 text-center"
|
||||
>
|
||||
<Icon icon="ph:image-square" class="text-4xl text-[var(--color-ink-muted)]" />
|
||||
<p class="text-xs text-[var(--color-ink-muted)]">
|
||||
No images or fonts imported yet.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each assets as asset (asset.name)}
|
||||
<div
|
||||
class="group flex items-center gap-3 rounded-md border border-[var(--color-line)] px-3 py-2"
|
||||
>
|
||||
<Icon
|
||||
icon={iconFor[asset.kind]}
|
||||
class="text-lg text-[var(--color-accent)]"
|
||||
/>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-xs font-medium">{asset.name}</p>
|
||||
{#if asset.font_families.length > 0}
|
||||
<div class="mt-0.5 flex flex-wrap gap-1">
|
||||
{#each asset.font_families as family}
|
||||
<button
|
||||
class="rounded bg-[var(--color-surface-sunken)] px-1.5 py-px text-[10px] text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]"
|
||||
onclick={() => copyFamily(family)}
|
||||
title="Copy family name"
|
||||
>
|
||||
{copied === family ? "Copied" : family}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-[10px] text-[var(--color-ink-muted)]">
|
||||
{formatSize(asset.size)}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if oninsert && (asset.kind === "image" || asset.font_families.length > 0)}
|
||||
<button
|
||||
class="rounded border border-[var(--color-line)] px-2 py-1 text-[10px] opacity-0 transition group-hover:opacity-100 hover:bg-[var(--color-surface-muted)]"
|
||||
onclick={() => insert(asset)}
|
||||
>
|
||||
Insert
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="rounded p-1 text-[var(--color-ink-muted)] opacity-0 transition group-hover:opacity-100 hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-danger)]"
|
||||
onclick={() => remove(asset)}
|
||||
aria-label="Delete"
|
||||
>
|
||||
<Icon icon="ph:trash" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#snippet footer()}
|
||||
<button
|
||||
class="rounded-md px-3 py-1.5 text-xs text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)]"
|
||||
onclick={onclose}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1.5 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={busy}
|
||||
onclick={importFiles}
|
||||
>
|
||||
<Icon icon="ph:upload-simple" />
|
||||
{busy ? "Importing..." : "Import files"}
|
||||
</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import Modal from "./Modal.svelte";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
onconfirm: () => void;
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
title,
|
||||
message,
|
||||
confirmLabel = "Delete",
|
||||
onconfirm,
|
||||
onclose,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<Modal {title} icon="ph:warning-circle" {onclose}>
|
||||
<p class="text-sm text-[var(--color-ink-muted)]">{message}</p>
|
||||
|
||||
{#snippet footer()}
|
||||
<button
|
||||
class="rounded-md px-3 py-1.5 text-xs text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)]"
|
||||
onclick={onclose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="rounded-md bg-[var(--color-danger)] px-3 py-1.5 text-xs font-medium text-white transition hover:opacity-90"
|
||||
onclick={onconfirm}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
@@ -0,0 +1,147 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import Modal from "./Modal.svelte";
|
||||
import type { Conflict, Resolution } from "$lib/ts/api";
|
||||
|
||||
interface Props {
|
||||
conflicts: Conflict[];
|
||||
onresolve: (resolutions: Resolution[]) => void;
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let { conflicts, onresolve, onclose }: Props = $props();
|
||||
|
||||
let index = $state(0);
|
||||
let choices = $state<string[]>([]);
|
||||
let mode = $state<("merged" | "local" | "remote")[]>([]);
|
||||
|
||||
$effect(() => {
|
||||
choices = conflicts.map((conflict) =>
|
||||
conflict.binary ? conflict.remote_text : conflict.merged_text,
|
||||
);
|
||||
mode = conflicts.map(() => "merged");
|
||||
index = 0;
|
||||
});
|
||||
|
||||
const current = $derived(conflicts[index]);
|
||||
|
||||
function choose(option: "merged" | "local" | "remote") {
|
||||
mode[index] = option;
|
||||
choices[index] =
|
||||
option === "local"
|
||||
? current.local_text
|
||||
: option === "remote"
|
||||
? current.remote_text
|
||||
: current.merged_text;
|
||||
}
|
||||
|
||||
const unresolvedMarkers = $derived(
|
||||
choices[index]?.includes("<<<<<<<") ?? false,
|
||||
);
|
||||
|
||||
const anyUnresolved = $derived(
|
||||
choices.some((text) => text.includes("<<<<<<<")),
|
||||
);
|
||||
|
||||
function submit() {
|
||||
onresolve(
|
||||
conflicts.map((conflict, position) => ({
|
||||
path: conflict.path,
|
||||
content: choices[position],
|
||||
server_hash: conflict.server_hash,
|
||||
})),
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
title="Resolve sync conflicts"
|
||||
icon="ph:git-merge"
|
||||
width="max-w-4xl"
|
||||
{onclose}
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-xs text-[var(--color-ink-muted)]">
|
||||
These files changed both on this device and in the cloud. Pick a version or
|
||||
edit the merged result, then save to upload your resolution.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each conflicts as conflict, position}
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs transition
|
||||
{position === index
|
||||
? '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={() => (index = position)}
|
||||
>
|
||||
{#if choices[position].includes("<<<<<<<")}
|
||||
<Icon icon="ph:warning" class="text-[var(--color-danger)]" />
|
||||
{:else}
|
||||
<Icon icon="ph:check-circle" class="text-[var(--color-success)]" />
|
||||
{/if}
|
||||
{conflict.path}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if current}
|
||||
{#if current.binary}
|
||||
<div
|
||||
class="rounded-md border border-[var(--color-line)] bg-[var(--color-surface-muted)] p-4 text-xs"
|
||||
>
|
||||
<p class="font-medium">{current.path}</p>
|
||||
<p class="mt-1 text-[var(--color-ink-muted)]">
|
||||
This is a binary file and cannot be merged automatically. The cloud
|
||||
version will be kept.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-1.5">
|
||||
{#each [["merged", "Merged"], ["local", "This device"], ["remote", "Cloud"]] as [option, label]}
|
||||
<button
|
||||
class="rounded-md border px-3 py-1.5 text-xs transition
|
||||
{mode[index] === option
|
||||
? '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={() => choose(option as "merged" | "local" | "remote")}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if unresolvedMarkers}
|
||||
<span
|
||||
class="ml-2 flex items-center gap-1 text-xs text-[var(--color-danger)]"
|
||||
>
|
||||
<Icon icon="ph:warning" />
|
||||
Conflict markers still present
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
class="scroll-thin h-80 w-full resize-none rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] p-3 font-mono text-xs leading-relaxed focus:border-[var(--color-accent)] focus:outline-none"
|
||||
bind:value={choices[index]}
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#snippet footer()}
|
||||
<button
|
||||
class="rounded-md px-3 py-1.5 text-xs text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)]"
|
||||
onclick={onclose}
|
||||
>
|
||||
Cancel
|
||||
</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"
|
||||
disabled={anyUnresolved}
|
||||
onclick={submit}
|
||||
>
|
||||
Save and upload
|
||||
</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
@@ -0,0 +1,230 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import {
|
||||
EditorView,
|
||||
keymap,
|
||||
lineNumbers,
|
||||
highlightActiveLine,
|
||||
} from "@codemirror/view";
|
||||
import { EditorState, Compartment } from "@codemirror/state";
|
||||
import {
|
||||
defaultKeymap,
|
||||
history,
|
||||
historyKeymap,
|
||||
indentWithTab,
|
||||
} from "@codemirror/commands";
|
||||
import {
|
||||
bracketMatching,
|
||||
indentOnInput,
|
||||
Language,
|
||||
StreamLanguage,
|
||||
} from "@codemirror/language";
|
||||
import { toml } from "@codemirror/legacy-modes/mode/toml";
|
||||
import {
|
||||
autocompletion,
|
||||
closeBrackets,
|
||||
closeBracketsKeymap,
|
||||
} from "@codemirror/autocomplete";
|
||||
import { lintGutter, setDiagnostics } from "@codemirror/lint";
|
||||
import { LSPClient, languageServerExtensions } from "@codemirror/lsp-client";
|
||||
|
||||
import { typstCompletions } from "$lib/ts/completions";
|
||||
import { editorTheme } from "$lib/ts/editor-theme";
|
||||
import { LspBridge } from "$lib/ts/lsp";
|
||||
import { app } from "$lib/ts/state.svelte";
|
||||
import type { Diagnostic } from "$lib/ts/api";
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
filePath: string;
|
||||
targetPath: string;
|
||||
enableLsp?: boolean;
|
||||
diagnostics?: Diagnostic[];
|
||||
onchange: (value: string) => void;
|
||||
onsave: () => void;
|
||||
onlspstatus?: (status: "off" | "starting" | "on" | "unavailable") => void;
|
||||
onready?: (view: EditorView | null) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
content,
|
||||
filePath,
|
||||
targetPath,
|
||||
enableLsp = true,
|
||||
diagnostics = [],
|
||||
onchange,
|
||||
onsave,
|
||||
onlspstatus,
|
||||
onready,
|
||||
}: Props = $props();
|
||||
|
||||
let host: HTMLDivElement;
|
||||
let view: EditorView | null = null;
|
||||
|
||||
const languageSlot = new Compartment();
|
||||
const lspSlot = new Compartment();
|
||||
const themeSlot = new Compartment();
|
||||
|
||||
const bridge = new LspBridge();
|
||||
let client: LSPClient | null = null;
|
||||
|
||||
const isToml = $derived(filePath.toLowerCase().endsWith(".toml"));
|
||||
|
||||
async function loadTypstLanguage() {
|
||||
const { typst, TypstParser, typstHighlight } = await import(
|
||||
"codemirror-lang-typst"
|
||||
);
|
||||
const support = typst();
|
||||
const parser = new (TypstParser as any)(typstHighlight);
|
||||
return new Language(
|
||||
support.language.data,
|
||||
parser,
|
||||
[parser.updateListener()],
|
||||
"typst",
|
||||
);
|
||||
}
|
||||
|
||||
async function connectLsp() {
|
||||
if (!enableLsp || isToml || !view) return;
|
||||
|
||||
onlspstatus?.("starting");
|
||||
try {
|
||||
const handle = await bridge.start(targetPath, () => {
|
||||
onlspstatus?.("off");
|
||||
});
|
||||
|
||||
client = new LSPClient({
|
||||
rootUri: handle.root_uri,
|
||||
timeout: 10000,
|
||||
extensions: languageServerExtensions(),
|
||||
}).connect(bridge.transport);
|
||||
|
||||
const documentUri = `${handle.root_uri}/${filePath}`;
|
||||
view.dispatch({
|
||||
effects: lspSlot.reconfigure(client.plugin(documentUri, "typst")),
|
||||
});
|
||||
onlspstatus?.("on");
|
||||
} catch {
|
||||
onlspstatus?.("unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
view = new EditorView({
|
||||
parent: host,
|
||||
state: EditorState.create({
|
||||
doc: content,
|
||||
extensions: [
|
||||
lineNumbers(),
|
||||
lintGutter(),
|
||||
history(),
|
||||
bracketMatching(),
|
||||
closeBrackets(),
|
||||
indentOnInput(),
|
||||
highlightActiveLine(),
|
||||
languageSlot.of(isToml ? StreamLanguage.define(toml) : []),
|
||||
lspSlot.of([]),
|
||||
themeSlot.of(editorTheme(app.theme === "dark")),
|
||||
...(isToml
|
||||
? []
|
||||
: [autocompletion({ override: [typstCompletions] })]),
|
||||
EditorView.lineWrapping,
|
||||
keymap.of([
|
||||
{
|
||||
key: "Mod-s",
|
||||
preventDefault: true,
|
||||
run: () => {
|
||||
onsave();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
...closeBracketsKeymap,
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
indentWithTab,
|
||||
]),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) onchange(update.state.doc.toString());
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!isToml) {
|
||||
loadTypstLanguage()
|
||||
.then((language) => {
|
||||
view?.dispatch({ effects: languageSlot.reconfigure(language) });
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
onready?.(view);
|
||||
|
||||
connectLsp();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
onready?.(null);
|
||||
bridge.stop();
|
||||
view?.destroy();
|
||||
});
|
||||
|
||||
function laterDispatch(build: (current: EditorView) => void) {
|
||||
queueMicrotask(() => {
|
||||
if (!view) return;
|
||||
build(view);
|
||||
});
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const next = content;
|
||||
if (!view) return;
|
||||
if (view.state.doc.toString() === next) return;
|
||||
|
||||
laterDispatch((current) => {
|
||||
if (current.state.doc.toString() === next) return;
|
||||
current.dispatch({
|
||||
changes: { from: 0, to: current.state.doc.length, insert: next },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const isDark = app.theme === "dark";
|
||||
laterDispatch((current) => {
|
||||
current.dispatch({ effects: themeSlot.reconfigure(editorTheme(isDark)) });
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const entries = diagnostics;
|
||||
if (!view) return;
|
||||
|
||||
laterDispatch((current) => {
|
||||
const doc = current.state.doc;
|
||||
const marks = entries
|
||||
.filter((entry) => entry.line !== null)
|
||||
.map((entry) => {
|
||||
const line = doc.line(
|
||||
Math.min(Math.max(entry.line ?? 1, 1), doc.lines),
|
||||
);
|
||||
const from = Math.min(
|
||||
line.from + Math.max((entry.column ?? 1) - 1, 0),
|
||||
line.to,
|
||||
);
|
||||
return {
|
||||
from,
|
||||
to: line.to,
|
||||
severity: entry.severity.includes("warn")
|
||||
? ("warning" as const)
|
||||
: ("error" as const),
|
||||
message: entry.message,
|
||||
};
|
||||
});
|
||||
|
||||
current.dispatch(setDiagnostics(current.state, marks));
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="h-full overflow-hidden bg-[var(--color-surface)]" bind:this={host}></div>
|
||||
@@ -0,0 +1,141 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import * as api from "$lib/ts/api";
|
||||
import {
|
||||
insertText,
|
||||
prefixLines,
|
||||
redoEdit,
|
||||
setTypstConfig,
|
||||
undoEdit,
|
||||
wrapSelection,
|
||||
} from "$lib/ts/editor-actions";
|
||||
import { app } from "$lib/ts/state.svelte";
|
||||
|
||||
interface Props {
|
||||
view: EditorView | null;
|
||||
disabled?: boolean;
|
||||
onassets: () => void;
|
||||
onpagesettings: () => void;
|
||||
}
|
||||
|
||||
let { view, disabled = false, onassets, onpagesettings }: Props = $props();
|
||||
|
||||
let fonts = $state<string[]>([]);
|
||||
let selectedFont = $state("");
|
||||
|
||||
$effect(() => {
|
||||
const path = app.target?.path;
|
||||
api
|
||||
.listFontFamilies(path)
|
||||
.then((families) => (fonts = families))
|
||||
.catch(() => (fonts = []));
|
||||
});
|
||||
|
||||
const stats = $derived(app.compiled?.stats ?? null);
|
||||
</script>
|
||||
|
||||
{#snippet action(
|
||||
icon: string,
|
||||
label: string,
|
||||
run: () => void,
|
||||
size = "text-base",
|
||||
)}
|
||||
<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"
|
||||
title={label}
|
||||
aria-label={label}
|
||||
{disabled}
|
||||
onclick={run}
|
||||
>
|
||||
<Icon {icon} class={size} />
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
{#snippet divider()}
|
||||
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--color-line)]"></div>
|
||||
{/snippet}
|
||||
|
||||
<div
|
||||
class="scroll-thin flex shrink-0 items-center gap-0.5 overflow-x-auto border-b border-[var(--color-line)] bg-[var(--color-surface)] px-2 py-1"
|
||||
>
|
||||
{@render action("ph:arrow-counter-clockwise", "Undo", () => undoEdit(view))}
|
||||
{@render action("ph:arrow-clockwise", "Redo", () => redoEdit(view))}
|
||||
|
||||
{@render divider()}
|
||||
|
||||
{@render action("ph:text-h", "Heading", () => prefixLines(view, "= ", "Heading"))}
|
||||
{@render action("ph:text-b", "Bold", () => wrapSelection(view, "*", "*", "bold"))}
|
||||
{@render action("ph:text-italic", "Italic", () =>
|
||||
wrapSelection(view, "_", "_", "italic"),
|
||||
)}
|
||||
{@render action("ph:code", "Raw", () => wrapSelection(view, "`", "`", "code"))}
|
||||
|
||||
{@render divider()}
|
||||
|
||||
{@render action("ph:sigma", "Inline math", () =>
|
||||
wrapSelection(view, "$", "$", "x = y"),
|
||||
)}
|
||||
{@render action("ph:function", "Block math", () =>
|
||||
wrapSelection(view, "$ \n ", "\n$", "x = y"),
|
||||
)}
|
||||
|
||||
{@render divider()}
|
||||
|
||||
{@render action("ph:list-bullets", "Bullet list", () =>
|
||||
prefixLines(view, "- ", "List item"),
|
||||
)}
|
||||
{@render action("ph:list-numbers", "Numbered list", () =>
|
||||
prefixLines(view, "+ ", "Numbered item"),
|
||||
)}
|
||||
|
||||
{@render divider()}
|
||||
|
||||
{@render action("ph:link", "Link", () =>
|
||||
insertText(view, '#link("https://")[text]'),
|
||||
)}
|
||||
{@render action("ph:table", "Table", () =>
|
||||
insertText(view, "#table(\n columns: 2,\n [a], [b],\n)"),
|
||||
)}
|
||||
{@render action("ph:image-square", "Figure", () =>
|
||||
insertText(view, '#figure(\n image("file.png"),\n caption: [Caption],\n)'),
|
||||
)}
|
||||
{@render action("ph:images", "Images and fonts", onassets)}
|
||||
|
||||
{@render divider()}
|
||||
|
||||
<select
|
||||
class="max-w-36 rounded border border-[var(--color-line)] bg-[var(--color-surface)] px-1.5 py-1 text-xs text-[var(--color-ink)] focus:border-[var(--color-accent)] focus:outline-none disabled:opacity-40"
|
||||
aria-label="Document font"
|
||||
{disabled}
|
||||
bind:value={selectedFont}
|
||||
onchange={(event) => {
|
||||
const family = event.currentTarget.value;
|
||||
if (family) setTypstConfig(view, "text", "font", `"${family}"`);
|
||||
}}
|
||||
>
|
||||
<option value="">Font</option>
|
||||
{#each fonts as family}
|
||||
<option value={family}>{family}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded px-2 py-1 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)] disabled:opacity-40"
|
||||
{disabled}
|
||||
onclick={onpagesettings}
|
||||
>
|
||||
<Icon icon="ph:file-text" />
|
||||
Page
|
||||
</button>
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
{#if stats}
|
||||
<span
|
||||
class="shrink-0 whitespace-nowrap pr-1 text-[10px] text-[var(--color-ink-muted)]"
|
||||
>
|
||||
{stats.pages} pages · {stats.words} words · {stats.characters} chars
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,182 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import type { FileEntry } from "$lib/ts/api";
|
||||
|
||||
interface Props {
|
||||
files: FileEntry[];
|
||||
activePath: string | null;
|
||||
entrypoint: string;
|
||||
onopen: (path: string) => void;
|
||||
onrename: (path: string) => void;
|
||||
ondelete: (path: string) => void;
|
||||
onsetentry: (path: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
files,
|
||||
activePath,
|
||||
entrypoint,
|
||||
onopen,
|
||||
onrename,
|
||||
ondelete,
|
||||
onsetentry,
|
||||
}: Props = $props();
|
||||
|
||||
interface TreeNode {
|
||||
name: string;
|
||||
path: string;
|
||||
file: FileEntry | null;
|
||||
children: TreeNode[];
|
||||
}
|
||||
|
||||
const tree = $derived(buildTree(files));
|
||||
|
||||
function buildTree(entries: FileEntry[]): TreeNode[] {
|
||||
const root: TreeNode = { name: "", path: "", file: null, children: [] };
|
||||
|
||||
for (const entry of entries) {
|
||||
const segments = entry.path.split("/");
|
||||
let node = root;
|
||||
|
||||
segments.forEach((segment, index) => {
|
||||
const path = segments.slice(0, index + 1).join("/");
|
||||
const isLeaf = index === segments.length - 1;
|
||||
let child = node.children.find((c) => c.name === segment);
|
||||
|
||||
if (!child) {
|
||||
child = {
|
||||
name: segment,
|
||||
path,
|
||||
file: isLeaf ? entry : null,
|
||||
children: [],
|
||||
};
|
||||
node.children.push(child);
|
||||
}
|
||||
node = child;
|
||||
});
|
||||
}
|
||||
|
||||
return sortNodes(root.children);
|
||||
}
|
||||
|
||||
function sortNodes(nodes: TreeNode[]): TreeNode[] {
|
||||
nodes.sort((a, b) => {
|
||||
const aIsFolder = a.file === null;
|
||||
const bIsFolder = b.file === null;
|
||||
if (aIsFolder !== bIsFolder) return aIsFolder ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
for (const node of nodes) sortNodes(node.children);
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function iconFor(node: TreeNode): string {
|
||||
if (!node.file) return "ph:folder";
|
||||
if (node.name.endsWith(".typ")) return "ph:file-text";
|
||||
if (node.name.endsWith(".toml")) return "ph:gear-six";
|
||||
if (node.file.is_text) return "ph:file";
|
||||
return "ph:image";
|
||||
}
|
||||
|
||||
let collapsed = $state<Record<string, boolean>>({});
|
||||
let menuPath = $state<string | null>(null);
|
||||
</script>
|
||||
|
||||
{#snippet branch(nodes: TreeNode[], depth: number)}
|
||||
{#each nodes as node (node.path)}
|
||||
<div>
|
||||
<div
|
||||
class="group flex items-center gap-1.5 rounded px-2 py-1 text-xs transition
|
||||
{node.path === activePath
|
||||
? 'bg-[var(--color-accent-soft)] text-[var(--color-accent)]'
|
||||
: 'text-[var(--color-ink)] hover:bg-[var(--color-surface-sunken)]'}"
|
||||
style="padding-left: {depth * 12 + 8}px"
|
||||
>
|
||||
<button
|
||||
class="flex min-w-0 flex-1 items-center gap-1.5 text-left"
|
||||
onclick={() => {
|
||||
if (node.file) {
|
||||
onopen(node.path);
|
||||
} else {
|
||||
collapsed[node.path] = !collapsed[node.path];
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if !node.file}
|
||||
<Icon
|
||||
icon={collapsed[node.path] ? "ph:caret-right" : "ph:caret-down"}
|
||||
class="shrink-0 text-[10px] text-[var(--color-ink-muted)]"
|
||||
/>
|
||||
{/if}
|
||||
<Icon icon={iconFor(node)} class="shrink-0 text-sm" />
|
||||
<span class="truncate">{node.name}</span>
|
||||
{#if node.path === entrypoint}
|
||||
<span
|
||||
class="shrink-0 rounded bg-[var(--color-accent)] px-1 py-px text-[9px] font-medium text-white"
|
||||
>
|
||||
main
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if node.file}
|
||||
<button
|
||||
class="shrink-0 rounded p-0.5 text-[var(--color-ink-muted)] opacity-0 transition group-hover:opacity-100 hover:bg-[var(--color-surface)]"
|
||||
onclick={() => (menuPath = menuPath === node.path ? null : node.path)}
|
||||
aria-label="File actions"
|
||||
>
|
||||
<Icon icon="ph:dots-three-vertical" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if node.file && menuPath === node.path}
|
||||
<div
|
||||
class="ml-6 mb-1 flex flex-col rounded border border-[var(--color-line)] bg-[var(--color-surface)] py-1 text-xs shadow-sm"
|
||||
>
|
||||
{#if node.name.endsWith(".typ")}
|
||||
<button
|
||||
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
|
||||
onclick={() => {
|
||||
onsetentry(node.path);
|
||||
menuPath = null;
|
||||
}}
|
||||
>
|
||||
Set as entrypoint
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
|
||||
onclick={() => {
|
||||
onrename(node.path);
|
||||
menuPath = null;
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1.5 text-left text-[var(--color-danger)] hover:bg-[var(--color-surface-sunken)]"
|
||||
onclick={() => {
|
||||
ondelete(node.path);
|
||||
menuPath = null;
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if node.children.length > 0 && !collapsed[node.path]}
|
||||
{@render branch(node.children, depth + 1)}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/snippet}
|
||||
|
||||
<div class="scroll-thin flex-1 overflow-y-auto py-1">
|
||||
{#if files.length === 0}
|
||||
<p class="px-3 py-4 text-xs text-[var(--color-ink-muted)]">No files yet</p>
|
||||
{:else}
|
||||
{@render branch(tree, 0)}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,513 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import * as api from "$lib/ts/api";
|
||||
import type { BrowseEntry, EntryKind } from "$lib/ts/api";
|
||||
import {
|
||||
app,
|
||||
breadcrumbs,
|
||||
browseTo,
|
||||
openTarget,
|
||||
} from "$lib/ts/state.svelte";
|
||||
|
||||
interface Props {
|
||||
onnewfolder: () => void;
|
||||
onnewproject: () => void;
|
||||
onnewdocument: () => void;
|
||||
onupload: () => void;
|
||||
onassets: () => void;
|
||||
onrename: (entry: BrowseEntry) => void;
|
||||
ondelete: (entry: BrowseEntry) => void;
|
||||
onlink: (entry: BrowseEntry) => void;
|
||||
onviewimage: (paths: string[], index: number) => void;
|
||||
onclonespace: (spaceId: string, name: string) => void;
|
||||
ondeletespace: (spaceId: string) => void;
|
||||
onnewspace: () => void;
|
||||
onsignin: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
onnewfolder,
|
||||
onnewproject,
|
||||
onnewdocument,
|
||||
onupload,
|
||||
onassets,
|
||||
onrename,
|
||||
ondelete,
|
||||
onlink,
|
||||
onviewimage,
|
||||
onclonespace,
|
||||
ondeletespace,
|
||||
onnewspace,
|
||||
onsignin,
|
||||
}: Props = $props();
|
||||
|
||||
let menuFor = $state<string | null>(null);
|
||||
|
||||
const trail = $derived(breadcrumbs());
|
||||
|
||||
const containers = $derived(
|
||||
app.entries.filter(
|
||||
(entry) => entry.kind === "folder" || entry.kind === "project",
|
||||
),
|
||||
);
|
||||
|
||||
const documents = $derived(
|
||||
app.entries.filter(
|
||||
(entry) => entry.kind === "document" || entry.kind === "file",
|
||||
),
|
||||
);
|
||||
|
||||
let thumbs = $state<Record<string, { kind: string; data: string }>>({});
|
||||
|
||||
$effect(() => {
|
||||
const pending = documents.map((entry) => entry.path);
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
for (const path of pending) {
|
||||
if (cancelled) return;
|
||||
if (thumbs[path]) continue;
|
||||
try {
|
||||
const result = await api.thumbnail(path);
|
||||
if (!cancelled) thumbs[path] = result;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
const localSpaceIds = $derived(
|
||||
new Set(
|
||||
app.entries
|
||||
.filter((entry) => entry.space_id)
|
||||
.map((entry) => entry.space_id as string),
|
||||
),
|
||||
);
|
||||
|
||||
const iconFor: Record<EntryKind, string> = {
|
||||
project: "ph:folder-star",
|
||||
folder: "ph:folder",
|
||||
document: "ph:file-text",
|
||||
file: "ph:file",
|
||||
};
|
||||
|
||||
const colorFor: Record<EntryKind, string> = {
|
||||
project: "text-[var(--color-accent)]",
|
||||
folder: "text-[var(--color-ink-muted)]",
|
||||
document: "text-[var(--color-accent)]",
|
||||
file: "text-[var(--color-ink-muted)]",
|
||||
};
|
||||
|
||||
const imagePaths = $derived(
|
||||
app.entries
|
||||
.filter((entry) => api.isImagePath(entry.path))
|
||||
.map((entry) => entry.path),
|
||||
);
|
||||
|
||||
function activate(entry: BrowseEntry) {
|
||||
if (entry.kind === "folder") {
|
||||
browseTo(entry.path);
|
||||
} else if (entry.kind === "project" || entry.kind === "document") {
|
||||
openTarget(entry.path);
|
||||
} else if (api.isImagePath(entry.path)) {
|
||||
onviewimage(imagePaths, imagePaths.indexOf(entry.path));
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function formatDate(value: string | null): string {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? "" : date.toLocaleDateString();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet actions(entry: BrowseEntry, offset: string)}
|
||||
<button
|
||||
class="absolute right-2 {offset} rounded p-1 text-[var(--color-ink-muted)] opacity-0 transition group-hover:opacity-100 hover:bg-[var(--color-surface-sunken)]"
|
||||
onclick={() => (menuFor = menuFor === entry.path ? null : entry.path)}
|
||||
aria-label="Actions"
|
||||
>
|
||||
<Icon icon="ph:dots-three-vertical" />
|
||||
</button>
|
||||
|
||||
{#if menuFor === entry.path}
|
||||
<div
|
||||
class="absolute right-2 top-9 z-10 flex w-40 flex-col rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] py-1 text-xs shadow-lg"
|
||||
>
|
||||
{#if entry.kind === "project" || entry.kind === "document"}
|
||||
<button
|
||||
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
|
||||
onclick={() => {
|
||||
openTarget(entry.path);
|
||||
menuFor = null;
|
||||
}}
|
||||
>
|
||||
Open in editor
|
||||
</button>
|
||||
{:else if api.isImagePath(entry.path)}
|
||||
<button
|
||||
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
|
||||
onclick={() => {
|
||||
onviewimage(imagePaths, imagePaths.indexOf(entry.path));
|
||||
menuFor = null;
|
||||
}}
|
||||
>
|
||||
Open image
|
||||
</button>
|
||||
{/if}
|
||||
{#if entry.kind === "project" && !entry.space_id && app.account}
|
||||
<button
|
||||
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
|
||||
onclick={() => {
|
||||
onlink(entry);
|
||||
menuFor = null;
|
||||
}}
|
||||
>
|
||||
Upload to cloud
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
|
||||
onclick={() => {
|
||||
onrename(entry);
|
||||
menuFor = null;
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1.5 text-left text-[var(--color-danger)] hover:bg-[var(--color-surface-sunken)]"
|
||||
onclick={() => {
|
||||
ondelete(entry);
|
||||
menuFor = null;
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<div class="flex h-full flex-col bg-[var(--color-surface-muted)]">
|
||||
<div
|
||||
class="flex items-center gap-2 border-b border-[var(--color-line)] bg-[var(--color-surface)] px-4 py-2.5"
|
||||
>
|
||||
<div class="flex rounded-lg bg-[var(--color-surface-sunken)] p-0.5 text-xs font-medium">
|
||||
{#each [["local", "Local", "ph:hard-drives"], ["cloud", "Cloud", "ph:cloud"]] as [value, label, icon]}
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 transition
|
||||
{app.scope === value
|
||||
? 'bg-[var(--color-surface)] text-[var(--color-ink)] shadow-sm'
|
||||
: 'text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]'}"
|
||||
onclick={() => (app.scope = value as "local" | "cloud")}
|
||||
>
|
||||
<Icon {icon} />
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
{#if app.scope === "local"}
|
||||
<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={onassets}
|
||||
>
|
||||
<Icon icon="ph:images" />
|
||||
Assets
|
||||
</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={onupload}
|
||||
>
|
||||
<Icon icon="ph:upload-simple" />
|
||||
Import
|
||||
</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={onnewfolder}
|
||||
>
|
||||
<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={onnewdocument}
|
||||
>
|
||||
<Icon icon="ph:file-plus" />
|
||||
Document
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-md bg-[var(--color-accent)] px-2.5 py-1.5 text-xs font-medium text-white transition hover:opacity-90"
|
||||
onclick={onnewproject}
|
||||
>
|
||||
<Icon icon="ph:plus" />
|
||||
New project
|
||||
</button>
|
||||
{:else if app.account}
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-md bg-[var(--color-accent)] px-2.5 py-1.5 text-xs font-medium text-white transition hover:opacity-90"
|
||||
onclick={onnewspace}
|
||||
>
|
||||
<Icon icon="ph:plus" />
|
||||
New space
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if app.scope === "local"}
|
||||
<div
|
||||
class="flex items-center gap-1 border-b border-[var(--color-line)] bg-[var(--color-surface)] px-4 py-2 text-xs"
|
||||
>
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded px-2 py-1 transition hover:bg-[var(--color-surface-muted)]
|
||||
{app.currentDir === '' ? 'font-medium' : 'text-[var(--color-ink-muted)]'}"
|
||||
onclick={() => browseTo("")}
|
||||
>
|
||||
<Icon icon="ph:house" />
|
||||
Workspace
|
||||
</button>
|
||||
|
||||
{#each trail as crumb, index}
|
||||
<Icon icon="ph:caret-right" class="text-[10px] text-[var(--color-ink-muted)]" />
|
||||
<button
|
||||
class="rounded px-2 py-1 transition hover:bg-[var(--color-surface-muted)]
|
||||
{index === trail.length - 1 ? 'font-medium' : 'text-[var(--color-ink-muted)]'}"
|
||||
onclick={() => browseTo(crumb.path)}
|
||||
>
|
||||
{crumb.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="scroll-thin flex-1 overflow-y-auto p-4">
|
||||
{#if app.entries.length === 0}
|
||||
<div
|
||||
class="flex h-full flex-col items-center justify-center gap-3 text-[var(--color-ink-muted)]"
|
||||
>
|
||||
<Icon icon="ph:folder-open" class="text-5xl" />
|
||||
<p class="text-sm">This folder is empty.</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-white hover:opacity-90"
|
||||
onclick={onnewproject}
|
||||
>
|
||||
New project
|
||||
</button>
|
||||
<button
|
||||
class="rounded-md border border-[var(--color-line)] px-3 py-1.5 text-xs hover:bg-[var(--color-surface)]"
|
||||
onclick={onnewdocument}
|
||||
>
|
||||
New document
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-5">
|
||||
{#if containers.length > 0}
|
||||
<section class="flex flex-col gap-2">
|
||||
<h2
|
||||
class="text-[10px] font-semibold uppercase tracking-wider text-[var(--color-ink-muted)]"
|
||||
>
|
||||
Folders
|
||||
</h2>
|
||||
<div class="grid grid-cols-[repeat(auto-fill,minmax(210px,1fr))] gap-2">
|
||||
{#each containers as entry (entry.path)}
|
||||
<div
|
||||
class="group relative flex items-center gap-2.5 rounded-lg border border-[var(--color-line)] bg-[var(--color-surface-sunken)] px-3 py-2.5 transition hover:border-[var(--color-accent)] hover:bg-[var(--color-surface)]"
|
||||
>
|
||||
<button
|
||||
class="flex min-w-0 flex-1 items-center gap-2.5 text-left"
|
||||
ondblclick={() => activate(entry)}
|
||||
onclick={() => activate(entry)}
|
||||
>
|
||||
<Icon
|
||||
icon={entry.kind === "project"
|
||||
? "ph:folder-star-fill"
|
||||
: "ph:folder-fill"}
|
||||
class="shrink-0 text-2xl {entry.kind === 'project'
|
||||
? 'text-[var(--color-accent)]'
|
||||
: 'text-[var(--color-ink-muted)]'}"
|
||||
/>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span
|
||||
class="truncate text-xs font-medium"
|
||||
title={entry.name}
|
||||
>
|
||||
{entry.name}
|
||||
</span>
|
||||
{#if entry.space_id}
|
||||
<Icon
|
||||
icon="ph:cloud-check"
|
||||
class="shrink-0 text-xs text-[var(--color-success)]"
|
||||
/>
|
||||
{/if}
|
||||
</span>
|
||||
<span
|
||||
class="block truncate text-[10px] text-[var(--color-ink-muted)]"
|
||||
>
|
||||
{entry.kind === "project" ? "Project · " : ""}{entry.child_count}
|
||||
items
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{@render actions(entry, "top-2")}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if documents.length > 0}
|
||||
<section class="flex flex-col gap-2">
|
||||
<h2
|
||||
class="text-[10px] font-semibold uppercase tracking-wider text-[var(--color-ink-muted)]"
|
||||
>
|
||||
Documents
|
||||
</h2>
|
||||
<div class="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
|
||||
{#each documents as entry (entry.path)}
|
||||
<div
|
||||
class="group relative flex flex-col overflow-hidden rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] transition hover:border-[var(--color-accent)] hover:shadow-md"
|
||||
>
|
||||
<button
|
||||
class="flex flex-col text-left"
|
||||
ondblclick={() => activate(entry)}
|
||||
onclick={() => activate(entry)}
|
||||
>
|
||||
<span
|
||||
class="flex h-24 items-center justify-center overflow-hidden border-b border-[var(--color-line)] bg-[var(--color-surface-muted)]"
|
||||
>
|
||||
{#if thumbs[entry.path]?.kind === "svg"}
|
||||
<span
|
||||
class="flex w-full origin-top scale-100 items-start justify-center bg-white p-1 [&_svg]:h-auto [&_svg]:w-full"
|
||||
>
|
||||
{@html thumbs[entry.path].data}
|
||||
</span>
|
||||
{:else if thumbs[entry.path]?.kind === "image"}
|
||||
<img
|
||||
src={thumbs[entry.path].data}
|
||||
alt={entry.name}
|
||||
class="h-full w-full object-contain"
|
||||
/>
|
||||
{:else}
|
||||
<Icon
|
||||
icon={iconFor[entry.kind]}
|
||||
class="text-3xl {colorFor[entry.kind]}"
|
||||
/>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<span class="flex flex-col gap-0.5 px-2.5 py-2">
|
||||
<span
|
||||
class="truncate text-xs font-medium"
|
||||
title={entry.name}
|
||||
>
|
||||
{entry.name}
|
||||
</span>
|
||||
<span class="text-[10px] text-[var(--color-ink-muted)]">
|
||||
{formatSize(entry.size)}
|
||||
{#if entry.modified}
|
||||
· {formatDate(entry.modified)}
|
||||
{/if}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{@render actions(entry, "top-2")}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="scroll-thin flex-1 overflow-y-auto p-4">
|
||||
{#if !app.account}
|
||||
<div
|
||||
class="flex h-full flex-col items-center justify-center gap-3 text-[var(--color-ink-muted)]"
|
||||
>
|
||||
<Icon icon="ph:cloud-slash" class="text-5xl" />
|
||||
<p class="max-w-xs text-center text-sm">
|
||||
Connect a TypstDrive account to sync your projects across devices.
|
||||
</p>
|
||||
<button
|
||||
class="rounded-md bg-[var(--color-accent)] px-3 py-2 text-xs font-medium text-white hover:opacity-90"
|
||||
onclick={onsignin}
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
{:else if app.spaces.length === 0}
|
||||
<div
|
||||
class="flex h-full flex-col items-center justify-center gap-3 text-[var(--color-ink-muted)]"
|
||||
>
|
||||
<Icon icon="ph:cloud" class="text-5xl" />
|
||||
<p class="text-sm">No cloud spaces yet.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-3">
|
||||
{#each app.spaces as space (space.id)}
|
||||
<div
|
||||
class="group flex flex-col gap-2 rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] p-3 transition hover:border-[var(--color-accent)]"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon="ph:cloud" class="text-xl text-[var(--color-accent)]" />
|
||||
{#if localSpaceIds.has(space.id)}
|
||||
<span title="Downloaded to this device" class="flex">
|
||||
<Icon
|
||||
icon="ph:hard-drives"
|
||||
class="text-sm text-[var(--color-success)]"
|
||||
/>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<span class="truncate text-xs font-medium">{space.name}</span>
|
||||
<span class="text-[10px] text-[var(--color-ink-muted)]">
|
||||
{space.role} · {formatDate(space.updated_at)}
|
||||
</span>
|
||||
|
||||
<div class="mt-1 flex gap-1">
|
||||
{#if !localSpaceIds.has(space.id)}
|
||||
<button
|
||||
class="flex flex-1 items-center justify-center gap-1 rounded border border-[var(--color-line)] px-2 py-1 text-[10px] hover:bg-[var(--color-surface-muted)]"
|
||||
onclick={() => onclonespace(space.id, space.name)}
|
||||
>
|
||||
<Icon icon="ph:download-simple" />
|
||||
Download
|
||||
</button>
|
||||
{/if}
|
||||
{#if space.role === "owner"}
|
||||
<button
|
||||
class="rounded border border-[var(--color-line)] px-2 py-1 text-[10px] text-[var(--color-danger)] hover:bg-[var(--color-surface-muted)]"
|
||||
onclick={() => ondeletespace(space.id)}
|
||||
aria-label="Delete space"
|
||||
>
|
||||
<Icon icon="ph:trash" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,172 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import { untrack } from "svelte";
|
||||
import * as api from "$lib/ts/api";
|
||||
import type { ImageData } from "$lib/ts/api";
|
||||
|
||||
interface Props {
|
||||
paths: string[];
|
||||
index: number;
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let { paths, index, onclose }: Props = $props();
|
||||
|
||||
let current = $state(untrack(() => index));
|
||||
let image = $state<ImageData | null>(null);
|
||||
let error = $state("");
|
||||
let loading = $state(true);
|
||||
let zoom = $state(1);
|
||||
let fit = $state(true);
|
||||
|
||||
$effect(() => {
|
||||
const path = paths[current];
|
||||
if (!path) return;
|
||||
|
||||
loading = true;
|
||||
error = "";
|
||||
api
|
||||
.readImage(path)
|
||||
.then((result) => {
|
||||
image = result;
|
||||
zoom = 1;
|
||||
fit = true;
|
||||
})
|
||||
.catch((caught) => {
|
||||
image = null;
|
||||
error = api.errorMessage(caught);
|
||||
})
|
||||
.finally(() => (loading = false));
|
||||
});
|
||||
|
||||
function step(delta: number) {
|
||||
const next = current + delta;
|
||||
if (next < 0 || next >= paths.length) return;
|
||||
current = next;
|
||||
}
|
||||
|
||||
function handleKey(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") onclose();
|
||||
if (event.key === "ArrowRight") step(1);
|
||||
if (event.key === "ArrowLeft") step(-1);
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleKey} />
|
||||
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex flex-col bg-black/80"
|
||||
role="presentation"
|
||||
onclick={(event) => {
|
||||
if (event.target === event.currentTarget) onclose();
|
||||
}}
|
||||
>
|
||||
<header
|
||||
class="flex h-11 shrink-0 items-center gap-3 bg-[var(--color-surface)] px-3 text-xs"
|
||||
>
|
||||
<Icon icon="ph:image" class="text-base text-[var(--color-accent)]" />
|
||||
<span class="font-medium">{image?.name ?? paths[current]?.split("/").pop()}</span>
|
||||
|
||||
{#if image}
|
||||
<span class="text-[var(--color-ink-muted)]">
|
||||
{#if image.width && image.height}
|
||||
{image.width} × {image.height} ·
|
||||
{/if}
|
||||
{formatSize(image.size)}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
{#if paths.length > 1}
|
||||
<span class="text-[var(--color-ink-muted)]">
|
||||
{current + 1} of {paths.length}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="rounded p-1.5 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
|
||||
onclick={() => {
|
||||
fit = false;
|
||||
zoom = Math.max(0.1, zoom - 0.25);
|
||||
}}
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
<Icon icon="ph:minus" />
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-2 py-1 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
|
||||
onclick={() => {
|
||||
fit = !fit;
|
||||
zoom = 1;
|
||||
}}
|
||||
>
|
||||
{fit ? "Fit" : `${Math.round(zoom * 100)}%`}
|
||||
</button>
|
||||
<button
|
||||
class="rounded p-1.5 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
|
||||
onclick={() => {
|
||||
fit = false;
|
||||
zoom = Math.min(8, zoom + 0.25);
|
||||
}}
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
<Icon icon="ph:plus" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="rounded p-1.5 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-danger)] hover:text-white"
|
||||
onclick={onclose}
|
||||
aria-label="Close"
|
||||
>
|
||||
<Icon icon="ph:x" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="relative flex min-h-0 flex-1 items-center justify-center">
|
||||
{#if paths.length > 1}
|
||||
<button
|
||||
class="absolute left-3 z-10 rounded-full bg-black/50 p-2 text-white transition hover:bg-black/70 disabled:opacity-30"
|
||||
disabled={current === 0}
|
||||
onclick={() => step(-1)}
|
||||
aria-label="Previous image"
|
||||
>
|
||||
<Icon icon="ph:caret-left" class="text-lg" />
|
||||
</button>
|
||||
<button
|
||||
class="absolute right-3 z-10 rounded-full bg-black/50 p-2 text-white transition hover:bg-black/70 disabled:opacity-30"
|
||||
disabled={current === paths.length - 1}
|
||||
onclick={() => step(1)}
|
||||
aria-label="Next image"
|
||||
>
|
||||
<Icon icon="ph:caret-right" class="text-lg" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<Icon icon="ph:circle-notch" class="animate-spin text-3xl text-white/70" />
|
||||
{:else if error}
|
||||
<div class="flex flex-col items-center gap-2 text-white/70">
|
||||
<Icon icon="ph:warning-circle" class="text-3xl" />
|
||||
<p class="text-sm">{error}</p>
|
||||
</div>
|
||||
{:else if image}
|
||||
<div class="scroll-thin h-full w-full overflow-auto p-6">
|
||||
<img
|
||||
src={image.data}
|
||||
alt={image.name}
|
||||
class={fit
|
||||
? "mx-auto max-h-full max-w-full object-contain"
|
||||
: "mx-auto max-w-none"}
|
||||
style={fit ? "" : `width: ${zoom * 100}%`}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import Modal from "./Modal.svelte";
|
||||
import * as api from "$lib/ts/api";
|
||||
|
||||
interface Props {
|
||||
serverUrl: string;
|
||||
onsuccess: () => void;
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let { serverUrl, onsuccess, onclose }: Props = $props();
|
||||
|
||||
let url = $state(serverUrl);
|
||||
let email = $state("");
|
||||
let password = $state("");
|
||||
let busy = $state(false);
|
||||
let error = $state("");
|
||||
|
||||
async function submit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!email.trim() || !password) {
|
||||
error = "Enter your email and password";
|
||||
return;
|
||||
}
|
||||
|
||||
busy = true;
|
||||
error = "";
|
||||
try {
|
||||
await api.cloudLogin(url.trim(), email.trim(), password);
|
||||
onsuccess();
|
||||
} catch (caught) {
|
||||
error = api.errorMessage(caught);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal title="Connect to TypstDrive" icon="ph:cloud" {onclose}>
|
||||
<form id="login-form" class="flex flex-col gap-3" onsubmit={submit}>
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="font-medium text-[var(--color-ink-muted)]">Server</span>
|
||||
<input
|
||||
class="rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] px-3 py-2 text-sm focus:border-[var(--color-accent)] focus:outline-none"
|
||||
bind:value={url}
|
||||
placeholder="https://drive.example.com"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="font-medium text-[var(--color-ink-muted)]">Email</span>
|
||||
<input
|
||||
type="email"
|
||||
class="rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] px-3 py-2 text-sm focus:border-[var(--color-accent)] focus:outline-none"
|
||||
bind:value={email}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="font-medium text-[var(--color-ink-muted)]">Password</span>
|
||||
<input
|
||||
type="password"
|
||||
class="rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] px-3 py-2 text-sm focus:border-[var(--color-accent)] focus:outline-none"
|
||||
bind:value={password}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{#if error}
|
||||
<p class="text-xs text-[var(--color-danger)]">{error}</p>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
{#snippet footer()}
|
||||
<button
|
||||
class="rounded-md px-3 py-1.5 text-xs text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)]"
|
||||
onclick={onclose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
form="login-form"
|
||||
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={busy}
|
||||
>
|
||||
{busy ? "Connecting..." : "Connect"}
|
||||
</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
icon?: string;
|
||||
width?: string;
|
||||
onclose: () => void;
|
||||
children: Snippet;
|
||||
footer?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
title,
|
||||
icon = "ph:squares-four",
|
||||
width = "max-w-md",
|
||||
onclose,
|
||||
children,
|
||||
footer,
|
||||
}: Props = $props();
|
||||
|
||||
function handleKey(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") onclose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleKey} />
|
||||
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-6"
|
||||
role="presentation"
|
||||
onclick={(event) => {
|
||||
if (event.target === event.currentTarget) onclose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="w-full {width} overflow-hidden rounded-xl border border-[var(--color-line)] bg-[var(--color-surface)] shadow-2xl"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
>
|
||||
<header
|
||||
class="flex items-center gap-2 border-b border-[var(--color-line)] px-5 py-3.5"
|
||||
>
|
||||
<Icon {icon} class="text-lg text-[var(--color-accent)]" />
|
||||
<h2 class="flex-1 text-sm font-semibold">{title}</h2>
|
||||
<button
|
||||
class="rounded p-1 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
|
||||
onclick={onclose}
|
||||
aria-label="Close"
|
||||
>
|
||||
<Icon icon="ph:x" class="text-base" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="scroll-thin max-h-[70vh] overflow-y-auto px-5 py-4">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
{#if footer}
|
||||
<footer
|
||||
class="flex items-center justify-end gap-2 border-t border-[var(--color-line)] bg-[var(--color-surface-muted)] px-5 py-3"
|
||||
>
|
||||
{@render footer()}
|
||||
</footer>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,113 @@
|
||||
<script lang="ts">
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import Modal from "./Modal.svelte";
|
||||
import { setTypstConfig } from "$lib/ts/editor-actions";
|
||||
|
||||
interface Props {
|
||||
view: EditorView | null;
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let { view, onclose }: Props = $props();
|
||||
|
||||
const papers = [
|
||||
"a4",
|
||||
"a3",
|
||||
"a5",
|
||||
"us-letter",
|
||||
"us-legal",
|
||||
"presentation-16-9",
|
||||
];
|
||||
|
||||
let paper = $state("a4");
|
||||
let margin = $state("2.5cm");
|
||||
let columns = $state(1);
|
||||
let flipped = $state(false);
|
||||
let numbering = $state("none");
|
||||
|
||||
function apply() {
|
||||
setTypstConfig(view, "page", "paper", `"${paper}"`);
|
||||
if (margin.trim()) {
|
||||
setTypstConfig(view, "page", "margin", margin.trim());
|
||||
}
|
||||
if (columns > 1) {
|
||||
setTypstConfig(view, "page", "columns", String(columns));
|
||||
}
|
||||
if (flipped) {
|
||||
setTypstConfig(view, "page", "flipped", "true");
|
||||
}
|
||||
if (numbering !== "none") {
|
||||
setTypstConfig(view, "page", "numbering", `"${numbering}"`);
|
||||
}
|
||||
onclose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal title="Page settings" icon="ph:file-text" {onclose}>
|
||||
<div class="flex flex-col gap-3">
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="font-medium text-[var(--color-ink-muted)]">Paper</span>
|
||||
<select
|
||||
class="rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] px-3 py-2 text-sm focus:border-[var(--color-accent)] focus:outline-none"
|
||||
bind:value={paper}
|
||||
>
|
||||
{#each papers as option}
|
||||
<option value={option}>{option}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="font-medium text-[var(--color-ink-muted)]">Margin</span>
|
||||
<input
|
||||
class="rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] px-3 py-2 text-sm focus:border-[var(--color-accent)] focus:outline-none"
|
||||
bind:value={margin}
|
||||
placeholder="2.5cm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="font-medium text-[var(--color-ink-muted)]">Columns</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="6"
|
||||
class="rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] px-3 py-2 text-sm focus:border-[var(--color-accent)] focus:outline-none"
|
||||
bind:value={columns}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="font-medium text-[var(--color-ink-muted)]">Page numbers</span>
|
||||
<select
|
||||
class="rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] px-3 py-2 text-sm focus:border-[var(--color-accent)] focus:outline-none"
|
||||
bind:value={numbering}
|
||||
>
|
||||
<option value="none">None</option>
|
||||
<option value="1">1, 2, 3</option>
|
||||
<option value="1 / 1">1 / 10</option>
|
||||
<option value="i">i, ii, iii</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 text-xs">
|
||||
<input type="checkbox" bind:checked={flipped} />
|
||||
<span>Landscape</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#snippet footer()}
|
||||
<button
|
||||
class="rounded-md px-3 py-1.5 text-xs text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)]"
|
||||
onclick={onclose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-white transition hover:opacity-90"
|
||||
onclick={apply}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import type { CompileResult, Diagnostic } from "$lib/ts/api";
|
||||
|
||||
interface Props {
|
||||
compiled: CompileResult | null;
|
||||
diagnostics: Diagnostic[];
|
||||
compiling: boolean;
|
||||
}
|
||||
|
||||
let { compiled, diagnostics, compiling }: Props = $props();
|
||||
|
||||
let zoom = $state(1);
|
||||
|
||||
const errors = $derived(diagnostics.filter((d) => d.severity === "error"));
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col bg-[var(--color-surface-sunken)]">
|
||||
<div
|
||||
class="flex items-center gap-2 border-b border-[var(--color-line)] bg-[var(--color-surface)] px-3 py-2 text-xs"
|
||||
>
|
||||
<Icon icon="ph:file-text" class="text-[var(--color-ink-muted)]" />
|
||||
<span class="text-[var(--color-ink-muted)]">
|
||||
{#if compiled}
|
||||
{compiled.stats.pages} pages, {compiled.stats.words} words
|
||||
{:else}
|
||||
Preview
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
{#if compiling}
|
||||
<Icon icon="ph:circle-notch" class="animate-spin text-[var(--color-accent)]" />
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="rounded p-1 text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)]"
|
||||
onclick={() => (zoom = Math.max(0.4, zoom - 0.15))}
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
<Icon icon="ph:minus" />
|
||||
</button>
|
||||
<span class="w-10 text-center tabular-nums text-[var(--color-ink-muted)]">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
<button
|
||||
class="rounded p-1 text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)]"
|
||||
onclick={() => (zoom = Math.min(2.5, zoom + 0.15))}
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
<Icon icon="ph:plus" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if errors.length > 0}
|
||||
<div
|
||||
class="scroll-thin max-h-40 shrink-0 overflow-y-auto border-b border-[var(--color-line)] bg-[var(--color-surface)] px-3 py-2"
|
||||
>
|
||||
{#each errors as diagnostic}
|
||||
<div class="flex items-start gap-2 py-1 text-xs text-[var(--color-danger)]">
|
||||
<Icon icon="ph:warning-circle" class="mt-0.5 shrink-0" />
|
||||
<span>
|
||||
{#if diagnostic.line}
|
||||
<span class="font-medium">Line {diagnostic.line}:</span>
|
||||
{/if}
|
||||
{diagnostic.message}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="scroll-thin flex-1 overflow-auto p-6">
|
||||
{#if compiled && compiled.pages.length > 0}
|
||||
<div
|
||||
class="mx-auto flex flex-col items-center gap-6"
|
||||
style="width: {Math.round(zoom * 100)}%; max-width: {zoom > 1 ? 'none' : '820px'};"
|
||||
>
|
||||
{#each compiled.pages as page}
|
||||
<div
|
||||
class="preview-page w-full overflow-hidden rounded bg-white shadow-lg ring-1 ring-black/5"
|
||||
>
|
||||
{@html page}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-full flex-col items-center justify-center gap-2 text-[var(--color-ink-muted)]"
|
||||
>
|
||||
<Icon icon="ph:file-dashed" class="text-4xl" />
|
||||
<p class="text-sm">
|
||||
{errors.length > 0 ? "Fix the errors above to see a preview" : "Nothing to preview yet"}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import Modal from "./Modal.svelte";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
confirmLabel?: string;
|
||||
danger?: boolean;
|
||||
suffix?: string;
|
||||
onsubmit: (value: string) => void;
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
title,
|
||||
label,
|
||||
icon = "ph:pencil-simple",
|
||||
value = "",
|
||||
placeholder = "",
|
||||
confirmLabel = "Create",
|
||||
danger = false,
|
||||
suffix = "",
|
||||
onsubmit,
|
||||
onclose,
|
||||
}: Props = $props();
|
||||
|
||||
let text = $state(
|
||||
untrack(() =>
|
||||
suffix && value.endsWith(suffix) ? value.slice(0, -suffix.length) : value,
|
||||
),
|
||||
);
|
||||
|
||||
function submit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!text.trim()) return;
|
||||
onsubmit(text.trim());
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal {title} {icon} {onclose}>
|
||||
<form id="prompt-form" onsubmit={submit}>
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="font-medium text-[var(--color-ink-muted)]">{label}</span>
|
||||
<div
|
||||
class="flex items-center rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] focus-within:border-[var(--color-accent)]"
|
||||
>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
autofocus
|
||||
class="min-w-0 flex-1 bg-transparent px-3 py-2 text-sm focus:outline-none"
|
||||
bind:value={text}
|
||||
{placeholder}
|
||||
/>
|
||||
{#if suffix}
|
||||
<span class="pr-3 text-sm text-[var(--color-ink-muted)]">{suffix}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
</form>
|
||||
|
||||
{#snippet footer()}
|
||||
<button
|
||||
class="rounded-md px-3 py-1.5 text-xs text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)]"
|
||||
onclick={onclose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
form="prompt-form"
|
||||
class="rounded-md px-3 py-1.5 text-xs font-medium text-white transition hover:opacity-90
|
||||
{danger ? 'bg-[var(--color-danger)]' : 'bg-[var(--color-accent)]'}"
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
@@ -0,0 +1,153 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import Modal from "./Modal.svelte";
|
||||
import * as api from "$lib/ts/api";
|
||||
import { app, applyTheme, refreshEntries, setError } from "$lib/ts/state.svelte";
|
||||
|
||||
interface Props {
|
||||
onclose: () => void;
|
||||
onsignin: () => void;
|
||||
}
|
||||
|
||||
let { onclose, onsignin }: Props = $props();
|
||||
|
||||
let workspaceRoot = $state(app.settings?.workspace_root ?? "");
|
||||
let serverUrl = $state(app.settings?.server_url ?? "");
|
||||
let saving = $state(false);
|
||||
|
||||
async function browse() {
|
||||
const selected = await open({ directory: true, multiple: false });
|
||||
if (typeof selected === "string") {
|
||||
workspaceRoot = selected;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving = true;
|
||||
try {
|
||||
app.settings = await api.updateSettings({
|
||||
workspaceRoot,
|
||||
serverUrl,
|
||||
});
|
||||
await refreshEntries();
|
||||
onclose();
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
try {
|
||||
await api.cloudLogout();
|
||||
app.account = null;
|
||||
app.spaces = [];
|
||||
app.settings = await api.getSettings();
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal title="Settings" icon="ph:gear-six" {onclose}>
|
||||
<div class="flex flex-col gap-5">
|
||||
<div class="flex flex-col gap-1 text-xs">
|
||||
<span class="font-medium text-[var(--color-ink-muted)]">Workspace folder</span>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
class="flex-1 rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] px-3 py-2 text-sm focus:border-[var(--color-accent)] focus:outline-none"
|
||||
bind:value={workspaceRoot}
|
||||
/>
|
||||
<button
|
||||
class="flex items-center gap-1 rounded-md border border-[var(--color-line)] px-3 text-xs hover:bg-[var(--color-surface-muted)]"
|
||||
onclick={browse}
|
||||
>
|
||||
<Icon icon="ph:folder-open" />
|
||||
Browse
|
||||
</button>
|
||||
</div>
|
||||
<span class="text-[var(--color-ink-muted)]">
|
||||
Projects are stored as plain folders here.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1 text-xs">
|
||||
<span class="font-medium text-[var(--color-ink-muted)]">TypstDrive server</span>
|
||||
<input
|
||||
class="rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] px-3 py-2 text-sm focus:border-[var(--color-accent)] focus:outline-none"
|
||||
bind:value={serverUrl}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-3 rounded-md border border-[var(--color-line)] bg-[var(--color-surface-muted)] p-3"
|
||||
>
|
||||
<Icon
|
||||
icon={app.account ? "ph:user-circle-check" : "ph:user-circle"}
|
||||
class="text-2xl {app.account ? 'text-[var(--color-success)]' : 'text-[var(--color-ink-muted)]'}"
|
||||
/>
|
||||
<div class="flex-1 text-xs">
|
||||
{#if app.account}
|
||||
<p class="font-medium">{app.account.username}</p>
|
||||
<p class="text-[var(--color-ink-muted)]">{app.account.email}</p>
|
||||
{:else}
|
||||
<p class="font-medium">Not connected</p>
|
||||
<p class="text-[var(--color-ink-muted)]">
|
||||
Sign in to sync projects to the cloud.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if app.account}
|
||||
<button
|
||||
class="rounded-md border border-[var(--color-line)] px-3 py-1.5 text-xs hover:bg-[var(--color-surface)]"
|
||||
onclick={signOut}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-white hover:opacity-90"
|
||||
onclick={onsignin}
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="font-medium text-[var(--color-ink-muted)]">Appearance</span>
|
||||
<div class="flex gap-1">
|
||||
{#each [["light", "ph:sun"], ["dark", "ph:moon"]] as [value, icon]}
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-md border px-3 py-1.5 transition
|
||||
{app.theme === value
|
||||
? 'border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-accent)]'
|
||||
: 'border-[var(--color-line)] text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-muted)]'}"
|
||||
onclick={() => applyTheme(value as "light" | "dark")}
|
||||
>
|
||||
<Icon {icon} />
|
||||
{value === "light" ? "Light" : "Dark"}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#snippet footer()}
|
||||
<button
|
||||
class="rounded-md px-3 py-1.5 text-xs text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)]"
|
||||
onclick={onclose}
|
||||
>
|
||||
Cancel
|
||||
</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"
|
||||
disabled={saving}
|
||||
onclick={save}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
|
||||
const appWindow = getCurrentWindow();
|
||||
|
||||
let maximized = $state(false);
|
||||
|
||||
async function sync() {
|
||||
try {
|
||||
maximized = await appWindow.isMaximized();
|
||||
} catch {
|
||||
maximized = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
sync();
|
||||
const pending = appWindow.onResized(sync);
|
||||
return () => {
|
||||
pending.then((unlisten) => unlisten());
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex items-center">
|
||||
<button
|
||||
class="flex h-11 w-11 items-center justify-center text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
|
||||
onclick={() => appWindow.minimize()}
|
||||
aria-label="Minimize"
|
||||
>
|
||||
<Icon icon="ph:minus" class="text-sm" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex h-11 w-11 items-center justify-center text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
|
||||
onclick={async () => {
|
||||
await appWindow.toggleMaximize();
|
||||
sync();
|
||||
}}
|
||||
aria-label={maximized ? "Restore" : "Maximize"}
|
||||
>
|
||||
<Icon icon={maximized ? "ph:corners-in" : "ph:square"} class="text-sm" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex h-11 w-11 items-center justify-center text-[var(--color-ink-muted)] transition hover:bg-[var(--color-danger)] hover:text-white"
|
||||
onclick={() => appWindow.close()}
|
||||
aria-label="Close"
|
||||
>
|
||||
<Icon icon="ph:x" class="text-sm" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,268 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export interface Settings {
|
||||
workspace_root: string;
|
||||
server_url: string;
|
||||
device_token: string | null;
|
||||
account_email: string | null;
|
||||
account_username: string | null;
|
||||
}
|
||||
|
||||
export interface FileEntry {
|
||||
path: string;
|
||||
name: string;
|
||||
is_text: boolean;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface FilePayload {
|
||||
path: string;
|
||||
is_text: boolean;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface Diagnostic {
|
||||
message: string;
|
||||
severity: string;
|
||||
line: number | null;
|
||||
column: number | null;
|
||||
}
|
||||
|
||||
export interface DocumentStats {
|
||||
pages: number;
|
||||
words: number;
|
||||
characters: number;
|
||||
}
|
||||
|
||||
export interface CompileResult {
|
||||
pages: string[];
|
||||
stats: DocumentStats;
|
||||
diagnostics: Diagnostic[];
|
||||
}
|
||||
|
||||
export interface CompileFailure {
|
||||
diagnostics: Diagnostic[];
|
||||
}
|
||||
|
||||
export interface Account {
|
||||
user_id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface SpaceSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
entrypoint: string;
|
||||
role: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Conflict {
|
||||
path: string;
|
||||
local_text: string;
|
||||
remote_text: string;
|
||||
merged_text: string;
|
||||
server_hash: string;
|
||||
auto_merged: boolean;
|
||||
binary: boolean;
|
||||
}
|
||||
|
||||
export interface SyncReport {
|
||||
pushed: string[];
|
||||
pulled: string[];
|
||||
deleted_local: string[];
|
||||
deleted_remote: string[];
|
||||
merged: string[];
|
||||
conflicts: Conflict[];
|
||||
}
|
||||
|
||||
export interface Resolution {
|
||||
path: string;
|
||||
content: string;
|
||||
server_hash: string;
|
||||
}
|
||||
|
||||
export type EntryKind = "folder" | "project" | "document" | "file";
|
||||
|
||||
export interface BrowseEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
kind: EntryKind;
|
||||
size: number;
|
||||
modified: string | null;
|
||||
space_id: string | null;
|
||||
last_synced_at: string | null;
|
||||
child_count: number;
|
||||
}
|
||||
|
||||
export interface TargetInfo {
|
||||
path: string;
|
||||
entrypoint: string;
|
||||
standalone: boolean;
|
||||
is_project: boolean;
|
||||
space_id: string | null;
|
||||
files: FileEntry[];
|
||||
}
|
||||
|
||||
export const browseWorkspace = (path: string) =>
|
||||
invoke<BrowseEntry[]>("browse_workspace", { path });
|
||||
|
||||
export const createFolderEntry = (parent: string, name: string) =>
|
||||
invoke<string>("create_folder_entry", { parent, name });
|
||||
|
||||
export const createDocumentEntry = (parent: string, name: string) =>
|
||||
invoke<string>("create_document_entry", { parent, name });
|
||||
|
||||
export const createProjectEntry = (parent: string, name: string) =>
|
||||
invoke<string>("create_project_entry", { parent, name });
|
||||
|
||||
export const renameEntry = (path: string, newName: string) =>
|
||||
invoke<string>("rename_entry", { path, newName });
|
||||
|
||||
export const deleteEntry = (path: string) =>
|
||||
invoke<void>("delete_entry", { path });
|
||||
|
||||
export const uploadEntry = (
|
||||
parent: string,
|
||||
name: string,
|
||||
base64Content: string,
|
||||
) => invoke<string>("upload_entry", { parent, name, base64Content });
|
||||
|
||||
export const targetInfo = (path: string) =>
|
||||
invoke<TargetInfo>("target_info", { path });
|
||||
|
||||
export const readTargetFile = (path: string, file: string) =>
|
||||
invoke<FilePayload>("read_target_file", { path, file });
|
||||
|
||||
export const writeTargetFile = (path: string, file: string, content: string) =>
|
||||
invoke<void>("write_target_file", { path, file, content });
|
||||
|
||||
export const setTargetEntrypoint = (path: string, entrypoint: string) =>
|
||||
invoke<void>("set_target_entrypoint", { path, entrypoint });
|
||||
|
||||
export const compileTarget = (
|
||||
path: string,
|
||||
overrides?: Record<string, string>,
|
||||
) => invoke<CompileResult>("compile_target", { path, overrides });
|
||||
|
||||
export const exportTarget = (
|
||||
path: string,
|
||||
format: string,
|
||||
destination: string,
|
||||
) => invoke<string>("export_target", { path, format, destination });
|
||||
|
||||
export interface Asset {
|
||||
name: string;
|
||||
kind: "font" | "image" | "file";
|
||||
size: number;
|
||||
font_families: string[];
|
||||
}
|
||||
|
||||
export const listAssets = () => invoke<Asset[]>("list_assets");
|
||||
|
||||
export interface Thumbnail {
|
||||
kind: "svg" | "image";
|
||||
data: string;
|
||||
}
|
||||
|
||||
export const thumbnail = (path: string) =>
|
||||
invoke<Thumbnail>("thumbnail", { path });
|
||||
|
||||
export const clearThumbnails = () => invoke<void>("clear_thumbnails");
|
||||
|
||||
export interface ImageData {
|
||||
name: string;
|
||||
data: string;
|
||||
size: number;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
}
|
||||
|
||||
export const readImage = (path: string) =>
|
||||
invoke<ImageData>("read_image", { path });
|
||||
|
||||
export const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "svg", "webp"];
|
||||
|
||||
export function isImagePath(path: string): boolean {
|
||||
const extension = path.split(".").pop()?.toLowerCase() ?? "";
|
||||
return IMAGE_EXTENSIONS.includes(extension);
|
||||
}
|
||||
|
||||
export const listFontFamilies = (path?: string) =>
|
||||
invoke<string[]>("list_font_families", { path: path ?? null });
|
||||
|
||||
export const importAssets = (sources: string[]) =>
|
||||
invoke<string[]>("import_assets", { sources });
|
||||
|
||||
export const deleteAsset = (name: string) =>
|
||||
invoke<void>("delete_asset", { name });
|
||||
|
||||
export const importIntoTarget = (path: string, sources: string[]) =>
|
||||
invoke<string[]>("import_into_target", { path, sources });
|
||||
|
||||
export const importIntoFolder = (parent: string, sources: string[]) =>
|
||||
invoke<string[]>("import_into_folder", { parent, sources });
|
||||
|
||||
export const getSettings = () => invoke<Settings>("get_settings");
|
||||
|
||||
export const updateSettings = (changes: {
|
||||
workspaceRoot?: string;
|
||||
serverUrl?: string;
|
||||
}) => invoke<Settings>("update_settings", changes);
|
||||
|
||||
export const cloudLogin = (
|
||||
serverUrl: string,
|
||||
email: string,
|
||||
password: string,
|
||||
) => invoke<Account>("cloud_login", { serverUrl, email, password });
|
||||
|
||||
export const cloudLogout = () => invoke<void>("cloud_logout");
|
||||
|
||||
export const cloudAccount = () => invoke<Account | null>("cloud_account");
|
||||
|
||||
export const cloudListSpaces = () =>
|
||||
invoke<SpaceSummary[]>("cloud_list_spaces");
|
||||
|
||||
export const cloudCreateSpace = (name: string) =>
|
||||
invoke<SpaceSummary>("cloud_create_space", { name });
|
||||
|
||||
export const cloudDeleteSpace = (spaceId: string) =>
|
||||
invoke<void>("cloud_delete_space", { spaceId });
|
||||
|
||||
export const cloudCloneSpace = (spaceId: string, projectName: string) =>
|
||||
invoke<SyncReport>("cloud_clone_space", { spaceId, projectName });
|
||||
|
||||
export const cloudLinkProject = (project: string, spaceId?: string) =>
|
||||
invoke<SyncReport>("cloud_link_project", { project, spaceId: spaceId ?? null });
|
||||
|
||||
export const cloudUnlinkProject = (project: string) =>
|
||||
invoke<void>("cloud_unlink_project", { project });
|
||||
|
||||
export const cloudPush = (project: string) =>
|
||||
invoke<SyncReport>("cloud_push", { project });
|
||||
|
||||
export const cloudPull = (project: string) =>
|
||||
invoke<SyncReport>("cloud_pull", { project });
|
||||
|
||||
export const cloudSync = (project: string) =>
|
||||
invoke<SyncReport>("cloud_sync", { project });
|
||||
|
||||
export const cloudResolveConflicts = (
|
||||
project: string,
|
||||
resolutions: Resolution[],
|
||||
) => invoke<SyncReport>("cloud_resolve_conflicts", { project, resolutions });
|
||||
|
||||
export function errorMessage(error: unknown): string {
|
||||
if (typeof error === "string") return error;
|
||||
if (error && typeof error === "object" && "diagnostics" in error) {
|
||||
const failure = error as CompileFailure;
|
||||
return failure.diagnostics.map((d) => d.message).join("; ");
|
||||
}
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
|
||||
export function isCompileFailure(error: unknown): error is CompileFailure {
|
||||
return Boolean(error && typeof error === "object" && "diagnostics" in error);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
snippetCompletion,
|
||||
type CompletionContext,
|
||||
} from "@codemirror/autocomplete";
|
||||
|
||||
const typstOptions = [
|
||||
|
||||
snippetCompletion("let ${name} = ${value}", { label: "let", type: "keyword", info: "Variable declaration" }),
|
||||
snippetCompletion("set ${rule}(${value})", { label: "set", type: "keyword", info: "Set rule" }),
|
||||
snippetCompletion("show ${selector}: ${rule}", { label: "show", type: "keyword", info: "Show rule" }),
|
||||
snippetCompletion("import \"${module}\": ${items}", { label: "import", type: "keyword", info: "Import module" }),
|
||||
snippetCompletion("include \"${file}\"", { label: "include", type: "keyword", info: "Include file" }),
|
||||
snippetCompletion("if ${condition} {\n\t${}\n}", { label: "if", type: "keyword", info: "If statement" }),
|
||||
snippetCompletion("else {\n\t${}\n}", { label: "else", type: "keyword", info: "Else statement" }),
|
||||
snippetCompletion("for ${item} in ${collection} {\n\t${}\n}", { label: "for", type: "keyword", info: "For loop" }),
|
||||
snippetCompletion("while ${condition} {\n\t${}\n}", { label: "while", type: "keyword", info: "While loop" }),
|
||||
snippetCompletion("break", { label: "break", type: "keyword", info: "Break loop" }),
|
||||
snippetCompletion("continue", { label: "continue", type: "keyword", info: "Continue loop" }),
|
||||
snippetCompletion("return ${value}", { label: "return", type: "keyword", info: "Return value" }),
|
||||
snippetCompletion("context", { label: "context", type: "keyword", info: "Context expression" }),
|
||||
snippetCompletion("align(${alignment})[${content}]", { label: "align", type: "function", info: "Align content" }),
|
||||
snippetCompletion("page(${content})", { label: "page", type: "function", info: "Page configuration" }),
|
||||
snippetCompletion("pagebreak()", { label: "pagebreak", type: "function", info: "Break page" }),
|
||||
snippetCompletion("colbreak()", { label: "colbreak", type: "function", info: "Break column" }),
|
||||
snippetCompletion("place(${alignment})[${content}]", { label: "place", type: "function", info: "Place content" }),
|
||||
snippetCompletion("columns(${2})[${content}]", { label: "columns", type: "function", info: "Multiple columns" }),
|
||||
snippetCompletion("pad(${10pt})[${content}]", { label: "pad", type: "function", info: "Pad content" }),
|
||||
snippetCompletion("stack(dir: ${ttb}, spacing: ${10pt}, ${items})", { label: "stack", type: "function", info: "Stack items" }),
|
||||
snippetCompletion("grid(columns: ${2}, gutter: ${10pt}, ${items})", { label: "grid", type: "function", info: "Grid layout" }),
|
||||
snippetCompletion("table(columns: ${2}, ${items})", { label: "table", type: "function", info: "Table layout" }),
|
||||
snippetCompletion("rect(width: ${100%}, height: ${100%})[${content}]", { label: "rect", type: "function", info: "Draw rectangle" }),
|
||||
snippetCompletion("square(size: ${10pt})[${content}]", { label: "square", type: "function", info: "Draw square" }),
|
||||
snippetCompletion("circle(radius: ${10pt})[${content}]", { label: "circle", type: "function", info: "Draw circle" }),
|
||||
snippetCompletion("ellipse(width: ${20pt}, height: ${10pt})[${content}]", { label: "ellipse", type: "function", info: "Draw ellipse" }),
|
||||
snippetCompletion("line(length: ${100%})", { label: "line", type: "function", info: "Draw line" }),
|
||||
snippetCompletion("polygon(${vertices})", { label: "polygon", type: "function", info: "Draw polygon" }),
|
||||
snippetCompletion("path(${vertices})", { label: "path", type: "function", info: "Draw path" }),
|
||||
snippetCompletion("image(\"${path}\", width: ${100%})", { label: "image", type: "function", info: "Insert image" }),
|
||||
snippetCompletion("box[${content}]", { label: "box", type: "function", info: "Box inline content" }),
|
||||
snippetCompletion("block[${content}]", { label: "block", type: "function", info: "Block content" }),
|
||||
snippetCompletion("figure(${content}, caption: [${caption}])", { label: "figure", type: "function", info: "Figure with caption" }),
|
||||
snippetCompletion("text(size: ${11pt}, font: \"${Arial}\")[${content}]", { label: "text", type: "function", info: "Text styling" }),
|
||||
snippetCompletion("heading(level: ${1})[${title}]", { label: "heading", type: "function", info: "Heading" }),
|
||||
snippetCompletion("par[${content}]", { label: "par", type: "function", info: "Paragraph" }),
|
||||
snippetCompletion("list([${item}])", { label: "list", type: "function", info: "Bullet list" }),
|
||||
snippetCompletion("enum([${item}])", { label: "enum", type: "function", info: "Numbered list" }),
|
||||
snippetCompletion("terms([${term}], [${description}])", { label: "terms", type: "function", info: "Terms list" }),
|
||||
snippetCompletion("strong[${content}]", { label: "strong", type: "function", info: "Bold text" }),
|
||||
snippetCompletion("emph[${content}]", { label: "emph", type: "function", info: "Italic text" }),
|
||||
snippetCompletion("underline[${content}]", { label: "underline", type: "function", info: "Underline text" }),
|
||||
snippetCompletion("strike[${content}]", { label: "strike", type: "function", info: "Strikethrough text" }),
|
||||
snippetCompletion("overline[${content}]", { label: "overline", type: "function", info: "Overline text" }),
|
||||
snippetCompletion("sub[${content}]", { label: "sub", type: "function", info: "Subscript text" }),
|
||||
snippetCompletion("super[${content}]", { label: "super", type: "function", info: "Superscript text" }),
|
||||
snippetCompletion("raw(\"${code}\", block: ${true})", { label: "raw", type: "function", info: "Raw code block" }),
|
||||
snippetCompletion("link(\"${url}\")[${text}]", { label: "link", type: "function", info: "Hyperlink" }),
|
||||
snippetCompletion("ref(<${label}>)", { label: "ref", type: "function", info: "Reference" }),
|
||||
snippetCompletion("cite(<${label}>)", { label: "cite", type: "function", info: "Citation" }),
|
||||
snippetCompletion("bibliography(\"${file.bib}\")", { label: "bibliography", type: "function", info: "Bibliography" }),
|
||||
snippetCompletion("outline(title: [${Contents}])", { label: "outline", type: "function", info: "Table of contents" }),
|
||||
snippetCompletion("rgb(\"${#000000}\")", { label: "rgb", type: "function", info: "RGB Color" }),
|
||||
snippetCompletion("cmyk(${0%}, ${0%}, ${0%}, ${100%})", { label: "cmyk", type: "function", info: "CMYK Color" }),
|
||||
snippetCompletion("luma(${0%})", { label: "luma", type: "function", info: "Luma (Grayscale) Color" }),
|
||||
snippetCompletion("color", { label: "color", type: "variable" }),
|
||||
snippetCompletion("gradient", { label: "gradient", type: "variable" }),
|
||||
snippetCompletion("pattern(size: (${10pt}, ${10pt}))[${content}]", { label: "pattern", type: "function", info: "Fill pattern" }),
|
||||
snippetCompletion("type(${value})", { label: "type", type: "function", info: "Get type of value" }),
|
||||
snippetCompletion("repr(${value})", { label: "repr", type: "function", info: "String representation" }),
|
||||
snippetCompletion("str(${value})", { label: "str", type: "function", info: "Convert to string" }),
|
||||
snippetCompletion("int(${value})", { label: "int", type: "function", info: "Convert to integer" }),
|
||||
snippetCompletion("float(${value})", { label: "float", type: "function", info: "Convert to float" }),
|
||||
snippetCompletion("datetime(year: ${2024}, month: ${1}, day: ${1})", { label: "datetime", type: "function", info: "Date and time" }),
|
||||
snippetCompletion("math", { label: "math", type: "variable", info: "Math module" }),
|
||||
snippetCompletion("calc", { label: "calc", type: "variable", info: "Calc module" }),
|
||||
snippetCompletion("sys", { label: "sys", type: "variable", info: "System module" }),
|
||||
snippetCompletion("frac(${num}, ${denom})", { label: "frac", type: "function", info: "Fraction (Math)" }),
|
||||
snippetCompletion("binom(${n}, ${k})", { label: "binom", type: "function", info: "Binomial (Math)" }),
|
||||
snippetCompletion("mat(${1}, ${2}; ${3}, ${4})", { label: "mat", type: "function", info: "Matrix (Math)" }),
|
||||
snippetCompletion("vec(${1}, ${2})", { label: "vec", type: "function", info: "Vector (Math)" }),
|
||||
snippetCompletion("cases(${a}, ${b})", { label: "cases", type: "function", info: "Cases (Math)" }),
|
||||
snippetCompletion("sqrt(${x})", { label: "sqrt", type: "function", info: "Square root (Math)" }),
|
||||
snippetCompletion("root(${3}, ${x})", { label: "root", type: "function", info: "N-th root (Math)" }),
|
||||
snippetCompletion("abs(${x})", { label: "abs", type: "function", info: "Absolute value (Math)" }),
|
||||
snippetCompletion("norm(${x})", { label: "norm", type: "function", info: "Norm (Math)" }),
|
||||
snippetCompletion("floor(${x})", { label: "floor", type: "function", info: "Floor (Math)" }),
|
||||
snippetCompletion("ceil(${x})", { label: "ceil", type: "function", info: "Ceiling (Math)" }),
|
||||
snippetCompletion("round(${x})", { label: "round", type: "function", info: "Round (Math)" }),
|
||||
snippetCompletion("cancel(${x})", { label: "cancel", type: "function", info: "Cancel/strike (Math)" }),
|
||||
snippetCompletion("attach(${base}, t: ${top}, b: ${bottom})", { label: "attach", type: "function", info: "Attach scripts (Math)" }),
|
||||
snippetCompletion("scripts(${expr})", { label: "scripts", type: "function", info: "Scripts (Math)" }),
|
||||
snippetCompletion("limits(${expr})", { label: "limits", type: "function", info: "Limits (Math)" }),
|
||||
snippetCompletion("op(\"${name}\")", { label: "op", type: "function", info: "Operator (Math)" }),
|
||||
snippetCompletion("lr(${expr})", { label: "lr", type: "function", info: "Left/Right scales (Math)" }),
|
||||
snippetCompletion("mid(${|})", { label: "mid", type: "function", info: "Mid delimiter (Math)" })
|
||||
];
|
||||
|
||||
export function typstCompletions(context: CompletionContext) {
|
||||
const word = context.matchBefore(/[\w#]*/);
|
||||
if (!word || (word.from === word.to && !context.explicit)) return null;
|
||||
|
||||
return {
|
||||
from: word.text.startsWith("#") ? word.from + 1 : word.from,
|
||||
options: typstOptions,
|
||||
validFor: /^[\w]*$/,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { undo, redo } from "@codemirror/commands";
|
||||
|
||||
export function insertText(view: EditorView | null, text: string) {
|
||||
if (!view) return;
|
||||
const { from, to } = view.state.selection.main;
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: text },
|
||||
selection: { anchor: from + text.length },
|
||||
});
|
||||
view.focus();
|
||||
}
|
||||
|
||||
export function wrapSelection(
|
||||
view: EditorView | null,
|
||||
prefix: string,
|
||||
suffix: string,
|
||||
placeholder = "",
|
||||
) {
|
||||
if (!view) return;
|
||||
const selection = view.state.selection.main;
|
||||
const selected = view.state.doc.sliceString(selection.from, selection.to);
|
||||
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();
|
||||
}
|
||||
|
||||
export function prefixLines(
|
||||
view: EditorView | null,
|
||||
prefix: string,
|
||||
placeholder = "",
|
||||
) {
|
||||
if (!view) return;
|
||||
const selection = view.state.selection.main;
|
||||
const startLine = view.state.doc.lineAt(selection.from);
|
||||
const endLine = view.state.doc.lineAt(selection.to);
|
||||
|
||||
if (selection.empty && startLine.text.trim() === "") {
|
||||
insertText(view, prefix + placeholder);
|
||||
return;
|
||||
}
|
||||
|
||||
const changes = [];
|
||||
for (let number = startLine.number; number <= endLine.number; number += 1) {
|
||||
const line = view.state.doc.line(number);
|
||||
if (line.text.startsWith(prefix)) continue;
|
||||
changes.push({ from: line.from, insert: prefix });
|
||||
}
|
||||
|
||||
view.dispatch({ changes });
|
||||
view.focus();
|
||||
}
|
||||
|
||||
export function undoEdit(view: EditorView | null) {
|
||||
if (!view) return;
|
||||
undo(view);
|
||||
view.focus();
|
||||
}
|
||||
|
||||
export function redoEdit(view: EditorView | null) {
|
||||
if (!view) return;
|
||||
redo(view);
|
||||
view.focus();
|
||||
}
|
||||
|
||||
export function setTypstConfig(
|
||||
view: EditorView | null,
|
||||
setting: string,
|
||||
property: string,
|
||||
value: string,
|
||||
) {
|
||||
if (!view) return;
|
||||
|
||||
const content = view.state.doc.toString();
|
||||
const rule = new RegExp(`^#set\\s+${setting}\\s*\\(([^)]*)\\)`, "m");
|
||||
const match = content.match(rule);
|
||||
|
||||
if (!match || match.index === undefined) {
|
||||
view.dispatch({
|
||||
changes: { from: 0, insert: `#set ${setting}(${property}: ${value})\n` },
|
||||
});
|
||||
view.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = match[1];
|
||||
const property_rule = new RegExp(
|
||||
`${property}\\s*:\\s*(?:\\([^)]*\\)|"[^"]*"|[^,)]+)`,
|
||||
);
|
||||
|
||||
const next = property_rule.test(existing)
|
||||
? existing.replace(property_rule, `${property}: ${value}`)
|
||||
: existing.trim()
|
||||
? `${existing}, ${property}: ${value}`
|
||||
: `${property}: ${value}`;
|
||||
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: match.index,
|
||||
to: match.index + match[0].length,
|
||||
insert: `#set ${setting}(${next})`,
|
||||
},
|
||||
});
|
||||
view.focus();
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
||||
import { tags as t } from "@lezer/highlight";
|
||||
|
||||
interface ThemeColors {
|
||||
background: string;
|
||||
surface: string;
|
||||
text: string;
|
||||
selection: string;
|
||||
activeLine: string;
|
||||
cursor: string;
|
||||
border: string;
|
||||
keyword: string;
|
||||
string: string;
|
||||
number: string;
|
||||
comment: string;
|
||||
variable: string;
|
||||
function: string;
|
||||
heading: string;
|
||||
}
|
||||
|
||||
const palette: Record<"light" | "dark", ThemeColors> = {
|
||||
light: {
|
||||
background: "#ffffff",
|
||||
surface: "#f6f7f9",
|
||||
text: "#14161a",
|
||||
selection: "#dbe6fe",
|
||||
activeLine: "#f6f7f9",
|
||||
cursor: "#3b6cf6",
|
||||
border: "#dfe2e7",
|
||||
keyword: "#7c3aed",
|
||||
string: "#0f766e",
|
||||
number: "#b45309",
|
||||
comment: "#6b7280",
|
||||
variable: "#14161a",
|
||||
function: "#2563eb",
|
||||
heading: "#1d4ed8",
|
||||
},
|
||||
dark: {
|
||||
background: "#16181d",
|
||||
surface: "#1d2026",
|
||||
text: "#eef0f4",
|
||||
selection: "#2f3a52",
|
||||
activeLine: "#1d2026",
|
||||
cursor: "#6b93ff",
|
||||
border: "#2f343d",
|
||||
keyword: "#c4a7f7",
|
||||
string: "#8ddba4",
|
||||
number: "#f0b37e",
|
||||
comment: "#7b8496",
|
||||
variable: "#eef0f4",
|
||||
function: "#7aa2ff",
|
||||
heading: "#8fb3ff",
|
||||
},
|
||||
};
|
||||
|
||||
export function editorTheme(isDark: boolean) {
|
||||
const colors = palette[isDark ? "dark" : "light"];
|
||||
|
||||
const theme = EditorView.theme(
|
||||
{
|
||||
"&": {
|
||||
color: colors.text,
|
||||
backgroundColor: colors.background,
|
||||
height: "100%",
|
||||
fontSize: "13px",
|
||||
},
|
||||
".cm-content": {
|
||||
caretColor: colors.cursor,
|
||||
padding: "12px 0",
|
||||
},
|
||||
".cm-cursor, .cm-dropCursor": { borderLeftColor: colors.cursor },
|
||||
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection":
|
||||
{ backgroundColor: colors.selection },
|
||||
".cm-activeLine": { backgroundColor: colors.activeLine },
|
||||
".cm-gutters": {
|
||||
backgroundColor: colors.background,
|
||||
color: colors.comment,
|
||||
border: "none",
|
||||
},
|
||||
".cm-activeLineGutter": { backgroundColor: colors.activeLine },
|
||||
"&.cm-focused .cm-matchingBracket": {
|
||||
backgroundColor: colors.selection,
|
||||
outline: `1px solid ${colors.border}`,
|
||||
},
|
||||
|
||||
".cm-tooltip": {
|
||||
backgroundColor: colors.surface,
|
||||
color: colors.text,
|
||||
border: `1px solid ${colors.border}`,
|
||||
borderRadius: "6px",
|
||||
maxWidth: "500px",
|
||||
},
|
||||
".cm-tooltip-hover": { maxHeight: "300px", overflow: "auto" },
|
||||
".cm-tooltip .cm-tooltip-arrow:before": {
|
||||
borderTopColor: colors.border,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
".cm-tooltip .cm-tooltip-arrow:after": {
|
||||
borderTopColor: colors.surface,
|
||||
borderBottomColor: colors.surface,
|
||||
},
|
||||
|
||||
".cm-tooltip.cm-tooltip-autocomplete": {
|
||||
backgroundColor: colors.surface,
|
||||
border: `1px solid ${colors.border}`,
|
||||
padding: "4px",
|
||||
},
|
||||
".cm-tooltip.cm-tooltip-autocomplete > ul": {
|
||||
fontFamily: "inherit",
|
||||
maxHeight: "16em",
|
||||
},
|
||||
".cm-tooltip.cm-tooltip-autocomplete > ul > li": {
|
||||
color: colors.text,
|
||||
padding: "3px 8px",
|
||||
borderRadius: "4px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
},
|
||||
".cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected]": {
|
||||
backgroundColor: colors.cursor,
|
||||
color: "#ffffff",
|
||||
},
|
||||
".cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected] .cm-completionDetail":
|
||||
{ color: "#ffffff" },
|
||||
".cm-completionLabel": { color: "inherit" },
|
||||
".cm-completionMatchedText": {
|
||||
textDecoration: "none",
|
||||
fontWeight: "600",
|
||||
color: "inherit",
|
||||
},
|
||||
".cm-completionDetail": {
|
||||
color: colors.comment,
|
||||
fontStyle: "normal",
|
||||
marginLeft: "auto",
|
||||
fontSize: "0.85em",
|
||||
},
|
||||
".cm-completionIcon": {
|
||||
color: colors.comment,
|
||||
opacity: "1",
|
||||
width: "1.1em",
|
||||
},
|
||||
".cm-completionInfo": {
|
||||
backgroundColor: colors.surface,
|
||||
color: colors.text,
|
||||
border: `1px solid ${colors.border}`,
|
||||
borderRadius: "6px",
|
||||
padding: "6px 8px",
|
||||
},
|
||||
|
||||
".cm-panels": { backgroundColor: colors.surface, color: colors.text },
|
||||
".cm-searchMatch": { backgroundColor: "#72a1ff59" },
|
||||
".cm-selectionMatch": { backgroundColor: "#aafe661a" },
|
||||
},
|
||||
{ dark: isDark },
|
||||
);
|
||||
|
||||
const highlightStyle = HighlightStyle.define([
|
||||
{ tag: t.keyword, color: colors.keyword },
|
||||
{
|
||||
tag: [t.name, t.deleted, t.character, t.propertyName, t.macroName],
|
||||
color: colors.variable,
|
||||
},
|
||||
{ tag: [t.function(t.variableName), t.labelName], color: colors.function },
|
||||
{
|
||||
tag: [t.color, t.constant(t.name), t.standard(t.name)],
|
||||
color: colors.function,
|
||||
},
|
||||
{ tag: [t.definition(t.name), t.separator], color: colors.variable },
|
||||
{
|
||||
tag: [
|
||||
t.typeName,
|
||||
t.className,
|
||||
t.number,
|
||||
t.changed,
|
||||
t.annotation,
|
||||
t.modifier,
|
||||
t.self,
|
||||
t.namespace,
|
||||
],
|
||||
color: colors.number,
|
||||
},
|
||||
{
|
||||
tag: [
|
||||
t.operator,
|
||||
t.operatorKeyword,
|
||||
t.url,
|
||||
t.escape,
|
||||
t.regexp,
|
||||
t.special(t.string),
|
||||
],
|
||||
color: colors.keyword,
|
||||
},
|
||||
{ tag: [t.meta, t.comment], color: colors.comment, fontStyle: "italic" },
|
||||
{ tag: t.strong, fontWeight: "bold" },
|
||||
{ tag: t.emphasis, fontStyle: "italic" },
|
||||
{ tag: t.strikethrough, textDecoration: "line-through" },
|
||||
{ tag: t.link, color: colors.function, textDecoration: "underline" },
|
||||
{ tag: t.heading, fontWeight: "bold", color: colors.heading },
|
||||
{ tag: [t.atom, t.bool, t.special(t.variableName)], color: colors.number },
|
||||
{
|
||||
tag: [t.processingInstruction, t.string, t.inserted],
|
||||
color: colors.string,
|
||||
},
|
||||
{ tag: t.invalid, color: "#ff5c57" },
|
||||
]);
|
||||
|
||||
return [theme, syntaxHighlighting(highlightStyle, { fallback: true })];
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
|
||||
export const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "svg", "webp"];
|
||||
export const FONT_EXTENSIONS = ["ttf", "otf", "ttc", "otc"];
|
||||
export const DATA_EXTENSIONS = ["bib", "csl", "json", "yaml", "yml", "csv", "toml"];
|
||||
|
||||
export type PickKind = "all" | "assets" | "images" | "fonts";
|
||||
|
||||
export async function pickFiles(kind: PickKind = "all"): Promise<string[]> {
|
||||
const filters =
|
||||
kind === "images"
|
||||
? [{ name: "Images", extensions: IMAGE_EXTENSIONS }]
|
||||
: kind === "fonts"
|
||||
? [{ name: "Fonts", extensions: FONT_EXTENSIONS }]
|
||||
: kind === "assets"
|
||||
? [
|
||||
{
|
||||
name: "Images and fonts",
|
||||
extensions: [...IMAGE_EXTENSIONS, ...FONT_EXTENSIONS],
|
||||
},
|
||||
{ name: "Images", extensions: IMAGE_EXTENSIONS },
|
||||
{ name: "Fonts", extensions: FONT_EXTENSIONS },
|
||||
]
|
||||
: [
|
||||
{
|
||||
name: "Typst files",
|
||||
extensions: ["typ", ...DATA_EXTENSIONS, ...IMAGE_EXTENSIONS, ...FONT_EXTENSIONS],
|
||||
},
|
||||
];
|
||||
|
||||
const selected = await open({ multiple: true, filters });
|
||||
|
||||
if (!selected) return [];
|
||||
return Array.isArray(selected) ? selected : [selected];
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
|
||||
export interface LspHandle {
|
||||
root_uri: string;
|
||||
document_uri: string;
|
||||
}
|
||||
|
||||
type Handler = (value: string) => void;
|
||||
|
||||
export class LspBridge {
|
||||
private handlers: Handler[] = [];
|
||||
private unlistenMessage: UnlistenFn | null = null;
|
||||
private unlistenClosed: UnlistenFn | null = null;
|
||||
|
||||
handle: LspHandle | null = null;
|
||||
|
||||
readonly transport = {
|
||||
send: (message: string) => {
|
||||
invoke("lsp_send", { message }).catch(() => {});
|
||||
},
|
||||
subscribe: (handler: Handler) => {
|
||||
this.handlers.push(handler);
|
||||
},
|
||||
unsubscribe: (handler: Handler) => {
|
||||
this.handlers = this.handlers.filter((existing) => existing !== handler);
|
||||
},
|
||||
};
|
||||
|
||||
async start(path: string, onClosed?: () => void): Promise<LspHandle> {
|
||||
await this.stop();
|
||||
|
||||
this.unlistenMessage = await listen<string>("lsp://message", (event) => {
|
||||
const message = this.filterDiagnostics(event.payload);
|
||||
for (const handler of this.handlers) handler(message);
|
||||
});
|
||||
|
||||
this.unlistenClosed = await listen("lsp://closed", () => {
|
||||
onClosed?.();
|
||||
});
|
||||
|
||||
this.handle = await invoke<LspHandle>("lsp_start", { path });
|
||||
return this.handle;
|
||||
}
|
||||
|
||||
private filterDiagnostics(message: string): string {
|
||||
if (!message.includes("publishDiagnostics")) return message;
|
||||
try {
|
||||
const parsed = JSON.parse(message);
|
||||
if (parsed.method === "textDocument/publishDiagnostics") {
|
||||
parsed.params.diagnostics = parsed.params.diagnostics.filter(
|
||||
(diagnostic: { message: string }) =>
|
||||
!diagnostic.message.toLowerCase().includes("unknown font family"),
|
||||
);
|
||||
return JSON.stringify(parsed);
|
||||
}
|
||||
} catch {
|
||||
return message;
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
async stop() {
|
||||
this.unlistenMessage?.();
|
||||
this.unlistenClosed?.();
|
||||
this.unlistenMessage = null;
|
||||
this.unlistenClosed = null;
|
||||
this.handlers = [];
|
||||
this.handle = null;
|
||||
await invoke("lsp_stop").catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export const lspAvailable = () => invoke<boolean>("lsp_running");
|
||||
@@ -0,0 +1,348 @@
|
||||
import * as api from "./api";
|
||||
import type {
|
||||
Account,
|
||||
BrowseEntry,
|
||||
CompileResult,
|
||||
Conflict,
|
||||
Diagnostic,
|
||||
Settings,
|
||||
SpaceSummary,
|
||||
TargetInfo,
|
||||
} from "./api";
|
||||
|
||||
export type Scope = "local" | "cloud";
|
||||
export type View = "files" | "editor";
|
||||
export type LspStatus = "off" | "starting" | "on" | "unavailable";
|
||||
|
||||
interface AppState {
|
||||
view: View;
|
||||
scope: Scope;
|
||||
settings: Settings | null;
|
||||
account: Account | null;
|
||||
|
||||
currentDir: string;
|
||||
entries: BrowseEntry[];
|
||||
spaces: SpaceSummary[];
|
||||
|
||||
target: TargetInfo | null;
|
||||
activePath: string | null;
|
||||
editorContent: string;
|
||||
dirty: boolean;
|
||||
compiled: CompileResult | null;
|
||||
diagnostics: Diagnostic[];
|
||||
compiling: boolean;
|
||||
lspStatus: LspStatus;
|
||||
|
||||
syncing: boolean;
|
||||
conflicts: Conflict[];
|
||||
status: string;
|
||||
error: string;
|
||||
theme: "light" | "dark";
|
||||
}
|
||||
|
||||
export const app = $state<AppState>({
|
||||
view: "files",
|
||||
scope: "local",
|
||||
settings: null,
|
||||
account: null,
|
||||
|
||||
currentDir: "",
|
||||
entries: [],
|
||||
spaces: [],
|
||||
|
||||
target: null,
|
||||
activePath: null,
|
||||
editorContent: "",
|
||||
dirty: false,
|
||||
compiled: null,
|
||||
diagnostics: [],
|
||||
compiling: false,
|
||||
lspStatus: "off",
|
||||
|
||||
syncing: false,
|
||||
conflicts: [],
|
||||
status: "",
|
||||
error: "",
|
||||
theme: "light",
|
||||
});
|
||||
|
||||
export function setError(error: unknown) {
|
||||
app.error = api.errorMessage(error);
|
||||
app.status = "";
|
||||
}
|
||||
|
||||
export function setStatus(message: string) {
|
||||
app.status = message;
|
||||
app.error = "";
|
||||
}
|
||||
|
||||
export function clearMessages() {
|
||||
app.status = "";
|
||||
app.error = "";
|
||||
}
|
||||
|
||||
export function applyTheme(theme: "light" | "dark") {
|
||||
app.theme = theme;
|
||||
document.documentElement.dataset.theme = theme;
|
||||
localStorage.setItem("typst-desktop-theme", theme);
|
||||
}
|
||||
|
||||
export function breadcrumbs(): { name: string; path: string }[] {
|
||||
if (!app.currentDir) return [];
|
||||
const segments = app.currentDir.split("/");
|
||||
return segments.map((name, index) => ({
|
||||
name,
|
||||
path: segments.slice(0, index + 1).join("/"),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function bootstrap() {
|
||||
const stored = localStorage.getItem("typst-desktop-theme");
|
||||
applyTheme(stored === "dark" ? "dark" : "light");
|
||||
|
||||
try {
|
||||
app.settings = await api.getSettings();
|
||||
await browseTo("");
|
||||
await refreshAccount();
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function browseTo(path: string) {
|
||||
try {
|
||||
app.entries = await api.browseWorkspace(path);
|
||||
app.currentDir = path;
|
||||
clearMessages();
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshEntries() {
|
||||
await browseTo(app.currentDir);
|
||||
}
|
||||
|
||||
export async function refreshAccount() {
|
||||
try {
|
||||
app.account = await api.cloudAccount();
|
||||
if (app.account) {
|
||||
await refreshSpaces();
|
||||
} else {
|
||||
app.spaces = [];
|
||||
}
|
||||
} catch {
|
||||
app.account = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshSpaces() {
|
||||
try {
|
||||
app.spaces = await api.cloudListSpaces();
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function openTarget(path: string) {
|
||||
try {
|
||||
const target = await api.targetInfo(path);
|
||||
app.target = target;
|
||||
app.view = "editor";
|
||||
app.activePath = null;
|
||||
app.editorContent = "";
|
||||
app.dirty = false;
|
||||
app.compiled = null;
|
||||
app.diagnostics = [];
|
||||
app.lspStatus = "off";
|
||||
clearMessages();
|
||||
|
||||
const preferred =
|
||||
target.files.find((file) => file.path === target.entrypoint) ??
|
||||
target.files.find((file) => file.path.endsWith(".typ")) ??
|
||||
target.files[0];
|
||||
|
||||
if (preferred) await openFile(preferred.path);
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeTarget() {
|
||||
cancelScheduledCompile();
|
||||
if (app.dirty) await saveActiveFile();
|
||||
app.view = "files";
|
||||
app.target = null;
|
||||
app.activePath = null;
|
||||
app.editorContent = "";
|
||||
app.compiled = null;
|
||||
app.diagnostics = [];
|
||||
app.lspStatus = "off";
|
||||
await refreshEntries();
|
||||
}
|
||||
|
||||
export async function refreshTarget() {
|
||||
if (!app.target) return;
|
||||
try {
|
||||
app.target = await api.targetInfo(app.target.path);
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function openFile(file: string) {
|
||||
if (!app.target) return;
|
||||
|
||||
cancelScheduledCompile();
|
||||
|
||||
if (app.dirty && app.activePath) await saveActiveFile();
|
||||
|
||||
try {
|
||||
const payload = await api.readTargetFile(app.target.path, file);
|
||||
app.activePath = file;
|
||||
app.editorContent = payload.is_text ? payload.content : "";
|
||||
app.dirty = false;
|
||||
if (payload.is_text) await compile();
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveActiveFile() {
|
||||
if (!app.target || !app.activePath) return;
|
||||
try {
|
||||
await api.writeTargetFile(
|
||||
app.target.path,
|
||||
app.activePath,
|
||||
app.editorContent,
|
||||
);
|
||||
app.dirty = false;
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
}
|
||||
|
||||
function liveOverrides(): Record<string, string> | undefined {
|
||||
if (!app.dirty || !app.activePath) return undefined;
|
||||
return { [app.activePath]: app.editorContent };
|
||||
}
|
||||
|
||||
let compileRunning = false;
|
||||
let compileQueued = false;
|
||||
|
||||
export async function compile() {
|
||||
if (!app.target) return;
|
||||
|
||||
if (compileRunning) {
|
||||
compileQueued = true;
|
||||
return;
|
||||
}
|
||||
|
||||
compileRunning = true;
|
||||
app.compiling = true;
|
||||
|
||||
try {
|
||||
const result = await api.compileTarget(app.target.path, liveOverrides());
|
||||
app.compiled = result;
|
||||
app.diagnostics = result.diagnostics;
|
||||
} catch (error) {
|
||||
if (api.isCompileFailure(error)) {
|
||||
app.diagnostics = error.diagnostics;
|
||||
} else {
|
||||
setError(error);
|
||||
}
|
||||
} finally {
|
||||
compileRunning = false;
|
||||
app.compiling = false;
|
||||
|
||||
if (compileQueued) {
|
||||
compileQueued = false;
|
||||
await compile();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const COMPILE_DEBOUNCE_MS = 400;
|
||||
let compileTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
export function scheduleCompile() {
|
||||
if (compileTimer) clearTimeout(compileTimer);
|
||||
compileTimer = setTimeout(() => {
|
||||
compileTimer = null;
|
||||
compile();
|
||||
}, COMPILE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
export function cancelScheduledCompile() {
|
||||
if (compileTimer) {
|
||||
clearTimeout(compileTimer);
|
||||
compileTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveAndCompile() {
|
||||
cancelScheduledCompile();
|
||||
await saveActiveFile();
|
||||
await compile();
|
||||
}
|
||||
|
||||
export async function runSync(
|
||||
action: "sync" | "push" | "pull",
|
||||
project = app.target?.path,
|
||||
) {
|
||||
if (!project) return;
|
||||
|
||||
app.syncing = true;
|
||||
clearMessages();
|
||||
|
||||
try {
|
||||
const report =
|
||||
action === "push"
|
||||
? await api.cloudPush(project)
|
||||
: action === "pull"
|
||||
? await api.cloudPull(project)
|
||||
: await api.cloudSync(project);
|
||||
|
||||
app.conflicts = report.conflicts;
|
||||
|
||||
if (report.conflicts.length > 0) {
|
||||
setError(`${report.conflicts.length} file(s) need conflict resolution`);
|
||||
} else {
|
||||
setStatus(summarize(report));
|
||||
}
|
||||
|
||||
await refreshTarget();
|
||||
if (app.activePath) {
|
||||
const payload = await api.readTargetFile(project, app.activePath);
|
||||
if (payload.is_text) {
|
||||
app.editorContent = payload.content;
|
||||
app.dirty = false;
|
||||
}
|
||||
}
|
||||
await compile();
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
} finally {
|
||||
app.syncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function summarize(report: {
|
||||
pushed: string[];
|
||||
pulled: string[];
|
||||
merged: string[];
|
||||
deleted_local: string[];
|
||||
deleted_remote: string[];
|
||||
}): string {
|
||||
const parts: string[] = [];
|
||||
if (report.pushed.length) parts.push(`${report.pushed.length} uploaded`);
|
||||
if (report.pulled.length) parts.push(`${report.pulled.length} downloaded`);
|
||||
if (report.merged.length) parts.push(`${report.merged.length} merged`);
|
||||
if (report.deleted_local.length)
|
||||
parts.push(`${report.deleted_local.length} removed locally`);
|
||||
if (report.deleted_remote.length)
|
||||
parts.push(`${report.deleted_remote.length} removed in cloud`);
|
||||
return parts.length
|
||||
? `Sync complete: ${parts.join(", ")}`
|
||||
: "Already up to date";
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
import "../app.css";
|
||||
import { onMount } from "svelte";
|
||||
import { bootstrap } from "$lib/ts/state.svelte";
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
onMount(bootstrap);
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Tauri doesn't have a Node.js server to do proper SSR
|
||||
// so we use adapter-static with a fallback to index.html to put the site in SPA mode
|
||||
// See: https://svelte.dev/docs/kit/single-page-apps
|
||||
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
|
||||
export const ssr = false;
|
||||
@@ -0,0 +1,768 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||
|
||||
import FileViewer from "$lib/components/FileViewer.svelte";
|
||||
import FileTree from "$lib/components/FileTree.svelte";
|
||||
import Editor from "$lib/components/Editor.svelte";
|
||||
import Preview from "$lib/components/Preview.svelte";
|
||||
import PromptModal from "$lib/components/PromptModal.svelte";
|
||||
import ConfirmModal from "$lib/components/ConfirmModal.svelte";
|
||||
import ConflictModal from "$lib/components/ConflictModal.svelte";
|
||||
import LoginModal from "$lib/components/LoginModal.svelte";
|
||||
import SettingsModal from "$lib/components/SettingsModal.svelte";
|
||||
import AssetsModal from "$lib/components/AssetsModal.svelte";
|
||||
import WindowControls from "$lib/components/WindowControls.svelte";
|
||||
import ImageViewer from "$lib/components/ImageViewer.svelte";
|
||||
import EditorToolbar from "$lib/components/EditorToolbar.svelte";
|
||||
import PageSettingsModal from "$lib/components/PageSettingsModal.svelte";
|
||||
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { insertText } from "$lib/ts/editor-actions";
|
||||
|
||||
import * as api from "$lib/ts/api";
|
||||
import type { BrowseEntry } from "$lib/ts/api";
|
||||
import { pickFiles } from "$lib/ts/import";
|
||||
import {
|
||||
app,
|
||||
browseTo,
|
||||
clearMessages,
|
||||
closeTarget,
|
||||
compile,
|
||||
openFile,
|
||||
openTarget,
|
||||
refreshAccount,
|
||||
refreshEntries,
|
||||
refreshSpaces,
|
||||
refreshTarget,
|
||||
runSync,
|
||||
saveAndCompile,
|
||||
scheduleCompile,
|
||||
setError,
|
||||
setStatus,
|
||||
} from "$lib/ts/state.svelte";
|
||||
|
||||
type Dialog =
|
||||
| { kind: "none" }
|
||||
| { kind: "new-project" }
|
||||
| { kind: "new-folder" }
|
||||
| { kind: "new-document" }
|
||||
| { kind: "rename-entry"; entry: BrowseEntry }
|
||||
| { kind: "delete-entry"; entry: BrowseEntry }
|
||||
| { kind: "link-entry"; entry: BrowseEntry }
|
||||
| { kind: "new-space" }
|
||||
| { kind: "delete-space"; id: string }
|
||||
| { kind: "clone-space"; id: string; name: string }
|
||||
| { kind: "new-file" }
|
||||
| { kind: "new-subfolder" }
|
||||
| { kind: "rename-file"; path: string }
|
||||
| { kind: "delete-file"; path: string }
|
||||
| { kind: "login" }
|
||||
| { kind: "settings" }
|
||||
| { kind: "assets" }
|
||||
| { kind: "page-settings" }
|
||||
| { kind: "conflicts" };
|
||||
|
||||
let dialog = $state<Dialog>({ kind: "none" });
|
||||
let editorView = $state<EditorView | null>(null);
|
||||
let imageViewer = $state<{ paths: string[]; index: number } | null>(null);
|
||||
|
||||
const activeFile = $derived(
|
||||
app.target?.files.find((file) => file.path === app.activePath) ?? null,
|
||||
);
|
||||
|
||||
function close() {
|
||||
dialog = { kind: "none" };
|
||||
}
|
||||
|
||||
async function guard(action: () => Promise<void>) {
|
||||
try {
|
||||
await action();
|
||||
close();
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
}
|
||||
|
||||
const createProject = (name: string) =>
|
||||
guard(async () => {
|
||||
const path = await api.createProjectEntry(app.currentDir, name);
|
||||
await refreshEntries();
|
||||
await openTarget(path);
|
||||
});
|
||||
|
||||
const createFolder = (name: string) =>
|
||||
guard(async () => {
|
||||
await api.createFolderEntry(app.currentDir, name);
|
||||
await refreshEntries();
|
||||
});
|
||||
|
||||
const createDocument = (name: string) =>
|
||||
guard(async () => {
|
||||
const path = await api.createDocumentEntry(app.currentDir, name);
|
||||
await refreshEntries();
|
||||
await openTarget(path);
|
||||
});
|
||||
|
||||
const renameEntry = (entry: BrowseEntry, name: string) =>
|
||||
guard(async () => {
|
||||
await api.renameEntry(entry.path, name);
|
||||
await refreshEntries();
|
||||
});
|
||||
|
||||
const deleteEntry = (entry: BrowseEntry) =>
|
||||
guard(async () => {
|
||||
await api.deleteEntry(entry.path);
|
||||
await refreshEntries();
|
||||
setStatus(`Deleted '${entry.name}'`);
|
||||
});
|
||||
|
||||
const linkEntry = (entry: BrowseEntry) =>
|
||||
guard(async () => {
|
||||
const report = await api.cloudLinkProject(entry.path);
|
||||
await refreshEntries();
|
||||
await refreshSpaces();
|
||||
setStatus(`Uploaded ${report.pushed.length} files to a new cloud space`);
|
||||
});
|
||||
|
||||
const createSpace = (name: string) =>
|
||||
guard(async () => {
|
||||
await api.cloudCreateSpace(name);
|
||||
await refreshSpaces();
|
||||
});
|
||||
|
||||
const deleteSpace = (id: string) =>
|
||||
guard(async () => {
|
||||
await api.cloudDeleteSpace(id);
|
||||
await refreshSpaces();
|
||||
});
|
||||
|
||||
const cloneSpace = (id: string, name: string) =>
|
||||
guard(async () => {
|
||||
await api.cloudCloneSpace(id, name);
|
||||
app.scope = "local";
|
||||
await browseTo("");
|
||||
setStatus(`Downloaded '${name}' to this device`);
|
||||
});
|
||||
|
||||
async function importFiles() {
|
||||
const sources = await pickFiles("all");
|
||||
if (sources.length === 0) return;
|
||||
|
||||
try {
|
||||
if (app.view === "editor" && app.target) {
|
||||
const imported = await api.importIntoTarget(app.target.path, sources);
|
||||
await refreshTarget();
|
||||
await compile();
|
||||
setStatus(`Imported ${imported.length} file(s)`);
|
||||
} else {
|
||||
const imported = await api.importIntoFolder(app.currentDir, sources);
|
||||
await refreshEntries();
|
||||
setStatus(`Imported ${imported.length} file(s)`);
|
||||
}
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
}
|
||||
|
||||
const createFileInTarget = (name: string) =>
|
||||
guard(async () => {
|
||||
const path = name.includes(".") ? name : `${name}.typ`;
|
||||
await api.writeTargetFile(app.target!.path, path, "");
|
||||
await refreshTarget();
|
||||
await openFile(path);
|
||||
});
|
||||
|
||||
const createFolderInTarget = (path: string) =>
|
||||
guard(async () => {
|
||||
await api.createFolderEntry(app.target!.path, path);
|
||||
await refreshTarget();
|
||||
});
|
||||
|
||||
const renameFile = (path: string, next: string) =>
|
||||
guard(async () => {
|
||||
const payload = await api.readTargetFile(app.target!.path, path);
|
||||
await api.writeTargetFile(app.target!.path, next, payload.content);
|
||||
await api.deleteEntry(`${app.target!.path}/${path}`);
|
||||
await refreshTarget();
|
||||
if (app.activePath === path) await openFile(next);
|
||||
});
|
||||
|
||||
const deleteFile = (path: string) =>
|
||||
guard(async () => {
|
||||
await api.deleteEntry(`${app.target!.path}/${path}`);
|
||||
if (app.activePath === path) {
|
||||
app.activePath = null;
|
||||
app.editorContent = "";
|
||||
}
|
||||
await refreshTarget();
|
||||
await compile();
|
||||
});
|
||||
|
||||
async function setEntrypoint(path: string) {
|
||||
if (!app.target) return;
|
||||
try {
|
||||
await api.setTargetEntrypoint(app.target.path, path);
|
||||
await refreshTarget();
|
||||
await compile();
|
||||
setStatus(`'${path}' is now the entrypoint`);
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportAs(format: string) {
|
||||
if (!app.target) return;
|
||||
|
||||
const name = app.target.path.split("/").pop() ?? "document";
|
||||
const destination = await save({
|
||||
defaultPath: `${name.replace(/\.typ$/i, "")}.${format}`,
|
||||
filters: [{ name: format.toUpperCase(), extensions: [format] }],
|
||||
});
|
||||
if (!destination) return;
|
||||
|
||||
try {
|
||||
await api.exportTarget(app.target.path, format, destination);
|
||||
setStatus(`Exported to ${destination}`);
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
}
|
||||
|
||||
const resolveConflicts = (resolutions: api.Resolution[]) =>
|
||||
guard(async () => {
|
||||
const report = await api.cloudResolveConflicts(
|
||||
app.target!.path,
|
||||
resolutions,
|
||||
);
|
||||
app.conflicts = report.conflicts;
|
||||
await refreshTarget();
|
||||
if (app.activePath) await openFile(app.activePath);
|
||||
setStatus("Conflicts resolved and uploaded");
|
||||
});
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "s") {
|
||||
event.preventDefault();
|
||||
if (app.view === "editor") saveAndCompile();
|
||||
}
|
||||
}
|
||||
|
||||
let dropActive = $state(false);
|
||||
|
||||
function dropDestination():
|
||||
| { kind: "folder"; path: string }
|
||||
| { kind: "target"; path: string }
|
||||
| null {
|
||||
if (app.view === "editor" && app.target) {
|
||||
return { kind: "target", path: app.target.path };
|
||||
}
|
||||
if (app.view === "files" && app.scope === "local") {
|
||||
return { kind: "folder", path: app.currentDir };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function dropPaths(paths: string[]) {
|
||||
const destination = dropDestination();
|
||||
if (!destination || paths.length === 0) return;
|
||||
|
||||
try {
|
||||
const imported =
|
||||
destination.kind === "target"
|
||||
? await api.importIntoTarget(destination.path, paths)
|
||||
: await api.importIntoFolder(destination.path, paths);
|
||||
|
||||
if (destination.kind === "target") {
|
||||
await refreshTarget();
|
||||
await compile();
|
||||
} else {
|
||||
await refreshEntries();
|
||||
}
|
||||
setStatus(`Imported ${imported.length} item(s)`);
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const pending = getCurrentWebview().onDragDropEvent((event) => {
|
||||
if (event.payload.type === "over") {
|
||||
dropActive = dropDestination() !== null;
|
||||
} else if (event.payload.type === "drop") {
|
||||
dropActive = false;
|
||||
dropPaths(event.payload.paths);
|
||||
} else {
|
||||
dropActive = false;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
pending.then((unlisten) => unlisten());
|
||||
};
|
||||
});
|
||||
|
||||
const lspLabel: Record<string, string> = {
|
||||
off: "LSP off",
|
||||
starting: "LSP starting",
|
||||
on: "LSP ready",
|
||||
unavailable: "LSP unavailable",
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleKeydown} />
|
||||
|
||||
<div class="flex h-screen flex-col">
|
||||
<header
|
||||
data-tauri-drag-region
|
||||
class="flex h-11 shrink-0 select-none items-center gap-2 border-b border-[var(--color-line)] bg-[var(--color-surface)] pl-3"
|
||||
>
|
||||
{#if app.view === "editor"}
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
|
||||
onclick={closeTarget}
|
||||
>
|
||||
<Icon icon="ph:arrow-left" />
|
||||
Files
|
||||
</button>
|
||||
<span data-tauri-drag-region class="text-sm font-medium">
|
||||
{app.target?.path.split("/").pop()}
|
||||
</span>
|
||||
{#if app.target?.standalone}
|
||||
<span
|
||||
class="rounded bg-[var(--color-surface-sunken)] px-1.5 py-0.5 text-[10px] text-[var(--color-ink-muted)]"
|
||||
>
|
||||
single file
|
||||
</span>
|
||||
{/if}
|
||||
{#if app.dirty}
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-[var(--color-accent)]"></span>
|
||||
{/if}
|
||||
{:else}
|
||||
<Icon icon="ph:file-code" class="text-lg text-[var(--color-accent)]" />
|
||||
<span data-tauri-drag-region class="text-sm font-semibold">
|
||||
Typst Desktop
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<div data-tauri-drag-region class="h-full flex-1"></div>
|
||||
|
||||
{#if app.view === "editor"}
|
||||
<span
|
||||
class="flex items-center gap-1 text-[10px] text-[var(--color-ink-muted)]"
|
||||
title="Typst language server (tinymist)"
|
||||
>
|
||||
<span
|
||||
class="h-1.5 w-1.5 rounded-full
|
||||
{app.lspStatus === 'on'
|
||||
? 'bg-[var(--color-success)]'
|
||||
: app.lspStatus === 'starting'
|
||||
? 'bg-[var(--color-accent)]'
|
||||
: 'bg-[var(--color-ink-muted)]'}"
|
||||
></span>
|
||||
{lspLabel[app.lspStatus]}
|
||||
</span>
|
||||
|
||||
<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={() => (dialog = { kind: "assets" })}
|
||||
>
|
||||
<Icon icon="ph:images" />
|
||||
Assets
|
||||
</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={saveAndCompile}
|
||||
>
|
||||
<Icon icon="ph:floppy-disk" />
|
||||
Save
|
||||
</button>
|
||||
|
||||
<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:export" />
|
||||
Export
|
||||
</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 ["pdf", "png", "html"] as format}
|
||||
<button
|
||||
class="px-3 py-1.5 text-left uppercase hover:bg-[var(--color-surface-sunken)]"
|
||||
onclick={() => exportAs(format)}
|
||||
>
|
||||
{format}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if app.target?.space_id}
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-md bg-[var(--color-accent)] px-2.5 py-1.5 text-xs font-medium text-white transition hover:opacity-90 disabled:opacity-50"
|
||||
disabled={app.syncing}
|
||||
onclick={() => runSync("sync")}
|
||||
>
|
||||
<Icon
|
||||
icon={app.syncing ? "ph:circle-notch" : "ph:arrows-clockwise"}
|
||||
class={app.syncing ? "animate-spin" : ""}
|
||||
/>
|
||||
Sync
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="rounded-md p-1.5 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
|
||||
onclick={() => (dialog = { kind: "settings" })}
|
||||
aria-label="Settings"
|
||||
>
|
||||
<Icon icon="ph:gear-six" />
|
||||
</button>
|
||||
|
||||
<div class="ml-1 h-5 w-px bg-[var(--color-line)]"></div>
|
||||
|
||||
<WindowControls />
|
||||
</header>
|
||||
|
||||
{#if app.status || app.error}
|
||||
<div
|
||||
class="flex shrink-0 items-center gap-2 border-b px-3 py-1.5 text-xs
|
||||
{app.error
|
||||
? 'border-[var(--color-danger)]/30 bg-[var(--color-danger)]/10 text-[var(--color-danger)]'
|
||||
: 'border-[var(--color-success)]/30 bg-[var(--color-success)]/10 text-[var(--color-success)]'}"
|
||||
>
|
||||
<Icon icon={app.error ? "ph:warning-circle" : "ph:check-circle"} />
|
||||
<span class="flex-1">{app.error || app.status}</span>
|
||||
{#if app.conflicts.length > 0}
|
||||
<button
|
||||
class="rounded border border-current px-2 py-0.5 font-medium"
|
||||
onclick={() => (dialog = { kind: "conflicts" })}
|
||||
>
|
||||
Resolve
|
||||
</button>
|
||||
{/if}
|
||||
<button onclick={clearMessages} aria-label="Dismiss">
|
||||
<Icon icon="ph:x" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex min-h-0 flex-1">
|
||||
{#if app.view === "files"}
|
||||
<div class="flex-1">
|
||||
<FileViewer
|
||||
onnewproject={() => (dialog = { kind: "new-project" })}
|
||||
onnewfolder={() => (dialog = { kind: "new-folder" })}
|
||||
onnewdocument={() => (dialog = { kind: "new-document" })}
|
||||
onupload={importFiles}
|
||||
onassets={() => (dialog = { kind: "assets" })}
|
||||
onrename={(entry) => (dialog = { kind: "rename-entry", entry })}
|
||||
ondelete={(entry) => (dialog = { kind: "delete-entry", entry })}
|
||||
onlink={(entry) => (dialog = { kind: "link-entry", entry })}
|
||||
onviewimage={(paths, index) => (imageViewer = { paths, index })}
|
||||
onnewspace={() => (dialog = { kind: "new-space" })}
|
||||
onclonespace={(id, name) => (dialog = { kind: "clone-space", id, name })}
|
||||
ondeletespace={(id) => (dialog = { kind: "delete-space", id })}
|
||||
onsignin={() => (dialog = { kind: "login" })}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
{#if !app.target?.standalone}
|
||||
<div
|
||||
class="flex w-56 shrink-0 flex-col border-r border-[var(--color-line)] bg-[var(--color-surface)]"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between border-b border-[var(--color-line)] px-3 py-1.5"
|
||||
>
|
||||
<span
|
||||
class="text-[10px] font-semibold uppercase tracking-wider text-[var(--color-ink-muted)]"
|
||||
>
|
||||
Files
|
||||
</span>
|
||||
<div class="flex gap-0.5">
|
||||
<button
|
||||
class="rounded p-1 text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
|
||||
onclick={() => (dialog = { kind: "new-file" })}
|
||||
aria-label="New file"
|
||||
>
|
||||
<Icon icon="ph:file-plus" />
|
||||
</button>
|
||||
<button
|
||||
class="rounded p-1 text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
|
||||
onclick={() => (dialog = { kind: "new-subfolder" })}
|
||||
aria-label="New folder"
|
||||
>
|
||||
<Icon icon="ph:folder-plus" />
|
||||
</button>
|
||||
<button
|
||||
class="rounded p-1 text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
|
||||
onclick={importFiles}
|
||||
aria-label="Import files from this computer"
|
||||
>
|
||||
<Icon icon="ph:upload-simple" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FileTree
|
||||
files={app.target?.files ?? []}
|
||||
activePath={app.activePath}
|
||||
entrypoint={app.target?.entrypoint ?? "main.typ"}
|
||||
onopen={openFile}
|
||||
onrename={(path) => (dialog = { kind: "rename-file", path })}
|
||||
ondelete={(path) => (dialog = { kind: "delete-file", path })}
|
||||
onsetentry={setEntrypoint}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex min-w-0 flex-1">
|
||||
<div class="flex min-w-0 flex-1 flex-col border-r border-[var(--color-line)]">
|
||||
{#if app.activePath && activeFile?.is_text}
|
||||
<EditorToolbar
|
||||
view={editorView}
|
||||
disabled={app.activePath.toLowerCase().endsWith(".toml")}
|
||||
onassets={() => (dialog = { kind: "assets" })}
|
||||
onpagesettings={() => (dialog = { kind: "page-settings" })}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="flex shrink-0 items-center gap-2 border-b border-[var(--color-line)] bg-[var(--color-surface)] px-3 py-2 text-xs"
|
||||
>
|
||||
<Icon icon="ph:file-text" class="text-[var(--color-ink-muted)]" />
|
||||
<span>{app.activePath}</span>
|
||||
{#if app.dirty}
|
||||
<span class="text-[var(--color-ink-muted)]">edited</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#key app.target?.path + ":" + app.activePath}
|
||||
<div class="min-h-0 flex-1">
|
||||
<Editor
|
||||
content={app.editorContent}
|
||||
filePath={app.activePath}
|
||||
targetPath={app.target?.path ?? ""}
|
||||
diagnostics={app.diagnostics}
|
||||
onchange={(value) => {
|
||||
app.editorContent = value;
|
||||
app.dirty = true;
|
||||
scheduleCompile();
|
||||
}}
|
||||
onsave={saveAndCompile}
|
||||
onlspstatus={(status) => (app.lspStatus = status)}
|
||||
onready={(view) => (editorView = view)}
|
||||
/>
|
||||
</div>
|
||||
{/key}
|
||||
{:else}
|
||||
<div
|
||||
class="flex flex-1 flex-col items-center justify-center gap-2 bg-[var(--color-surface)] text-[var(--color-ink-muted)]"
|
||||
>
|
||||
<Icon icon="ph:file-dashed" class="text-4xl" />
|
||||
<p class="text-sm">
|
||||
{app.activePath
|
||||
? "This file cannot be edited as text"
|
||||
: "Select a file to edit"}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<Preview
|
||||
compiled={app.compiled}
|
||||
diagnostics={app.diagnostics}
|
||||
compiling={app.compiling}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if imageViewer}
|
||||
<ImageViewer
|
||||
paths={imageViewer.paths}
|
||||
index={imageViewer.index}
|
||||
onclose={() => (imageViewer = null)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if dropActive}
|
||||
<div
|
||||
class="pointer-events-none fixed inset-0 z-40 flex items-center justify-center bg-[var(--color-accent)]/10 p-8"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col items-center gap-3 rounded-xl border-2 border-dashed border-[var(--color-accent)] bg-[var(--color-surface)]/95 px-10 py-8 shadow-xl"
|
||||
>
|
||||
<Icon icon="ph:tray-arrow-down" class="text-4xl text-[var(--color-accent)]" />
|
||||
<p class="text-sm font-medium">
|
||||
{app.view === "editor"
|
||||
? `Drop to add files to ${app.target?.path.split("/").pop()}`
|
||||
: app.currentDir
|
||||
? `Drop to add files to ${app.currentDir.split("/").pop()}`
|
||||
: "Drop to add files to your workspace"}
|
||||
</p>
|
||||
<p class="text-xs text-[var(--color-ink-muted)]">
|
||||
Images, fonts, and Typst files are copied in.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if dialog.kind === "new-project"}
|
||||
<PromptModal
|
||||
title="New project"
|
||||
label="Project name"
|
||||
icon="ph:folder-plus"
|
||||
placeholder="My thesis"
|
||||
onsubmit={createProject}
|
||||
onclose={close}
|
||||
/>
|
||||
{:else if dialog.kind === "new-folder"}
|
||||
<PromptModal
|
||||
title="New folder"
|
||||
label="Folder name"
|
||||
icon="ph:folder-plus"
|
||||
onsubmit={createFolder}
|
||||
onclose={close}
|
||||
/>
|
||||
{:else if dialog.kind === "new-document"}
|
||||
<PromptModal
|
||||
title="New document"
|
||||
label="Document name"
|
||||
icon="ph:file-plus"
|
||||
placeholder="notes"
|
||||
suffix=".typ"
|
||||
onsubmit={createDocument}
|
||||
onclose={close}
|
||||
/>
|
||||
{:else if dialog.kind === "rename-entry"}
|
||||
{@const target = dialog}
|
||||
<PromptModal
|
||||
title="Rename"
|
||||
label="New name"
|
||||
value={target.entry.name}
|
||||
confirmLabel="Rename"
|
||||
onsubmit={(name) => renameEntry(target.entry, name)}
|
||||
onclose={close}
|
||||
/>
|
||||
{:else if dialog.kind === "delete-entry"}
|
||||
{@const target = dialog}
|
||||
<ConfirmModal
|
||||
title="Delete"
|
||||
message="'{target.entry.name}' will be permanently removed from this device."
|
||||
onconfirm={() => deleteEntry(target.entry)}
|
||||
onclose={close}
|
||||
/>
|
||||
{:else if dialog.kind === "link-entry"}
|
||||
{@const target = dialog}
|
||||
<ConfirmModal
|
||||
title="Upload to cloud"
|
||||
message="A new cloud space will be created for '{target.entry.name}' and its files uploaded."
|
||||
confirmLabel="Upload"
|
||||
onconfirm={() => linkEntry(target.entry)}
|
||||
onclose={close}
|
||||
/>
|
||||
{:else if dialog.kind === "new-space"}
|
||||
<PromptModal
|
||||
title="New cloud space"
|
||||
label="Space name"
|
||||
icon="ph:cloud-plus"
|
||||
onsubmit={createSpace}
|
||||
onclose={close}
|
||||
/>
|
||||
{:else if dialog.kind === "delete-space"}
|
||||
{@const target = dialog}
|
||||
<ConfirmModal
|
||||
title="Delete cloud space"
|
||||
message="This permanently deletes the space and its files from TypstDrive. Local copies are kept."
|
||||
onconfirm={() => deleteSpace(target.id)}
|
||||
onclose={close}
|
||||
/>
|
||||
{:else if dialog.kind === "clone-space"}
|
||||
{@const target = dialog}
|
||||
<PromptModal
|
||||
title="Download space"
|
||||
label="Save as project"
|
||||
icon="ph:download-simple"
|
||||
value={target.name}
|
||||
confirmLabel="Download"
|
||||
onsubmit={(name) => cloneSpace(target.id, name)}
|
||||
onclose={close}
|
||||
/>
|
||||
{:else if dialog.kind === "new-file"}
|
||||
<PromptModal
|
||||
title="New file"
|
||||
label="File name"
|
||||
icon="ph:file-plus"
|
||||
placeholder="chapter-1"
|
||||
onsubmit={createFileInTarget}
|
||||
onclose={close}
|
||||
/>
|
||||
{:else if dialog.kind === "new-subfolder"}
|
||||
<PromptModal
|
||||
title="New folder"
|
||||
label="Folder name"
|
||||
icon="ph:folder-plus"
|
||||
placeholder="figures"
|
||||
onsubmit={createFolderInTarget}
|
||||
onclose={close}
|
||||
/>
|
||||
{:else if dialog.kind === "rename-file"}
|
||||
{@const target = dialog}
|
||||
<PromptModal
|
||||
title="Rename file"
|
||||
label="New path"
|
||||
value={target.path}
|
||||
confirmLabel="Rename"
|
||||
onsubmit={(next) => renameFile(target.path, next)}
|
||||
onclose={close}
|
||||
/>
|
||||
{:else if dialog.kind === "delete-file"}
|
||||
{@const target = dialog}
|
||||
<ConfirmModal
|
||||
title="Delete file"
|
||||
message="'{target.path}' will be permanently deleted."
|
||||
onconfirm={() => deleteFile(target.path)}
|
||||
onclose={close}
|
||||
/>
|
||||
{:else if dialog.kind === "login"}
|
||||
<LoginModal
|
||||
serverUrl={app.settings?.server_url ?? ""}
|
||||
onsuccess={async () => {
|
||||
close();
|
||||
await refreshAccount();
|
||||
app.scope = "cloud";
|
||||
setStatus("Connected to TypstDrive");
|
||||
}}
|
||||
onclose={close}
|
||||
/>
|
||||
{:else if dialog.kind === "settings"}
|
||||
<SettingsModal onclose={close} onsignin={() => (dialog = { kind: "login" })} />
|
||||
{:else if dialog.kind === "assets"}
|
||||
<AssetsModal
|
||||
oninsert={app.view === "editor" && editorView
|
||||
? (snippet) => {
|
||||
insertText(editorView, snippet);
|
||||
close();
|
||||
}
|
||||
: undefined}
|
||||
onchanged={compile}
|
||||
onclose={close}
|
||||
/>
|
||||
{:else if dialog.kind === "page-settings"}
|
||||
<PageSettingsModal view={editorView} onclose={close} />
|
||||
{:else if dialog.kind === "conflicts"}
|
||||
<ConflictModal
|
||||
conflicts={app.conflicts}
|
||||
onresolve={resolveConflicts}
|
||||
onclose={close}
|
||||
/>
|
||||
{/if}
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
|
||||
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,18 @@
|
||||
// Tauri doesn't have a Node.js server to do proper SSR
|
||||
// so we use adapter-static with a fallback to index.html to put the site in SPA mode
|
||||
// See: https://svelte.dev/docs/kit/single-page-apps
|
||||
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
|
||||
import adapter from "@sveltejs/adapter-static";
|
||||
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
fallback: "index.html",
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { defineConfig } from "vite";
|
||||
import { sveltekit } from "@sveltejs/kit/vite";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import wasm from "vite-plugin-wasm";
|
||||
import topLevelAwait from "vite-plugin-top-level-await";
|
||||
|
||||
// @ts-expect-error process is a nodejs global
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [tailwindcss(), sveltekit(), wasm(), topLevelAwait()],
|
||||
|
||||
optimizeDeps: {
|
||||
exclude: ["codemirror-lang-typst"],
|
||||
},
|
||||
build: {
|
||||
target: "esnext",
|
||||
},
|
||||
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
}));
|
||||