Connect to server over websocket for live cloud sync

Desktop app now holds a persistent websocket to the server and syncs
on push notifications instead of a fixed timer, falling back to the
old polling interval only when the socket is down. Cloud folder/file
listings are cached locally so the browser stays usable offline.

Claude-Session: https://claude.ai/code/session_01PoLmSR1pFVyHf8i5b1rNWk
This commit is contained in:
2026-07-21 12:44:25 -04:00
parent d7b113c59a
commit 566bf1a6cb
10 changed files with 431 additions and 24 deletions
+90 -17
View File
@@ -1,3 +1,4 @@
import { listen } from "@tauri-apps/api/event";
import * as api from "./api";
import type {
Account,
@@ -7,6 +8,7 @@ import type {
CloudFolder,
CompileResult,
Conflict,
DeviceEvent,
Diagnostic,
DocumentLink,
LinkedDocument,
@@ -47,6 +49,8 @@ interface AppState {
cloudDocuments: CloudDocument[];
cloudFiles: CloudFile[];
cloudLoading: boolean;
cloudOffline: boolean;
wsStatus: string;
linkedDocuments: LinkedDocument[];
linkedProjects: LinkedProject[];
documentLink: DocumentLink | null;
@@ -89,6 +93,8 @@ export const app = $state<AppState>({
cloudDocuments: [],
cloudFiles: [],
cloudLoading: false,
cloudOffline: false,
wsStatus: "offline",
linkedDocuments: [],
linkedProjects: [],
documentLink: null,
@@ -340,6 +346,10 @@ export async function bootstrap() {
try {
app.settings = await api.getSettings();
restartAutoSync();
await initWsSync();
if (app.settings?.device_token) {
api.cloudWsStart().catch(() => {});
}
await browseTo("");
await refreshAccount();
} catch (error) {
@@ -347,6 +357,37 @@ export async function bootstrap() {
}
}
async function initWsSync() {
await listen<string>("cloud://ws-status", (event) => {
app.wsStatus = event.payload;
});
await listen<DeviceEvent>("cloud://sync-event", (event) => {
handleDeviceEvent(event.payload);
});
app.wsStatus = await api.cloudWsStatus().catch(() => "offline");
}
function handleDeviceEvent(event: DeviceEvent) {
const linkedProject = app.target?.cloud_project_id;
const linkedDocument = app.documentLink?.document_id;
const matchesOpenTarget =
(event.kind === "project" &&
event.project_id &&
event.project_id === linkedProject) ||
(event.kind === "document" &&
event.document_id &&
event.document_id === linkedDocument);
if (matchesOpenTarget) {
autoSync();
} else if (app.scope === "cloud") {
refreshCloud();
}
}
export async function browseTo(path: string) {
try {
app.entries = await api.browseWorkspace(path);
@@ -382,20 +423,52 @@ export async function refreshCloudProjects() {
}
}
interface CloudSnapshot {
folders: CloudFolder[];
documents: CloudDocument[];
projects: ProjectSummary[];
files: CloudFile[];
}
function cloudCacheKey() {
return `cloud:${app.cloudFolder ?? "root"}`;
}
function applyCloudSnapshot(snapshot: CloudSnapshot) {
if (app.cloudFolder === "shared") {
app.cloudDocuments = snapshot.documents;
app.cloudProjects = snapshot.projects;
app.cloudFolders = [];
app.cloudFiles = [];
} else {
app.cloudFolderTree = snapshot.folders;
app.cloudFolders = snapshot.folders.filter(
(folder) => (folder.parent_id ?? null) === app.cloudFolder,
);
app.cloudDocuments = snapshot.documents;
app.cloudProjects = snapshot.projects.filter(
(project) =>
project.role !== "owner" ||
(project.folder_id ?? null) === app.cloudFolder,
);
app.cloudFiles = snapshot.files;
}
}
export async function refreshCloud() {
if (!app.account) return;
app.cloudLoading = true;
const cacheKey = cloudCacheKey();
try {
app.linkedDocuments = await api.cloudLinkedDocuments().catch(() => []);
app.linkedProjects = await api.cloudLinkedProjects().catch(() => []);
let snapshot: CloudSnapshot;
if (app.cloudFolder === "shared") {
const shared = await api.cloudListShared();
app.cloudDocuments = shared.documents;
app.cloudProjects = shared.projects;
app.cloudFolders = [];
app.cloudFiles = [];
snapshot = { folders: [], documents: shared.documents, projects: shared.projects, files: [] };
} else {
const [folders, documents, projects, files] = await Promise.all([
api.cloudListFolders(),
@@ -403,20 +476,20 @@ export async function refreshCloud() {
api.cloudListProjects(),
api.cloudListFiles(app.cloudFolder),
]);
app.cloudFolderTree = folders;
app.cloudFolders = folders.filter(
(folder) => (folder.parent_id ?? null) === app.cloudFolder,
);
app.cloudDocuments = documents;
app.cloudProjects = projects.filter(
(project) =>
project.role !== "owner" ||
(project.folder_id ?? null) === app.cloudFolder,
);
app.cloudFiles = files;
snapshot = { folders, documents, projects, files };
}
applyCloudSnapshot(snapshot);
app.cloudOffline = false;
api.saveCloudCache(cacheKey, JSON.stringify(snapshot)).catch(() => {});
} catch (error) {
setError(error);
const cached = await api.getCloudCache(cacheKey).catch(() => null);
if (cached) {
applyCloudSnapshot(JSON.parse(cached));
app.cloudOffline = true;
} else {
setError(error);
}
} finally {
app.cloudLoading = false;
}
@@ -668,7 +741,7 @@ export function restartAutoSync() {
if (seconds <= 0) return;
syncTimer = setInterval(() => {
autoSync();
if (app.wsStatus !== "connected") autoSync();
}, seconds * 1000);
}