diff --git a/src/client/CodeEditor/getOrCreateEditor.ts b/src/client/CodeEditor/getOrCreateEditor.ts index e9b9cb6f..5451a581 100644 --- a/src/client/CodeEditor/getOrCreateEditor.ts +++ b/src/client/CodeEditor/getOrCreateEditor.ts @@ -40,6 +40,7 @@ import { keymap } from '@codemirror/view'; import { basicSetup } from './basicSetup'; import { InteractRule } from '@replit/codemirror-interact'; import rainbowBrackets from '../CodeEditor/rainbowBrackets'; +import { TabState } from '../vzReducer'; import { cssLanguage } from '@codemirror/lang-css'; import { javascriptLanguage } from '@codemirror/lang-javascript'; @@ -115,6 +116,7 @@ export const getOrCreateEditor = ({ customInteractRules, allowGlobals, enableAutoFollowRef, + openTab, }: { fileId: FileId; @@ -148,6 +150,7 @@ export const getOrCreateEditor = ({ // Ref to a boolean that determines whether to // enable auto-following the cursors of remote users. enableAutoFollowRef: React.MutableRefObject; + openTab: (tabState: TabState) => void; }): EditorCacheValue => { // Cache hit if (editorCache.has(fileId)) { @@ -202,6 +205,7 @@ export const getOrCreateEditor = ({ path: textPath, docPresence, enableAutoFollowRef, + openTab, }), ); } diff --git a/src/client/CodeEditor/index.tsx b/src/client/CodeEditor/index.tsx index a51d9301..5afe7da1 100644 --- a/src/client/CodeEditor/index.tsx +++ b/src/client/CodeEditor/index.tsx @@ -38,6 +38,7 @@ export const CodeEditor = ({ theme, codeEditorRef, enableAutoFollow, + openTab, } = useContext(VZCodeContext); // Set `doc.data.isInteracting` to `true` when the user is interacting @@ -97,6 +98,7 @@ export const CodeEditor = ({ customInteractRules, allowGlobals, enableAutoFollowRef, + openTab, }), [ activeFileId, diff --git a/src/client/CodeEditor/json1PresenceDisplay.ts b/src/client/CodeEditor/json1PresenceDisplay.ts index 6dc73003..d2f15733 100644 --- a/src/client/CodeEditor/json1PresenceDisplay.ts +++ b/src/client/CodeEditor/json1PresenceDisplay.ts @@ -6,7 +6,13 @@ import { } from '@codemirror/view'; import { Annotation, RangeSet } from '@codemirror/state'; import ColorHash from 'color-hash'; -import { Username } from '../../types'; +import { + FileId, + Presence, + PresenceId, + Username, +} from '../../types'; +import { TabState } from '../vzReducer'; const debug = false; @@ -26,6 +32,12 @@ export const json1PresenceDisplay = ({ path, docPresence, enableAutoFollowRef, + openTab, +}: { + path: Array; + docPresence: any; + enableAutoFollowRef: React.MutableRefObject; + openTab: (tabState: TabState) => void; }) => [ ViewPlugin.fromClass( class { @@ -44,113 +56,139 @@ export const json1PresenceDisplay = ({ // Mutable state local to this closure representing aggregated presence. // * Keys are presence ids // * Values are presence objects as defined by ot-json1-presence - const presenceState = {}; + const presenceState: Record = + {}; // Add the scroll event listener //This runs for the arrow key scrolling, it should result in the users scrolling to eachother's location. - view.dom.addEventListener('scroll', () => { - this.scrollToCursor(view); - }); + // view.dom.addEventListener('scroll', () => { + // this.scrollToCursor(view); + // }); // Receive remote presence changes. - docPresence.on('receive', (id, presence) => { - if (debug) { - console.log( - `Received presence for id ${id}`, + docPresence.on( + 'receive', + (id: PresenceId, presence: Presence) => { + if (debug) { + console.log( + `Received presence for id ${id}`, + presence, + ); // Debug statement + } + + // If presence === null, the user has disconnected / exited + if (!presence) { + delete presenceState[id]; + return; + } + + // Check if the presence is for the current file or not. + const isPresenceInCurrentFile = pathMatches( + path, presence, - ); // Debug statement - } - // If presence === null, the user has disconnected / exited - // We also check if the presence is for the current file or not. - if (presence && pathMatches(path, presence)) { - presenceState[id] = presence; - } else { - delete presenceState[id]; - } - // Update decorations to reflect new presence state. - // TODO consider mutating this rather than recomputing it on each change. + ); + + // If the presence is in the current file, update the presence state. + if (isPresenceInCurrentFile) { + presenceState[id] = presence; + } else if (presence) { + // Otherwise, delete the presence state. + delete presenceState[id]; - const presenceDecorations = []; + // If auto-follow is enabled, and the presence is NOT + // in the current file, then open the tab of the other user. + if (enableAutoFollowRef.current) { + openTab({ + fileId: presence.start[1] as FileId, + isTransient: true, + }); + } + } + // Update decorations to reflect new presence state. + // TODO consider mutating this rather than recomputing it on each change. - // Object.keys(presenceState).map((id) => { - for (const id of Object.keys(presenceState)) { - const presence = presenceState[id]; - const { start, end } = presence; - const from = start[start.length - 1]; - const to = end[end.length - 1]; - const userColor = new ColorHash({ - lightness: 0.75, - }).rgb(id); - const { username } = presence; + const presenceDecorations = []; - presenceDecorations.push({ - from, - to: from, - value: Decoration.widget({ - side: -1, - block: false, - widget: new PresenceWidget( - // TODO see if we can figure out why - // updateDOM was not being called when passing - // the presence id as the id - // id, - '' + Math.random(), - userColor, - username, - ), - }), - }); + // Object.keys(presenceState).map((id) => { + for (const id of Object.keys(presenceState)) { + const presence: Presence = presenceState[id]; + const { start, end } = presence; + const from = +start[start.length - 1]; + const to = +end[end.length - 1]; + const userColor = new ColorHash({ + lightness: 0.75, + }).rgb(id); + const { username } = presence; - // This is `true` when the presence is a cursor, - // with no selection. - if (from !== to) { - // This is the case when the presence is a selection. presenceDecorations.push({ from, - to, - value: Decoration.mark({ - class: 'cm-json1-presence', - attributes: { - style: ` + to: from, + value: Decoration.widget({ + side: -1, + block: false, + widget: new PresenceWidget( + // TODO see if we can figure out why + // updateDOM was not being called when passing + // the presence id as the id + // id, + '' + Math.random(), + userColor, + username, + ), + }), + }); + + // This is `true` when the presence is a cursor, + // with no selection. + if (from !== to) { + // This is the case when the presence is a selection. + presenceDecorations.push({ + from, + to, + value: Decoration.mark({ + class: 'cm-json1-presence', + attributes: { + style: ` background-color: rgba(${userColor}, 0.75); mix-blend-mode: luminosity; `, - }, - }), - }); + }, + }), + }); + } + if (view.state.doc.length >= from) { + // Ensure position is valid + this.cursorPosition[id] = from; // Store the cursor position, important to run if we cant get the regular scroll to work + // console.log(`Stored cursor position for id ${id}: ${from}`); // Debug statement + } else { + // console.warn(`Invalid cursor position for id ${id}: ${from}`); // Debug statement + } } - if (view.state.doc.length >= from) { - // Ensure position is valid - this.cursorPosition[id] = from; // Store the cursor position, important to run if we cant get the regular scroll to work - // console.log(`Stored cursor position for id ${id}: ${from}`); // Debug statement - } else { - // console.warn(`Invalid cursor position for id ${id}: ${from}`); // Debug statement - } - } - this.decorations = Decoration.set( - presenceDecorations, - // Without this argument, we get the following error: - // Uncaught Error: Ranges must be added sorted by `from` position and `startSide` - true, - ); + this.decorations = Decoration.set( + presenceDecorations, + // Without this argument, we get the following error: + // Uncaught Error: Ranges must be added sorted by `from` position and `startSide` + true, + ); - // Somehow this triggers re-rendering of the Decorations. - // Not sure if this is the correct usage of the API. - // Inspired by https://github.com/yjs/y-codemirror.next/blob/main/src/y-remote-selections.js - // Set timeout so that the current CodeMirror update finishes - // before the next ones that render presence begin. - setTimeout(() => { - view.dispatch({ - annotations: [presenceAnnotation.of(true)], - }); - }, 0); + // Somehow this triggers re-rendering of the Decorations. + // Not sure if this is the correct usage of the API. + // Inspired by https://github.com/yjs/y-codemirror.next/blob/main/src/y-remote-selections.js + // Set timeout so that the current CodeMirror update finishes + // before the next ones that render presence begin. + setTimeout(() => { + view.dispatch({ + annotations: [presenceAnnotation.of(true)], + }); + }, 0); - // Auto-follow all users when their presence is broadcast - // by scrolling them into view. - if (enableAutoFollowRef.current) { - this.scrollToCursor(view); - } - }); + // Auto-follow all users when their presence is broadcast + // by scrolling them into view. + if (enableAutoFollowRef.current) { + this.scrollToCursor(view); + } + }, + ); } // Method to scroll the view to keep the cursor in view scrollToCursor(view) { diff --git a/src/client/VZCodeContext.tsx b/src/client/VZCodeContext.tsx index aecf681b..598ada66 100644 --- a/src/client/VZCodeContext.tsx +++ b/src/client/VZCodeContext.tsx @@ -52,7 +52,9 @@ export type VZCodeContextValue = { submitOperation: ( next: (content: VZCodeContent) => VZCodeContent, ) => void; + // TODO pull in this type from ShareDB if possible localPresence: any; + // TODO pull in this type from ShareDB if possible docPresence: any; files: Files | null; @@ -76,13 +78,7 @@ export type VZCodeContextValue = { setActiveFileRight: () => void; tabList: Array; - openTab: ({ - fileId, - isTransient, - }: { - fileId: string; - isTransient?: boolean; - }) => void; + openTab: (tabState: TabState) => void; closeTabs: (fileIds: string[]) => void; isSettingsOpen: boolean; diff --git a/src/types.ts b/src/types.ts index f6d77dd7..7785f908 100644 --- a/src/types.ts +++ b/src/types.ts @@ -115,57 +115,6 @@ export type ShareDBDoc = { // Generated by the client. export type AIStreamId = string; -// // TODO define this interface. -// export type AIStreamStatus = { -// // This field is managed by the client. -// // * `true` if the client wants the server to start the generation. -// // * `false` if the client does not want the server to start the generation. -// clientWantsToStart: boolean; - -// // This field is managed by the server. -// // * `true` if the server is running the generation. -// // * `false` if the server is not running the generation. -// serverIsRunning: boolean; - -// text: string; - -// startingInsertionCursor: number; -// fileId: FileId; -// }; - -/// Alternative universe - -// // // If the generation started yet or not -// // // * If not, the server will start it. -// // started: boolean; - -// // Required? -// // aiStreamId: AIStreamId - -// // The generation status -// // * "running" if the generation is running -// // * "finished" if the generation is finished -// // * "error" if the generation errored -// // * "stopped" if the generation was stopped -// status: -// | 'unstarted' -// | 'running' -// | 'finished' -// | 'error' -// | 'stopped'; - -// // Possible states: -// // * { started: 'unstarted' } - client added this, -// // It should trigger the server to start the generation. -// // * { status: 'running' } - server added this, -// // It indicates that the generation is running. -// // * { status: 'finished' } - server added this, -// // It indicates that the generation is finished. -// // * { status: 'stopped' } - server added this, -// // It indicates that the generation was stopped. -// // -// // After `status` transitions to `finished` or `stopped`, - // The ShareDB document type for VZCode. export type VZCodeContent = { // `files` @@ -179,25 +128,13 @@ export type VZCodeContent = { // * `false` or `undefined` when they are not (e.g. normal typing) // * Hot reloading is debounced when this is `false`. isInteracting?: boolean; - - // `aiStreams` - // * The AI streams in the VZCode instance. - // * Keys are AI stream IDs. - // * Values are AI streams. - // What is this for? - // * Synchronize the status of streams between the - // client and server. - // * This can power the transition of the icon from - // lightning bolt to stop sign and back (when stream finishes) - // aiStreams?: { - // // These need to be removed at some point? - // [aiStreamId: AIStreamId]: { - // AIStreamStatus: AIStreamStatus; - // }; - // }; - - // TODO consider is there a way to replace the HTTP request to - // the AIAssist endpoint with a manipulation of the ShareDB document? +}; +// Example Presence object, from ShareDB: +// {"start":["files","69064344","text",183],"end":["files","69064344","text",183],"username":"Jim"} +export type Presence = { + start: Array; + end: Array; + username: Username; }; // An id used for presence.