[web] open comments and changes via url (#36546)

* [web] open a comment from a URL

* [web] add "View comment" button to digest email

* [web] open a tracked change from a URL

* [web] add "View suggestion" button to digest email

* [web] open the review panel from an open=review-panel URL

* [web] drop the digest email CTA and collapse the deep links to t=review

* [web] add an Open project button to the section overflow row and plain-text view links

* [web] validate the comment and change deep-link ids as object ids

* [web] collapse the deep-link resolvers into a single DeepLink component

* fix: scrolling into view when below the fold

* fix: address claude review

* fix: style adjustments

* fix: updating types to accomodate accepted/rejected changes

---------

Co-authored-by: Jimmy Domagala-Tang <jimmy.domagala-tang@overleaf.com>
GitOrigin-RevId: dab535591a93e1c3849a6b2a0dcc3e3934354385
This commit is contained in:
Kristina
2026-09-17 12:07:22 +00:00
committed by Copybot
co-authored by Jimmy Domagala-Tang
parent 5dc8c2b0c3
commit 44910202f3
15 changed files with 977 additions and 91 deletions
@@ -106,6 +106,12 @@ function NoCTAEmailTemplate(content) {
if (!content.message) {
throw new Error('missing message')
}
if (!content.secondaryMessage) {
content.secondaryMessage = () => []
}
if (!content.footerMessage) {
content.footerMessage = () => {}
}
return {
subject(opts) {
return content.subject(opts)
@@ -113,15 +119,11 @@ function NoCTAEmailTemplate(content) {
layout(opts) {
return BaseEmailLayout(opts)
},
footerMessage(opts) {
return content.footerMessage(opts)
},
plainTextTemplate(opts) {
return `\
${content.greeting(opts)}
${content.message(opts, true).join('\r\n\r\n')}
Regards,
The ${settings.appName} Team - ${settings.siteUrl}\
`
return _emailBodyPlainText(content, opts, false)
},
compiledTemplate(opts) {
return NoCTAEmailBody({
@@ -349,6 +349,7 @@
"command_palette": "",
"command_palette_search": "",
"comment": "",
"comment_not_found": "",
"comment_only": "",
"comment_only_upgrade_for_track_changes": "",
"comment_only_upgrade_to_enable_track_changes": "",
@@ -0,0 +1,35 @@
import { GlobalToastGeneratorEntry } from '@/features/ide-react/components/global-toasts'
import { useTranslation } from 'react-i18next'
const COMMENT_NOT_FOUND_KEY = 'comment:not-found'
export const CommentNotFoundToast = () => {
const { t } = useTranslation()
return <span>{t('comment_not_found')}</span>
}
const generators: GlobalToastGeneratorEntry[] = [
{
key: COMMENT_NOT_FOUND_KEY,
generator: () => ({
content: <CommentNotFoundToast />,
type: 'warning',
autoHide: true,
delay: 4000,
isDismissible: true,
}),
},
]
export default generators
export const showCommentNotFoundToast = () => {
window.dispatchEvent(
new CustomEvent('ide:show-toast', {
detail: {
key: COMMENT_NOT_FOUND_KEY,
},
})
)
}
@@ -8,6 +8,7 @@ import { OLToastContainer } from '@/shared/components/ol/ol-toast-container'
import clipboardToastGenerators from '@/features/source-editor/components/clipboard-toasts'
import exportDocumentToastGenerators from '@/features/ide-react/components/toolbar/export-document-toasts'
import connectionRestoredToastGenerators from '@/features/ide-react/components/connection-restored-toast'
import deepLinkToastGenerators from '@/features/ide-react/components/deep-link-toasts'
const moduleGeneratorsImport = importOverleafModules('toastGenerators') as {
import: { default: GlobalToastGeneratorEntry[] }
@@ -31,6 +32,7 @@ const GENERATOR_LIST: GlobalToastGeneratorEntry[] = [
...clipboardToastGenerators,
...exportDocumentToastGenerators,
...connectionRestoredToastGenerators,
...deepLinkToastGenerators,
]
const GENERATOR_MAP: Map<string, GlobalToastGenerator> = new Map(
GENERATOR_LIST.map(({ key, generator }) => [key, generator])
@@ -0,0 +1,153 @@
import {
createContext,
FC,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { ThreadId } from '../../../../../types/review-panel/review-panel'
import { showCommentNotFoundToast } from '@/features/ide-react/components/deep-link-toasts'
const DEEP_LINK_PARAMS = ['doc', 'comment', 'change', 'open'] as const
// the `open` param names either an editor panel or a settings tab, so it
// resolves into one of two fields and the other stays null
type DeepLinkPanel = 'review-panel'
type DeepLinkSettings = 'project-notifications'
type DeepLinkParams = {
docId: string | null
threadId: ThreadId | null
changeId: string | null
panel: DeepLinkPanel | null
settings: DeepLinkSettings | null
}
type DeepLinkContextValue = {
deepLinkedDocId: string | null
deepLinkedThreadId: ThreadId | null
deepLinkedChangeId: string | null
deepLinkedPanel: DeepLinkPanel | null
deepLinkedSettings: DeepLinkSettings | null
clearDeepLinkedThread: () => void
clearDeepLinkedChange: () => void
reportDeepLinkedThreadNotFound: () => void
}
const DeepLinkContext = createContext<DeepLinkContextValue | undefined>(
undefined
)
function readPanel(value: string | null): DeepLinkPanel | null {
if (value === 'review-panel') {
return value
}
return null
}
function readSettings(value: string | null): DeepLinkSettings | null {
if (value === 'project-notifications') {
return value
}
return null
}
function readDeepLinkParams(): DeepLinkParams {
const params = new URLSearchParams(window.location.search)
const open = params.get('open')
return {
docId: params.get('doc'),
threadId: params.get('comment') as ThreadId | null,
changeId: params.get('change'),
panel: readPanel(open),
settings: readSettings(open),
}
}
function stripDeepLinkParams() {
const url = new URL(window.location.href)
let changed = false
for (const param of DEEP_LINK_PARAMS) {
if (url.searchParams.has(param)) {
url.searchParams.delete(param)
changed = true
}
}
if (changed) {
window.history.replaceState(window.history.state, '', url.toString())
}
}
export const DeepLinkProvider: FC<React.PropsWithChildren> = ({ children }) => {
const paramsRef = useRef<DeepLinkParams | null>(null)
if (paramsRef.current === null) {
paramsRef.current = readDeepLinkParams()
}
const { docId, panel, settings } = paramsRef.current
const [threadId, setThreadId] = useState(paramsRef.current.threadId)
const [changeId, setChangeId] = useState(paramsRef.current.changeId)
useEffect(() => {
stripDeepLinkParams()
}, [])
const clearDeepLinkedThread = useCallback(() => setThreadId(null), [])
const clearDeepLinkedChange = useCallback(() => setChangeId(null), [])
// several places give up on the comment at different points, so keep the
// toast and the clear together in one call rather than pairing them by hand
const reportDeepLinkedThreadNotFound = useCallback(() => {
showCommentNotFoundToast()
setThreadId(null)
}, [])
const value = useMemo(
() => ({
deepLinkedDocId: docId,
deepLinkedThreadId: threadId,
deepLinkedChangeId: changeId,
deepLinkedPanel: panel,
deepLinkedSettings: settings,
clearDeepLinkedThread,
clearDeepLinkedChange,
reportDeepLinkedThreadNotFound,
}),
[
docId,
threadId,
changeId,
panel,
settings,
clearDeepLinkedThread,
clearDeepLinkedChange,
reportDeepLinkedThreadNotFound,
]
)
return (
<DeepLinkContext.Provider value={value}>
{children}
</DeepLinkContext.Provider>
)
}
export function useDeepLinkContext(): DeepLinkContextValue {
const context = useContext(DeepLinkContext)
if (!context) {
throw new Error(
'useDeepLinkContext is only available inside DeepLinkProvider'
)
}
return context
}
@@ -39,6 +39,7 @@ import { convertFileRefToBinaryFile } from '@/features/ide-react/util/file-view'
import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context'
import { showConnectionRestoredToast } from '@/features/ide-react/components/connection-restored-toast'
import { useDeepLinkContext } from '@/features/ide-react/context/deep-link-context'
export interface GotoOffsetOptions {
gotoOffset: number
@@ -119,6 +120,12 @@ export const EditorManagerProvider: FC<React.PropsWithChildren> = ({
wantTrackChangesRef.current = wantTrackChanges
}, [wantTrackChanges])
const {
deepLinkedDocId,
deepLinkedThreadId,
reportDeepLinkedThreadNotFound,
} = useDeepLinkContext()
const { fileTreeData } = useFileTreeData()
const fileTreeDataRef = useRef(fileTreeData)
useEffect(() => {
@@ -527,13 +534,34 @@ export const EditorManagerProvider: FC<React.PropsWithChildren> = ({
const openInitialDoc = useCallback(
async (fallbackDocId?: string) => {
if (deepLinkedDocId) {
const deepLinkedDoc = findDocEntityById(fileTreeData, deepLinkedDocId)
if (deepLinkedDoc) {
return await openDoc(deepLinkedDoc)
}
// the deep link points at a doc that is no longer in the project, so
// any comment it referenced is unreachable
if (deepLinkedThreadId) {
reportDeepLinkedThreadNotFound()
}
}
const docId =
customLocalStorage.getItem(currentDocumentIdStorageKey) || fallbackDocId
if (docId) {
return await openDocWithId(docId)
}
},
[currentDocumentIdStorageKey, openDocWithId]
[
currentDocumentIdStorageKey,
deepLinkedDocId,
deepLinkedThreadId,
fileTreeData,
openDoc,
openDocWithId,
reportDeepLinkedThreadNotFound,
]
)
useEffect(() => {
@@ -2,6 +2,7 @@ import React, { ElementType, FC, PropsWithChildren } from 'react'
import { ChatProvider } from '@/features/chat/context/chat-context'
import { ChangesUsersProvider } from '@/shared/context/changes-users-context'
import { ConnectionProvider } from './connection-context'
import { DeepLinkProvider } from '@/features/ide-react/context/deep-link-context'
import { DetachCompileProvider } from '@/shared/context/detach-compile-context'
import { DetachProvider } from '@/shared/context/detach-context'
import { EditorManagerProvider } from '@/features/ide-react/context/editor-manager-context'
@@ -50,6 +51,7 @@ export const ReactContextRoot: FC<
ChatProvider,
ChangesUsersProvider,
ConnectionProvider,
DeepLinkProvider,
DetachCompileProvider,
DetachProvider,
EditorManagerProvider,
@@ -98,78 +100,80 @@ export const ReactContextRoot: FC<
)
return (
<Providers.SplitTestProvider>
<Providers.ModalsContextProvider>
<Providers.ConnectionProvider>
<Providers.ProjectProvider>
<Providers.UserSettingsProvider>
<Providers.IdeReactProvider>
<Providers.UserProvider>
<Providers.SnapshotProvider>
<Providers.DetachProvider>
<Providers.EditorOpenDocProvider>
<Providers.EditorViewProvider>
<Providers.EditorPropertiesProvider>
<Providers.EditorProvider>
<Providers.TutorialProvider>
<Providers.FileTreeDataProvider>
<Providers.FileTreePathProvider>
<Providers.UserFeaturesProvider>
<Providers.PermissionsProvider>
<Providers.RailProvider>
<Providers.LayoutProvider>
<Providers.ProjectSettingsProvider>
<Providers.EditorManagerProvider>
<Providers.UnsavedDocsProvider>
<Providers.ReferencesProvider>
<Providers.LocalCompileProvider>
<Providers.DetachCompileProvider>
<Providers.ChatProvider>
<Providers.FileTreeOpenProvider>
<Providers.TabsProvider>
<Providers.OnlineUsersProvider>
<Providers.MetadataProvider>
<Providers.OutlineProvider>
<Providers.CommandRegistryProvider>
<Providers.EditorSelectionProvider>
<Providers.ChangesUsersProvider>
{
childrenWrappedWithDynamicProviders
}
</Providers.ChangesUsersProvider>
</Providers.EditorSelectionProvider>
</Providers.CommandRegistryProvider>
</Providers.OutlineProvider>
</Providers.MetadataProvider>
</Providers.OnlineUsersProvider>
</Providers.TabsProvider>
</Providers.FileTreeOpenProvider>
</Providers.ChatProvider>
</Providers.DetachCompileProvider>
</Providers.LocalCompileProvider>
</Providers.ReferencesProvider>
</Providers.UnsavedDocsProvider>
</Providers.EditorManagerProvider>
</Providers.ProjectSettingsProvider>
</Providers.LayoutProvider>
</Providers.RailProvider>
</Providers.PermissionsProvider>
</Providers.UserFeaturesProvider>
</Providers.FileTreePathProvider>
</Providers.FileTreeDataProvider>
</Providers.TutorialProvider>
</Providers.EditorProvider>
</Providers.EditorPropertiesProvider>
</Providers.EditorViewProvider>
</Providers.EditorOpenDocProvider>
</Providers.DetachProvider>
</Providers.SnapshotProvider>
</Providers.UserProvider>
</Providers.IdeReactProvider>
</Providers.UserSettingsProvider>
</Providers.ProjectProvider>
</Providers.ConnectionProvider>
</Providers.ModalsContextProvider>
</Providers.SplitTestProvider>
<Providers.DeepLinkProvider>
<Providers.SplitTestProvider>
<Providers.ModalsContextProvider>
<Providers.ConnectionProvider>
<Providers.ProjectProvider>
<Providers.UserSettingsProvider>
<Providers.IdeReactProvider>
<Providers.UserProvider>
<Providers.SnapshotProvider>
<Providers.DetachProvider>
<Providers.EditorOpenDocProvider>
<Providers.EditorViewProvider>
<Providers.EditorPropertiesProvider>
<Providers.EditorProvider>
<Providers.TutorialProvider>
<Providers.FileTreeDataProvider>
<Providers.FileTreePathProvider>
<Providers.UserFeaturesProvider>
<Providers.PermissionsProvider>
<Providers.RailProvider>
<Providers.LayoutProvider>
<Providers.ProjectSettingsProvider>
<Providers.EditorManagerProvider>
<Providers.UnsavedDocsProvider>
<Providers.ReferencesProvider>
<Providers.LocalCompileProvider>
<Providers.DetachCompileProvider>
<Providers.ChatProvider>
<Providers.FileTreeOpenProvider>
<Providers.TabsProvider>
<Providers.OnlineUsersProvider>
<Providers.MetadataProvider>
<Providers.OutlineProvider>
<Providers.CommandRegistryProvider>
<Providers.EditorSelectionProvider>
<Providers.ChangesUsersProvider>
{
childrenWrappedWithDynamicProviders
}
</Providers.ChangesUsersProvider>
</Providers.EditorSelectionProvider>
</Providers.CommandRegistryProvider>
</Providers.OutlineProvider>
</Providers.MetadataProvider>
</Providers.OnlineUsersProvider>
</Providers.TabsProvider>
</Providers.FileTreeOpenProvider>
</Providers.ChatProvider>
</Providers.DetachCompileProvider>
</Providers.LocalCompileProvider>
</Providers.ReferencesProvider>
</Providers.UnsavedDocsProvider>
</Providers.EditorManagerProvider>
</Providers.ProjectSettingsProvider>
</Providers.LayoutProvider>
</Providers.RailProvider>
</Providers.PermissionsProvider>
</Providers.UserFeaturesProvider>
</Providers.FileTreePathProvider>
</Providers.FileTreeDataProvider>
</Providers.TutorialProvider>
</Providers.EditorProvider>
</Providers.EditorPropertiesProvider>
</Providers.EditorViewProvider>
</Providers.EditorOpenDocProvider>
</Providers.DetachProvider>
</Providers.SnapshotProvider>
</Providers.UserProvider>
</Providers.IdeReactProvider>
</Providers.UserSettingsProvider>
</Providers.ProjectProvider>
</Providers.ConnectionProvider>
</Providers.ModalsContextProvider>
</Providers.SplitTestProvider>
</Providers.DeepLinkProvider>
)
}
@@ -1,9 +1,11 @@
import { useEffect } from 'react'
import { useSettingsModalContext } from '../context/settings-modal-context'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
import { useDeepLinkContext } from '@/features/ide-react/context/deep-link-context'
export default function useOpenSettingsViaQueryParam() {
const { setShow, setActiveTab } = useSettingsModalContext()
const { deepLinkedSettings } = useDeepLinkContext()
useEffect(() => {
const inNotificationsSplitTest = isSplitTestEnabled('email-notifications')
@@ -11,16 +13,11 @@ export default function useOpenSettingsViaQueryParam() {
return
}
const params = new URLSearchParams(window.location.search)
if (params.get('open') !== 'project-notifications') {
if (deepLinkedSettings !== 'project-notifications') {
return
}
setShow(true)
setActiveTab('project_notifications')
const url = new URL(window.location.href)
url.searchParams.delete('open')
window.history.replaceState(window.history.state, '', url.toString())
}, [setShow, setActiveTab])
}, [deepLinkedSettings, setShow, setActiveTab])
}
@@ -0,0 +1,187 @@
import { useCallback, useEffect, useRef } from 'react'
import { useDeepLinkContext } from '@/features/ide-react/context/deep-link-context'
import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
import { useProjectContext } from '@/shared/context/project-context'
import { EditorSelection } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { useCodeMirrorViewContext } from '@/features/source-editor/components/codemirror-context'
import { useRangesContext } from '../context/ranges-context'
import { useThreadsContext } from '../context/threads-context'
import useReviewPanelLayout from '../hooks/use-review-panel-layout'
// how long to wait for a doc to restore its stored scroll position. The restore
// runs two ticks after the doc opens, so this only has to outlast a couple of
// frames. It doubles as the delay the user waits when the restore has already
// run and the event never arrives, so keep it short.
const SCROLL_RESTORE_TIMEOUT = 100
export default function DeepLink() {
const {
deepLinkedPanel,
deepLinkedThreadId,
deepLinkedChangeId,
clearDeepLinkedChange,
reportDeepLinkedThreadNotFound,
} = useDeepLinkContext()
const { features } = useProjectContext()
const { currentDocumentId } = useEditorOpenDocContext()
const ranges = useRangesContext()
const threads = useThreadsContext()
const view = useCodeMirrorViewContext()
const { openReviewPanel } = useReviewPanelLayout()
const scrolledRangeRef = useRef<string | null>(null)
const pendingScrollsRef = useRef(new Set<() => void>())
// the review panel only renders an entry for a range that the editor has
// rendered, so scroll the deep-linked range into view first. Without this the
// entry never mounts for a range below the initial viewport, and the panel
// opens on nothing. Ranges rebuild on every edit, so only scroll once per id.
const scrollRangeIntoView = useCallback(
(id: string, position: number) => {
if (scrolledRangeRef.current === id) {
return
}
scrolledRangeRef.current = id
const pendingScrolls = pendingScrollsRef.current
const cancel = () => {
window.clearTimeout(timer)
window.removeEventListener('editor:scroll-position-restored', scroll)
pendingScrolls.delete(cancel)
}
// set the cursor at the range without taking focus, so that the review
// panel entry takes the focus once the panel has positioned it
function scroll() {
cancel()
const selection = EditorSelection.cursor(
Math.min(position, view.state.doc.length)
)
view.dispatch({
selection,
effects: EditorView.scrollIntoView(selection, { y: 'center' }),
})
}
// a doc restores its stored scroll position two ticks after it opens,
// which would drag the editor away from the range again, so wait for the
// restore. It has already run when the doc was open before the range
// resolved, so give up waiting after a short delay.
const timer = window.setTimeout(scroll, SCROLL_RESTORE_TIMEOUT)
window.addEventListener('editor:scroll-position-restored', scroll)
pendingScrolls.add(cancel)
},
[view]
)
useEffect(() => {
const pendingScrolls = pendingScrollsRef.current
return () => {
for (const cancel of Array.from(pendingScrolls)) {
cancel()
}
}
}, [])
// without track changes the review panel is not rendered at all, and the
// ranges and threads providers are absent, so every effect below no-ops
useEffect(() => {
if (deepLinkedPanel !== 'review-panel' || !features.trackChangesVisible) {
return
}
openReviewPanel()
}, [deepLinkedPanel, features.trackChangesVisible, openReviewPanel])
useEffect(() => {
if (!deepLinkedThreadId) {
return
}
if (!features.trackChangesVisible) {
reportDeepLinkedThreadNotFound()
return
}
// ranges are only built for the currently open doc, so wait for the
// deep-linked doc to finish opening before looking for the comment
if (!threads || !ranges || ranges.docId !== currentDocumentId) {
return
}
const comment = ranges.comments.find(
({ op }) => op.t === deepLinkedThreadId
)
const thread = threads[deepLinkedThreadId]
if (!comment || !thread || thread.messages.length === 0) {
reportDeepLinkedThreadNotFound()
return
}
openReviewPanel()
scrollRangeIntoView(deepLinkedThreadId, comment.op.p)
// the review panel renders no entry for a resolved comment, so nothing
// would select and nothing would explain why. Scroll to the comment anyway
// so the panel shows the right part of the doc, then say it is not there.
if (comment.resolved || thread.resolved) {
reportDeepLinkedThreadNotFound()
}
}, [
currentDocumentId,
deepLinkedThreadId,
features.trackChangesVisible,
openReviewPanel,
ranges,
reportDeepLinkedThreadNotFound,
scrollRangeIntoView,
threads,
])
useEffect(() => {
if (!deepLinkedChangeId || !features.trackChangesVisible) {
return
}
// ranges are only built for the currently open doc, so wait for the
// deep-linked doc to finish opening before looking for the change
if (!ranges || ranges.docId !== currentDocumentId) {
return
}
openReviewPanel()
// aggregated deletions stay in ranges.changes even when they are folded
// into the preceding insert's entry visually, so a single lookup covers
// both cases
const change = ranges.changes.find(({ id }) => id === deepLinkedChangeId)
if (!change) {
// the change was accepted or rejected before the link was followed,
// leave the review panel open and drop the pending id silently
clearDeepLinkedChange()
return
}
scrollRangeIntoView(deepLinkedChangeId, change.op.p)
}, [
clearDeepLinkedChange,
currentDocumentId,
deepLinkedChangeId,
features.trackChangesVisible,
openReviewPanel,
ranges,
scrollRangeIntoView,
])
return null
}
@@ -21,6 +21,7 @@ import {
DeleteIcon,
EditIcon,
} from '@/features/review-panel/components/review-panel-action-icons'
import { useDeepLinkContext } from '@/features/ide-react/context/deep-link-context'
export const ReviewPanelChange = memo<{
change: Change<EditOperation>
@@ -50,6 +51,7 @@ export const ReviewPanelChange = memo<{
const changesUsers = useChangesUsersContext()
const { showGenericMessageModal } = useModalsContext()
const user = useUserContext()
const { deepLinkedChangeId, clearDeepLinkedChange } = useDeepLinkContext()
const [accepting, setAccepting] = useState(false)
@@ -124,6 +126,11 @@ export const ReviewPanelChange = memo<{
handleEnter={handleMouseEnter}
handleLeave={handleMouseLeave}
entryIndicator="edit"
autoSelect={
deepLinkedChangeId === change.id ||
(aggregate != null && deepLinkedChangeId === aggregate.id)
}
onAutoSelected={clearDeepLinkedChange}
>
<div
className="review-panel-entry-content"
@@ -15,6 +15,7 @@ import { useModalsContext } from '@/features/ide-react/context/modals-context'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
import { useTranslation } from 'react-i18next'
import { debugConsole } from '@/utils/debugging'
import { useDeepLinkContext } from '@/features/ide-react/context/deep-link-context'
export const ReviewPanelComment = memo<{
comment: Change<CommentOperation>
@@ -38,6 +39,7 @@ export const ReviewPanelComment = memo<{
const { showGenericMessageModal } = useModalsContext()
const { t } = useTranslation()
const permissions = usePermissionsContext()
const { deepLinkedThreadId, clearDeepLinkedThread } = useDeepLinkContext()
const [processing, setProcessing] = useState(false)
@@ -170,6 +172,8 @@ export const ReviewPanelComment = memo<{
handleEnter={handleMouseEnter}
handleLeave={handleMouseLeave}
entryIndicator="comment"
autoSelect={deepLinkedThreadId === comment.op.t}
onAutoSelected={clearDeepLinkedThread}
>
<ReviewPanelCommentContent
comment={comment}
@@ -17,6 +17,12 @@ import useReviewPanelLayout from '../hooks/use-review-panel-layout'
import { EntryIndicator } from './review-panel-entry-indicator'
import { EditorView } from '@codemirror/view'
// how many frames to wait for the panel to position and show an entry. The
// panel repositions on a 100ms debounce and animates the move over 300ms, so
// 60 frames is roughly a second at 60fps: long enough to outlast both, short
// enough that a stuck entry stops polling instead of spinning forever.
const MAX_FOCUS_FRAMES = 60
export const ReviewPanelEntry: FC<
React.PropsWithChildren<{
position: number
@@ -30,6 +36,8 @@ export const ReviewPanelEntry: FC<
handleEnter?: () => void
handleLeave?: () => void
entryIndicator?: 'comment' | 'edit'
autoSelect?: boolean
onAutoSelected?: () => void
}>
> = ({
children,
@@ -44,6 +52,8 @@ export const ReviewPanelEntry: FC<
handleEnter,
handleLeave,
entryIndicator,
autoSelect,
onAutoSelected,
}) => {
const state = useCodeMirrorStateContext()
const view = useCodeMirrorViewContext()
@@ -57,13 +67,13 @@ export const ReviewPanelEntry: FC<
const mousePressedRef = useRef(false)
const selectEntry = useCallback(
(event: React.FocusEvent | React.MouseEvent) => {
(event?: React.FocusEvent | React.MouseEvent) => {
setFocused(true)
// The add-comment input is a CodeMirror editor (contenteditable div)
// rather than a textarea, so also treat focus landing inside it as a
// text-input focus.
const target = event.target
const target = event?.target
const isTextInput =
target instanceof HTMLTextAreaElement ||
(target instanceof HTMLElement &&
@@ -144,6 +154,46 @@ export const ReviewPanelEntry: FC<
]
)
const autoSelectDoneRef = useRef(false)
const focusFrameRef = useRef(0)
useEffect(() => {
if (!autoSelect || autoSelectDoneRef.current) {
return
}
autoSelectDoneRef.current = true
selectEntry()
onAutoSelected?.()
// the panel positions entries asynchronously and keeps them hidden until
// then, and focus() does nothing on a hidden element, so wait for the entry
// to become visible
let attempts = 0
const focusWhenVisible = () => {
const entry = entryRef.current
if (!entry) {
return
}
if (entry.style.visibility === 'hidden' && attempts < MAX_FOCUS_FRAMES) {
attempts++
focusFrameRef.current = window.requestAnimationFrame(focusWhenVisible)
return
}
entry.focus({ preventScroll: true })
}
focusFrameRef.current = window.requestAnimationFrame(focusWhenVisible)
}, [autoSelect, onAutoSelected, selectEntry])
useEffect(() => {
return () => window.cancelAnimationFrame(focusFrameRef.current)
}, [])
// Clear op highlight on dismount
useEffect(() => {
return () => {
@@ -13,6 +13,7 @@ import { ReviewPanelProviders } from '@/features/review-panel/context/review-pan
import { ReviewPanelRoot } from '@/features/review-panel/components/review-panel-root'
import ReviewPanelTabsHeaderPortal from '@/features/review-panel/components/review-panel-tabs-header-portal'
import ReviewTooltipMenu from '@/features/review-panel/components/review-tooltip-menu'
import DeepLink from '@/features/review-panel/components/deep-link'
import EditorFloatingMenu from '@/features/editor-floating-menu/editor-floating-menu'
import AddCommentCommand from '@/features/editor-floating-menu/components/add-comment-command'
import {
@@ -109,6 +110,7 @@ function CodeMirrorEditorComponents({
<MathPreviewTooltip />
<EditorContextMenu />
<DeepLink />
{features.trackChangesVisible &&
(writefullToolbarMigrationEnabled ? (
<>
+1
View File
@@ -446,6 +446,7 @@
"command_palette": "Command palette",
"command_palette_search": "Command palette search",
"comment": "Comment",
"comment_not_found": "Comment not found",
"comment_only": "Comment only",
"comment_only_upgrade_for_track_changes": "Comment only. Upgrade for track changes.",
"comment_only_upgrade_to_enable_track_changes": "Comment only. <0>Upgrade</0> to enable track changes.",
@@ -0,0 +1,413 @@
import CodeMirrorEditor from '../../../../frontend/js/features/source-editor/components/codemirror-editor'
import { GlobalToasts } from '../../../../frontend/js/features/ide-react/components/global-toasts'
import {
EditorProviders,
makeProjectProvider,
USER_EMAIL,
USER_ID,
} from '../../helpers/editor-providers'
import { mockScope } from '../source-editor/helpers/mock-scope'
import { TestContainer } from '../source-editor/helpers/test-container'
import { docId, mockDocContent } from '../source-editor/helpers/mock-doc'
import { mockProject } from '../source-editor/helpers/mock-project'
const userData = {
avatar_text: 'User',
email: USER_EMAIL,
hue: 180,
id: USER_ID,
isSelf: true,
first_name: 'Test',
last_name: 'User',
}
const unresolvedThreadId = 'unresolved-thread-id'
const commentOps = [
{
id: 'unresolved-op-id',
op: { p: 210, c: 'Your results', t: unresolvedThreadId },
},
]
const threads = {
[unresolvedThreadId]: {
messages: [
{
content: 'unresolved comment text',
id: `${unresolvedThreadId}-1`,
timestamp: new Date('2025-01-01T00:00:00.000Z'),
user: userData,
user_id: USER_ID,
},
],
},
}
const resolvedThreadId = 'resolved-thread-id'
const resolvedCommentOps = [
{
id: 'resolved-op-id',
op: { p: 210, c: 'Your results', t: resolvedThreadId },
},
]
const resolvedThreads = {
[resolvedThreadId]: {
resolved: true,
resolved_at: '2025-01-02T00:00:00.000Z',
resolved_by_user_id: USER_ID,
resolved_by_user: userData,
messages: [
{
content: 'resolved comment text',
id: `${resolvedThreadId}-1`,
timestamp: new Date('2025-01-01T00:00:00.000Z'),
user: userData,
user_id: USER_ID,
},
],
},
}
const insertChangeId = 'insert-change-id'
const aggregatedDeleteChangeId = 'aggregated-delete-change-id'
const standaloneDeleteChangeId = 'standalone-delete-change-id'
const changesOps = [
{
metadata: {
user_id: USER_ID,
ts: new Date('2025-01-01T00:00:00.000Z'),
},
id: insertChangeId,
op: { p: 166, t: insertChangeId, i: 'introduction' },
},
// adjacent same-user deletion starting at insert end -> aggregated into
// the preceding insert's entry by review-panel-current-file
{
metadata: {
user_id: USER_ID,
ts: new Date('2025-01-01T00:00:01.000Z'),
},
id: aggregatedDeleteChangeId,
op: {
p: 166 + 'introduction'.length,
t: aggregatedDeleteChangeId,
d: 'preface',
},
},
{
metadata: {
user_id: USER_ID,
ts: new Date('2025-01-01T01:00:00.000Z'),
},
id: standaloneDeleteChangeId,
op: { p: 110, t: standaloneDeleteChangeId, d: 'beautiful ' },
},
]
// a doc long enough that its end sits outside the rendered CodeMirror viewport,
// so the review panel renders no entry for a range there until the editor
// scrolls to it
const belowFoldMarker = 'content line 1900'
const belowFoldContent = mockDocContent(
Array.from(Array(2000), (e, i) => `content line ${i}`).join('\n')
)
const belowFoldPosition = belowFoldContent.indexOf(belowFoldMarker)
const belowFoldThreadId = 'below-fold-thread-id'
const belowFoldCommentOps = [
{
id: 'below-fold-op-id',
op: { p: belowFoldPosition, c: belowFoldMarker, t: belowFoldThreadId },
},
]
const belowFoldThreads = {
[belowFoldThreadId]: {
messages: [
{
content: 'below fold comment text',
id: `${belowFoldThreadId}-1`,
timestamp: new Date('2025-01-01T00:00:00.000Z'),
user: userData,
user_id: USER_ID,
},
],
},
}
const belowFoldChangeId = 'below-fold-change-id'
const belowFoldChangeOps = [
{
metadata: {
user_id: USER_ID,
ts: new Date('2025-01-01T00:00:00.000Z'),
},
id: belowFoldChangeId,
op: { p: belowFoldPosition, t: belowFoldChangeId, i: belowFoldMarker },
},
]
type MountOptions = {
search?: string
comments?: typeof commentOps
changes?: typeof changesOps
threads?: object
trackChangesVisible?: boolean
content?: string
}
function mountEditor({
search = '',
comments = [],
changes = [],
threads = {},
trackChangesVisible = true,
content,
}: MountOptions = {}) {
cy.intercept('GET', '/project/*/changes/users', [
{
id: USER_ID,
email: USER_EMAIL,
first_name: 'Test',
last_name: 'User',
},
])
cy.intercept('GET', '/project/*/threads', threads)
cy.intercept('GET', '/project/*/ranges', [
{
id: docId,
ranges: { changes, comments, docId },
},
])
cy.intercept('POST', `/project/*/doc/${docId}/metadata`, {})
cy.window().then(win => {
win.history.replaceState({}, '', `${win.location.pathname}${search}`)
})
const scope = mockScope(content, {
docOptions: {
rangesOptions: {
comments,
changes,
getChanges: cy.stub().returns([]),
removeChangeIds: cy.stub(),
},
},
})
const project = mockProject({
projectOwner: { _id: USER_ID },
projectFeatures: { trackChanges: false, trackChangesVisible },
})
cy.mount(
<TestContainer className="rp-size-expanded">
<EditorProviders
scope={scope}
providers={{ ProjectProvider: makeProjectProvider(project) }}
>
{/* mounted first so the toast listener is registered before the
editor, as it is in the real IDE page */}
<GlobalToasts />
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
// Wait for the editor to be ready before making assertions
cy.get('.cm-content').should('have.css', 'opacity', '1')
}
describe('<DeepLink />', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
cy.interceptEvents()
})
describe('review panel deep link', function () {
it('opens the review panel', function () {
mountEditor({ search: '?open=review-panel' })
cy.findByTestId('review-panel').should(
'not.have.class',
'review-panel-mini'
)
})
it('strips the deep link param from the URL', function () {
mountEditor({ search: '?open=review-panel' })
cy.location('search').should('not.contain', 'open=')
})
it('leaves the review panel in mini mode without the param', function () {
mountEditor({ comments: commentOps, threads })
cy.findByTestId('review-panel').should('have.class', 'review-panel-mini')
})
it('does not render the review panel when track changes is not available', function () {
mountEditor({
search: '?open=review-panel',
trackChangesVisible: false,
})
cy.findByTestId('review-panel').should('not.exist')
})
})
describe('comment deep link', function () {
function mountWithDeepLink(
threadId: string,
{ trackChangesVisible = true } = {}
) {
mountEditor({
search: `?comment=${threadId}`,
comments: commentOps,
threads,
trackChangesVisible,
})
}
it('opens the review panel and selects an unresolved comment', function () {
mountWithDeepLink(unresolvedThreadId)
cy.findByTestId('review-panel').should(
'not.have.class',
'review-panel-mini'
)
cy.findByText('unresolved comment text')
.closest('.review-panel-entry')
.should('have.class', 'review-panel-entry-selected')
})
it('strips the deep link params from the URL', function () {
mountWithDeepLink(unresolvedThreadId)
cy.location('search').should('not.contain', 'comment=')
})
it('selects and focuses a comment below the rendered viewport', function () {
mountEditor({
search: `?comment=${belowFoldThreadId}`,
comments: belowFoldCommentOps,
threads: belowFoldThreads,
content: belowFoldContent,
})
cy.findByText('below fold comment text')
.closest('.review-panel-entry')
.should('have.class', 'review-panel-entry-selected')
.and('have.focus')
})
it('shows a toast when the comment does not exist', function () {
mountWithDeepLink('missing-thread-id')
cy.findByText('Comment not found').should('exist')
})
it('opens the review panel and shows a toast when the comment is resolved', function () {
mountEditor({
search: `?comment=${resolvedThreadId}`,
comments: resolvedCommentOps,
threads: resolvedThreads,
})
cy.findByTestId('review-panel').should(
'not.have.class',
'review-panel-mini'
)
cy.findByText('Comment not found').should('exist')
})
it('shows a toast when track changes is not available', function () {
mountWithDeepLink(unresolvedThreadId, { trackChangesVisible: false })
cy.findByText('Comment not found').should('exist')
})
})
describe('tracked change deep link', function () {
function mountWithDeepLink(
changeId: string,
{ trackChangesVisible = true } = {}
) {
mountEditor({
search: `?change=${changeId}`,
changes: changesOps,
trackChangesVisible,
})
}
it('opens the review panel and selects the matching change', function () {
mountWithDeepLink(standaloneDeleteChangeId)
cy.findByTestId('review-panel').should(
'not.have.class',
'review-panel-mini'
)
cy.findByText('beautiful')
.closest('.review-panel-entry')
.should('have.class', 'review-panel-entry-selected')
})
it('selects the aggregated entry when the id belongs to the absorbed deletion', function () {
mountWithDeepLink(aggregatedDeleteChangeId)
cy.findByText('preface')
.closest('.review-panel-entry')
.should('have.class', 'review-panel-entry-selected')
.and('have.class', 'review-panel-entry-insert')
})
it('selects and focuses a change below the rendered viewport', function () {
mountEditor({
search: `?change=${belowFoldChangeId}`,
changes: belowFoldChangeOps,
content: belowFoldContent,
})
// the marker text also appears in the document, so scope the lookup to
// the review panel
cy.findByTestId('review-panel')
.findByText(belowFoldMarker)
.closest('.review-panel-entry')
.should('have.class', 'review-panel-entry-selected')
.and('have.focus')
})
it('strips the deep link params from the URL', function () {
mountWithDeepLink(insertChangeId)
cy.location('search').should('not.contain', 'change=')
})
it('opens the review panel and drops the pending id when the change does not exist', function () {
mountWithDeepLink('missing-change-id')
cy.findByTestId('review-panel').should(
'not.have.class',
'review-panel-mini'
)
cy.findAllByText('Comment not found').should('not.exist')
})
it('silently opens the doc when track changes is not available', function () {
mountWithDeepLink(insertChangeId, { trackChangesVisible: false })
cy.findAllByText('Comment not found').should('not.exist')
})
})
})