diff --git a/services/web/frontend/js/features/ide-react/components/unsaved-docs/unsaved-docs.tsx b/services/web/frontend/js/features/ide-react/components/unsaved-docs/unsaved-docs.tsx index d71324d6ef..4aa63cd3b5 100644 --- a/services/web/frontend/js/features/ide-react/components/unsaved-docs/unsaved-docs.tsx +++ b/services/web/frontend/js/features/ide-react/components/unsaved-docs/unsaved-docs.tsx @@ -11,7 +11,7 @@ import { useGlobalAlertsContainer } from '@/features/ide-react/context/global-al const MAX_UNSAVED_SECONDS = 15 // lock the editor after this time if unsaved export const UnsavedDocs: FC = () => { - const { openDocs } = useEditorManagerContext() + const { openDocs, debugTimers } = useEditorManagerContext() const { permissionsLevel, setPermissionsLevel } = useEditorContext() const [isLocked, setIsLocked] = useState(false) const [unsavedDocs, setUnsavedDocs] = useState(new Map()) @@ -47,6 +47,7 @@ export const UnsavedDocs: FC = () => { // NOTE: openDocs should never change, so it's safe to use as a dependency here useEffect(() => { const interval = window.setInterval(() => { + debugTimers.current.CheckUnsavedDocs = Date.now() const unsavedDocs = new Map() const unsavedDocIds = openDocs.unsavedDocIds() @@ -67,7 +68,7 @@ export const UnsavedDocs: FC = () => { return () => { window.clearInterval(interval) } - }, [openDocs]) + }, [openDocs, debugTimers]) const maxUnsavedSeconds = Math.max(0, ...unsavedDocs.values()) diff --git a/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx b/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx index 8fe6928565..8fa4505f8c 100644 --- a/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx +++ b/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx @@ -30,6 +30,7 @@ import useEventListener from '@/shared/hooks/use-event-listener' import { EditorType } from '@/features/ide-react/editor/types/editor-type' import { DocId } from '../../../../../types/project-settings' import { Update } from '@/features/history/services/types/update' +import { useDebugDiffTracker } from '../hooks/use-debug-diff-tracker' interface GotoOffsetOptions { gotoOffset: number @@ -60,6 +61,7 @@ export type EditorManager = { setWantTrackChanges: React.Dispatch< React.SetStateAction > + debugTimers: React.MutableRefObject> } function hasGotoLine(options: OpenDocOptions): options is GotoLineOptions { @@ -126,12 +128,32 @@ export const EditorManagerProvider: FC = ({ children }) => { const [ignoringExternalUpdates, setIgnoringExternalUpdates] = useState(false) + const { createDebugDiff, debugTimers } = useDebugDiffTracker( + projectId, + currentDocument + ) + const [globalEditorWatchdogManager] = useState( () => new EditorWatchdogManager({ onTimeoutHandler: (meta: Record) => { - sendMB('losing-edits', meta) - reportError('losing-edits', meta) + let diffSize: number | null = null + createDebugDiff() + .then(calculatedDiffSize => { + diffSize = calculatedDiffSize + }) + .finally(() => { + sendMB('losing-edits', { + ...meta, + diffSize, + timers: debugTimers.current, + }) + reportError('losing-edits', { + ...meta, + diffSize, + timers: debugTimers.current, + }) + }) }, }) ) @@ -616,6 +638,7 @@ export const EditorManagerProvider: FC = ({ children }) => { jumpToLine, wantTrackChanges, setWantTrackChanges, + debugTimers, }), [ getEditorType, @@ -633,6 +656,7 @@ export const EditorManagerProvider: FC = ({ children }) => { jumpToLine, wantTrackChanges, setWantTrackChanges, + debugTimers, ] ) diff --git a/services/web/frontend/js/features/ide-react/hooks/use-debug-diff-tracker.ts b/services/web/frontend/js/features/ide-react/hooks/use-debug-diff-tracker.ts new file mode 100644 index 0000000000..4cfea1188e --- /dev/null +++ b/services/web/frontend/js/features/ide-react/hooks/use-debug-diff-tracker.ts @@ -0,0 +1,79 @@ +import { useEffect, useMemo, useRef } from 'react' +import { DocumentContainer } from '../editor/document-container' +import { DocId } from '../../../../../types/project-settings' +import { debugConsole } from '@/utils/debugging' +import { diffChars } from 'diff' + +const DIFF_TIMEOUT_MS = 5000 + +async function tryGetDiffSize( + currentContents: string | null | undefined, + projectId: string | null, + docId: DocId | null | undefined +): Promise { + debugConsole.debug('tryGetDiffSize') + // If we don't know the current content or id, there's not much we can do + if (!projectId) { + debugConsole.debug('tryGetDiffSize: missing projectId') + return null + } + if (!currentContents) { + debugConsole.debug('tryGetDiffSize: missing currentContents') + return null + } + if (!docId) { + debugConsole.debug('tryGetDiffSize: missing docId') + return null + } + try { + const response = await fetch( + `/Project/${projectId}/doc/${docId}/download`, + { signal: AbortSignal.timeout(DIFF_TIMEOUT_MS) } + ) + const serverContent = await response.text() + + const differences = diffChars(serverContent, currentContents) + let diffSize = 0 + for (const diff of differences) { + if (diff.added || diff.removed) { + diffSize += diff.value.length + } + } + return diffSize + } catch { + // There's a good chance we're offline, so just return null + debugConsole.debug('tryGetDiffSize: fetch failed') + return null + } +} + +export const useDebugDiffTracker = ( + projectId: string, + currentDocument: DocumentContainer +) => { + const debugCurrentDocument = useRef(null) + const debugProjectId = useRef(null) + const debugTimers = useRef>({}) + + useEffect(() => { + debugCurrentDocument.current = currentDocument + }, [currentDocument]) + useEffect(() => { + debugProjectId.current = projectId + }, [projectId]) + + const createDebugDiff = useMemo( + () => async () => + await tryGetDiffSize( + debugCurrentDocument.current?.getSnapshot(), + debugProjectId.current, + debugCurrentDocument.current?.doc_id as DocId | undefined + ), + [] + ) + + return { + createDebugDiff, + debugTimers, + } +}