Add realtime Yjs collaboration for cloud-linked files
Claude-Session: https://claude.ai/code/session_01PoLmSR1pFVyHf8i5b1rNWk
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from "svelte";
|
||||
import Icon from "@iconify/svelte";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { MergeView } from "@codemirror/merge";
|
||||
import Modal from "./Modal.svelte";
|
||||
import type { Conflict, Resolution } from "$lib/ts/api";
|
||||
|
||||
@@ -25,6 +29,37 @@
|
||||
|
||||
const current = $derived(conflicts[index]);
|
||||
|
||||
let diffHost: HTMLDivElement | undefined = $state();
|
||||
let mergeView: MergeView | null = null;
|
||||
|
||||
function readOnlyState(doc: string) {
|
||||
return EditorState.create({
|
||||
doc,
|
||||
extensions: [EditorView.editable.of(false), EditorView.lineWrapping],
|
||||
});
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const conflict = current;
|
||||
mergeView?.destroy();
|
||||
mergeView = null;
|
||||
|
||||
if (!diffHost || !conflict || conflict.binary) return;
|
||||
|
||||
mergeView = new MergeView({
|
||||
a: readOnlyState(conflict.local_text),
|
||||
b: readOnlyState(conflict.remote_text),
|
||||
parent: diffHost,
|
||||
gutter: true,
|
||||
highlightChanges: true,
|
||||
collapseUnchanged: {},
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
mergeView?.destroy();
|
||||
});
|
||||
|
||||
function choose(option: "merged" | "local" | "remote") {
|
||||
mode[index] = option;
|
||||
choices[index] =
|
||||
@@ -97,6 +132,17 @@
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex justify-between text-[10px] text-[var(--color-ink-muted)]">
|
||||
<span>This device</span>
|
||||
<span>Cloud</span>
|
||||
</div>
|
||||
<div
|
||||
class="scroll-thin h-64 w-full overflow-auto rounded-md border border-[var(--color-line)] text-xs"
|
||||
bind:this={diffHost}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1.5">
|
||||
{#each [["merged", "Merged"], ["local", "This device"], ["remote", "Cloud"]] as [option, label]}
|
||||
<button
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
} from "@codemirror/autocomplete";
|
||||
import { lintGutter, setDiagnostics } from "@codemirror/lint";
|
||||
import { LSPClient, languageServerExtensions } from "@codemirror/lsp-client";
|
||||
import { yCollab } from "y-codemirror.next";
|
||||
import type { Awareness } from "y-protocols/awareness";
|
||||
import type * as Y from "yjs";
|
||||
|
||||
import { typstCompletions } from "$lib/ts/completions";
|
||||
import { editorTheme } from "$lib/ts/editor-theme";
|
||||
@@ -41,6 +44,7 @@
|
||||
targetPath: string;
|
||||
enableLsp?: boolean;
|
||||
diagnostics?: Diagnostic[];
|
||||
collab?: { text: Y.Text; awareness: Awareness } | null;
|
||||
onchange: (value: string) => void;
|
||||
onsave: () => void;
|
||||
onlspstatus?: (status: "off" | "starting" | "on" | "unavailable") => void;
|
||||
@@ -53,6 +57,7 @@
|
||||
targetPath,
|
||||
enableLsp = true,
|
||||
diagnostics = [],
|
||||
collab = null,
|
||||
onchange,
|
||||
onsave,
|
||||
onlspstatus,
|
||||
@@ -65,6 +70,7 @@
|
||||
const languageSlot = new Compartment();
|
||||
const lspSlot = new Compartment();
|
||||
const themeSlot = new Compartment();
|
||||
const collabSlot = new Compartment();
|
||||
|
||||
const bridge = new LspBridge();
|
||||
let client: LSPClient | null = null;
|
||||
@@ -158,6 +164,7 @@
|
||||
languageSlot.of(isToml ? StreamLanguage.define(toml) : []),
|
||||
lspSlot.of([]),
|
||||
themeSlot.of(editorTheme(app.theme === "dark")),
|
||||
collabSlot.of([]),
|
||||
...(isToml
|
||||
? []
|
||||
: [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(() => {
|
||||
const next = content;
|
||||
if (!view) return;
|
||||
if (!view || collab) return;
|
||||
if (view.state.doc.toString() === next) return;
|
||||
|
||||
laterDispatch((current) => {
|
||||
|
||||
@@ -404,6 +404,9 @@ export const cloudNewDocument = (title: string, folderId?: string | null) =>
|
||||
export const cloudSyncDocument = (path: string) =>
|
||||
invoke<SyncReport>("cloud_sync_document", { path });
|
||||
|
||||
export const cloudRoomId = (path: string, file: string) =>
|
||||
invoke<string | null>("cloud_room_id", { path, file });
|
||||
|
||||
export const cloudResolveDocument = (
|
||||
path: string,
|
||||
content: string,
|
||||
|
||||
+119
-1
@@ -1,5 +1,10 @@
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import * as api from "./api";
|
||||
import {
|
||||
openCollabSession,
|
||||
closeCollabSession,
|
||||
type CollabSession,
|
||||
} from "./yjs-client";
|
||||
import type {
|
||||
Account,
|
||||
BrowseEntry,
|
||||
@@ -67,6 +72,10 @@ interface AppState {
|
||||
download: DownloadProgress | null;
|
||||
syncing: boolean;
|
||||
conflicts: Conflict[];
|
||||
collab: CollabSession | null;
|
||||
collabIntent: boolean;
|
||||
collabStatus: "connecting" | "connected" | "offline" | null;
|
||||
collabConflict: Conflict | null;
|
||||
status: string;
|
||||
error: string;
|
||||
theme: "light" | "dark";
|
||||
@@ -111,6 +120,10 @@ export const app = $state<AppState>({
|
||||
download: null,
|
||||
syncing: false,
|
||||
conflicts: [],
|
||||
collab: null,
|
||||
collabIntent: false,
|
||||
collabStatus: null,
|
||||
collabConflict: null,
|
||||
status: "",
|
||||
error: "",
|
||||
theme: "light",
|
||||
@@ -382,6 +395,7 @@ function handleDeviceEvent(event: DeviceEvent) {
|
||||
event.document_id === linkedDocument);
|
||||
|
||||
if (matchesOpenTarget) {
|
||||
if (app.collabIntent) return;
|
||||
autoSync();
|
||||
} else if (app.scope === "cloud") {
|
||||
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() {
|
||||
cancelScheduledCompile();
|
||||
cancelAutosave();
|
||||
if (app.dirty) await saveActiveFile();
|
||||
stopCollab();
|
||||
app.view = "files";
|
||||
app.target = null;
|
||||
app.activePath = null;
|
||||
@@ -617,6 +640,7 @@ export async function openFile(file: string) {
|
||||
cancelAutosave();
|
||||
|
||||
if (app.dirty && app.activePath) await saveActiveFile();
|
||||
stopCollab();
|
||||
|
||||
try {
|
||||
const payload = await api.readTargetFile(app.target.path, file);
|
||||
@@ -624,11 +648,100 @@ export async function openFile(file: string) {
|
||||
app.editorContent = payload.is_text ? payload.content : "";
|
||||
app.dirty = false;
|
||||
if (payload.is_text) await compile();
|
||||
if (payload.is_text) await tryOpenCollab(file, app.editorContent);
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
}
|
||||
|
||||
function bindCollabSession(session: CollabSession) {
|
||||
app.collab = session;
|
||||
app.collabStatus = session.provider.wsconnected ? "connected" : "connecting";
|
||||
session.provider.on("status", (event: { status: string }) => {
|
||||
app.collabStatus =
|
||||
event.status === "connected"
|
||||
? "connected"
|
||||
: event.status === "connecting"
|
||||
? "connecting"
|
||||
: "offline";
|
||||
});
|
||||
}
|
||||
|
||||
async function tryOpenCollab(file: string, diskContent: string) {
|
||||
if (!app.target || !app.settings?.device_token) return;
|
||||
|
||||
let roomId: string | null;
|
||||
try {
|
||||
roomId = await api.cloudRoomId(app.target.path, file);
|
||||
} catch {
|
||||
roomId = null;
|
||||
}
|
||||
if (!roomId) return;
|
||||
|
||||
app.collabIntent = true;
|
||||
app.collabStatus = "connecting";
|
||||
|
||||
const session = openCollabSession(
|
||||
app.settings.server_url,
|
||||
app.settings.device_token,
|
||||
roomId,
|
||||
);
|
||||
|
||||
const synced = await new Promise<boolean>((resolve) => {
|
||||
session.provider.once("synced", (isSynced: boolean) => resolve(isSynced));
|
||||
}).catch(() => false);
|
||||
|
||||
if (app.activePath !== file) {
|
||||
closeCollabSession(session);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!synced) {
|
||||
bindCollabSession(session);
|
||||
return;
|
||||
}
|
||||
|
||||
const remoteText = session.text.toString();
|
||||
if (remoteText !== diskContent) {
|
||||
app.collabConflict = {
|
||||
path: file,
|
||||
local_text: diskContent,
|
||||
remote_text: remoteText,
|
||||
merged_text: remoteText,
|
||||
server_hash: "",
|
||||
auto_merged: false,
|
||||
binary: false,
|
||||
};
|
||||
pendingCollabSession = session;
|
||||
return;
|
||||
}
|
||||
|
||||
bindCollabSession(session);
|
||||
}
|
||||
|
||||
let pendingCollabSession: CollabSession | null = null;
|
||||
|
||||
export function resolveCollabConflict(content: string) {
|
||||
const session = pendingCollabSession;
|
||||
pendingCollabSession = null;
|
||||
app.collabConflict = null;
|
||||
if (!session) return;
|
||||
|
||||
const ytext = session.text;
|
||||
ytext.doc?.transact(() => {
|
||||
ytext.delete(0, ytext.length);
|
||||
ytext.insert(0, content);
|
||||
});
|
||||
|
||||
bindCollabSession(session);
|
||||
}
|
||||
|
||||
export function cancelCollabConflict() {
|
||||
closeCollabSession(pendingCollabSession);
|
||||
pendingCollabSession = null;
|
||||
app.collabConflict = null;
|
||||
}
|
||||
|
||||
export async function saveActiveFile() {
|
||||
if (!app.target || !app.activePath) return;
|
||||
try {
|
||||
@@ -711,9 +824,14 @@ export function cancelScheduledCompile() {
|
||||
|
||||
let autosaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const COLLAB_AUTOSAVE_SECONDS = 5;
|
||||
|
||||
export function scheduleAutosave() {
|
||||
const seconds = app.settings?.autosave_seconds ?? 0;
|
||||
if (autosaveTimer) clearTimeout(autosaveTimer);
|
||||
|
||||
const seconds = app.collabIntent
|
||||
? Math.min(app.settings?.autosave_seconds || COLLAB_AUTOSAVE_SECONDS, COLLAB_AUTOSAVE_SECONDS)
|
||||
: (app.settings?.autosave_seconds ?? 0);
|
||||
if (seconds <= 0) return;
|
||||
|
||||
autosaveTimer = setTimeout(() => {
|
||||
|
||||
@@ -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