Real-time collaboration within space (#411)
This commit is contained in:
+7
-1
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export function collabSyscalls(editor: Editor): SysCallMapping {
|
||||
"collab.stop": (
|
||||
_ctx,
|
||||
) => {
|
||||
editor.collabState?.stop();
|
||||
editor.stopCollab();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user