first commit
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import { useLayoutContext } from '@/shared/context/layout-context'
|
||||
import useCollapsiblePanel from '@/features/ide-react/hooks/use-collapsible-panel'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { ImperativePanelHandle } from 'react-resizable-panels'
|
||||
|
||||
export const useChatPane = () => {
|
||||
const { chatIsOpen: isOpen, setChatIsOpen: setIsOpen } = useLayoutContext()
|
||||
const [resizing, setResizing] = useState(false)
|
||||
const panelRef = useRef<ImperativePanelHandle>(null)
|
||||
|
||||
useCollapsiblePanel(isOpen, panelRef)
|
||||
|
||||
const togglePane = useCallback(() => {
|
||||
setIsOpen(value => !value)
|
||||
}, [setIsOpen])
|
||||
|
||||
const handlePaneExpand = useCallback(() => {
|
||||
setIsOpen(true)
|
||||
}, [setIsOpen])
|
||||
|
||||
const handlePaneCollapse = useCallback(() => {
|
||||
setIsOpen(false)
|
||||
}, [setIsOpen])
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
panelRef,
|
||||
resizing,
|
||||
setResizing,
|
||||
togglePane,
|
||||
handlePaneExpand,
|
||||
handlePaneCollapse,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { RefObject, useEffect } from 'react'
|
||||
import { ImperativePanelHandle } from 'react-resizable-panels'
|
||||
|
||||
export default function useCollapsiblePanel(
|
||||
panelIsOpen: boolean,
|
||||
panelRef: RefObject<ImperativePanelHandle>
|
||||
) {
|
||||
// collapse the panel when it is toggled closed (including on initial layout)
|
||||
useEffect(() => {
|
||||
const panelHandle = panelRef.current
|
||||
|
||||
if (panelHandle) {
|
||||
if (panelIsOpen) {
|
||||
panelHandle.expand()
|
||||
} else {
|
||||
panelHandle.collapse()
|
||||
}
|
||||
}
|
||||
}, [panelIsOpen, panelRef])
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { DependencyList, useEffect } from 'react'
|
||||
import {
|
||||
Command,
|
||||
useCommandRegistry,
|
||||
} from '../context/command-registry-context'
|
||||
|
||||
export const useCommandProvider = (
|
||||
generateElements: () => Command[] | undefined,
|
||||
dependencies: DependencyList
|
||||
) => {
|
||||
const { register, unregister } = useCommandRegistry()
|
||||
useEffect(() => {
|
||||
const elements = generateElements()
|
||||
if (!elements) return
|
||||
register(...elements)
|
||||
return () => {
|
||||
unregister(...elements.map(element => element.id))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, dependencies)
|
||||
}
|
||||
@@ -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<number | null> {
|
||||
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 | null
|
||||
) => {
|
||||
const debugCurrentDocument = useRef<DocumentContainer | null>(null)
|
||||
const debugProjectId = useRef<string | null>(null)
|
||||
const debugTimers = useRef<Record<string, number>>({})
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
|
||||
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
|
||||
import { EditorType } from '@/features/ide-react/editor/types/editor-type'
|
||||
import { putJSON } from '@/infrastructure/fetch-json'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import useEventListener from '@/shared/hooks/use-event-listener'
|
||||
import useDomEventListener from '@/shared/hooks/use-dom-event-listener'
|
||||
|
||||
function createEditingSessionHeartbeatData(editorType: EditorType) {
|
||||
return {
|
||||
editorType,
|
||||
}
|
||||
}
|
||||
|
||||
function sendEditingSessionHeartbeat(
|
||||
projectId: string,
|
||||
segmentation: Record<string, unknown>
|
||||
) {
|
||||
putJSON(`/editingSession/${projectId}`, {
|
||||
body: { segmentation },
|
||||
}).catch(debugConsole.error)
|
||||
}
|
||||
|
||||
export function useEditingSessionHeartbeat() {
|
||||
const { projectId } = useIdeReactContext()
|
||||
const { getEditorType } = useEditorManagerContext()
|
||||
|
||||
// Keep track of how many heartbeats we've sent so that we can calculate how
|
||||
// long to wait until the next one
|
||||
const heartBeatsSentRef = useRef(0)
|
||||
|
||||
const heartBeatSentRecentlyRef = useRef(false)
|
||||
|
||||
const heartBeatResetTimerRef = useRef<number>()
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
window.clearTimeout(heartBeatResetTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const editingSessionHeartbeat = useCallback(() => {
|
||||
debugConsole.log('[Event] heartbeat trigger')
|
||||
|
||||
const editorType = getEditorType()
|
||||
if (editorType === null) return
|
||||
|
||||
// Heartbeat already sent recently
|
||||
if (heartBeatSentRecentlyRef.current) return
|
||||
|
||||
heartBeatSentRecentlyRef.current = true
|
||||
|
||||
const segmentation = createEditingSessionHeartbeatData(editorType)
|
||||
|
||||
debugConsole.log('[Event] send heartbeat request', segmentation)
|
||||
sendEditingSessionHeartbeat(projectId, segmentation)
|
||||
|
||||
const heartbeatsSent = heartBeatsSentRef.current
|
||||
heartBeatsSentRef.current++
|
||||
|
||||
// Send two first heartbeats at 0 and 30s then increase the backoff time
|
||||
// 1min per call until we reach 5 min
|
||||
const backoffSecs =
|
||||
heartbeatsSent <= 2
|
||||
? 30
|
||||
: heartbeatsSent <= 6
|
||||
? (heartbeatsSent - 2) * 60
|
||||
: 300
|
||||
|
||||
heartBeatResetTimerRef.current = window.setTimeout(() => {
|
||||
heartBeatSentRecentlyRef.current = false
|
||||
}, backoffSecs * 1000)
|
||||
}, [getEditorType, projectId])
|
||||
|
||||
// Hook the heartbeat up to editor events
|
||||
useEventListener('cursor:editor:update', editingSessionHeartbeat)
|
||||
useEventListener('scroll:editor:update', editingSessionHeartbeat)
|
||||
useDomEventListener(document, 'click', editingSessionHeartbeat)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import useEventListener from '@/shared/hooks/use-event-listener'
|
||||
import { useLocalCompileContext } from '@/shared/context/local-compile-context'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
export function useHasLintingError() {
|
||||
const { setHasLintingError } = useLocalCompileContext()
|
||||
|
||||
// Listen for editor:lint event from CM6 linter and keep compile context
|
||||
// up to date
|
||||
useEventListener(
|
||||
'editor:lint',
|
||||
useCallback(
|
||||
(event: CustomEvent) => {
|
||||
setHasLintingError(event.detail.hasLintingError)
|
||||
},
|
||||
[setHasLintingError]
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useLayoutContext } from '@/shared/context/layout-context'
|
||||
import { useEffect } from 'react'
|
||||
import { sendMBOnce } from '@/infrastructure/event-tracking'
|
||||
|
||||
export function useLayoutEventTracking() {
|
||||
const { view, leftMenuShown, chatIsOpen } = useLayoutContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (view && view !== 'editor' && view !== 'pdf') {
|
||||
sendMBOnce(`ide-open-view-${view}-once`)
|
||||
}
|
||||
}, [view])
|
||||
|
||||
useEffect(() => {
|
||||
if (leftMenuShown) {
|
||||
sendMBOnce(`ide-open-left-menu-once`)
|
||||
}
|
||||
}, [leftMenuShown])
|
||||
|
||||
useEffect(() => {
|
||||
if (chatIsOpen) {
|
||||
sendMBOnce(`ide-open-chat-once`)
|
||||
}
|
||||
}, [chatIsOpen])
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useOutlineContext } from '@/features/ide-react/context/outline-context'
|
||||
import useCollapsiblePanel from '@/features/ide-react/hooks/use-collapsible-panel'
|
||||
import { useRef } from 'react'
|
||||
import { ImperativePanelHandle } from 'react-resizable-panels'
|
||||
|
||||
export const useOutlinePane = () => {
|
||||
const { canShowOutline, outlineExpanded } = useOutlineContext()
|
||||
const outlinePanelRef = useRef<ImperativePanelHandle>(null)
|
||||
const outlineEnabled = canShowOutline && outlineExpanded
|
||||
|
||||
useCollapsiblePanel(outlineEnabled, outlinePanelRef)
|
||||
|
||||
return { outlineEnabled, outlinePanelRef }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { ImperativePanelHandle } from 'react-resizable-panels'
|
||||
import useCollapsiblePanel from '@/features/ide-react/hooks/use-collapsible-panel'
|
||||
import { useLayoutContext } from '@/shared/context/layout-context'
|
||||
|
||||
export const usePdfPane = () => {
|
||||
const { view, pdfLayout, changeLayout, detachRole, reattach } =
|
||||
useLayoutContext()
|
||||
|
||||
const pdfPanelRef = useRef<ImperativePanelHandle>(null)
|
||||
const pdfIsOpen = pdfLayout === 'sideBySide' || view === 'pdf'
|
||||
|
||||
useCollapsiblePanel(pdfIsOpen, pdfPanelRef)
|
||||
|
||||
// triggered by a double-click on the resizer
|
||||
const togglePdfPane = useCallback(() => {
|
||||
if (pdfIsOpen) {
|
||||
changeLayout('flat', 'editor')
|
||||
} else {
|
||||
changeLayout('sideBySide')
|
||||
}
|
||||
}, [changeLayout, pdfIsOpen])
|
||||
|
||||
// triggered by a click on the toggle button
|
||||
const setPdfIsOpen = useCallback(
|
||||
(value: boolean) => {
|
||||
if (value) {
|
||||
// opening the PDF view, so close a detached PDF
|
||||
if (detachRole === 'detacher') {
|
||||
reattach()
|
||||
}
|
||||
changeLayout('sideBySide')
|
||||
} else {
|
||||
changeLayout('flat', 'editor')
|
||||
}
|
||||
},
|
||||
[changeLayout, detachRole, reattach]
|
||||
)
|
||||
|
||||
// triggered when the PDF pane becomes open
|
||||
const handlePdfPaneExpand = useCallback(() => {
|
||||
if (pdfLayout === 'flat' && view === 'editor') {
|
||||
changeLayout('sideBySide', 'editor')
|
||||
}
|
||||
}, [changeLayout, pdfLayout, view])
|
||||
|
||||
// triggered when the PDF pane becomes closed (either by dragging or toggling)
|
||||
const handlePdfPaneCollapse = useCallback(() => {
|
||||
if (pdfLayout === 'sideBySide') {
|
||||
changeLayout('flat', 'editor')
|
||||
}
|
||||
}, [changeLayout, pdfLayout])
|
||||
|
||||
return {
|
||||
togglePdfPane,
|
||||
handlePdfPaneExpand,
|
||||
handlePdfPaneCollapse,
|
||||
setPdfIsOpen,
|
||||
pdfIsOpen,
|
||||
pdfPanelRef,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
|
||||
import useEventListener from '@/shared/hooks/use-event-listener'
|
||||
import useDomEventListener from '@/shared/hooks/use-dom-event-listener'
|
||||
|
||||
export function useRegisterUserActivity() {
|
||||
const { registerUserActivity } = useConnectionContext()
|
||||
|
||||
useEventListener('cursor:editor:update', registerUserActivity)
|
||||
useDomEventListener(document.body, 'click', registerUserActivity)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import useCollapsiblePanel from '@/features/ide-react/hooks/use-collapsible-panel'
|
||||
import { ImperativePanelHandle } from 'react-resizable-panels'
|
||||
|
||||
export const useSidebarPane = () => {
|
||||
const [isOpen, setIsOpen] = useState(true)
|
||||
const [resizing, setResizing] = useState(false)
|
||||
const panelRef = useRef<ImperativePanelHandle>(null)
|
||||
useCollapsiblePanel(isOpen, panelRef)
|
||||
|
||||
const togglePane = useCallback(() => {
|
||||
setIsOpen(value => !value)
|
||||
}, [])
|
||||
|
||||
const handlePaneExpand = useCallback(() => {
|
||||
setIsOpen(true)
|
||||
}, [])
|
||||
|
||||
const handlePaneCollapse = useCallback(() => {
|
||||
setIsOpen(false)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
panelRef,
|
||||
togglePane,
|
||||
handlePaneExpand,
|
||||
handlePaneCollapse,
|
||||
resizing,
|
||||
setResizing,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Socket } from '@/features/ide-react/connection/types/socket'
|
||||
|
||||
type SocketOnParams = Parameters<Socket['on']>
|
||||
|
||||
export default function useSocketListener(
|
||||
socket: Socket,
|
||||
event: SocketOnParams[0],
|
||||
listener: SocketOnParams[1]
|
||||
) {
|
||||
useEffect(() => {
|
||||
socket.on(event, listener)
|
||||
|
||||
return () => {
|
||||
socket.removeListener(event, listener)
|
||||
}
|
||||
}, [event, listener, socket])
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useSocketListener from '@/features/ide-react/hooks/use-socket-listener'
|
||||
import {
|
||||
listProjectInvites,
|
||||
listProjectMembers,
|
||||
} from '@/features/share-project-modal/utils/api'
|
||||
import useScopeValue from '@/shared/hooks/use-scope-value'
|
||||
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
|
||||
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
|
||||
import { useModalsContext } from '@/features/ide-react/context/modals-context'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import { useCallback } from 'react'
|
||||
import { PublicAccessLevel } from '../../../../../types/public-access-level'
|
||||
import { useLocation } from '@/shared/hooks/use-location'
|
||||
import { useEditorContext } from '@/shared/context/editor-context'
|
||||
|
||||
function useSocketListeners() {
|
||||
const { t } = useTranslation()
|
||||
const { socket } = useConnectionContext()
|
||||
const { projectId } = useIdeReactContext()
|
||||
const { showGenericMessageModal } = useModalsContext()
|
||||
const { permissionsLevel } = useEditorContext()
|
||||
const [, setPublicAccessLevel] = useScopeValue('project.publicAccesLevel')
|
||||
const [, setProjectMembers] = useScopeValue('project.members')
|
||||
const [, setProjectInvites] = useScopeValue('project.invites')
|
||||
const location = useLocation()
|
||||
|
||||
useSocketListener(
|
||||
socket,
|
||||
'project:access:revoked',
|
||||
useCallback(() => {
|
||||
showGenericMessageModal(
|
||||
t('removed_from_project'),
|
||||
t(
|
||||
'you_have_been_removed_from_this_project_and_will_be_redirected_to_project_dashboard'
|
||||
)
|
||||
)
|
||||
|
||||
// redirect to project page before reconnect timer runs out and reloads the page
|
||||
const timer = window.setTimeout(() => {
|
||||
location.assign('/project')
|
||||
}, 5000)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}, [showGenericMessageModal, t, location])
|
||||
)
|
||||
|
||||
useSocketListener(
|
||||
socket,
|
||||
'project:publicAccessLevel:changed',
|
||||
useCallback(
|
||||
(data: { newAccessLevel?: PublicAccessLevel }) => {
|
||||
if (data.newAccessLevel) {
|
||||
setPublicAccessLevel(data.newAccessLevel)
|
||||
}
|
||||
},
|
||||
[setPublicAccessLevel]
|
||||
)
|
||||
)
|
||||
|
||||
useSocketListener(
|
||||
socket,
|
||||
'project:collaboratorAccessLevel:changed',
|
||||
useCallback(() => {
|
||||
listProjectMembers(projectId)
|
||||
.then(({ members }) => {
|
||||
if (members) {
|
||||
setProjectMembers(members)
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
debugConsole.error('Error fetching members for project', err)
|
||||
})
|
||||
}, [projectId, setProjectMembers])
|
||||
)
|
||||
|
||||
useSocketListener(
|
||||
socket,
|
||||
'project:membership:changed',
|
||||
useCallback(
|
||||
(data: { members?: boolean; invites?: boolean }) => {
|
||||
if (data.members) {
|
||||
listProjectMembers(projectId)
|
||||
.then(({ members }) => {
|
||||
if (members) {
|
||||
setProjectMembers(members)
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
debugConsole.error('Error fetching members for project', err)
|
||||
})
|
||||
}
|
||||
|
||||
if (data.invites && permissionsLevel === 'owner') {
|
||||
listProjectInvites(projectId)
|
||||
.then(({ invites }) => {
|
||||
if (invites) {
|
||||
setProjectInvites(invites)
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
debugConsole.error('Error fetching invites for project', err)
|
||||
})
|
||||
}
|
||||
},
|
||||
[projectId, setProjectInvites, setProjectMembers, permissionsLevel]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export default useSocketListeners
|
||||
Reference in New Issue
Block a user