Просмотр исходного кода

[terraform] clsi: add pre-emp C2D capacity in zone b (#26755)

GitOrigin-RevId: aa52dec1f7135f53f8c887199c1d1e4e31ef70ff
Jakob Ackermann 1 год назад
Родитель
Сommit
67342e9c33

+ 33 - 0
services/web/modules/full-project-search/frontend/js/components/full-project-match-counts.tsx

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

+ 57 - 0
services/web/modules/full-project-search/frontend/js/components/full-project-search-button.tsx

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

+ 78 - 0
services/web/modules/full-project-search/frontend/js/components/full-project-search-modifiers.tsx

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

+ 174 - 0
services/web/modules/full-project-search/frontend/js/components/full-project-search-results.tsx

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

+ 250 - 0
services/web/modules/full-project-search/frontend/js/components/full-project-search-ui.tsx

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

+ 22 - 0
services/web/modules/full-project-search/frontend/js/components/full-project-search.tsx

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

+ 49 - 0
services/web/modules/full-project-search/frontend/js/components/matched-hit-highlight.tsx

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

+ 45 - 0
services/web/modules/full-project-search/frontend/js/components/matched-hit.tsx

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

+ 7 - 0
services/web/modules/full-project-search/frontend/js/util/regexp.ts

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

+ 92 - 0
services/web/modules/full-project-search/frontend/js/util/search-snapshot.ts

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

+ 50 - 0
services/web/modules/full-project-search/frontend/js/util/search.ts

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

+ 367 - 0
services/web/modules/full-project-search/frontend/stylesheets/full-project-search.scss

@@ -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);
+      }
+    }
+  } */
+}

+ 113 - 0
services/web/modules/full-project-search/stories/full-project-search.stories.tsx

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