From c84b92c39f85b2deb0ad996b5e879046ceadc233 Mon Sep 17 00:00:00 2001 From: Curran Date: Tue, 9 Jul 2024 14:24:46 -0400 Subject: [PATCH 1/4] Starting changes for auto-follow open file feature --- src/client/CodeEditor/getOrCreateEditor.ts | 3 +++ src/client/CodeEditor/json1PresenceDisplay.ts | 1 + 2 files changed, 4 insertions(+) diff --git a/src/client/CodeEditor/getOrCreateEditor.ts b/src/client/CodeEditor/getOrCreateEditor.ts index 8d403c68..f5b37103 100644 --- a/src/client/CodeEditor/getOrCreateEditor.ts +++ b/src/client/CodeEditor/getOrCreateEditor.ts @@ -93,6 +93,7 @@ export const getOrCreateEditor = ({ customInteractRules, allowGlobals, enableAutoFollowRef, + openTab, }: { fileId: FileId; @@ -126,6 +127,7 @@ export const getOrCreateEditor = ({ // Ref to a boolean that determines whether to // enable auto-following the cursors of remote users. enableAutoFollowRef: React.MutableRefObject; + openTab: (fileId: FileId) => void; }): EditorCacheValue => { // Cache hit if (editorCache.has(fileId)) { @@ -179,6 +181,7 @@ export const getOrCreateEditor = ({ path: textPath, docPresence, enableAutoFollowRef, + openTab, }), ); } diff --git a/src/client/CodeEditor/json1PresenceDisplay.ts b/src/client/CodeEditor/json1PresenceDisplay.ts index 6dc73003..50722222 100644 --- a/src/client/CodeEditor/json1PresenceDisplay.ts +++ b/src/client/CodeEditor/json1PresenceDisplay.ts @@ -26,6 +26,7 @@ export const json1PresenceDisplay = ({ path, docPresence, enableAutoFollowRef, + openTab, }) => [ ViewPlugin.fromClass( class { From d5ba8fc46eaea5721e564b01c51bdaaeff34d10a Mon Sep 17 00:00:00 2001 From: "Pratheet.Joshi" <101433332+DaCatDude@users.noreply.github.com> Date: Tue, 9 Jul 2024 15:18:01 -0400 Subject: [PATCH 2/4] doesnt work yet, openTab not recognized as a func --- src/client/CodeEditor/json1PresenceDisplay.ts | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/client/CodeEditor/json1PresenceDisplay.ts b/src/client/CodeEditor/json1PresenceDisplay.ts index 50722222..70be403e 100644 --- a/src/client/CodeEditor/json1PresenceDisplay.ts +++ b/src/client/CodeEditor/json1PresenceDisplay.ts @@ -10,6 +10,7 @@ import { Username } from '../../types'; const debug = false; + // export let enableAutoFollow = false; // export const toggleAutoFollowButton = () => { // enableAutoFollow = !enableAutoFollow; @@ -22,13 +23,13 @@ const debug = false; // * https://codemirror.net/examples/decoration/ // * https://github.com/share/sharedb/blob/master/examples/rich-text-presence/client.js // * https://share.github.io/sharedb/presence -export const json1PresenceDisplay = ({ - path, - docPresence, - enableAutoFollowRef, - openTab, -}) => [ - ViewPlugin.fromClass( + export const json1PresenceDisplay = ({ + path, + docPresence, + enableAutoFollowRef, + openTab, + }) => [ + ViewPlugin.fromClass( class { // The decorations to display. // This is a RangeSet of Decoration objects. @@ -37,6 +38,7 @@ export const json1PresenceDisplay = ({ //Added variable for cursor position cursorPosition = {}; + constructor(view: EditorView) { // Initialize decorations to empty array so CodeMirror doesn't crash. @@ -62,10 +64,19 @@ export const json1PresenceDisplay = ({ } // If presence === null, the user has disconnected / exited // We also check if the presence is for the current file or not. + + console.log(typeof openTab); // Should log 'function' + if (presence && pathMatches(path, presence)) { presenceState[id] = presence; - } else { - delete presenceState[id]; + } else if (presence) { + if (enableAutoFollowRef.current) { + presenceState[id] = presence; + openTab({ fileId: presence.start[1], isTransient: true }); + this.scrollToCursor(view); + } else { + delete presenceState[id]; + } } // Update decorations to reflect new presence state. // TODO consider mutating this rather than recomputing it on each change. From e9df0a6a0eaea12190c1ada34732077b4d0cf65c Mon Sep 17 00:00:00 2001 From: Curran Date: Wed, 10 Jul 2024 12:02:25 -0400 Subject: [PATCH 3/4] First pass opening files in auto-follow --- src/client/CodeEditor/getOrCreateEditor.ts | 3 +- src/client/CodeEditor/index.tsx | 2 ++ src/client/CodeEditor/json1PresenceDisplay.ts | 33 +++++++++++-------- src/client/VZCodeContext.tsx | 10 ++---- 4 files changed, 27 insertions(+), 21 deletions(-) diff --git a/src/client/CodeEditor/getOrCreateEditor.ts b/src/client/CodeEditor/getOrCreateEditor.ts index f5b37103..6a03a02d 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'; // Feature flag to enable TypeScript completions & TypeScript Linter. const enableTypeScriptCompletions = true; @@ -127,7 +128,7 @@ export const getOrCreateEditor = ({ // Ref to a boolean that determines whether to // enable auto-following the cursors of remote users. enableAutoFollowRef: React.MutableRefObject; - openTab: (fileId: FileId) => void; + openTab: (tabState: TabState) => void; }): EditorCacheValue => { // Cache hit if (editorCache.has(fileId)) { 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 70be403e..50e36a0b 100644 --- a/src/client/CodeEditor/json1PresenceDisplay.ts +++ b/src/client/CodeEditor/json1PresenceDisplay.ts @@ -7,10 +7,10 @@ import { import { Annotation, RangeSet } from '@codemirror/state'; import ColorHash from 'color-hash'; import { Username } from '../../types'; +import { TabState } from '../vzReducer'; const debug = false; - // export let enableAutoFollow = false; // export const toggleAutoFollowButton = () => { // enableAutoFollow = !enableAutoFollow; @@ -23,13 +23,18 @@ const debug = false; // * https://codemirror.net/examples/decoration/ // * https://github.com/share/sharedb/blob/master/examples/rich-text-presence/client.js // * https://share.github.io/sharedb/presence - export const json1PresenceDisplay = ({ - path, - docPresence, - enableAutoFollowRef, - openTab, - }) => [ - ViewPlugin.fromClass( +export const json1PresenceDisplay = ({ + path, + docPresence, + enableAutoFollowRef, + openTab, +}: { + path: Array; + docPresence: any; + enableAutoFollowRef: React.MutableRefObject; + openTab: (tabState: TabState) => void; +}) => [ + ViewPlugin.fromClass( class { // The decorations to display. // This is a RangeSet of Decoration objects. @@ -38,7 +43,6 @@ const debug = false; //Added variable for cursor position cursorPosition = {}; - constructor(view: EditorView) { // Initialize decorations to empty array so CodeMirror doesn't crash. @@ -64,15 +68,18 @@ const debug = false; } // If presence === null, the user has disconnected / exited // We also check if the presence is for the current file or not. - - console.log(typeof openTab); // Should log 'function' - + + console.log(typeof openTab); // Should log 'function' + if (presence && pathMatches(path, presence)) { presenceState[id] = presence; } else if (presence) { if (enableAutoFollowRef.current) { presenceState[id] = presence; - openTab({ fileId: presence.start[1], isTransient: true }); + openTab({ + fileId: presence.start[1], + isTransient: true, + }); this.scrollToCursor(view); } else { delete presenceState[id]; 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; From 7aee913c38548335334e265314aa5e802633df4e Mon Sep 17 00:00:00 2001 From: Curran Date: Wed, 10 Jul 2024 12:22:05 -0400 Subject: [PATCH 4/4] Solve open file in auto-follow --- src/client/CodeEditor/json1PresenceDisplay.ts | 217 ++++++++++-------- src/types.ts | 77 +------ 2 files changed, 125 insertions(+), 169 deletions(-) diff --git a/src/client/CodeEditor/json1PresenceDisplay.ts b/src/client/CodeEditor/json1PresenceDisplay.ts index 50e36a0b..d2f15733 100644 --- a/src/client/CodeEditor/json1PresenceDisplay.ts +++ b/src/client/CodeEditor/json1PresenceDisplay.ts @@ -6,7 +6,12 @@ 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; @@ -51,125 +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}`, - presence, - ); // Debug statement - } - // If presence === null, the user has disconnected / exited - // We also check if the presence is for the current file or not. + docPresence.on( + 'receive', + (id: PresenceId, presence: Presence) => { + if (debug) { + console.log( + `Received presence for id ${id}`, + presence, + ); // Debug statement + } - console.log(typeof openTab); // Should log 'function' + // If presence === null, the user has disconnected / exited + if (!presence) { + delete presenceState[id]; + return; + } - if (presence && pathMatches(path, presence)) { - presenceState[id] = presence; - } else if (presence) { - if (enableAutoFollowRef.current) { + // Check if the presence is for the current file or not. + const isPresenceInCurrentFile = pathMatches( + path, + presence, + ); + + // If the presence is in the current file, update the presence state. + if (isPresenceInCurrentFile) { presenceState[id] = presence; - openTab({ - fileId: presence.start[1], - isTransient: true, - }); - this.scrollToCursor(view); - } else { + } else if (presence) { + // Otherwise, delete the presence state. delete presenceState[id]; - } - } - // Update decorations to reflect new presence state. - // TODO consider mutating this rather than recomputing it on each change. - 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/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.