mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 17:19:37 +02:00
[terraform] clsi: add pre-emp C2D capacity in zone b (#26755)
GitOrigin-RevId: aa52dec1f7135f53f8c887199c1d1e4e31ef70ff
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
import React, { FC } from 'react'
|
||||
import LoadingSpinner from '@/shared/components/loading-spinner'
|
||||
import { MatchedFile } from '../util/search-snapshot'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export const FullProjectMatchCounts: FC<{
|
||||
loading: boolean
|
||||
totalResults?: number
|
||||
matchedFiles?: MatchedFile[]
|
||||
}> = ({ loading, matchedFiles }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (loading) {
|
||||
return <LoadingSpinner delay={500} />
|
||||
}
|
||||
|
||||
if (matchedFiles === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
const totalResults = matchedFiles.flatMap(file => file.hits).length
|
||||
|
||||
if (totalResults === 0) {
|
||||
return <>{t('project_search_result_count', { count: totalResults })}</>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{t('project_search_result_count', { count: totalResults })}{' '}
|
||||
{t('project_search_file_count', { count: matchedFiles.length })}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import React, { FC, useMemo } from 'react'
|
||||
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
|
||||
import classnames from 'classnames'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import { useLayoutContext } from '@/shared/context/layout-context'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { TooltipProps } from '@/features/ui/components/bootstrap-5/tooltip'
|
||||
import { isMac } from '@/shared/utils/os'
|
||||
import { sendSearchEvent } from '@/features/event-tracking/search-events'
|
||||
|
||||
const FullProjectSearchButton: FC = () => {
|
||||
const { projectSearchIsOpen, setProjectSearchIsOpen } = useLayoutContext()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const tooltipProps: Pick<
|
||||
TooltipProps,
|
||||
'id' | 'description' | 'overlayProps'
|
||||
> = useMemo(
|
||||
() => ({
|
||||
id: 'search',
|
||||
description: (
|
||||
<>
|
||||
<div>{t('search_project')}</div>
|
||||
<div>{isMac ? '⇧⌘F' : 'Ctrl+Shift+F'}</div>
|
||||
</>
|
||||
),
|
||||
overlayProps: { placement: 'bottom' },
|
||||
}),
|
||||
[t]
|
||||
)
|
||||
|
||||
return (
|
||||
<OLTooltip {...tooltipProps}>
|
||||
<button
|
||||
className={classnames('btn', {
|
||||
active: projectSearchIsOpen,
|
||||
})}
|
||||
onClick={() => {
|
||||
if (!projectSearchIsOpen) {
|
||||
sendSearchEvent('search-open', {
|
||||
searchType: 'full-project',
|
||||
method: 'button',
|
||||
location: 'toolbar',
|
||||
})
|
||||
}
|
||||
setProjectSearchIsOpen(value => !value)
|
||||
}}
|
||||
tabIndex={-1}
|
||||
data-active={projectSearchIsOpen}
|
||||
>
|
||||
<MaterialIcon type="search" accessibilityLabel={t('search')} />
|
||||
</button>
|
||||
</OLTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default FullProjectSearchButton
|
||||
@@ -0,0 +1,78 @@
|
||||
import React, { useRef, forwardRef, useImperativeHandle } from 'react'
|
||||
import { SearchQuery } from '@codemirror/search'
|
||||
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
|
||||
import { Form } from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export const FullProjectSearchModifiers = forwardRef(
|
||||
function FullProjectSearchModifiers(props, ref) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const caseSensitiveRef = useRef<HTMLInputElement>(null)
|
||||
const regexpRef = useRef<HTMLInputElement>(null)
|
||||
const wholeWordRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
return {
|
||||
setQuery(query: SearchQuery) {
|
||||
caseSensitiveRef.current!.checked = query.caseSensitive
|
||||
regexpRef.current!.checked = query.regexp
|
||||
wholeWordRef.current!.checked = query.wholeWord
|
||||
},
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="full-project-search-modifiers" role="group">
|
||||
<OLTooltip
|
||||
id="project-search-caseSensitive"
|
||||
description={t('search_match_case')}
|
||||
overlayProps={{ placement: 'bottom' }}
|
||||
>
|
||||
<span>
|
||||
<Form.Check
|
||||
inline
|
||||
label="Aa"
|
||||
name="caseSensitive"
|
||||
type="checkbox"
|
||||
id="project-search-caseSensitive"
|
||||
ref={caseSensitiveRef}
|
||||
/>
|
||||
</span>
|
||||
</OLTooltip>
|
||||
<OLTooltip
|
||||
id="project-search-regexp"
|
||||
description={t('search_regexp')}
|
||||
overlayProps={{ placement: 'bottom' }}
|
||||
>
|
||||
<span>
|
||||
<Form.Check
|
||||
inline
|
||||
label="[.*]"
|
||||
name="regexp"
|
||||
type="checkbox"
|
||||
id="project-search-regexp"
|
||||
ref={regexpRef}
|
||||
/>
|
||||
</span>
|
||||
</OLTooltip>
|
||||
<OLTooltip
|
||||
id="project-search-wholeWord"
|
||||
description={t('search_whole_word')}
|
||||
overlayProps={{ placement: 'bottom' }}
|
||||
>
|
||||
<span>
|
||||
<Form.Check
|
||||
inline
|
||||
label="W"
|
||||
name="wholeWord"
|
||||
type="checkbox"
|
||||
id="project-search-wholeWord"
|
||||
ref={wholeWordRef}
|
||||
/>
|
||||
</span>
|
||||
</OLTooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,174 @@
|
||||
import React, { FC, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Hit, MatchedFile } from '../util/search-snapshot'
|
||||
import classnames from 'classnames'
|
||||
import { CollapsibleFileHeader } from '@/shared/components/collapsible-file-header'
|
||||
import { MatchedHit } from './matched-hit'
|
||||
import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
|
||||
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
|
||||
import { sendSearchEvent } from '@/features/event-tracking/search-events'
|
||||
|
||||
export const FullProjectSearchResults: FC<{
|
||||
matchedFiles: MatchedFile[]
|
||||
currentDocPath: string | null
|
||||
}> = ({ matchedFiles, currentDocPath }) => {
|
||||
const [collapsedFiles, setCollapsedFiles] = useState<Set<string>>(new Set())
|
||||
const [selectedHit, setSelectedHit] = useState<Hit>()
|
||||
|
||||
const toggleCollapse = useCallback((path: string) => {
|
||||
setCollapsedFiles(value => {
|
||||
const newValue = new Set(value)
|
||||
if (newValue.has(path)) {
|
||||
newValue.delete(path)
|
||||
} else {
|
||||
newValue.add(path)
|
||||
}
|
||||
return newValue
|
||||
})
|
||||
}, [])
|
||||
|
||||
const resultsContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const container = resultsContainerRef.current
|
||||
if (container) {
|
||||
const hits = matchedFiles.flatMap(file => file.hits)
|
||||
|
||||
const findSelectedHitIndex = () =>
|
||||
hits.findIndex(hit => hit === selectedHit)
|
||||
|
||||
const listener = (event: KeyboardEvent) => {
|
||||
if (event.ctrlKey || event.shiftKey || event.altKey || event.metaKey) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!matchedFiles) {
|
||||
return
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
case ' ': // Space
|
||||
window.setTimeout(() => {
|
||||
window.dispatchEvent(new Event('editor:focus'))
|
||||
})
|
||||
break
|
||||
|
||||
case 'ArrowUp':
|
||||
{
|
||||
event.preventDefault()
|
||||
let index = findSelectedHitIndex()
|
||||
if (index === 0) {
|
||||
index = hits.length
|
||||
}
|
||||
index--
|
||||
if (index < 0) {
|
||||
index = 0
|
||||
}
|
||||
setSelectedHit(hits[index])
|
||||
}
|
||||
break
|
||||
|
||||
case 'ArrowDown':
|
||||
{
|
||||
event.preventDefault()
|
||||
let index = findSelectedHitIndex()
|
||||
index++
|
||||
if (index >= hits.length) {
|
||||
index = 0
|
||||
}
|
||||
setSelectedHit(hits[index])
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
container.addEventListener('keydown', listener)
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('keydown', listener)
|
||||
}
|
||||
}
|
||||
}, [matchedFiles, selectedHit, setSelectedHit])
|
||||
|
||||
const { findEntityByPath } = useFileTreePathContext()
|
||||
const { openDocWithId, openFileWithId } = useEditorManagerContext()
|
||||
|
||||
const selectedHitRef = useRef<Hit>()
|
||||
|
||||
useEffect(() => {
|
||||
// only open the doc if selectedHit has actually changed
|
||||
if (selectedHit && selectedHit !== selectedHitRef.current) {
|
||||
selectedHitRef.current = selectedHit
|
||||
const selectedFile = matchedFiles.find(file =>
|
||||
file.hits.includes(selectedHit)
|
||||
)
|
||||
if (selectedFile) {
|
||||
const result = findEntityByPath(selectedFile.path)
|
||||
if (result) {
|
||||
sendSearchEvent('search-result-click', {
|
||||
searchType: 'full-project',
|
||||
})
|
||||
const line = selectedHit.lineIndex
|
||||
const column = selectedHit.matchIndex
|
||||
const text = selectedFile.lines[line].substring(
|
||||
column,
|
||||
column + selectedHit.length
|
||||
)
|
||||
if (result.type === 'doc') {
|
||||
openDocWithId(result.entity._id, {
|
||||
gotoLine: line + 1,
|
||||
gotoColumn: column,
|
||||
selectText: text,
|
||||
})
|
||||
} else if (result.type === 'fileRef') {
|
||||
openFileWithId(result.entity._id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [
|
||||
findEntityByPath,
|
||||
matchedFiles,
|
||||
openDocWithId,
|
||||
openFileWithId,
|
||||
selectedHit,
|
||||
])
|
||||
|
||||
const tabbableHit = selectedHit ?? matchedFiles?.[0]?.hits[0]
|
||||
|
||||
return (
|
||||
<div className="matched-files" ref={resultsContainerRef}>
|
||||
{matchedFiles.map(matchedFile => (
|
||||
<div
|
||||
key={matchedFile.path}
|
||||
className={classnames('matched-file', {
|
||||
'matched-file-open': currentDocPath === matchedFile.path,
|
||||
})}
|
||||
>
|
||||
<CollapsibleFileHeader
|
||||
name={matchedFile.path}
|
||||
count={matchedFile.hits.length}
|
||||
collapsed={collapsedFiles.has(matchedFile.path)}
|
||||
toggleCollapsed={() => toggleCollapse(matchedFile.path)}
|
||||
/>
|
||||
{!collapsedFiles.has(matchedFile.path) && (
|
||||
<div className="list-group matched-file-hits" role="listbox">
|
||||
{matchedFile.hits.map(hit => {
|
||||
return (
|
||||
<MatchedHit
|
||||
key={`${hit.lineIndex}:${hit.matchIndex}`}
|
||||
matchedFile={matchedFile}
|
||||
hit={hit}
|
||||
selected={hit === selectedHit}
|
||||
setSelectedHit={setSelectedHit}
|
||||
tabIndex={tabbableHit === hit ? 0 : -1}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import React, {
|
||||
FC,
|
||||
FormEventHandler,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLayoutContext } from '@/shared/context/layout-context'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import {
|
||||
MatchedFile as MatchedFileType,
|
||||
searchSnapshot,
|
||||
} from '../util/search-snapshot'
|
||||
import { SearchQuery } from '@codemirror/search'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import useEventListener from '@/shared/hooks/use-event-listener'
|
||||
import { Col, Form, Row } from 'react-bootstrap'
|
||||
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
|
||||
import Button from '@/features/ui/components/bootstrap-5/button'
|
||||
import Notification from '@/shared/components/notification'
|
||||
import '../../stylesheets/full-project-search.scss'
|
||||
import { userStyles } from '@/shared/utils/styles'
|
||||
import { useUserSettingsContext } from '@/shared/context/user-settings-context'
|
||||
import { FullProjectMatchCounts } from './full-project-match-counts'
|
||||
import { FullProjectSearchModifiers } from './full-project-search-modifiers'
|
||||
import { isMac } from '@/shared/utils/os'
|
||||
import { PanelHeading } from '@/shared/components/panel-heading'
|
||||
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
|
||||
import { createRegExp } from '../util/regexp'
|
||||
import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
|
||||
import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
|
||||
import { FullProjectSearchResults } from './full-project-search-results'
|
||||
import { signalWithTimeout } from '@/utils/abort-signal'
|
||||
import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils'
|
||||
import RailPanelHeader from '@/features/ide-redesign/components/rail-panel-header'
|
||||
|
||||
const FullProjectSearchUI: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { setProjectSearchIsOpen } = useLayoutContext()
|
||||
const { projectSnapshot } = useProjectContext()
|
||||
const { openDocs } = useEditorManagerContext()
|
||||
const { pathInFolder } = useFileTreePathContext()
|
||||
const newEditor = useIsNewEditorEnabled()
|
||||
|
||||
const { currentDocument: currentDoc } = useEditorOpenDocContext()
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string>()
|
||||
const [matchedFiles, setMatchedFiles] = useState<MatchedFileType[]>()
|
||||
|
||||
const { userSettings } = useUserSettingsContext()
|
||||
const { fontFamily, fontSize } = useMemo(
|
||||
() => userStyles(userSettings),
|
||||
[userSettings]
|
||||
)
|
||||
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
|
||||
// start fetching the snapshot when the project search UI opens
|
||||
useEffect(() => {
|
||||
projectSnapshot.refresh().catch(error => {
|
||||
debugConsole.error(error)
|
||||
})
|
||||
}, [projectSnapshot])
|
||||
|
||||
const currentDocPath = useMemo(() => {
|
||||
return currentDoc && pathInFolder(currentDoc.doc_id)
|
||||
}, [currentDoc, pathInFolder])
|
||||
|
||||
const handleSubmit: React.FormEventHandler<HTMLFormElement> = useCallback(
|
||||
async event => {
|
||||
event.preventDefault()
|
||||
|
||||
setMatchedFiles(undefined)
|
||||
|
||||
abortControllerRef.current?.abort()
|
||||
abortControllerRef.current = new AbortController()
|
||||
|
||||
const data = new FormData(event.target as HTMLFormElement)
|
||||
|
||||
const searchQuery = new SearchQuery({
|
||||
search: data.get('search') as string,
|
||||
// replace: data.get('replace') as string,
|
||||
caseSensitive: data.get('caseSensitive') === 'on',
|
||||
regexp: data.get('regexp') === 'on',
|
||||
wholeWord: data.get('wholeWord') === 'on',
|
||||
literal: data.get('regexp') !== 'on',
|
||||
})
|
||||
|
||||
if (searchQuery.regexp) {
|
||||
try {
|
||||
createRegExp(searchQuery)
|
||||
} catch (error) {
|
||||
setError(t('invalid_regular_expression'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
await openDocs.awaitBufferedOps(
|
||||
signalWithTimeout(abortControllerRef.current.signal, 5000)
|
||||
)
|
||||
|
||||
await projectSnapshot.refresh()
|
||||
if (!abortControllerRef.current.signal.aborted) {
|
||||
const results = await searchSnapshot(projectSnapshot, searchQuery)
|
||||
setMatchedFiles(results)
|
||||
}
|
||||
} catch (error) {
|
||||
debugConsole.error(error)
|
||||
setError(t('generic_something_went_wrong'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
},
|
||||
[openDocs, projectSnapshot, t]
|
||||
)
|
||||
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleKeyDown: React.KeyboardEventHandler<HTMLElement> = useCallback(
|
||||
event => {
|
||||
if (event.key === 'Escape') {
|
||||
setProjectSearchIsOpen(false)
|
||||
}
|
||||
},
|
||||
[setProjectSearchIsOpen]
|
||||
)
|
||||
|
||||
useEventListener(
|
||||
'keydown',
|
||||
useCallback((event: KeyboardEvent) => {
|
||||
if (
|
||||
(isMac ? event.metaKey : event.ctrlKey) &&
|
||||
event.shiftKey &&
|
||||
event.code === 'KeyF'
|
||||
) {
|
||||
searchInputRef.current?.focus()
|
||||
}
|
||||
}, [])
|
||||
)
|
||||
|
||||
const modifiersRef = useRef<{ setQuery(query: SearchQuery): void }>(null)
|
||||
|
||||
useEventListener(
|
||||
'editor:full-project-search',
|
||||
useCallback((event: CustomEvent<SearchQuery>) => {
|
||||
if (modifiersRef.current != null) {
|
||||
modifiersRef.current.setQuery(event.detail)
|
||||
}
|
||||
if (searchInputRef.current != null) {
|
||||
searchInputRef.current.value = event.detail.search
|
||||
searchInputRef.current.form?.dispatchEvent(
|
||||
new Event('submit', { cancelable: true, bubbles: true })
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
)
|
||||
|
||||
// clear the results when the form is cleared
|
||||
const handleInput: FormEventHandler<HTMLInputElement> = useCallback(event => {
|
||||
if (
|
||||
event instanceof InputEvent &&
|
||||
event.inputType === undefined &&
|
||||
(event.target as HTMLInputElement).value.length === 0
|
||||
) {
|
||||
setMatchedFiles(undefined)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const variableStyle = {
|
||||
'--font-family': fontFamily,
|
||||
'--font-size': fontSize,
|
||||
} as React.CSSProperties
|
||||
|
||||
return (
|
||||
<div
|
||||
className="full-project-search"
|
||||
style={variableStyle}
|
||||
data-bs-theme={userSettings.overallTheme === 'light-' ? 'light' : 'dark'}
|
||||
>
|
||||
{newEditor ? (
|
||||
<RailPanelHeader title={t('search')} />
|
||||
) : (
|
||||
<PanelHeading
|
||||
title={t('search')}
|
||||
handleClose={() => setProjectSearchIsOpen(false)}
|
||||
splitTestName="full-project-search"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div // eslint-disable-line jsx-a11y/no-static-element-interactions
|
||||
className="full-project-search-form"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
role="search"
|
||||
id="full-project-search"
|
||||
aria-label={t('search_all_project_files')}
|
||||
>
|
||||
<Row className="g-1">
|
||||
<Col>
|
||||
<OLFormControl
|
||||
type="search"
|
||||
name="search"
|
||||
size="sm"
|
||||
aria-label={t('search')}
|
||||
autoFocus // eslint-disable-line jsx-a11y/no-autofocus
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
ref={searchInputRef}
|
||||
onInput={handleInput}
|
||||
placeholder={`${t('search_all_project_files')}…`}
|
||||
/>
|
||||
</Col>
|
||||
<Col className="col-auto">
|
||||
<Button type="submit" className="btn btn-primary" size="sm">
|
||||
{t('search')}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<FullProjectSearchModifiers ref={modifiersRef} />
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
{error && <Notification type="error" content={error} />}
|
||||
|
||||
<div className="match-counts">
|
||||
<FullProjectMatchCounts loading={loading} matchedFiles={matchedFiles} />
|
||||
</div>
|
||||
|
||||
{matchedFiles && (
|
||||
<FullProjectSearchResults
|
||||
matchedFiles={matchedFiles}
|
||||
currentDocPath={currentDocPath}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(FullProjectSearchUI)
|
||||
@@ -0,0 +1,22 @@
|
||||
import React, { FC, lazy, Suspense } from 'react'
|
||||
import { useLayoutContext } from '@/shared/context/layout-context'
|
||||
import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils'
|
||||
|
||||
const FullProjectSearchUI = lazy(() => import('./full-project-search-ui'))
|
||||
|
||||
const FullProjectSearch: FC = () => {
|
||||
const { projectSearchIsOpen } = useLayoutContext()
|
||||
const newEditor = useIsNewEditorEnabled()
|
||||
|
||||
if (!projectSearchIsOpen && !newEditor) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<FullProjectSearchUI />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
export default FullProjectSearch
|
||||
@@ -0,0 +1,49 @@
|
||||
import React, { FC, useMemo } from 'react'
|
||||
import { Hit } from '../util/search-snapshot'
|
||||
|
||||
export const MatchedHitHighlight: FC<{ text: string; hit: Hit }> = ({
|
||||
text,
|
||||
hit,
|
||||
}) => {
|
||||
const parts = useMemo(() => {
|
||||
let before = text.substring(0, hit.matchIndex).trimStart()
|
||||
const match = text.substring(hit.matchIndex, hit.matchIndex + hit.length)
|
||||
let after = text.substring(hit.matchIndex + hit.length).trimEnd()
|
||||
|
||||
// reduce the prefix to a sensible size before trimming
|
||||
if (before.length > 250) {
|
||||
before = before.substring(before.length - 250)
|
||||
}
|
||||
|
||||
while (before.length > 10) {
|
||||
const replacement = before.replace(/^\S+\s+/, '')
|
||||
if (before.length === replacement.length) {
|
||||
break
|
||||
}
|
||||
before = replacement
|
||||
}
|
||||
|
||||
// reduce the suffix to a sensible size before trimming
|
||||
if (after.length > 250) {
|
||||
after = after.substring(0, 250)
|
||||
}
|
||||
|
||||
while (after.length > 100) {
|
||||
const replacement = after.replace(/\s+\S+$/, '')
|
||||
if (after.length === replacement.length) {
|
||||
break
|
||||
}
|
||||
after = replacement
|
||||
}
|
||||
|
||||
return { before, match, after }
|
||||
}, [hit, text])
|
||||
|
||||
return (
|
||||
<span className="matched-hit-snippet">
|
||||
{parts.before}
|
||||
<b className="matched-hit-highlight">{parts.match}</b>
|
||||
{parts.after}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { FC, useCallback, useLayoutEffect, useRef } from 'react'
|
||||
import { Hit, MatchedFile } from '../util/search-snapshot'
|
||||
import { MatchedHitHighlight } from './matched-hit-highlight'
|
||||
import classnames from 'classnames'
|
||||
|
||||
export const MatchedHit: FC<{
|
||||
matchedFile: MatchedFile
|
||||
hit: Hit
|
||||
selected?: boolean
|
||||
setSelectedHit(hit?: Hit): void
|
||||
tabIndex: 0 | -1
|
||||
}> = ({ matchedFile, hit, selected = false, setSelectedHit, tabIndex }) => {
|
||||
const containerRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (selected) {
|
||||
containerRef.current?.focus()
|
||||
}
|
||||
}, [selected])
|
||||
|
||||
const handleSelect: React.MouseEventHandler = useCallback(
|
||||
event => {
|
||||
event.preventDefault()
|
||||
setSelectedHit(hit)
|
||||
},
|
||||
[hit, setSelectedHit]
|
||||
)
|
||||
|
||||
return (
|
||||
<button
|
||||
className={classnames('list-group-item matched-file-hit', {
|
||||
'matched-file-hit-selected': selected,
|
||||
})}
|
||||
ref={containerRef}
|
||||
tabIndex={tabIndex}
|
||||
onMouseDown={handleSelect}
|
||||
aria-selected={selected}
|
||||
role="option"
|
||||
>
|
||||
<span className="matched-line-number">{hit.lineIndex + 1}</span>
|
||||
|
||||
<MatchedHitHighlight text={matchedFile.lines[hit.lineIndex]} hit={hit} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { SearchQuery } from '@codemirror/search'
|
||||
|
||||
export const createRegExp = (searchQuery: SearchQuery) => {
|
||||
const flags = 'gmu' + (searchQuery.caseSensitive ? '' : 'i')
|
||||
|
||||
return new RegExp(searchQuery.search, flags)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Text } from '@codemirror/state'
|
||||
import { RegExpCursor, SearchCursor, SearchQuery } from '@codemirror/search'
|
||||
import { ProjectSnapshot } from '@/infrastructure/project-snapshot'
|
||||
import { categorizer, regexpWordTest, stringWordTest } from './search'
|
||||
import { sendSearchEvent } from '@/features/event-tracking/search-events'
|
||||
|
||||
export type Hit = {
|
||||
lineIndex: number
|
||||
matchIndex: number
|
||||
length: number
|
||||
}
|
||||
|
||||
export type MatchedFile = {
|
||||
path: string
|
||||
lines: string[]
|
||||
hits: Hit[]
|
||||
}
|
||||
|
||||
const toLowerCase = (string: string) => string.toLowerCase()
|
||||
|
||||
export const searchSnapshot = async (
|
||||
projectSnapshot: ProjectSnapshot,
|
||||
searchQuery: SearchQuery
|
||||
) => {
|
||||
if (!searchQuery.search.trim().length) {
|
||||
return
|
||||
}
|
||||
|
||||
const matchedFiles = new Map<string, MatchedFile>()
|
||||
|
||||
const createCursor = (text: Text) => {
|
||||
if (searchQuery.regexp) {
|
||||
return new RegExpCursor(text, searchQuery.search, {
|
||||
ignoreCase: !searchQuery.caseSensitive,
|
||||
test: searchQuery.wholeWord ? regexpWordTest(categorizer) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return new SearchCursor(
|
||||
text,
|
||||
searchQuery.search,
|
||||
undefined,
|
||||
undefined,
|
||||
searchQuery.caseSensitive ? undefined : toLowerCase,
|
||||
searchQuery.wholeWord ? stringWordTest(text, categorizer) : undefined
|
||||
)
|
||||
}
|
||||
|
||||
const docPaths = projectSnapshot.getDocPaths()
|
||||
|
||||
for (const path of docPaths) {
|
||||
const content = projectSnapshot.getDocContents(path)
|
||||
if (content) {
|
||||
const lines = content.split('\n')
|
||||
const text = Text.of(lines)
|
||||
|
||||
const cursor = createCursor(text)
|
||||
|
||||
while (!cursor.next().done) {
|
||||
const { from, to } = cursor.value
|
||||
|
||||
const matchedFile: MatchedFile = matchedFiles.get(path) ?? {
|
||||
path,
|
||||
lines,
|
||||
hits: [],
|
||||
}
|
||||
|
||||
const line = text.lineAt(from)
|
||||
|
||||
matchedFile.hits.push({
|
||||
lineIndex: line.number - 1,
|
||||
matchIndex: from - line.from,
|
||||
length: to - from,
|
||||
})
|
||||
|
||||
matchedFiles.set(path, matchedFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const results = [...matchedFiles.values()].sort((a, b) =>
|
||||
a.path.localeCompare(b.path)
|
||||
)
|
||||
|
||||
sendSearchEvent('search-execute', {
|
||||
searchType: 'full-project',
|
||||
totalDocs: docPaths.length,
|
||||
totalResults: results.flatMap(file => file.hits).length,
|
||||
})
|
||||
|
||||
return results
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* The functions in this module are from @codemirror/search (MIT license)
|
||||
* https://github.com/codemirror/search/blob/c1ee7d4b0babd0de0d1198a7c1ece2a387c97c0d/src/search.ts
|
||||
*/
|
||||
|
||||
import { CharCategory, findClusterBreak, Text } from '@codemirror/state'
|
||||
|
||||
const charBefore = (str: string, index: number) =>
|
||||
str.slice(findClusterBreak(str, index, false), index)
|
||||
|
||||
const charAfter = (str: string, index: number) =>
|
||||
str.slice(index, findClusterBreak(str, index))
|
||||
|
||||
export const categorizer = (char: string) => {
|
||||
if (/\s/.test(char)) {
|
||||
return CharCategory.Space
|
||||
}
|
||||
|
||||
if (/\w/.test(char)) {
|
||||
return CharCategory.Word
|
||||
}
|
||||
|
||||
return CharCategory.Other
|
||||
}
|
||||
|
||||
export const stringWordTest =
|
||||
(doc: Text, categorizer: (ch: string) => CharCategory) =>
|
||||
(from: number, to: number, buf: string, bufPos: number) => {
|
||||
if (bufPos > from || bufPos + buf.length < to) {
|
||||
bufPos = Math.max(0, from - 2)
|
||||
buf = doc.sliceString(bufPos, Math.min(doc.length, to + 2))
|
||||
}
|
||||
return (
|
||||
(categorizer(charBefore(buf, from - bufPos)) !== CharCategory.Word ||
|
||||
categorizer(charAfter(buf, from - bufPos)) !== CharCategory.Word) &&
|
||||
(categorizer(charAfter(buf, to - bufPos)) !== CharCategory.Word ||
|
||||
categorizer(charBefore(buf, to - bufPos)) !== CharCategory.Word)
|
||||
)
|
||||
}
|
||||
|
||||
export const regexpWordTest =
|
||||
(categorizer: (ch: string) => CharCategory) =>
|
||||
(_from: number, _to: number, match: RegExpExecArray) =>
|
||||
!match[0].length ||
|
||||
((categorizer(charBefore(match.input, match.index)) !== CharCategory.Word ||
|
||||
categorizer(charAfter(match.input, match.index)) !== CharCategory.Word) &&
|
||||
(categorizer(charAfter(match.input, match.index + match[0].length)) !==
|
||||
CharCategory.Word ||
|
||||
categorizer(charBefore(match.input, match.index + match[0].length)) !==
|
||||
CharCategory.Word))
|
||||
@@ -0,0 +1,367 @@
|
||||
@use 'sass:color';
|
||||
|
||||
.ide-redesign-main {
|
||||
.full-project-search {
|
||||
--full-project-search-bg-color: var(--white);
|
||||
--full-project-search-color: var(--content-primary);
|
||||
--full-project-search-results-bg-color: var(--bg-light-primary);
|
||||
--full-project-search-selected-hit-bg-color: var(--bg-accent-03);
|
||||
--full-project-search-selected-hit-color: var(--green-70);
|
||||
--matched-hit-highlight-color: var(--yellow-10);
|
||||
--matched-hit-selected-highlight-color: var(--bg-accent-02);
|
||||
--matched-hit-selected-unfocused-highlight-color: var(--bg-accent-02);
|
||||
--collapsible-file-header-count-color: var(--content-primary);
|
||||
--collapsible-file-header-count-bg-color: var(--bg-light-tertiary);
|
||||
|
||||
// Redesign additions
|
||||
--full-project-search-border-color: var(--border-divider);
|
||||
--collapsible-file-header-hover-bg: var(--bg-light-secondary);
|
||||
--matched-hit-highlight-text-color: var(--content-primary);
|
||||
--matched-file-hit-hover-bg: var(--bg-light-secondary);
|
||||
--search-modifier-checked-bg: var(--bg-accent-03);
|
||||
--search-modifier-checked-color: var(--green-70);
|
||||
--search-modifier-hover-color: var(--bg-light-secondary);
|
||||
--search-modifier-color: var(--content-primary);
|
||||
|
||||
&[data-bs-theme='dark'] {
|
||||
--full-project-search-bg-color: var(--bg-dark-primary);
|
||||
--full-project-search-color: var(--content-secondary-dark);
|
||||
--full-project-search-results-bg-color: var(--bg-dark-primary);
|
||||
--full-project-search-selected-hit-bg-color: var(--green-70);
|
||||
--full-project-search-selected-hit-color: var(--green-10);
|
||||
--matched-hit-highlight-color: var(--yellow-70);
|
||||
--matched-hit-selected-highlight-color: var(--green-70);
|
||||
--matched-hit-selected-unfocused-highlight-color: var(--content-primary);
|
||||
--collapsible-file-header-count-color: var(--content-primary);
|
||||
--collapsible-file-header-count-bg-color: var(--bg-light-tertiary);
|
||||
--panel-heading-color: var(--content-primary-dark);
|
||||
|
||||
// Redesign additions
|
||||
--full-project-search-border-color: var(--border-divider-dark);
|
||||
--collapsible-file-header-hover-bg: var(--bg-dark-secondary);
|
||||
--matched-hit-highlight-text-color: var(--green-10);
|
||||
--matched-file-hit-hover-bg: var(--bg-dark-secondary);
|
||||
--search-modifier-checked-bg: var(--green-70);
|
||||
--search-modifier-checked-color: var(--green-10);
|
||||
--search-modifier-hover-color: var(--bg-dark-secondary);
|
||||
--search-modifier-color: var(--content-primary-dark);
|
||||
|
||||
input[type='search']::selection {
|
||||
background-color: #b4d1ff;
|
||||
}
|
||||
}
|
||||
|
||||
.full-project-search-modifiers {
|
||||
input:checked ~ .form-check-label {
|
||||
background-color: var(--search-modifier-checked-bg);
|
||||
color: var(--search-modifier-checked-color);
|
||||
}
|
||||
|
||||
.form-check-label {
|
||||
color: var(--search-modifier-color);
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: var(--search-modifier-hover-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.full-project-search-form {
|
||||
border-bottom: 1px solid var(--full-project-search-border-color);
|
||||
}
|
||||
|
||||
.match-counts {
|
||||
padding: var(--spacing-03) var(--spacing-04);
|
||||
font-size: var(--font-size-01);
|
||||
}
|
||||
|
||||
.matched-files {
|
||||
padding: 0 var(--spacing-02);
|
||||
}
|
||||
|
||||
.matched-hit-highlight {
|
||||
color: var(--matched-hit-highlight-text-color);
|
||||
}
|
||||
|
||||
.matched-file-hits {
|
||||
width: unset;
|
||||
overflow: unset;
|
||||
margin-left: var(--spacing-08);
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: calc(var(--spacing-04) * -1);
|
||||
top: 0;
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
background-color: var(--full-project-search-border-color);
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.matched-file-hit {
|
||||
border-radius: var(--border-radius-base);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--matched-file-hit-hover-bg);
|
||||
}
|
||||
|
||||
&.matched-file-hit-selected:hover {
|
||||
background-color: var(--full-project-search-selected-hit-bg-color);
|
||||
color: var(--full-project-search-selected-hit-color);
|
||||
}
|
||||
}
|
||||
|
||||
.collapsible-file-header {
|
||||
border-radius: var(--border-radius-base);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--collapsible-file-header-hover-bg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.full-project-search {
|
||||
--full-project-search-bg-color: var(--bg-light-secondary);
|
||||
--full-project-search-color: var(--content-secondary);
|
||||
--full-project-search-results-bg-color: var(--bg-light-primary);
|
||||
--full-project-search-selected-hit-bg-color: var(--bg-light-tertiary);
|
||||
--full-project-search-selected-hit-color: var(--content-primary-light);
|
||||
--matched-hit-highlight-color: var(--bg-light-tertiary);
|
||||
--matched-hit-selected-highlight-color: var(--bg-accent-02);
|
||||
--matched-hit-selected-unfocused-highlight-color: var(--bg-accent-02);
|
||||
--collapsible-file-header-count-color: var(--bs-body-color);
|
||||
--collapsible-file-header-count-bg-color: var(--bg-light-secondary);
|
||||
|
||||
&[data-bs-theme='dark'] {
|
||||
--full-project-search-bg-color: var(--bg-dark-secondary);
|
||||
--full-project-search-color: var(--content-secondary-dark);
|
||||
--full-project-search-results-bg-color: var(--bg-dark-tertiary);
|
||||
--full-project-search-selected-hit-bg-color: var(--bg-dark-secondary);
|
||||
--full-project-search-selected-hit-color: var(--content-primary-dark);
|
||||
--matched-hit-highlight-color: var(--bg-dark-secondary);
|
||||
--matched-hit-selected-highlight-color: var(--bg-accent-02);
|
||||
--matched-hit-selected-unfocused-highlight-color: var(--bg-dark-primary);
|
||||
--collapsible-file-header-count-color: var(--bs-body-color);
|
||||
--collapsible-file-header-count-bg-color: var(--bg-dark-secondary);
|
||||
--panel-heading-color: var(--content-primary-dark);
|
||||
|
||||
input[type='search']::selection {
|
||||
background-color: #b4d1ff;
|
||||
}
|
||||
|
||||
.full-project-search-modifiers {
|
||||
input:checked ~ .form-check-label {
|
||||
background-color: var(--neutral-90);
|
||||
}
|
||||
|
||||
.form-check-label {
|
||||
color: inherit;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: var(--neutral-70);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.panel-heading-close-button {
|
||||
color: var(--content-primary-dark);
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: var(--content-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
z-index: 2;
|
||||
background-color: var(--full-project-search-bg-color);
|
||||
color: var(--full-project-search-color);
|
||||
|
||||
.full-project-search-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--spacing-02);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.panel-heading-label {
|
||||
margin-left: var(--spacing-02);
|
||||
}
|
||||
|
||||
.full-project-search-form {
|
||||
padding: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.form-check {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.form-check-inline {
|
||||
display: inline-flex;
|
||||
gap: var(--spacing-02);
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.full-project-search-modifiers {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-03);
|
||||
margin-top: var(--spacing-02);
|
||||
|
||||
input[type='checkbox'] {
|
||||
position: relative;
|
||||
left: -999px;
|
||||
}
|
||||
|
||||
.form-check-label {
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 90%;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: var(--neutral-20);
|
||||
}
|
||||
}
|
||||
|
||||
input:focus-visible ~ .form-check-label {
|
||||
box-shadow: 0 0 0 2px var(--border-active-dark);
|
||||
}
|
||||
|
||||
input:checked ~ .form-check-label {
|
||||
background-color: var(--neutral-30);
|
||||
}
|
||||
}
|
||||
|
||||
.match-counts {
|
||||
font-size: 80%;
|
||||
padding: 0 var(--spacing-04) var(--spacing-04);
|
||||
}
|
||||
|
||||
.matched-files {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
font-size: 14px;
|
||||
background-color: var(--full-project-search-results-bg-color);
|
||||
}
|
||||
|
||||
.matched-file {
|
||||
margin-bottom: 8px;
|
||||
|
||||
.collapsible-file-header:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--border-active-dark);
|
||||
}
|
||||
}
|
||||
|
||||
.collapsible-file-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background-color: var(--full-project-search-results-bg-color);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.collapsible-file-header-count {
|
||||
background-color: var(--collapsible-file-header-count-bg-color);
|
||||
color: var(--collapsible-file-header-count-color);
|
||||
}
|
||||
|
||||
.matched-file-hits {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.matched-line-number {
|
||||
white-space: nowrap;
|
||||
margin-right: 8px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.matched-file-hit {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
align-items: flex-start;
|
||||
padding: var(--spacing-02) var(--spacing-05);
|
||||
word-break: break-all;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
font-family: var(--font-family);
|
||||
font-size: var(--font-size);
|
||||
min-height: fit-content;
|
||||
border-radius: 0;
|
||||
background-color: var(--full-project-search-results-bg-color);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--full-project-search-bg-color);
|
||||
}
|
||||
|
||||
&.matched-file-hit-highlighted {
|
||||
background-color: var(--full-project-search-bg-color);
|
||||
}
|
||||
}
|
||||
|
||||
.matched-hit-snippet {
|
||||
overflow-wrap: break-word;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.notification {
|
||||
margin: var(--spacing-02) var(--spacing-04) var(--spacing-06);
|
||||
width: auto;
|
||||
align-items: center;
|
||||
padding: 0 var(--spacing-04);
|
||||
|
||||
.notification-content {
|
||||
padding: var(--spacing-02) 0;
|
||||
}
|
||||
|
||||
.notification-icon {
|
||||
padding: var(--spacing-02) var(--spacing-04) 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.matched-hit-highlight {
|
||||
background-color: var(--matched-hit-highlight-color);
|
||||
border-radius: 4px;
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
|
||||
.matched-file-hit-selected {
|
||||
background-color: var(--full-project-search-selected-hit-bg-color);
|
||||
color: var(--full-project-search-selected-hit-color);
|
||||
|
||||
.matched-hit-highlight {
|
||||
background-color: var(--matched-hit-unfocused-highlight-color);
|
||||
}
|
||||
}
|
||||
|
||||
/* &:focus-within {
|
||||
.matched-file-hit-selected {
|
||||
background-color: var(--bg-accent-01);
|
||||
color: var(--bg-light-primary);
|
||||
|
||||
.matched-hit-highlight {
|
||||
background-color: var(--matched-hit-selected-highlight-color);
|
||||
}
|
||||
}
|
||||
} */
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Meta, StoryObj } from '@storybook/react'
|
||||
import { ScopeDecorator } from '../../../frontend/stories/decorators/scope'
|
||||
import useFetchMock from '../../../frontend/stories/hooks/use-fetch-mock'
|
||||
import FullProjectSearchUI from '../frontend/js/components/full-project-search-ui'
|
||||
|
||||
const meta = {
|
||||
title: 'Editor / Full Project Search',
|
||||
component: FullProjectSearchUI,
|
||||
decorators: [
|
||||
Story => ScopeDecorator(Story, { mockCompileOnLoad: true }),
|
||||
Story => {
|
||||
useFetchMock(fetchMock => {
|
||||
fetchMock.post('express:/project/:projectId/flush', { status: 204 })
|
||||
|
||||
fetchMock.get('express:/project/:projectId/latest/history', {
|
||||
status: 200,
|
||||
body: {
|
||||
chunk: {
|
||||
history: {
|
||||
snapshot: {
|
||||
files: {},
|
||||
},
|
||||
changes: [
|
||||
{
|
||||
operations: [
|
||||
{
|
||||
pathname: 'main.tex',
|
||||
file: {
|
||||
hash: '5199b66d9d1226551be436c66bad9d962cc05537',
|
||||
stringLength: 7066,
|
||||
},
|
||||
},
|
||||
],
|
||||
timestamp: '2025-01-03T10:10:40.840Z',
|
||||
authors: [],
|
||||
v2Authors: ['66e040e0da7136ec75ffe8a3'],
|
||||
projectVersion: '1.0',
|
||||
},
|
||||
{
|
||||
operations: [
|
||||
{
|
||||
pathname: 'sample.bib',
|
||||
file: {
|
||||
hash: 'a0e21c740cf81e868f158e30e88985b5ea1d6c19',
|
||||
stringLength: 244,
|
||||
},
|
||||
},
|
||||
],
|
||||
timestamp: '2025-01-03T10:10:40.856Z',
|
||||
authors: [],
|
||||
v2Authors: ['66e040e0da7136ec75ffe8a3'],
|
||||
projectVersion: '2.0',
|
||||
},
|
||||
{
|
||||
operations: [
|
||||
{
|
||||
pathname: 'frog.jpg',
|
||||
file: {
|
||||
hash: '5b889ef3cf71c83a4c027c4e4dc3d1a106b27809',
|
||||
byteLength: 97080,
|
||||
},
|
||||
},
|
||||
],
|
||||
timestamp: '2025-01-03T10:10:40.890Z',
|
||||
authors: [],
|
||||
v2Authors: ['66e040e0da7136ec75ffe8a3'],
|
||||
projectVersion: '3.0',
|
||||
},
|
||||
],
|
||||
},
|
||||
startVersion: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
fetchMock.get('express:/project/:projectId/changes', {
|
||||
status: 200,
|
||||
body: [],
|
||||
})
|
||||
|
||||
fetchMock.get(
|
||||
'express:/project/:projectId/blob/5199b66d9d1226551be436c66bad9d962cc05537',
|
||||
{
|
||||
status: 200,
|
||||
body: `Simply use the section and subsection commands, as in this example document! With Overleaf, all the formatting and numbering is handled automatically according to the template you've chosen. If you're using the Visual Editor, you can also create new section and subsections via the buttons in the editor toolbar.`,
|
||||
}
|
||||
)
|
||||
|
||||
fetchMock.get(
|
||||
'express:/project/:projectId/blob/a0e21c740cf81e868f158e30e88985b5ea1d6c19',
|
||||
{
|
||||
status: 200,
|
||||
body: `@article{greenwade93,
|
||||
author = "George D. Greenwade",
|
||||
title = "The {C}omprehensive {T}ex {A}rchive {N}etwork ({CTAN})",
|
||||
year = "1993",
|
||||
journal = "TUGBoat",
|
||||
volume = "14",
|
||||
number = "3",
|
||||
pages = "342--351"
|
||||
}`,
|
||||
}
|
||||
)
|
||||
})
|
||||
return <Story />
|
||||
},
|
||||
],
|
||||
} satisfies Meta<typeof FullProjectSearchUI>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof FullProjectSearchUI>
|
||||
|
||||
export const UI = {} satisfies Story
|
||||
Reference in New Issue
Block a user