Real-time collaboration within space (#411)

This commit is contained in:
Zef Hemel
2023-06-13 20:47:05 +02:00
committed by GitHub
parent 063a8e4767
commit 8e0a7cf177
54 changed files with 1358 additions and 187 deletions
+7 -1
View File
@@ -56,8 +56,14 @@
<body>
<header>
<h1>Login to <img src="/.client/logo.png" style="height: 1ch;" /> SilverBullet</h1>
<script>
function saveUsername() {
localStorage.setItem("username", document.getElementsByName("username")[0].value);
return true;
}
</script>
</header>
<form action="/.auth" method="POST">
<form action="/.auth" method="POST" onsubmit="saveUsername()">
<input type="hidden" name="refer" value="" />
<div class="error-message"></div>
<div>
+58 -16
View File
@@ -1,4 +1,6 @@
import { Extension, WebsocketProvider, Y, yCollab } from "../deps.ts";
import { safeRun } from "../../common/util.ts";
import { Extension, HocuspocusProvider, Y, yCollab } from "../deps.ts";
import { SyncService } from "../sync_service.ts";
const userColors = [
{ color: "#30bced", light: "#30bced33" },
@@ -12,27 +14,45 @@ const userColors = [
];
export class CollabState {
ydoc: Y.Doc;
collabProvider: WebsocketProvider;
ytext: Y.Text;
yundoManager: Y.UndoManager;
public ytext: Y.Text;
collabProvider: HocuspocusProvider;
private yundoManager: Y.UndoManager;
interval?: number;
constructor(serverUrl: string, token: string, username: string) {
this.ydoc = new Y.Doc();
this.collabProvider = new WebsocketProvider(
serverUrl,
token,
this.ydoc,
);
constructor(
serverUrl: string,
readonly path: string,
readonly token: string,
username: string,
private syncService: SyncService,
public isLocalCollab: boolean,
) {
this.collabProvider = new HocuspocusProvider({
url: serverUrl,
name: token,
// Receive broadcasted messages from the server (right now only "page has been persisted" notifications)
onStateless: (
{ payload },
) => {
const message = JSON.parse(payload);
switch (message.type) {
case "persisted": {
// Received remote persist notification, updating snapshot
syncService.updateRemoteLastModified(
message.path,
message.lastModified,
).catch(console.error);
}
}
},
});
this.collabProvider.on("status", (e: any) => {
console.log("Collab status change", e);
});
this.collabProvider.on("sync", (e: any) => {
console.log("Sync status", e);
});
this.ytext = this.ydoc.getText("codemirror");
this.ytext = this.collabProvider.document.getText("codemirror");
this.yundoManager = new Y.UndoManager(this.ytext);
const randomColor =
@@ -43,10 +63,32 @@ export class CollabState {
color: randomColor.color,
colorLight: randomColor.light,
});
if (isLocalCollab) {
syncService.excludeFromSync(path).catch(console.error);
this.interval = setInterval(() => {
// Ping the store to make sure the file remains in exclusion
syncService.excludeFromSync(path).catch(console.error);
}, 1000);
}
}
stop() {
console.log("[COLLAB] Destroying collab provider");
if (this.interval) {
clearInterval(this.interval);
}
this.collabProvider.destroy();
// For whatever reason, destroy() doesn't properly clean up everything so we need to help a bit
this.collabProvider.configuration.websocketProvider.webSocket = null;
this.collabProvider.configuration.websocketProvider.destroy();
// When stopping collaboration, we're going back to sync mode. Make sure we got the latest and greatest remote timestamp to avoid
// conflicts
safeRun(async () => {
await this.syncService.unExcludeFromSync(this.path);
await this.syncService.fetchAndPersistRemoteLastModified(this.path);
});
}
collabExtension(): Extension {
+88
View File
@@ -0,0 +1,88 @@
import { nanoid } from "https://esm.sh/nanoid@4.0.0";
import type { Editor } from "./editor.tsx";
const collabPingInterval = 2500;
export class CollabManager {
clientId = nanoid();
localCollabServer: string;
constructor(private editor: Editor) {
this.localCollabServer = location.protocol === "http:"
? `ws://${location.host}/.ws-collab`
: `wss://${location.host}/.ws-collab`;
editor.eventHook.addLocalListener(
"editor:pageLoaded",
(pageName, previousPage) => {
console.log("Page loaded", pageName, previousPage);
this.updatePresence(pageName, previousPage).catch(console.error);
},
);
}
start() {
setInterval(() => {
this.updatePresence(this.editor.currentPage!).catch(console.error);
}, collabPingInterval);
}
async updatePresence(currentPage?: string, previousPage?: string) {
try {
const resp = await this.editor.remoteSpacePrimitives.authenticatedFetch(
this.editor.remoteSpacePrimitives.url,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
operation: "presence",
clientId: this.clientId,
previousPage,
currentPage,
}),
keepalive: true, // important for beforeunload event
},
);
const { collabId } = await resp.json();
if (this.editor.collabState && !this.editor.collabState.isLocalCollab) {
// We're in a remote collab mode, don't do anything
return;
}
// console.log("Collab ID", collabId);
const previousCollabId = this.editor.collabState?.token.split("/")[0];
if (!collabId && this.editor.collabState) {
// Stop collab
console.log("Stopping collab");
if (this.editor.collabState.path === `${currentPage}.md`) {
this.editor.flashNotification(
"Other users have left this page, switched back to single-user mode.",
);
}
this.editor.stopCollab();
} else if (collabId && collabId !== previousCollabId) {
// Start collab
console.log("Starting collab");
this.editor.flashNotification(
"Opening page in multi-user mode.",
);
this.editor.startCollab(
this.localCollabServer,
`${collabId}/${currentPage}.md`,
this.editor.getUsername(),
true,
);
}
} catch (e: any) {
// console.error("Ping error", e);
if (
e.message.toLowerCase().includes("failed") && this.editor.collabState
) {
console.log("Offline, stopping collab");
this.editor.stopCollab();
}
}
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ export {
yCollab,
yUndoManagerKeymap,
} from "https://esm.sh/y-codemirror.next@0.3.2?external=yjs,@codemirror/state,@codemirror/commands,@codemirror/history,@codemirror/view";
export { WebsocketProvider } from "https://esm.sh/y-websocket@1.4.5?external=yjs";
export { HocuspocusProvider } from "https://esm.sh/@hocuspocus/provider@2.1.0?external=yjs,ws&target=es2022";
// Vim mode
export {
+107 -46
View File
@@ -136,7 +136,7 @@ import { HttpSpacePrimitives } from "../common/spaces/http_space_primitives.ts";
import { FallbackSpacePrimitives } from "../common/spaces/fallback_space_primitives.ts";
import { syncSyscalls } from "./syscalls/sync.ts";
import { FilteredSpacePrimitives } from "../common/spaces/filtered_space_primitives.ts";
import { globToRegExp } from "https://deno.land/std@0.189.0/path/glob.ts";
import { CollabManager } from "./collab_manager.ts";
const frontMatterRegex = /^---\n(([^\n]|\n)*?)---\n/;
@@ -193,6 +193,7 @@ export class Editor {
syncService: SyncService;
settings?: BuiltinSettings;
kvStore: DexieKVStore;
collabManager: CollabManager;
constructor(
parent: Element,
@@ -214,6 +215,8 @@ export class Editor {
this.eventHook = new EventHook();
system.addHook(this.eventHook);
this.collabManager = new CollabManager(this);
// Cron hook
const cronHook = new CronHook(system);
system.addHook(cronHook);
@@ -368,6 +371,11 @@ export class Editor {
}
});
globalThis.addEventListener("beforeunload", (e) => {
console.log("Pinging with with undefined page name");
this.collabManager.updatePresence(undefined, this.currentPage);
});
this.eventHook.addLocalListener("plug:changed", async (fileName) => {
console.log("Plug updated, reloading:", fileName);
system.unload(fileName);
@@ -389,7 +397,8 @@ export class Editor {
this.space.on({
pageChanged: (meta) => {
if (this.currentPage === meta.name) {
// Only reload when watching the current page (to avoid reloading when switching pages and in collab mode)
if (this.space.watchInterval && this.currentPage === meta.name) {
console.log("Page changed elsewhere, reloading");
this.flashNotification("Page changed elsewhere, reloading");
this.reloadPage();
@@ -471,6 +480,7 @@ export class Editor {
// Kick off background sync
this.syncService.start();
this.collabManager.start();
this.eventHook.addLocalListener("sync:success", async (operations) => {
// console.log("Operations", operations);
@@ -557,8 +567,13 @@ export class Editor {
this.editorView!.state.sliceDoc(0),
true,
)
.then(() => {
.then(async (meta) => {
this.viewDispatch({ type: "page-saved" });
await this.dispatchAppEvent(
"editor:pageSaved",
this.currentPage,
meta,
);
resolve();
})
.catch((e) => {
@@ -656,8 +671,8 @@ export class Editor {
});
}
dispatchAppEvent(name: AppEvent, data?: any): Promise<any[]> {
return this.eventHook.dispatchEvent(name, data);
dispatchAppEvent(name: AppEvent, ...args: any[]): Promise<any[]> {
return this.eventHook.dispatchEvent(name, ...args);
}
createEditorState(
@@ -950,38 +965,42 @@ export class Editor {
touchCount = 0;
},
mousedown: (event: MouseEvent, view: EditorView) => {
// Make sure <a> tags are clicked without moving the cursor there
if (!event.altKey && event.target instanceof Element) {
const parentA = event.target.closest("a");
if (parentA) {
event.stopPropagation();
event.preventDefault();
const clickEvent: ClickEvent = {
page: pageName,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
altKey: event.altKey,
pos: view.posAtCoords({
x: event.x,
y: event.y,
})!,
};
this.dispatchAppEvent("page:click", clickEvent).catch(
console.error,
);
}
}
},
click: (event: MouseEvent, view: EditorView) => {
safeRun(async () => {
const clickEvent: ClickEvent = {
const pos = view.posAtCoords(event);
if (!pos) {
return;
}
const potentialClickEvent: ClickEvent = {
page: pageName,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
altKey: event.altKey,
pos: view.posAtCoords(event)!,
pos: view.posAtCoords({
x: event.x,
y: event.y,
})!,
};
await this.dispatchAppEvent("page:click", clickEvent);
// Make sure <a> tags are clicked without moving the cursor there
if (!event.altKey && event.target instanceof Element) {
const parentA = event.target.closest("a");
if (parentA) {
event.stopPropagation();
event.preventDefault();
await this.dispatchAppEvent(
"page:click",
potentialClickEvent,
);
return;
}
}
const distanceX = event.x - view.coordsAtPos(pos)!.left;
// What we're trying to determine here is if the click occured anywhere near the looked up position
// this may not be the case with locations that expand signifcantly based on live preview (such as links), we don't want any accidental clicks
// Fixes #357
if (distanceX <= view.defaultCharacterWidth) {
await this.dispatchAppEvent("page:click", potentialClickEvent);
}
});
},
}),
@@ -1107,6 +1126,10 @@ export class Editor {
this.editorView!.focus();
}
getUsername(): string {
return localStorage.getItem("username") || "you";
}
async navigate(
name: string,
pos?: number | string,
@@ -1144,8 +1167,7 @@ export class Editor {
await this.save(true);
// And stop the collab session
if (this.collabState) {
this.collabState.stop();
this.collabState = undefined;
this.stopCollab();
}
}
}
@@ -1187,9 +1209,10 @@ export class Editor {
// Note: these events are dispatched asynchronously deliberately (not waiting for results)
if (loadingDifferentPage) {
this.eventHook.dispatchEvent("editor:pageLoaded", pageName).catch(
console.error,
);
this.eventHook.dispatchEvent("editor:pageLoaded", pageName, previousPage)
.catch(
console.error,
);
} else {
this.eventHook.dispatchEvent("editor:pageReloaded", pageName).catch(
console.error,
@@ -1226,10 +1249,18 @@ export class Editor {
if (pageState) {
// Restore state
editorView.scrollDOM.scrollTop = pageState!.scrollTop;
editorView.dispatch({
selection: pageState.selection,
scrollIntoView: true,
});
try {
editorView.dispatch({
selection: pageState.selection,
scrollIntoView: true,
});
} catch {
// This is fine, just go to the top
editorView.dispatch({
selection: { anchor: 0 },
scrollIntoView: true,
});
}
} else {
editorView.scrollDOM.scrollTop = 0;
editorView.dispatch({
@@ -1502,19 +1533,49 @@ export class Editor {
return;
}
startCollab(serverUrl: string, token: string, username: string) {
startCollab(
serverUrl: string,
token: string,
username: string,
isLocalCollab = false,
) {
if (this.collabState) {
// Clean up old collab state
this.collabState.stop();
}
const initialText = this.editorView!.state.sliceDoc();
this.collabState = new CollabState(serverUrl, token, username);
this.collabState.collabProvider.once("sync", (synced: boolean) => {
if (this.collabState?.ytext.toString() === "") {
console.log("Synced value is empty, putting back original text");
this.collabState?.ytext.insert(0, initialText);
this.collabState = new CollabState(
serverUrl,
`${this.currentPage!}.md`,
token,
username,
this.syncService,
isLocalCollab,
);
this.collabState.collabProvider.on("synced", () => {
if (this.collabState!.ytext.toString() === "") {
console.log(
"[Collab]",
"Synced value is empty (new collab session), inserting local copy",
);
this.collabState!.ytext.insert(0, initialText);
}
});
this.rebuildEditorState();
// Don't watch for local changes in this mode
this.space.unwatch();
}
stopCollab() {
if (this.collabState) {
this.collabState.stop();
this.collabState = undefined;
this.rebuildEditorState();
}
// Start file watching again
this.space.watch();
}
}
+11 -5
View File
@@ -55,13 +55,16 @@
<body>
<header>
<h1>Reset page</h1>
<h1>Logout</h1>
</header>
<button onclick="resetAll()">Flush everything</button>
<button onclick="javascript:location='/'">Back</button>
<button onclick="resetAll()">Logout</button>
<button onclick="javascript:location='/'">Cancel</button>
<script>
function resetAll() {
// Reset local storage username
localStorage.removeItem("username");
if (indexedDB.databases) {
// get a list of all existing IndexedDB databases
indexedDB.databases().then((databases) => {
@@ -75,8 +78,12 @@
})
);
}).then(() => {
alert("All IndexedDB databases deleted");
alert("Flushed local data, you're now logged out");
location.href = "/.auth?logout";
});
} else {
alert("Cannot flush local data (Firefox user?), will now log you out");
location.href = "/.auth?logout";
}
if (navigator.serviceWorker) {
@@ -90,7 +97,6 @@
navigator.serviceWorker.getRegistrations().then((registrations) => {
for (const registration of registrations) {
registration.unregister();
alert("Service worker unregistered");
}
});
+3 -3
View File
@@ -31,7 +31,7 @@ export class PathPageNavigator {
`${this.root}/${encodedPage}`,
);
}
window.dispatchEvent(
globalThis.dispatchEvent(
new PopStateEvent("popstate", {
state: { page, pos },
}),
@@ -60,12 +60,12 @@ export class PathPageNavigator {
}
});
};
window.addEventListener("popstate", cb);
globalThis.addEventListener("popstate", cb);
cb();
}
decodeURI(): [string, number | string] {
let [page, pos] = decodeURI(
const [page, pos] = decodeURI(
location.pathname.substring(this.root.length + 1),
).split("@");
if (pos) {
+4
View File
@@ -43,6 +43,8 @@ export default function reducer(
return {
...state,
showPageNavigator: true,
showCommandPalette: false,
showFilterBox: false,
};
case "stop-navigate":
return {
@@ -69,6 +71,8 @@ export default function reducer(
return {
...state,
showCommandPalette: true,
showPageNavigator: false,
showFilterBox: false,
showCommandPaletteContext: action.context,
};
}
+7 -6
View File
@@ -8,7 +8,7 @@ const CACHE_NAME = "{{CACHE_NAME}}";
const precacheFiles = Object.fromEntries([
"/",
"/.client/reset.html",
"/.client/logout.html",
"/.client/client.js",
"/.client/favicon.png",
"/.client/iAWriterMonoS-Bold.woff2",
@@ -83,13 +83,14 @@ self.addEventListener("fetch", (event: any) => {
const requestUrl = new URL(event.request.url);
const pathname = requestUrl.pathname;
// console.log("In service worker, pathname is", pathname);
// If this is a /.fs request, this can either be a plug worker load or an attachment load
if (pathname.startsWith("/.fs")) {
if (fileContentTable && !event.request.headers.has("x-sync-mode")) {
console.log(
"Attempting to serve file from locally synced space:",
pathname,
);
// console.log(
// "Attempting to serve file from locally synced space:",
// pathname,
// );
// Don't fetch from DB when in sync mode (because then updates won't sync)
const path = decodeURIComponent(
requestUrl.pathname.slice("/.fs/".length),
@@ -97,7 +98,7 @@ self.addEventListener("fetch", (event: any) => {
return fileContentTable.get(path).then(
(data) => {
if (data) {
console.log("Serving from space", path);
// console.log("Serving from space", path);
return new Response(data.data, {
headers: {
"Content-type": mime.getType(path) ||
+3 -7
View File
@@ -51,7 +51,7 @@ export class Space extends EventEmitter<SpaceEvents> {
super();
this.kvStore.get("imageHeightCache").then((cache) => {
if (cache) {
console.log("Loaded image height cache from KV store", cache);
// console.log("Loaded image height cache from KV store", cache);
this.imageHeightCache = cache;
}
});
@@ -200,13 +200,9 @@ export class Space extends EventEmitter<SpaceEvents> {
writeAttachment(
name: string,
data: Uint8Array,
selfUpdate?: boolean | undefined,
selfUpdate?: boolean,
): Promise<AttachmentMeta> {
return this.spacePrimitives.writeFile(
name,
data as Uint8Array,
selfUpdate,
);
return this.spacePrimitives.writeFile(name, data, selfUpdate);
}
deleteAttachment(name: string): Promise<void> {
+158 -17
View File
@@ -1,3 +1,4 @@
import { sleep } from "../common/async_util.ts";
import type { SpacePrimitives } from "../common/spaces/space_primitives.ts";
import {
SpaceSync,
@@ -16,6 +17,8 @@ const syncStartTimeKey = "syncStartTime";
// Keeps the last time an activity was registered, used to detect if a sync is still alive and whether a new one should be started already
const syncLastActivityKey = "syncLastActivity";
const syncExcludePrefix = "syncExclude:";
// maximum time between two activities before we consider a sync crashed
const syncMaxIdleTimeout = 1000 * 20; // 20s
@@ -31,8 +34,8 @@ export class SyncService {
lastReportedSyncStatus = Date.now();
constructor(
private localSpacePrimitives: SpacePrimitives,
private remoteSpace: SpacePrimitives,
readonly localSpacePrimitives: SpacePrimitives,
readonly remoteSpace: SpacePrimitives,
private kvStore: KVStore,
private eventHook: EventHook,
private isSyncCandidate: (path: string) => boolean,
@@ -53,8 +56,17 @@ export class SyncService {
await this.syncFile(`${name}.md`);
});
eventHook.addLocalListener("page:saved", async (name) => {
await this.syncFile(`${name}.md`);
eventHook.addLocalListener("editor:pageSaved", async (name, meta) => {
const path = `${name}.md`;
await this.syncFile(path);
if (await this.isExcludedFromSync(path)) {
// So we're editing a page and just saved it, but it's excluded from sync
// Assumption: we're in collab mode for this file, so we're going to constantly update our local hash
// console.log(
// "Locally updating last modified in snapshot because we're in collab mode",
// );
await this.updateLocalLastModified(path, meta.lastModified);
}
});
}
@@ -82,8 +94,13 @@ export class SyncService {
async registerSyncStart(): Promise<void> {
// Assumption: this is called after an isSyncing() check
await this.kvStore.set(syncStartTimeKey, Date.now());
await this.kvStore.set(syncLastActivityKey, Date.now());
await this.kvStore.batchSet([{
key: syncStartTimeKey,
value: Date.now(),
}, {
key: syncLastActivityKey,
value: Date.now(),
}]);
}
async registerSyncProgress(status?: SyncStatus): Promise<void> {
@@ -101,6 +118,40 @@ export class SyncService {
await this.kvStore.del(syncStartTimeKey);
}
// Temporarily exclude a specific file from sync (e.g. when in collab mode)
excludeFromSync(path: string): Promise<void> {
return this.kvStore.set(syncExcludePrefix + path, Date.now());
}
unExcludeFromSync(path: string): Promise<void> {
return this.kvStore.del(syncExcludePrefix + path);
}
async isExcludedFromSync(path: string): Promise<boolean> {
const lastExcluded = await this.kvStore.get(syncExcludePrefix + path);
return lastExcluded && Date.now() - lastExcluded < syncMaxIdleTimeout;
}
async fetchAllExcludedFromSync(): Promise<string[]> {
const entries = await this.kvStore.queryPrefix(syncExcludePrefix);
const expiredPaths: string[] = [];
const now = Date.now();
const result = entries.filter(({ key, value }) => {
if (now - value > syncMaxIdleTimeout) {
expiredPaths.push(key);
return false;
}
return true;
}).map(({ key }) => key.slice(syncExcludePrefix.length));
if (expiredPaths.length > 0) {
console.log("Purging expired sync exclusions: ", expiredPaths);
await this.kvStore.batchDelete(expiredPaths);
}
return result;
}
async getSnapshot(): Promise<Map<string, SyncStatusItem>> {
const snapshot = (await this.kvStore.get(syncSnapshotKey)) || {};
return new Map<string, SyncStatusItem>(
@@ -108,6 +159,91 @@ export class SyncService {
);
}
// Await a moment when the sync is no longer running
async noOngoingSync(): Promise<void> {
// Not completely safe, could have race condition on setting the syncStartTimeKey
while (await this.isSyncing()) {
await sleep(100);
}
}
// When in collab mode, we delegate the sync to the CDRT engine, to avoid conflicts, we try to keep the lastModified time in sync with the remote
async updateRemoteLastModified(path: string, lastModified: number) {
await this.noOngoingSync();
await this.registerSyncStart();
const snapshot = await this.getSnapshot();
const entry = snapshot.get(path);
if (entry) {
snapshot.set(path, [entry[0], lastModified]);
} else {
// In the unlikely scenario that a space first openen on a collab page before every being synced
try {
console.log(
"Received lastModified time for file not in snapshot",
path,
lastModified,
);
snapshot.set(path, [
(await this.localSpacePrimitives.getFileMeta(path)).lastModified,
lastModified,
]);
} catch (e) {
console.warn(
"Received lastModified time for non-existing file not in snapshot",
path,
lastModified,
e,
);
}
}
await this.saveSnapshot(snapshot);
await this.registerSyncStop();
}
// Reach out out to remote space, fetch the latest lastModified time and update the local snapshot
// This is used when exiting collab mode
async fetchAndPersistRemoteLastModified(path: string) {
const meta = await this.remoteSpace.getFileMeta(path);
await this.updateRemoteLastModified(
path,
meta.lastModified,
);
}
// When in collab mode, we delegate the sync to the CDRT engine, to avoid conflicts, we try to keep the lastModified time in sync when local changes happen
async updateLocalLastModified(path: string, lastModified: number) {
await this.noOngoingSync();
await this.registerSyncStart();
const snapshot = await this.getSnapshot();
const entry = snapshot.get(path);
if (entry) {
snapshot.set(path, [lastModified, entry[1]]);
} else {
// In the unlikely scenario that a space first openen on a collab page before every being synced
try {
console.log(
"Setting lastModified time for file not in snapshot",
path,
lastModified,
);
snapshot.set(path, [
lastModified,
(await this.localSpacePrimitives.getFileMeta(path)).lastModified,
]);
} catch (e) {
console.warn(
"Received lastModified time for non-existing file not in snapshot",
path,
lastModified,
e,
);
}
}
await this.saveSnapshot(snapshot);
await this.registerSyncStop();
// console.log("All done!");
}
start() {
this.syncSpace().catch(
console.error,
@@ -135,8 +271,15 @@ export class SyncService {
await this.registerSyncStart();
let operations = 0;
const snapshot = await this.getSnapshot();
// Fetch the list of files that are excluded from sync (e.g. because they're in collab mode)
const excludedFromSync = await this.fetchAllExcludedFromSync();
// console.log("Excluded from sync", excludedFromSync);
try {
operations = await this.spaceSync!.syncFiles(snapshot);
operations = await this.spaceSync!.syncFiles(
snapshot,
(path) =>
this.isSyncCandidate(path) && !excludedFromSync.includes(path),
);
this.eventHook.dispatchEvent("sync:success", operations);
} catch (e: any) {
this.eventHook.dispatchEvent("sync:error", e.message);
@@ -152,15 +295,15 @@ export class SyncService {
// console.log("Already syncing");
return;
}
if (!this.isSyncCandidate(name)) {
if (!this.isSyncCandidate(name) || (await this.isExcludedFromSync(name))) {
return;
}
await this.registerSyncStart();
console.log("Syncing file", name);
const snapshot = await this.getSnapshot();
try {
let localHash: number | undefined = undefined;
let remoteHash: number | undefined = undefined;
let localHash: number | undefined;
let remoteHash: number | undefined;
try {
localHash =
(await this.localSpacePrimitives.getFileMeta(name)).lastModified;
@@ -169,8 +312,7 @@ export class SyncService {
}
try {
// This is wasteful, but Netlify (silverbullet.md) doesn't support OPTIONS call (404s) so we'll just fetch the whole file
const { meta } = await this.remoteSpace!.readFile(name);
remoteHash = meta.lastModified;
remoteHash = (await this.remoteSpace!.readFile(name)).meta.lastModified;
} catch (e: any) {
if (e.message === "Not found") {
// File doesn't exist remotely, that's ok
@@ -220,10 +362,8 @@ export class SyncService {
name,
"will pick the version from secondary and be done with it.",
);
const fileMeta = await primary.getFileMeta(name);
// Read file from secondary
const { data } = await secondary.readFile(
const { data, meta } = await secondary.readFile(
name,
);
// Write file to primary
@@ -231,13 +371,14 @@ export class SyncService {
name,
data,
false,
fileMeta.lastModified,
meta.lastModified,
);
// Update snapshot
snapshot.set(name, [
newMeta.lastModified,
fileMeta.lastModified,
meta.lastModified,
]);
return 1;
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ export function collabSyscalls(editor: Editor): SysCallMapping {
"collab.stop": (
_ctx,
) => {
editor.collabState?.stop();
editor.stopCollab();
},
};
}
+8 -4
View File
@@ -32,10 +32,14 @@ export function editorSyscalls(editor: Editor): SysCallMapping {
"editor.reloadPage": async () => {
await editor.reloadPage();
},
"editor.openUrl": (_ctx, url: string) => {
const win = window.open(url, "_blank");
if (win) {
win.focus();
"editor.openUrl": (_ctx, url: string, existingWindow = false) => {
if (!existingWindow) {
const win = window.open(url, "_blank");
if (win) {
win.focus();
}
} else {
location.href = url;
}
},
"editor.downloadFile": (_ctx, filename: string, dataUrl: string) => {