first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -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,
}
}

View File

@@ -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])
}

View File

@@ -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)
}

View File

@@ -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,
}
}

View File

@@ -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)
}

View File

@@ -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]
)
)
}

View File

@@ -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])
}

View File

@@ -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 }
}

View File

@@ -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,
}
}

View File

@@ -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)
}

View File

@@ -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,
}
}

View File

@@ -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])
}

View File

@@ -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