Task ID: ses_1e9362f08ffe78oVgw2ByjnB5r
Repository: /Users/tony/arc/dev/eztex
Product name in planning docs: extex
Implementation target: browser-only Wave 0, no server changes.
Wave 0 converts the current single-project in-memory file store into a Yjs-backed local collaboration model while preserving the existing single-user workflow.
Wave 0 includes:
- Add Yjs dependencies.
- Create
app/src/lib/y_project_doc.ts. - Refactor
app/src/lib/project_store.tsto use Yjs internally while keeping most public API compatibility. - Refactor
app/src/components/Editor.tsxto bind CodeMirror directly toY.Textviay-codemirror.next. - Add same-origin multi-window sync using
BroadcastChannel. - Update OPFS persistence to store Yjs snapshots and content-addressed binary blobs.
- Migrate existing
eztex-projects/default/data into OPFS v2 project layout. - Keep compile, preview, upload, folder sync, diagnostics, Vim mode, Synctex forward/reverse sync, binary preview, and auto-save working.
Wave 0 does not include:
- Cloudflare Workers.
- Durable Objects.
- Remote WebSocket collaboration.
- Share links.
- MCP server implementation.
- Multi-user permissions.
- WebRTC.
- Full project switcher UI beyond safe internal project identity support.
- CRDT-backed project document exists locally.
- Editor no longer writes full document strings on every keystroke.
- Editor no longer replaces entire CodeMirror document on file switch.
- Two browser tabs/windows for the same local project converge through
BroadcastChannel. - OPFS persists/restores the Yjs document snapshot.
- Binary files remain outside Yjs and continue to preview correctly.
- Existing public store methods keep working for current UI code.
- Existing compile flow still receives
ProjectFilessnapshots.
Wave 0 is done when:
bun installsucceeds inapp/.bun run buildsucceeds inapp/.- App loads a saved v1 project from
eztex-projects/default/and migrates it to v2 layout. - Single-user editing works with no obvious UI regression.
- File switching preserves each file's text and does not dispatch full replacement edits into Yjs.
- Undo/redo works through Yjs undo manager.
- Two tabs editing the same project converge without server involvement.
- Compile still receives all text and binary project files through
snapshot_files(). - Binary images still display in
Editor.tsx. - Existing local folder upload/import does not crash.
- Existing PDF diagnostics and Synctex interactions still work.
- Code is implemented with minimal changes outside listed files.
- No server code is added.
- Old v1 OPFS project data is read and migrated once.
store.filesremains available as a compatibility facade, but new code paths usesnapshot_files().- All binary content is stored in a local blob store, not directly in Yjs.
- BroadcastChannel messages are project-scoped and do not leak between projects.
- Implementation is documented in code only where behavior is non-obvious.
Project state is durable document data.
Session state is local runtime/UI state.
Project state includes:
- Project metadata.
- File path mapping.
- Text file CRDT contents.
- Binary file references.
- Main file.
- Updated timestamp.
Session state includes:
- Current file selection.
- Editor view instance.
- Vim enabled setting.
- Diagnostics currently displayed.
- Compile sequence guards.
- Active BroadcastChannel provider.
- Dirty/save timers.
- Local in-memory binary blob cache.
- Current PDF/Synctex output handles.
Create this abstraction in project_store.ts or a future project_manager.ts. For Wave 0, it can be implemented inside create_project_store() with the interface prepared for extraction.
export type ProjectId = string;
export type RoomId = string;
export type FileId = string;
export type ContentHash = string;
export interface ProjectMetadata {
id: ProjectId;
room_id?: RoomId;
name: string;
created_at: number;
updated_at: number;
main_file: string;
}
export type FileContent = string | Uint8Array;
export type ProjectFiles = Record<string, FileContent>;
export interface BlobStore {
get(hash: ContentHash): Uint8Array | undefined;
put(bytes: Uint8Array): Promise<ContentHash>;
put_sync_for_existing_hash(hash: ContentHash, bytes: Uint8Array): void;
delete(hash: ContentHash): void;
entries(): IterableIterator<[ContentHash, Uint8Array]>;
}
export interface ProjectRuntime {
project_id: ProjectId;
room_id?: RoomId;
metadata: ProjectMetadata;
doc: Y.Doc;
awareness: Awareness | null;
blob_store: BlobStore;
snapshot_files(): Promise<ProjectFiles>;
persist(): Promise<void>;
destroy(): Promise<void>;
}Wave 0 does not need a full UI project switcher, but persistence should be shaped for this async interface.
export interface ProjectCatalogEntry {
id: ProjectId;
name: string;
main_file: string;
created_at: number;
updated_at: number;
room_id?: RoomId;
}
export interface ProjectCatalog {
version: 2;
current_project_id: ProjectId | null;
projects: ProjectCatalogEntry[];
}
export interface ProjectManager {
catalog(): Promise<ProjectCatalog>;
current(): ProjectRuntime | null;
create_project(name?: string): Promise<ProjectRuntime>;
open_project(id: ProjectId): Promise<ProjectRuntime>;
open_room(room_id: RoomId, token: string): Promise<ProjectRuntime>;
close_project(id?: ProjectId): Promise<void>;
delete_project(id: ProjectId): Promise<void>;
}Wave 0 should not implement full routing, but it must not block the future URL model.
Supported now:
/?project=<project_id>Future remote collaboration:
/c/<room_id>#<permission>.<signature>
/?project=<project_id>&room=<room_id>#<permission>.<signature>Wave 0 behavior:
- If
?project=exists and project exists in catalog, open it. - If
?project=does not exist, open current catalog project. - If no project exists, create one and set it as current.
- If routing work is too large, preserve current one-project UI but store the project in v2 layout with a generated
ProjectId.
Wave 0 must support multiple same-origin tabs editing the same project.
Rules:
- Each tab has its own
Y.Doc. - Each tab opens the same OPFS project snapshot.
- BroadcastChannel sync exchanges Yjs updates between tabs.
- Every update originated by local editing is applied to local Yjs, then broadcast to other tabs.
- Every remote tab update is applied to local Yjs and updates the editor through
yCollab. - Persistence can be done by every tab with debounce, but must write compacted snapshots, not raw full
ProjectFiles. - Channel names must include project id:
eztex:yjs:<project_id>.
Wave 0 can keep a single visible project, but implementation must be safe for future switching.
Switching rules:
- Persist current project before opening another project.
- Destroy current editor binding/provider before binding the new project.
- Close previous BroadcastChannel.
- Reset session state: current file, diagnostics view state, compile sequence.
- Keep outputs project-scoped under
outputs/. - Worker compile requests must include
project_idwhere supported. - If
worker_client.tscannot acceptproject_idyet, keep current behavior but add TODO-level internal plumbing only if minimal.
Use this layout:
eztex-projects/
catalog.json
projects/
<project_id>/
project.json
ydoc.bin
blobs/
<sha256>
outputs/
output.pdf
output.synctexcatalog.json:
export interface ProjectCatalogFile {
version: 2;
current_project_id: ProjectId | null;
projects: ProjectCatalogEntry[];
}project.json:
export interface ProjectManifestV2 {
version: 2;
id: ProjectId;
name: string;
created_at: number;
updated_at: number;
main_file: string;
room_id?: RoomId;
ydoc_file: "ydoc.bin";
blobs_dir: "blobs";
outputs_dir: "outputs";
}Create app/src/lib/y_project_doc.ts.
import * as Y from "yjs";
export type ProjectId = string;
export type RoomId = string;
export type FileId = string;
export type ContentHash = string;
export type FileKind = "text" | "binary";
export interface ProjectMetadata {
id: ProjectId;
room_id?: RoomId;
name: string;
created_at: number;
updated_at: number;
main_file: string;
}
export interface FileMetadata {
id: FileId;
path: string;
kind: FileKind;
created_at: number;
updated_at: number;
content_hash?: ContentHash;
mime?: string;
size?: number;
}
export interface YProjectDoc {
doc: Y.Doc;
meta: Y.Map<unknown>;
paths: Y.Map<FileId>;
file_meta: Y.Map<Y.Map<unknown>>;
texts: Y.Map<Y.Text>;
blob_refs: Y.Map<ContentHash>;
}Use these exact keys:
export const Y_META = "meta";
export const Y_PATHS = "paths";
export const Y_FILE_META = "file_meta";
export const Y_TEXTS = "texts";
export const Y_BLOB_REFS = "blob_refs";Mapping:
meta: project metadata fields.paths: path string to stable file id.file_meta: file id to metadata map.texts: file id toY.Text.blob_refs: file id to content hash for binary files.
Implement these helpers:
export function create_y_project_doc(project_id: ProjectId, name?: string): YProjectDoc;
export function bind_y_project_doc(doc: Y.Doc): YProjectDoc;
export function get_project_metadata(yp: YProjectDoc): ProjectMetadata;
export function set_project_metadata(yp: YProjectDoc, patch: Partial<ProjectMetadata>): void;
export function get_file_id(yp: YProjectDoc, path: string): FileId | undefined;
export function get_or_create_text_file(yp: YProjectDoc, path: string, initial?: string): Y.Text;
export function create_binary_file_ref(
yp: YProjectDoc,
path: string,
hash: ContentHash,
size: number,
mime?: string,
): FileId;
export function rename_file_path(yp: YProjectDoc, old_path: string, new_path: string): boolean;
export function delete_file_entry(yp: YProjectDoc, path: string): boolean;
export function list_paths(yp: YProjectDoc): string[];
export function encode_snapshot(doc: Y.Doc): Uint8Array;
export function apply_snapshot(doc: Y.Doc, bytes: Uint8Array): void;Use stable IDs that survive rename.
Strategy:
export function create_file_id(): FileId {
return `f_${crypto.randomUUID().replaceAll("-", "")}`;
}Rules:
- Never derive
FileIdfrom path. - Rename changes
paths,file_meta.path, and leavestexts[file_id]unchanged. - Delete removes path mapping and metadata.
- Delete may leave old
Y.Textin the map for Wave 0 if physical cleanup is risky, but new code should not reference it. - New text file creates
Y.Textattexts[file_id]. - New binary file creates
blob_refs[file_id] = hash.
Text files:
- Store content in
Y.Text. get_text_content(path)returnsytext.toString().update_content(path, string)mutatesY.Textusing delete/insert insidedoc.transact.
Binary files:
- Do not store bytes in Yjs.
- Store bytes in
BlobStore. - Store hash reference in
blob_refs. - Store size/mime/kind in
file_meta. get_content(path)returnsUint8Arrayfrom blob store if present.snapshot_files()includes binary files by reading blob refs.
Set project metadata on creation:
{
id: project_id,
name: name ?? "Untitled Project",
created_at: Date.now(),
updated_at: Date.now(),
main_file: "main.tex",
}Update updated_at on structural and text changes with debounce or immediate mutation. Minimal implementation can update immediately on mutating store methods.
Modify app/src/lib/project_store.ts.
Current important lines:
FileContentandProjectFiles: lines6-7.create_project_store(): line24.- Solid store
files: lines25-27. current_file,main_file,revision: lines29-36.file_names(): lines46-53.add_file,remove_file,rename_file,update_content: lines55-88.get_content,get_text_content: lines90-98.load_files,merge_files,init_from_template: lines109-160.- Returned API: lines
162-181.
These methods must remain:
files: ProjectFiles;
current_file: () => string;
set_current_file: (name: string) => void;
main_file: () => string;
set_main_file: (name: string) => void;
revision: () => number;
file_names: () => string[];
add_file: (name: string, content?: FileContent) => void;
remove_file: (name: string) => void;
rename_file: (old_name: string, new_name: string) => void;
update_content: (name: string, content: FileContent) => void;
get_content: (name: string) => FileContent;
get_text_content: (name: string) => string;
clear_all: () => void;
load_files: (new_files: ProjectFiles) => void;
merge_files: (new_files: ProjectFiles) => void;
on_change: (cb: () => void) => () => void;
init_from_template: () => Promise<void>;Add:
project_id: () => ProjectId;
ydoc: () => Y.Doc;
get_ytext: (path: string) => Y.Text;
snapshot_files: () => Promise<ProjectFiles>;
encode_ydoc_snapshot: () => Uint8Array;
apply_ydoc_snapshot: (bytes: Uint8Array) => void;
destroy: () => void;store.files must continue to exist because current code uses it.
Known access points include:
App.tsx:91: watch controllerget_files.App.tsx:219: initial compile snapshot.App.tsx:230: save project.- Other UI upload/export code may use
store.files.
Implementation requirement:
- Maintain a Solid
createStore<ProjectFiles>facade calledfiles. - Update facade from Yjs on every local or remote document change.
- Do not use facade as source of truth.
- New/modified code should prefer
await store.snapshot_files().
Minimal acceptable approach:
const [files, set_files] = createStore<ProjectFiles>({ "main.tex": "" });
function refresh_files_facade() {
snapshot_files_sync_if_possible();
set_revision((r) => r + 1);
_notify();
}If async binary reads are needed, keep binary bytes in memory so snapshot_files() can be async but facade refresh remains synchronous.
Rules:
- Increment
revisionon Yjs updates that affect current UI. - Call
_notify()after mutating methods and after remote BroadcastChannel updates. - Avoid infinite loops: remote updates applied to Yjs should refresh facade but not rebroadcast the same update.
- Use Yjs transaction origins.
Suggested origins:
const ORIGIN_LOCAL = "local";
const ORIGIN_REMOTE_BC = "remote-broadcast";
const ORIGIN_LOAD = "load";file_names():
- Read from Yjs
paths. - Sort main file first, then alphabetical.
add_file(name, content):
- If
contentis string, create text file in Yjs. - If
contentisUint8Array, put bytes in blob store and create binary file ref. - Set current file to new file.
- Notify.
remove_file(name):
- Keep current guard: cannot remove main file.
- Keep current guard: cannot remove last file.
- Delete path mapping and metadata.
- If current file deleted, switch to main file.
- Notify.
rename_file(old_name, new_name):
- If target exists, no-op.
- Rename path mapping, preserve file id.
- If current file was old path, switch to new path.
- If main file was old path, update main file metadata.
- Notify.
update_content(name, content):
- For string: mutate
Y.Textto match content. - For binary: store bytes in blob store, update hash ref and metadata.
- This method remains for compatibility and non-editor write paths.
- Editor typing must not call this method on every keystroke after refactor.
get_content(name):
- Return string from
Y.Textfor text file. - Return bytes from blob store for binary file.
- Return
""if missing.
get_text_content(name):
- Return
""for binary. - Return
Y.Text.toString()for text.
load_files(new_files):
- Create a fresh Y.Doc or clear current doc.
- Import all files into Yjs/blob store.
- Detect main file using current logic.
- Set current file to detected main.
- Notify.
merge_files(new_files):
- Add or replace files through Yjs.
- Preserve current project metadata and main file unless main missing.
- Notify.
clear_all():
- Reset to default
main.tex. - Clear blob store.
- Notify.
Wave 0 should not remove store.files.
Rules:
- Existing call sites can keep using it if refactoring them is risky.
- New compile/persist code should use
snapshot_files(). - Add a code comment above returned
filesexplaining it is a compatibility snapshot, not the source of truth. - Do not expose Yjs internals except through explicit methods.
Modify app/src/components/Editor.tsx.
Current important lines:
- Imports: lines
1-18. historyandhistoryKeymapimport: line4.viewandupdating_from_outside: lines159-160.- Initial
EditorState.create: lines206-245. history()extension: line216.keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]): line220.- Keystroke writeback: lines
222-228. - Full replacement on file/revision change: lines
280-293. - Diagnostics effect: lines
296-315. - Synctex reverse sync: lines
317-338.
Add:
import { yCollab, yUndoManagerKeymap } from "y-codemirror.next";
import * as Y from "yjs";Remove from @codemirror/commands import:
history
historyKeymapKeep:
defaultKeymap
indentWithTabEditor must bind to current file's Y.Text.
Do not call props.store.update_content() inside EditorView.updateListener.
Do not dispatch a full document replacement when switching files.
Use view.setState(EditorState.create(...)) for rebind.
Create a local function in Editor.tsx:
function create_editor_state(ytext: Y.Text, awareness: any | null, undoManager: Y.UndoManager): EditorState {
return EditorState.create({
doc: ytext.toString(),
extensions: [
lineNumbers(),
highlightActiveLine(),
highlightSpecialChars(),
drawSelection(),
bracketMatching(),
indentOnInput(),
foldGutter(),
StreamLanguage.define(stex),
syntaxHighlighting(tokyo_night_highlight),
tokyo_night_theme,
yCollab(ytext, awareness ?? undefined, { undoManager }),
keymap.of([...defaultKeymap, ...yUndoManagerKeymap, indentWithTab]),
vim_compartment.of([]),
EditorView.updateListener.of((update) => {
if (update.selectionSet || update.docChanged) {
schedule_forward_synctex(update);
}
}),
EditorView.lineWrapping,
],
});
}If yCollab requires a non-null awareness, create a minimal local awareness object or omit awareness only if library supports it. Prefer creating an Awareness from y-protocols/awareness in store and exposing it.
- Remove CodeMirror
history(). - Remove
historyKeymap. - Use
Y.UndoManagerper current file or per text type. - Minimum acceptable Wave 0: create a new
Y.UndoManager(ytext)on each file bind. - Better implementation: cache undo managers by file id/path in
Editor.tsx.
Suggested cache:
const undo_managers = new Map<string, Y.UndoManager>();
function get_undo_manager(path: string, ytext: Y.Text): Y.UndoManager {
let manager = undo_managers.get(path);
if (!manager) {
manager = new Y.UndoManager(ytext);
undo_managers.set(path, manager);
}
return manager;
}Replace current effect at Editor.tsx:280-293.
New behavior:
createEffect(
on(
() => props.store.current_file(),
(file) => {
if (!view) return;
if (current_is_binary()) return;
const ytext = props.store.get_ytext(file);
const undoManager = get_undo_manager(file, ytext);
view.setState(create_editor_state(ytext, props.store.awareness?.() ?? null, undoManager));
if (props.vim_enabled) {
import("@replit/codemirror-vim").then(({ vim }) => {
if (view) view.dispatch({ effects: vim_compartment.reconfigure(vim()) });
});
}
},
),
);Important:
- Do not include
props.store.revision()in the file-switch effect. - Yjs remote/local changes update CodeMirror through
yCollab. - Binary files hide editor as current code already does.
At Editor.tsx:206-245, use:
const file = props.store.current_file();
const ytext = props.store.get_ytext(file);
const undoManager = get_undo_manager(file, ytext);
const state = create_editor_state(ytext, props.store.awareness?.() ?? null, undoManager);Keep diagnostics effect lines 296-315.
Risk:
- Recreating EditorState on file switch removes current diagnostics extension state.
- Existing effect re-applies diagnostics reactively from
worker_client.diagnostics()and current file.
Requirement:
- After rebind, diagnostics effect must still dispatch
cmSetDiagnostics. - If diagnostics do not reappear on file switch, include
props.store.current_file()in the diagnostics effect dependencies by reading it as current code already does. - Do not remove
cmSetDiagnostics.
Current Vim logic:
vim_compartmentline163.- Initial load lines
255-260. - Toggle effect lines
263-278.
Requirement:
- Keep
vim_compartment. - Include
vim_compartment.of([])in every recreated editor state. - After
view.setState, re-apply Vim ifprops.vim_enabled. - Keep toggle effect unchanged if possible.
Current forward sync is inside update listener lines 229-240.
Requirement:
- Preserve forward sync scheduling on selection change and document change.
- Use current file from
props.store.current_file(). - Keep debounce at
300ms. - Keep reverse sync effect lines
317-338. - Ensure reverse sync dispatching selection still works after yCollab state creation.
Current binary preview logic lines 165-191 and render lines 347-366 must keep working.
Requirement:
props.store.get_content(file)returnsUint8Arrayfor binary files.is_binary()remains exported fromproject_store.ts.- Binary files do not call
get_ytext()unless creating an empty placeholder is harmless.
Implement inside project_store.ts or a small new file if cleaner.
No extra dependency required.
const channel_name = `eztex:yjs:${project_id}`;type BroadcastMessage =
| {
type: "hello";
sender_id: string;
project_id: ProjectId;
}
| {
type: "sync";
sender_id: string;
project_id: ProjectId;
update: Uint8Array;
}
| {
type: "state-request";
sender_id: string;
project_id: ProjectId;
}
| {
type: "state-response";
sender_id: string;
project_id: ProjectId;
update: Uint8Array;
};Structured clone supports Uint8Array in BroadcastChannel.
Each tab gets a runtime id:
const sender_id = crypto.randomUUID();Ignore messages from self.
On store creation:
- Open channel.
- Send
hello. - Send
state-request. - Existing tabs respond with
state-responsecontainingY.encodeStateAsUpdate(doc). - New tab applies response via
Y.applyUpdate(doc, update, ORIGIN_REMOTE_BC).
Register:
doc.on("update", (update: Uint8Array, origin: unknown) => {
if (origin === ORIGIN_REMOTE_BC || origin === ORIGIN_LOAD) return;
channel.postMessage({
type: "sync",
sender_id,
project_id,
update,
});
});On receive sync:
Y.applyUpdate(doc, message.update, ORIGIN_REMOTE_BC);Rules:
- Ignore messages with different
project_id. - Ignore messages from same
sender_id. - Applying remote updates must not rebroadcast them.
- On remote update, refresh Solid facade and notify subscribers.
- Persist with debounce, not every remote keystroke.
- If BroadcastChannel unsupported, app still works single-tab.
Wave 0 can skip cross-tab awareness.
Remote cursor rendering between same-user tabs is not required.
Modify app/src/lib/project_persist.ts.
Current important lines:
- v1 root constants: lines
6-7. get_project_dir(): lines9-17.SavedManifest: lines19-22.save_project: lines24-68.load_project: lines70-101.has_saved_project: lines103-112.- PDF/Synctex functions: lines
144-195. reset_all_persistence: lines197-211.
Add v2 APIs while preserving old exported names if needed.
export type ProjectId = string;
export interface ProjectCatalogEntry {
id: ProjectId;
name: string;
main_file: string;
created_at: number;
updated_at: number;
room_id?: string;
}
export interface ProjectCatalogFile {
version: 2;
current_project_id: ProjectId | null;
projects: ProjectCatalogEntry[];
}
export interface ProjectManifestV2 {
version: 2;
id: ProjectId;
name: string;
created_at: number;
updated_at: number;
main_file: string;
room_id?: string;
ydoc_file: "ydoc.bin";
blobs_dir: "blobs";
outputs_dir: "outputs";
}
export async function load_catalog(): Promise<ProjectCatalogFile>;
export async function save_catalog(catalog: ProjectCatalogFile): Promise<void>;
export async function save_ydoc_snapshot(project_id: ProjectId, bytes: Uint8Array): Promise<void>;
export async function load_ydoc_snapshot(project_id: ProjectId): Promise<Uint8Array | null>;
export async function save_blob(project_id: ProjectId, hash: string, bytes: Uint8Array): Promise<void>;
export async function load_blob(project_id: ProjectId, hash: string): Promise<Uint8Array | null>;
export async function save_project_manifest(project_id: ProjectId, manifest: ProjectManifestV2): Promise<void>;
export async function load_project_manifest(project_id: ProjectId): Promise<ProjectManifestV2 | null>;
export async function migrate_v1_default_project(): Promise<ProjectId | null>;Keep these exports for existing App.tsx until App is migrated:
save_project(files: ProjectFiles, main_file?: string): Promise<boolean>;
load_project(): Promise<{ files: ProjectFiles; main_file?: string } | null>;
has_saved_project(): Promise<boolean>;
clear_project(): Promise<void>;
save_pdf(bytes: Uint8Array): Promise<void>;
load_pdf(): Promise<Uint8Array | null>;
save_synctex(text: string): Promise<void>;
load_synctex(): Promise<string | null>;Minimal acceptable strategy:
load_project()first attempts v2 current project and returnssnapshot_files()equivalent if easy.- If v2 missing, load v1 default as current implementation does.
- Store refactor can call new
load_ydoc_snapshot()directly ifApp.tsxis adjusted. - Do not remove v1 reading in Wave 0.
v1 location:
eztex-projects/default/
__manifest.json
<encoded files>
_output.pdf
_output.synctexMigration steps:
- Check if v2 catalog exists and has projects.
- If yes, do nothing.
- If no, call existing v1
load_project()logic. - If v1 project exists, generate new
ProjectId. - Create v2 Yjs document from files through store
load_files. - Save
ydoc.bin. - Save binary blobs under
blobs/<sha256>. - Create
project.json. - Create
catalog.json. - Copy
_output.pdftoprojects/<project_id>/outputs/output.pdfif present. - Copy
_output.synctextoprojects/<project_id>/outputs/output.synctexif present. - Leave v1 data in place for rollback; do not delete in Wave 0.
Update output helpers to be project-aware if practical:
save_pdf(project_id: ProjectId, bytes: Uint8Array): Promise<void>;
load_pdf(project_id: ProjectId): Promise<Uint8Array | null>;
save_synctex(project_id: ProjectId, text: string): Promise<void>;
load_synctex(project_id: ProjectId): Promise<string | null>;If changing call sites is too large, keep default current project internally.
Modify app/package.json.
Add dependencies:
{
"yjs": "^13.6.27",
"y-codemirror.next": "^0.3.5",
"y-protocols": "^1.0.6",
"y-indexeddb": "^9.0.12",
"lib0": "^0.2.114"
}Run:
bun installImplement schema, helpers, file ID creation, metadata helpers, snapshot encode/apply.
Can be parallel with OPFS work.
Modify project_store.ts.
Required output:
- Existing API still compiles.
- New Yjs methods exist.
- Facade
filesupdates from Yjs. - Text and binary paths work.
Add inside store or separate file.
Required output:
- Two store instances for same project sync.
- Updates do not echo infinitely.
- Remote update refreshes Solid facade.
Modify project_persist.ts.
Required output:
- v2 catalog read/write.
- Yjs snapshot read/write.
- Blob read/write.
- v1 default migration path.
Modify App.tsx.
Current known edits:
App.tsx:91:get_filesshould use sync facade for now or async snapshot with watch adjustment.App.tsx:185: load project/PDF/Synctex should use v2 current project if implemented.App.tsx:219: compile should useawait store.snapshot_files()if callback can be async, otherwise keep facade temporarily.App.tsx:230: save should use Yjs snapshot persistence, not oldsave_project(store.files).
Minimal approach:
- Keep compile using
{ ...store.files }for Wave 0 compatibility. - Change save path to save Yjs snapshot if new helper is ready.
- Keep PDF/Synctex restore working.
Modify Editor.tsx.
Required output:
- yCollab binding works.
- No keystroke writeback.
- No full replacement on revision change.
- File switch rebind works.
- Undo/redo works.
- Diagnostics, Vim, Synctex still work.
Modify worker_client.ts only if needed.
Expected minimal changes:
- Accept compile files from
snapshot_files(). - Preserve existing compile request shape.
- Do not introduce server assumptions.
Run:
bun run buildManual test matrix in section 9.
Can be parallelized:
y_project_doc.tsandproject_persist.tsv2 helpers.- Editor refactor after
get_ytext()API shape is fixed. - BroadcastChannel provider after basic Yjs doc exists.
Must be sequential:
- Dependencies before TypeScript imports.
- Store Yjs internals before Editor yCollab.
- OPFS save/load before migration verification.
From app/:
bun run buildMust pass.
Test:
- Open app.
- Type in
main.tex. - Text appears immediately.
- No console errors.
- Reload page.
- Text persists.
Expected:
- Editor updates through yCollab.
- Store facade has latest text.
- OPFS restores Yjs snapshot.
Test:
- Open app in tab A.
- Open same app in tab B.
- Type
Ain tab A. - Confirm tab B receives
A. - Type
Bin tab B. - Confirm tab A receives
B. - Reload tab A.
- Confirm full document restored.
Expected:
- No infinite BroadcastChannel loop.
- Both tabs converge.
- Save/reload keeps latest content.
Test:
- Create
a.tex. - Type
AAA. - Create
b.tex. - Type
BBB. - Switch between files repeatedly.
Expected:
- Each file retains correct content.
- Undo in
b.texdoes not corrupta.tex. - No full document replacement transaction appears from file switch.
Test:
- Use default template.
- Compile preview.
- Full compile if UI supports it.
- Add included
.texfile and\input. - Compile again.
Expected:
- Worker receives complete
ProjectFiles. - PDF renders.
- Diagnostics still map to current file.
Test:
- Type three edits.
- Press Cmd/Ctrl+Z.
- Press Cmd/Ctrl+Shift+Z or redo binding.
- Switch files and return.
- Undo still applies sensibly.
Expected:
- Yjs undo manager handles local edits.
- CodeMirror history is not used.
- No crash after file switch.
Test:
- Upload/import image.
- Select image in file panel.
- Confirm preview renders.
- Compile document using image if existing workflow supports it.
Expected:
- Image bytes are returned by
get_content. - Image not stored in Yjs.
snapshot_files()includes image bytes.
Test:
- Start with existing v1 saved project.
- Load app after Wave 0.
- Confirm project appears.
- Reload.
- Confirm v2 path loads.
Expected:
- No data loss.
- v1 data left intact.
- v2 catalog exists.
Location:
- Dependencies block lines
11-25.
Add:
"yjs": "^13.6.27",
"y-codemirror.next": "^0.3.5",
"y-protocols": "^1.0.6",
"y-indexeddb": "^9.0.12",
"lib0": "^0.2.114"New file.
Must contain:
- Type exports.
- Top-level key constants.
- Doc create/bind helpers.
- File create/rename/delete helpers.
- Snapshot encode/apply helpers.
Major refactor.
Known line anchors:
- Types lines
6-7. - Binary extension helpers lines
9-22. create_project_storeline24.- Existing CRUD methods lines
55-136. - Return object lines
162-181.
Required additions:
project_id
ydoc
awareness
get_ytext
snapshot_files
encode_ydoc_snapshot
apply_ydoc_snapshot
destroyMajor refactor.
Known line anchors:
- Imports lines
1-18. - Initial state creation lines
206-245. - Keystroke writeback lines
222-228. - File replacement effect lines
280-293. - Diagnostics lines
296-315. - Synctex reverse sync lines
317-338.
Required changes:
- Add yCollab imports.
- Remove CodeMirror history import/use.
- Add Y.UndoManager.
- Replace update_content writeback.
- Replace revision-based full replacement effect with file-only rebind.
Moderate refactor.
Known line anchors:
- Root constants lines
6-7. - v1 directory helper lines
9-17. save_projectlines24-68.load_projectlines70-101.- output helpers lines
144-195.
Required additions:
- v2 catalog helpers.
- project manifest helpers.
- Yjs snapshot save/load.
- blob save/load.
- v1 migration.
Minor integration.
Known line anchors:
- Store creation line
42. - Folder sync line
43. - Watch
get_filesline91. - Initial load line
185. - Initial compile lines
217-221. - Auto-save lines
224-231.
Required changes:
- Initialize v2 project load/migration if implemented outside store.
- Use Yjs snapshot persistence on auto-save.
- Prefer
snapshot_files()for compile when feasible. - Keep old facade path if changing watch async is too risky.
Minor only if compile request typing requires updates.
Requirement:
- Do not change worker protocol unless necessary.
- Compile should continue receiving plain
ProjectFiles.
What could break:
- Typing not reflected in store.
- Remote BroadcastChannel updates not reflected in editor.
- File switch loses content.
- Undo/redo broken.
Mitigation:
- Bind CodeMirror directly to
Y.Text. - Remove keystroke writeback only after yCollab works.
- Rebind with
view.setState, not document replacement dispatch. - Test single file and multi-file editing before OPFS migration.
Rollback:
- Revert
Editor.tsxto oldupdate_contentmodel. - Keep Yjs store facade compatible so old editor can still read/write strings.
What could break:
- Huge Yjs snapshots.
- Slow BroadcastChannel messages.
- Memory spikes.
Mitigation:
- Enforce
Uint8Arraypath inupdate_content. - Store only
content_hashin Yjs. - Add assertions/comments in
y_project_doc.ts. - Test image upload.
Rollback:
- Keep binary bytes in
store.filesfacade temporarily. - Persist binary through old OPFS file path until blob store is fixed.
What could break:
- Compile uses old contents.
- Save persists old contents.
- Watch hash misses edits.
Mitigation:
- Refresh facade on every Yjs update.
- Increment
revision. - Call
_notify. - Prefer
snapshot_files()for new compile/save paths.
Rollback:
- Use
update_contentcompatibility path to force facade update. - Temporarily keep editor writeback if compile breaks, then remove after snapshot path is fixed.
What could break:
- CPU spike.
- Repeated updates.
- Tab lockup.
Mitigation:
- Use
sender_id. - Ignore self messages.
- Use Yjs transaction origin
ORIGIN_REMOTE_BC. - Do not broadcast updates with remote origin.
Rollback:
- Disable BroadcastChannel provider behind a local constant.
- Single-tab workflow remains functional.
What could break:
- Existing saved projects disappear.
- PDF restore breaks.
- Synctex restore breaks.
Mitigation:
- Do not delete v1 data.
- Read v1 first in migration tests.
- Write v2 alongside v1.
- Keep old
load_project()code path until v2 verified.
Rollback:
- Ignore v2 catalog and call old
load_project(). - Since v1 data remains, user data is recoverable.
What could break:
- Watch controller expects sync
get_files. - Compile receives proxy/store object instead of plain object.
- Binary missing from compile.
Mitigation:
- Keep
store.filesfacade sync for Wave 0. - Add
snapshot_files()but migrate compile call only if straightforward. - Ensure snapshot returns plain object.
Rollback:
- Compile from
{ ...store.files }. - Fix facade freshness before reattempting async compile path.
What could break:
- File switch disables Vim.
- Toggle state inconsistent.
Mitigation:
- Keep
vim_compartment. - Include it in every new EditorState.
- Re-apply after
view.setState.
Rollback:
- If dynamic Vim reconfigure fails, force editor remount on file switch as temporary workaround.
What could break:
- Recreated EditorState clears lint diagnostics.
- Diagnostics not reapplied until next compile.
Mitigation:
- Preserve diagnostics effect.
- Make effect read
current_file. - Dispatch diagnostics after rebind.
Rollback:
- Trigger diagnostics effect by incrementing store revision after file switch.
Keep the implementation minimal.
Prefer:
- Yjs as source of truth.
- Store facade for compatibility.
- BroadcastChannel only for same-origin local sync.
- OPFS v2 alongside v1, not destructive migration.
- Small, reversible changes in
App.tsx. - No server assumptions.
Avoid:
- Remote collaboration code in Wave 0.
- Accounts, share links, permissions.
- Putting binary bytes in Yjs.
- Rewriting unrelated UI.
- Removing
store.filesbefore all call sites are migrated.