Add realtime Yjs collaboration for cloud-linked files
Claude-Session: https://claude.ai/code/session_01PoLmSR1pFVyHf8i5b1rNWk
This commit is contained in:
@@ -11,6 +11,7 @@
|
|||||||
"@codemirror/legacy-modes": "^6.5.3",
|
"@codemirror/legacy-modes": "^6.5.3",
|
||||||
"@codemirror/lint": "^6.9.7",
|
"@codemirror/lint": "^6.9.7",
|
||||||
"@codemirror/lsp-client": "^6.2.5",
|
"@codemirror/lsp-client": "^6.2.5",
|
||||||
|
"@codemirror/merge": "^6.12.2",
|
||||||
"@codemirror/state": "^6.7.1",
|
"@codemirror/state": "^6.7.1",
|
||||||
"@codemirror/view": "^6.43.6",
|
"@codemirror/view": "^6.43.6",
|
||||||
"@iconify/svelte": "^5.2.2",
|
"@iconify/svelte": "^5.2.2",
|
||||||
@@ -21,6 +22,9 @@
|
|||||||
"@tauri-apps/plugin-opener": "^2.5.4",
|
"@tauri-apps/plugin-opener": "^2.5.4",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"codemirror-lang-typst": "^0.4.0",
|
"codemirror-lang-typst": "^0.4.0",
|
||||||
|
"y-codemirror.next": "^0.3.5",
|
||||||
|
"y-websocket": "^3.0.0",
|
||||||
|
"yjs": "^13.6.31",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
@@ -51,6 +55,8 @@
|
|||||||
|
|
||||||
"@codemirror/lsp-client": ["@codemirror/[email protected]", "", { "dependencies": { "@codemirror/autocomplete": "^6.20.0", "@codemirror/language": "^6.11.0", "@codemirror/lint": "^6.8.5", "@codemirror/state": "^6.5.2", "@codemirror/view": "^6.37.0", "@lezer/highlight": "^1.2.1", "marked": "^15.0.12", "vscode-languageserver-protocol": "^3.17.5" } }, "sha512-1EqhGRmCZOV7Me+rRuwwkTuvkNoD4Nz6UcE1yx5gdwTVTLD4D9xIy48MJc0LeBQGFLn/HNRW/pHmet4EAEkJFQ=="],
|
"@codemirror/lsp-client": ["@codemirror/[email protected]", "", { "dependencies": { "@codemirror/autocomplete": "^6.20.0", "@codemirror/language": "^6.11.0", "@codemirror/lint": "^6.8.5", "@codemirror/state": "^6.5.2", "@codemirror/view": "^6.37.0", "@lezer/highlight": "^1.2.1", "marked": "^15.0.12", "vscode-languageserver-protocol": "^3.17.5" } }, "sha512-1EqhGRmCZOV7Me+rRuwwkTuvkNoD4Nz6UcE1yx5gdwTVTLD4D9xIy48MJc0LeBQGFLn/HNRW/pHmet4EAEkJFQ=="],
|
||||||
|
|
||||||
|
"@codemirror/merge": ["@codemirror/[email protected]", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/highlight": "^1.0.0", "style-mod": "^4.1.0" } }, "sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w=="],
|
||||||
|
|
||||||
"@codemirror/search": ["@codemirror/[email protected]", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA=="],
|
"@codemirror/search": ["@codemirror/[email protected]", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA=="],
|
||||||
|
|
||||||
"@codemirror/state": ["@codemirror/[email protected]", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A=="],
|
"@codemirror/state": ["@codemirror/[email protected]", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A=="],
|
||||||
@@ -341,10 +347,14 @@
|
|||||||
|
|
||||||
"is-reference": ["[email protected]", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
|
"is-reference": ["[email protected]", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
|
||||||
|
|
||||||
|
"isomorphic.js": ["[email protected]", "", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="],
|
||||||
|
|
||||||
"jiti": ["[email protected]", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
"jiti": ["[email protected]", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
||||||
|
|
||||||
"kleur": ["[email protected]", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
"kleur": ["[email protected]", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||||
|
|
||||||
|
"lib0": ["[email protected]", "", { "dependencies": { "isomorphic.js": "^0.2.4" }, "bin": { "0serve": "bin/0serve.js", "0gentesthtml": "bin/gentesthtml.js", "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js" } }, "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw=="],
|
||||||
|
|
||||||
"lightningcss": ["[email protected]", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
"lightningcss": ["[email protected]", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||||
|
|
||||||
"lightningcss-android-arm64": ["[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
"lightningcss-android-arm64": ["[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||||
@@ -435,6 +445,14 @@
|
|||||||
|
|
||||||
"w3c-keyname": ["[email protected]", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
|
"w3c-keyname": ["[email protected]", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
|
||||||
|
|
||||||
|
"y-codemirror.next": ["[email protected]", "", { "dependencies": { "lib0": "^0.2.42" }, "peerDependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "yjs": "^13.5.6" } }, "sha512-VluNu3e5HfEXybnypnsGwKAj+fKLd4iAnR7JuX1Sfyydmn1jCBS5wwEL/uS04Ch2ib0DnMAOF6ZRR/8kK3wyGw=="],
|
||||||
|
|
||||||
|
"y-protocols": ["[email protected]", "", { "dependencies": { "lib0": "^0.2.85" }, "peerDependencies": { "yjs": "^13.0.0" } }, "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw=="],
|
||||||
|
|
||||||
|
"y-websocket": ["[email protected]", "", { "dependencies": { "lib0": "^0.2.102", "y-protocols": "^1.0.5" }, "peerDependencies": { "yjs": "^13.5.6" } }, "sha512-mUHy7AzkOZ834T/7piqtlA8Yk6AchqKqcrCXjKW8J1w2lPtRDjz8W5/CvXz9higKAHgKRKqpI3T33YkRFLkPtg=="],
|
||||||
|
|
||||||
|
"yjs": ["[email protected]", "", { "dependencies": { "lib0": "^0.2.99" } }, "sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw=="],
|
||||||
|
|
||||||
"zimmerframe": ["[email protected]", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
|
"zimmerframe": ["[email protected]", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/[email protected]", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="],
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/[email protected]", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="],
|
||||||
|
|||||||
+5
-1
@@ -20,6 +20,7 @@
|
|||||||
"@codemirror/legacy-modes": "^6.5.3",
|
"@codemirror/legacy-modes": "^6.5.3",
|
||||||
"@codemirror/lint": "^6.9.7",
|
"@codemirror/lint": "^6.9.7",
|
||||||
"@codemirror/lsp-client": "^6.2.5",
|
"@codemirror/lsp-client": "^6.2.5",
|
||||||
|
"@codemirror/merge": "^6.12.2",
|
||||||
"@codemirror/state": "^6.7.1",
|
"@codemirror/state": "^6.7.1",
|
||||||
"@codemirror/view": "^6.43.6",
|
"@codemirror/view": "^6.43.6",
|
||||||
"@iconify/svelte": "^5.2.2",
|
"@iconify/svelte": "^5.2.2",
|
||||||
@@ -29,7 +30,10 @@
|
|||||||
"@tauri-apps/plugin-dialog": "^2.7.2",
|
"@tauri-apps/plugin-dialog": "^2.7.2",
|
||||||
"@tauri-apps/plugin-opener": "^2.5.4",
|
"@tauri-apps/plugin-opener": "^2.5.4",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"codemirror-lang-typst": "^0.4.0"
|
"codemirror-lang-typst": "^0.4.0",
|
||||||
|
"y-codemirror.next": "^0.3.5",
|
||||||
|
"y-websocket": "^3.0.0",
|
||||||
|
"yjs": "^13.6.31"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
|
|||||||
@@ -1062,6 +1062,32 @@ fn cloud_sync_document(
|
|||||||
sync::sync_document(&server_url, &token, &app, &store, &path)
|
sync::sync_document(&server_url, &token, &app, &store, &path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn cloud_room_id(
|
||||||
|
app: AppHandle,
|
||||||
|
store: State<'_, Store>,
|
||||||
|
path: String,
|
||||||
|
file: String,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
if let Some(link) = store.document_link(&path)? {
|
||||||
|
return Ok(Some(link.document_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
let meta = store.meta(&path)?;
|
||||||
|
let Some(cloud_project_id) = meta.cloud_project_id else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||||
|
let manifest = sync::get_manifest(&server_url, &token, &cloud_project_id)?;
|
||||||
|
|
||||||
|
Ok(manifest
|
||||||
|
.files
|
||||||
|
.into_iter()
|
||||||
|
.find(|entry| entry.path == file)
|
||||||
|
.map(|entry| format!("project:{}:{}", cloud_project_id, entry.id)))
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn cloud_resolve_document(
|
fn cloud_resolve_document(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
@@ -1425,6 +1451,7 @@ pub fn run() {
|
|||||||
cloud_download_document,
|
cloud_download_document,
|
||||||
cloud_delete_document,
|
cloud_delete_document,
|
||||||
cloud_sync_document,
|
cloud_sync_document,
|
||||||
|
cloud_room_id,
|
||||||
cloud_resolve_document,
|
cloud_resolve_document,
|
||||||
cloud_document_link,
|
cloud_document_link,
|
||||||
cloud_linked_documents,
|
cloud_linked_documents,
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ pub fn move_cloud_project(
|
|||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct ManifestEntry {
|
pub struct ManifestEntry {
|
||||||
|
pub id: String,
|
||||||
pub path: String,
|
pub path: String,
|
||||||
pub hash: String,
|
pub hash: String,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onDestroy } from "svelte";
|
||||||
import Icon from "@iconify/svelte";
|
import Icon from "@iconify/svelte";
|
||||||
|
import { EditorView } from "@codemirror/view";
|
||||||
|
import { EditorState } from "@codemirror/state";
|
||||||
|
import { MergeView } from "@codemirror/merge";
|
||||||
import Modal from "./Modal.svelte";
|
import Modal from "./Modal.svelte";
|
||||||
import type { Conflict, Resolution } from "$lib/ts/api";
|
import type { Conflict, Resolution } from "$lib/ts/api";
|
||||||
|
|
||||||
@@ -25,6 +29,37 @@
|
|||||||
|
|
||||||
const current = $derived(conflicts[index]);
|
const current = $derived(conflicts[index]);
|
||||||
|
|
||||||
|
let diffHost: HTMLDivElement | undefined = $state();
|
||||||
|
let mergeView: MergeView | null = null;
|
||||||
|
|
||||||
|
function readOnlyState(doc: string) {
|
||||||
|
return EditorState.create({
|
||||||
|
doc,
|
||||||
|
extensions: [EditorView.editable.of(false), EditorView.lineWrapping],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const conflict = current;
|
||||||
|
mergeView?.destroy();
|
||||||
|
mergeView = null;
|
||||||
|
|
||||||
|
if (!diffHost || !conflict || conflict.binary) return;
|
||||||
|
|
||||||
|
mergeView = new MergeView({
|
||||||
|
a: readOnlyState(conflict.local_text),
|
||||||
|
b: readOnlyState(conflict.remote_text),
|
||||||
|
parent: diffHost,
|
||||||
|
gutter: true,
|
||||||
|
highlightChanges: true,
|
||||||
|
collapseUnchanged: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
mergeView?.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
function choose(option: "merged" | "local" | "remote") {
|
function choose(option: "merged" | "local" | "remote") {
|
||||||
mode[index] = option;
|
mode[index] = option;
|
||||||
choices[index] =
|
choices[index] =
|
||||||
@@ -97,6 +132,17 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="flex justify-between text-[10px] text-[var(--color-ink-muted)]">
|
||||||
|
<span>This device</span>
|
||||||
|
<span>Cloud</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="scroll-thin h-64 w-full overflow-auto rounded-md border border-[var(--color-line)] text-xs"
|
||||||
|
bind:this={diffHost}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
{#each [["merged", "Merged"], ["local", "This device"], ["remote", "Cloud"]] as [option, label]}
|
{#each [["merged", "Merged"], ["local", "This device"], ["remote", "Cloud"]] as [option, label]}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -28,6 +28,9 @@
|
|||||||
} from "@codemirror/autocomplete";
|
} from "@codemirror/autocomplete";
|
||||||
import { lintGutter, setDiagnostics } from "@codemirror/lint";
|
import { lintGutter, setDiagnostics } from "@codemirror/lint";
|
||||||
import { LSPClient, languageServerExtensions } from "@codemirror/lsp-client";
|
import { LSPClient, languageServerExtensions } from "@codemirror/lsp-client";
|
||||||
|
import { yCollab } from "y-codemirror.next";
|
||||||
|
import type { Awareness } from "y-protocols/awareness";
|
||||||
|
import type * as Y from "yjs";
|
||||||
|
|
||||||
import { typstCompletions } from "$lib/ts/completions";
|
import { typstCompletions } from "$lib/ts/completions";
|
||||||
import { editorTheme } from "$lib/ts/editor-theme";
|
import { editorTheme } from "$lib/ts/editor-theme";
|
||||||
@@ -41,6 +44,7 @@
|
|||||||
targetPath: string;
|
targetPath: string;
|
||||||
enableLsp?: boolean;
|
enableLsp?: boolean;
|
||||||
diagnostics?: Diagnostic[];
|
diagnostics?: Diagnostic[];
|
||||||
|
collab?: { text: Y.Text; awareness: Awareness } | null;
|
||||||
onchange: (value: string) => void;
|
onchange: (value: string) => void;
|
||||||
onsave: () => void;
|
onsave: () => void;
|
||||||
onlspstatus?: (status: "off" | "starting" | "on" | "unavailable") => void;
|
onlspstatus?: (status: "off" | "starting" | "on" | "unavailable") => void;
|
||||||
@@ -53,6 +57,7 @@
|
|||||||
targetPath,
|
targetPath,
|
||||||
enableLsp = true,
|
enableLsp = true,
|
||||||
diagnostics = [],
|
diagnostics = [],
|
||||||
|
collab = null,
|
||||||
onchange,
|
onchange,
|
||||||
onsave,
|
onsave,
|
||||||
onlspstatus,
|
onlspstatus,
|
||||||
@@ -65,6 +70,7 @@
|
|||||||
const languageSlot = new Compartment();
|
const languageSlot = new Compartment();
|
||||||
const lspSlot = new Compartment();
|
const lspSlot = new Compartment();
|
||||||
const themeSlot = new Compartment();
|
const themeSlot = new Compartment();
|
||||||
|
const collabSlot = new Compartment();
|
||||||
|
|
||||||
const bridge = new LspBridge();
|
const bridge = new LspBridge();
|
||||||
let client: LSPClient | null = null;
|
let client: LSPClient | null = null;
|
||||||
@@ -158,6 +164,7 @@
|
|||||||
languageSlot.of(isToml ? StreamLanguage.define(toml) : []),
|
languageSlot.of(isToml ? StreamLanguage.define(toml) : []),
|
||||||
lspSlot.of([]),
|
lspSlot.of([]),
|
||||||
themeSlot.of(editorTheme(app.theme === "dark")),
|
themeSlot.of(editorTheme(app.theme === "dark")),
|
||||||
|
collabSlot.of([]),
|
||||||
...(isToml
|
...(isToml
|
||||||
? []
|
? []
|
||||||
: [autocompletion({ override: [typstCompletions] })]),
|
: [autocompletion({ override: [typstCompletions] })]),
|
||||||
@@ -209,9 +216,34 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let boundCollab: { text: Y.Text; awareness: Awareness } | null = null;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const next = collab;
|
||||||
|
if (next === boundCollab) return;
|
||||||
|
|
||||||
|
laterDispatch((current) => {
|
||||||
|
if (next) {
|
||||||
|
current.dispatch({
|
||||||
|
changes: {
|
||||||
|
from: 0,
|
||||||
|
to: current.state.doc.length,
|
||||||
|
insert: next.text.toString(),
|
||||||
|
},
|
||||||
|
effects: collabSlot.reconfigure([
|
||||||
|
yCollab(next.text, next.awareness),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
current.dispatch({ effects: collabSlot.reconfigure([]) });
|
||||||
|
}
|
||||||
|
boundCollab = next;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const next = content;
|
const next = content;
|
||||||
if (!view) return;
|
if (!view || collab) return;
|
||||||
if (view.state.doc.toString() === next) return;
|
if (view.state.doc.toString() === next) return;
|
||||||
|
|
||||||
laterDispatch((current) => {
|
laterDispatch((current) => {
|
||||||
|
|||||||
@@ -404,6 +404,9 @@ export const cloudNewDocument = (title: string, folderId?: string | null) =>
|
|||||||
export const cloudSyncDocument = (path: string) =>
|
export const cloudSyncDocument = (path: string) =>
|
||||||
invoke<SyncReport>("cloud_sync_document", { path });
|
invoke<SyncReport>("cloud_sync_document", { path });
|
||||||
|
|
||||||
|
export const cloudRoomId = (path: string, file: string) =>
|
||||||
|
invoke<string | null>("cloud_room_id", { path, file });
|
||||||
|
|
||||||
export const cloudResolveDocument = (
|
export const cloudResolveDocument = (
|
||||||
path: string,
|
path: string,
|
||||||
content: string,
|
content: string,
|
||||||
|
|||||||
+119
-1
@@ -1,5 +1,10 @@
|
|||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import * as api from "./api";
|
import * as api from "./api";
|
||||||
|
import {
|
||||||
|
openCollabSession,
|
||||||
|
closeCollabSession,
|
||||||
|
type CollabSession,
|
||||||
|
} from "./yjs-client";
|
||||||
import type {
|
import type {
|
||||||
Account,
|
Account,
|
||||||
BrowseEntry,
|
BrowseEntry,
|
||||||
@@ -67,6 +72,10 @@ interface AppState {
|
|||||||
download: DownloadProgress | null;
|
download: DownloadProgress | null;
|
||||||
syncing: boolean;
|
syncing: boolean;
|
||||||
conflicts: Conflict[];
|
conflicts: Conflict[];
|
||||||
|
collab: CollabSession | null;
|
||||||
|
collabIntent: boolean;
|
||||||
|
collabStatus: "connecting" | "connected" | "offline" | null;
|
||||||
|
collabConflict: Conflict | null;
|
||||||
status: string;
|
status: string;
|
||||||
error: string;
|
error: string;
|
||||||
theme: "light" | "dark";
|
theme: "light" | "dark";
|
||||||
@@ -111,6 +120,10 @@ export const app = $state<AppState>({
|
|||||||
download: null,
|
download: null,
|
||||||
syncing: false,
|
syncing: false,
|
||||||
conflicts: [],
|
conflicts: [],
|
||||||
|
collab: null,
|
||||||
|
collabIntent: false,
|
||||||
|
collabStatus: null,
|
||||||
|
collabConflict: null,
|
||||||
status: "",
|
status: "",
|
||||||
error: "",
|
error: "",
|
||||||
theme: "light",
|
theme: "light",
|
||||||
@@ -382,6 +395,7 @@ function handleDeviceEvent(event: DeviceEvent) {
|
|||||||
event.document_id === linkedDocument);
|
event.document_id === linkedDocument);
|
||||||
|
|
||||||
if (matchesOpenTarget) {
|
if (matchesOpenTarget) {
|
||||||
|
if (app.collabIntent) return;
|
||||||
autoSync();
|
autoSync();
|
||||||
} else if (app.scope === "cloud") {
|
} else if (app.scope === "cloud") {
|
||||||
refreshCloud();
|
refreshCloud();
|
||||||
@@ -587,10 +601,19 @@ export async function openTarget(path: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stopCollab() {
|
||||||
|
closeCollabSession(app.collab);
|
||||||
|
app.collab = null;
|
||||||
|
app.collabIntent = false;
|
||||||
|
app.collabStatus = null;
|
||||||
|
app.collabConflict = null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function closeTarget() {
|
export async function closeTarget() {
|
||||||
cancelScheduledCompile();
|
cancelScheduledCompile();
|
||||||
cancelAutosave();
|
cancelAutosave();
|
||||||
if (app.dirty) await saveActiveFile();
|
if (app.dirty) await saveActiveFile();
|
||||||
|
stopCollab();
|
||||||
app.view = "files";
|
app.view = "files";
|
||||||
app.target = null;
|
app.target = null;
|
||||||
app.activePath = null;
|
app.activePath = null;
|
||||||
@@ -617,6 +640,7 @@ export async function openFile(file: string) {
|
|||||||
cancelAutosave();
|
cancelAutosave();
|
||||||
|
|
||||||
if (app.dirty && app.activePath) await saveActiveFile();
|
if (app.dirty && app.activePath) await saveActiveFile();
|
||||||
|
stopCollab();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = await api.readTargetFile(app.target.path, file);
|
const payload = await api.readTargetFile(app.target.path, file);
|
||||||
@@ -624,11 +648,100 @@ export async function openFile(file: string) {
|
|||||||
app.editorContent = payload.is_text ? payload.content : "";
|
app.editorContent = payload.is_text ? payload.content : "";
|
||||||
app.dirty = false;
|
app.dirty = false;
|
||||||
if (payload.is_text) await compile();
|
if (payload.is_text) await compile();
|
||||||
|
if (payload.is_text) await tryOpenCollab(file, app.editorContent);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError(error);
|
setError(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bindCollabSession(session: CollabSession) {
|
||||||
|
app.collab = session;
|
||||||
|
app.collabStatus = session.provider.wsconnected ? "connected" : "connecting";
|
||||||
|
session.provider.on("status", (event: { status: string }) => {
|
||||||
|
app.collabStatus =
|
||||||
|
event.status === "connected"
|
||||||
|
? "connected"
|
||||||
|
: event.status === "connecting"
|
||||||
|
? "connecting"
|
||||||
|
: "offline";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryOpenCollab(file: string, diskContent: string) {
|
||||||
|
if (!app.target || !app.settings?.device_token) return;
|
||||||
|
|
||||||
|
let roomId: string | null;
|
||||||
|
try {
|
||||||
|
roomId = await api.cloudRoomId(app.target.path, file);
|
||||||
|
} catch {
|
||||||
|
roomId = null;
|
||||||
|
}
|
||||||
|
if (!roomId) return;
|
||||||
|
|
||||||
|
app.collabIntent = true;
|
||||||
|
app.collabStatus = "connecting";
|
||||||
|
|
||||||
|
const session = openCollabSession(
|
||||||
|
app.settings.server_url,
|
||||||
|
app.settings.device_token,
|
||||||
|
roomId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const synced = await new Promise<boolean>((resolve) => {
|
||||||
|
session.provider.once("synced", (isSynced: boolean) => resolve(isSynced));
|
||||||
|
}).catch(() => false);
|
||||||
|
|
||||||
|
if (app.activePath !== file) {
|
||||||
|
closeCollabSession(session);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!synced) {
|
||||||
|
bindCollabSession(session);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteText = session.text.toString();
|
||||||
|
if (remoteText !== diskContent) {
|
||||||
|
app.collabConflict = {
|
||||||
|
path: file,
|
||||||
|
local_text: diskContent,
|
||||||
|
remote_text: remoteText,
|
||||||
|
merged_text: remoteText,
|
||||||
|
server_hash: "",
|
||||||
|
auto_merged: false,
|
||||||
|
binary: false,
|
||||||
|
};
|
||||||
|
pendingCollabSession = session;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bindCollabSession(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pendingCollabSession: CollabSession | null = null;
|
||||||
|
|
||||||
|
export function resolveCollabConflict(content: string) {
|
||||||
|
const session = pendingCollabSession;
|
||||||
|
pendingCollabSession = null;
|
||||||
|
app.collabConflict = null;
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
const ytext = session.text;
|
||||||
|
ytext.doc?.transact(() => {
|
||||||
|
ytext.delete(0, ytext.length);
|
||||||
|
ytext.insert(0, content);
|
||||||
|
});
|
||||||
|
|
||||||
|
bindCollabSession(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelCollabConflict() {
|
||||||
|
closeCollabSession(pendingCollabSession);
|
||||||
|
pendingCollabSession = null;
|
||||||
|
app.collabConflict = null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function saveActiveFile() {
|
export async function saveActiveFile() {
|
||||||
if (!app.target || !app.activePath) return;
|
if (!app.target || !app.activePath) return;
|
||||||
try {
|
try {
|
||||||
@@ -711,9 +824,14 @@ export function cancelScheduledCompile() {
|
|||||||
|
|
||||||
let autosaveTimer: ReturnType<typeof setTimeout> | null = null;
|
let autosaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const COLLAB_AUTOSAVE_SECONDS = 5;
|
||||||
|
|
||||||
export function scheduleAutosave() {
|
export function scheduleAutosave() {
|
||||||
const seconds = app.settings?.autosave_seconds ?? 0;
|
|
||||||
if (autosaveTimer) clearTimeout(autosaveTimer);
|
if (autosaveTimer) clearTimeout(autosaveTimer);
|
||||||
|
|
||||||
|
const seconds = app.collabIntent
|
||||||
|
? Math.min(app.settings?.autosave_seconds || COLLAB_AUTOSAVE_SECONDS, COLLAB_AUTOSAVE_SECONDS)
|
||||||
|
: (app.settings?.autosave_seconds ?? 0);
|
||||||
if (seconds <= 0) return;
|
if (seconds <= 0) return;
|
||||||
|
|
||||||
autosaveTimer = setTimeout(() => {
|
autosaveTimer = setTimeout(() => {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import * as Y from "yjs";
|
||||||
|
import { WebsocketProvider } from "y-websocket";
|
||||||
|
|
||||||
|
export interface CollabSession {
|
||||||
|
doc: Y.Doc;
|
||||||
|
text: Y.Text;
|
||||||
|
provider: WebsocketProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wsUrl(serverUrl: string) {
|
||||||
|
return serverUrl
|
||||||
|
.replace(/^https:/, "wss:")
|
||||||
|
.replace(/^http:/, "ws:")
|
||||||
|
.replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openCollabSession(
|
||||||
|
serverUrl: string,
|
||||||
|
deviceToken: string,
|
||||||
|
roomId: string,
|
||||||
|
): CollabSession {
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
const text = doc.getText("typst");
|
||||||
|
|
||||||
|
const provider = new WebsocketProvider(`${wsUrl(serverUrl)}/yjs`, roomId, doc, {
|
||||||
|
params: { token: deviceToken },
|
||||||
|
disableBc: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { doc, text, provider };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeCollabSession(session: CollabSession | null) {
|
||||||
|
if (!session) return;
|
||||||
|
session.provider.disconnect();
|
||||||
|
session.provider.destroy();
|
||||||
|
session.doc.destroy();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user