Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions architecture/webapp-frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ classDiagram
+fetchBlobText(url) BlobText
+fileURLFor(apiBase, path, version) string
}
note for lib "collab.ts is the Yjs provider: SSE down, POST up, over the same pair /events already uses — no websocket dependency, no upgrade handshake, nothing special asked of a proxy that already carries the change stream. Only the client the hub calls `seed` builds the document from the file text; everyone else rebuilds from the relay log, because two independent seeds of the same text are two DIFFERENT Yjs documents and merging them duplicates every character. Awareness is posted separately and never logged. peerCount() is what tells a co-editor's snapshot (already in my buffer) from an outside write (a CLI, another device), which is the only case the peer-wrote banner should fire for"
note for lib "collab.ts is a thin wrapper over y-websocket, and used to be ~250 lines of hand-rolled provider. The document is held by the hub, so the client neither seeds it nor replays a log to catch up: it connects, the sync protocol hands over what it is missing, and `sync` firing is the moment the editor may mount. What went with the relay is every compensation for not owning the document the seed claim and its grace timer, the byte cap, the rebuild-on-full, the resync frame, and solo mode, which let a disconnected client edit its own copy and overwrite a teammate. A hub that cannot serve the route still leaves the editor open and saving; what it loses is LIVE collaboration, not the ability to write, and that is deliberately not a second CRDT path. peerCount() still tells a co-editor's presence from an outside write (a CLI, another device), which is the only case the peer-wrote banner should fire for"
note for hooks "usePresence beats every 10s with the path you are on and renders the roster the hub pushes back — the roster ARRIVES on useProjectEvents' stream (onPresence), not on the POST, which is used only for first paint so a tab opening into a quiet project still sees who is there. One EventSource carries both frame types: presence invalidates nothing. The path is read through a ref so navigating does not tear the timer down, and the unmount beat sends leave:true as courtesy — the 15s TTL is the real guarantee. Every failure is swallowed: presence is decoration and must never surface an error"
note for hooks "useProjectEvents is the one non-polling source: an EventSource on {apiBase}events whose frames invalidate exactly what a peer's write touched (tree/history/heat always; render per named path; text wholesale). It is what makes an OPEN file update at all — useTextAt has no refetchInterval, so before this a body fetched once stayed on screen until the reader navigated away. A resync frame, a truncated path list, or an unparseable frame all fall back to invalidating every body rather than guessing. Errors are deliberately silent: EventSource retries itself and a 5-minute tree refetch is still underneath, so a hub restart or a sleeping laptop must not write a log line. That interval used to be 15s, for tree AND heat AND projects — belt-and-braces from before this stream existed, and on a real project it re-sent the whole 1.65 MB tree four times a minute to a client that already knew nothing had changed. What remains is insurance against a stream that dies quietly on a tab nobody touches, not a freshness mechanism (docs/network-efficiency-prd.md)"
note for hooks "ONE stream per browser, not per tab. Every tab of a project gets the identical fan-out, so tab two onward cost a slot at both ends for nothing: a permanently in-flight request against the hub's per-instance concurrency, and one of the browser's ~6 per-origin HTTP/1.1 sockets — which is how six tabs wedged the whole app, not just live updates. One tab holds the EventSource and relays each frame verbatim over a BroadcastChannel keyed per project; followers run the same handler on the same raw data string. Leadership is a Web Lock held for the leader's lifetime, so the browser reassigns it when that tab dies — a crash or force-quit included, which is the case a heartbeat-and-TTL scheme gets wrong. Frames lost in the handover gap are the poll's job, as they always were. Web Locks needs a secure context, so a plain-http LAN hub falls back to a stream per tab. /collab is deliberately NOT shared: two tabs editing one document are two distinct CRDT peers with their own awareness state"
Expand All @@ -136,7 +136,6 @@ classDiagram
note for components "VisualEdit is click-to-edit for a synced HTML file: the page is rendered by the server's ?edit=1 view inside the SAME sandboxed iframe reading uses, and the editor is injected into it as a separate bundle (src/inline-edit.ts, built IIFE by vite.inline-edit.config.ts — an opaque-origin iframe cannot load a module script without CORS the hub has no business growing). Nothing here serializes the document: the iframe reports ONE element's inner HTML and the source range it belongs to, and this splices that range into the shared Y.Text, which is why the rest of the file survives byte-for-byte. Those ranges are held as Y.RelativePosition, never offsets — a peer's edit earlier in the file moves every number — and they are anchored against the CRDT ITSELF, never openSharedFile.current(), whose seed fallback reports a full document while the Y.Text being measured is empty and collapses every anchor onto index 0 (one edit then replaced an entire file). A patch whose resolved range would swallow a document the stamped range was only part of is refused outright. The iframe does NOT debounce its patch: for 700ms the edit lived only inside it, and Done tears the iframe down — typing and pressing Done is what finishing an edit looks like, and it silently lost the text"
note for components "FileView's HtmlView re-mounts its iframe on the change stream. An iframe loads once, so leaving the editor rendered the file as it was when Done was pressed — BEFORE the save landed — and then sat there with the edit saved on the hub and invisible on screen; the same reload makes a teammate's edit appear in a page you are already looking at. EditView's banner is raised by the MERGE VERDICT for the source editor and by the change stream only for the visual one: the verdict knows whether the write could be folded in and the event cannot, so raising a banner on the event would flash the wrong answer ahead of it. For that visual path `mine` is a COUNT, not a flag: the change stream announces a write as soon as the hub journals it, often before the PUT's own response, so openSharedFile reports a write BEFORE it goes out, and two saves in flight (routine — clicking between paragraphs saves each) left the second event with nothing to claim it and raised the peer banner on the user's own edit"
note for lib "A save carries the version its buffer was read at (If-Match), and a 409 is not an error to retry — it means somebody else's write is already the file. By then merge() has necessarily declined, because a save only happens when this buffer has changes of its own, so there is nothing left to reconcile: the losing version is written BESIDE the file as <name>.bdrive-conflict-<who>-<utc>, the same name the sync path has used since the beginning and the same one ConflictBanner already explains. conflictName is therefore a second implementation of a pure function that lives in Go (syncer.go) — round-tripped through parseConflict in the unit tests, because two formats for one filename would be two explanations for one reader."
note for lib "collab.ts publishes a caret at most every 200ms, only its OWN client state, and not at all when nobody else is in the room. Every awareness change used to be its own POST, so arrow-keying around a file spent a request per keypress drawing a caret for nobody. Staying quiet while alone is only safe with the two forced announcements around it — the first one, and an answer to each new arrival — because awareness is relayed and never logged, so an announcement is lost to anyone who shows up after it and two people would otherwise stay invisible to each other forever."
note for lib "sharedfile.merge is how an agent's write reaches a document somebody is typing in. The editor still never re-seeds itself from the server — that resets the buffer under a cursor — so the write arrives as textEdit's single splice instead: everything it does not change is untouched, and so is the caret sitting in it. It REFUSES in the two cases where a splice destroys something, and the refusal is FileView's banner: unsaved local edits (never overwrite half a sentence), and a co-editor in the room (both clients would compute the same splice, and two identical splices into one CRDT is the change applied twice). `saved` moves to the incoming text BEFORE the splice, or the document change it causes schedules a save that writes back what was just read. A relay-less surface has no Y.Text to splice, so it passes soloApply — without one merge reports blocked rather than claiming a change it could not make"
note for lib "sharedfile.ts is everything about having a file open that is not about a keyboard — join the room, hold the CRDT, save on idle, save once more on the way out. Both surfaces sit on it: Editor binds CodeMirror to the Y.Text, VisualEdit splices ranges into the same one, so somebody typing markup and somebody clicking a headline are in one room and neither has to know the other exists"
note for components "components/ui — shadcn/ui primitives (Radix, copied in), themed from BearDrive tokens in tw.css; rendered markdown is transformed as a string before mounting, link clicks delegated on the container — never patch the dangerouslySetInnerHTML subtree"
Expand All @@ -152,7 +151,7 @@ classDiagram
+heat.ts placeLabels LABEL_MAX (scatter danger-dot labels)
+heat.ts HOT_READS STALE_DAYS isDanger daysSince agoLabel staleNote
+conflict.ts parseConflict conflictName Conflict
+collab.ts CollabDoc peerCount (Yjs over SSE + POST)
+collab.ts CollabDoc peerCount (y-websocket to the hub-held document)
+sharedfile.ts openSharedFile SharedFile SAVE_IDLE_MS
+sharedfile.ts merge MergeResult (an outside write, offered to an open document)
+sharedfile.ts preserve (a save that lost, parked beside the file)
Expand Down
Loading
Loading