From 67342e9c3322722157b196dc97a2b2006b70fa42 Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Wed, 2 Jul 2025 10:41:44 +0200 Subject: [PATCH] [terraform] clsi: add pre-emp C2D capacity in zone b (#26755) GitOrigin-RevId: aa52dec1f7135f53f8c887199c1d1e4e31ef70ff --- .../components/full-project-match-counts.tsx | 33 ++ .../components/full-project-search-button.tsx | 57 +++ .../full-project-search-modifiers.tsx | 78 ++++ .../full-project-search-results.tsx | 174 +++++++++ .../js/components/full-project-search-ui.tsx | 250 ++++++++++++ .../js/components/full-project-search.tsx | 22 ++ .../js/components/matched-hit-highlight.tsx | 49 +++ .../frontend/js/components/matched-hit.tsx | 45 +++ .../frontend/js/util/regexp.ts | 7 + .../frontend/js/util/search-snapshot.ts | 92 +++++ .../frontend/js/util/search.ts | 50 +++ .../stylesheets/full-project-search.scss | 367 ++++++++++++++++++ .../stories/full-project-search.stories.tsx | 113 ++++++ 13 files changed, 1337 insertions(+) create mode 100644 services/web/modules/full-project-search/frontend/js/components/full-project-match-counts.tsx create mode 100644 services/web/modules/full-project-search/frontend/js/components/full-project-search-button.tsx create mode 100644 services/web/modules/full-project-search/frontend/js/components/full-project-search-modifiers.tsx create mode 100644 services/web/modules/full-project-search/frontend/js/components/full-project-search-results.tsx create mode 100644 services/web/modules/full-project-search/frontend/js/components/full-project-search-ui.tsx create mode 100644 services/web/modules/full-project-search/frontend/js/components/full-project-search.tsx create mode 100644 services/web/modules/full-project-search/frontend/js/components/matched-hit-highlight.tsx create mode 100644 services/web/modules/full-project-search/frontend/js/components/matched-hit.tsx create mode 100644 services/web/modules/full-project-search/frontend/js/util/regexp.ts create mode 100644 services/web/modules/full-project-search/frontend/js/util/search-snapshot.ts create mode 100644 services/web/modules/full-project-search/frontend/js/util/search.ts create mode 100644 services/web/modules/full-project-search/frontend/stylesheets/full-project-search.scss create mode 100644 services/web/modules/full-project-search/stories/full-project-search.stories.tsx diff --git a/services/web/modules/full-project-search/frontend/js/components/full-project-match-counts.tsx b/services/web/modules/full-project-search/frontend/js/components/full-project-match-counts.tsx new file mode 100644 index 0000000000..7483488b0c --- /dev/null +++ b/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 + } + + 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 })} + + ) +} diff --git a/services/web/modules/full-project-search/frontend/js/components/full-project-search-button.tsx b/services/web/modules/full-project-search/frontend/js/components/full-project-search-button.tsx new file mode 100644 index 0000000000..83031ba595 --- /dev/null +++ b/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: ( + <> +
{t('search_project')}
+
{isMac ? '⇧⌘F' : 'Ctrl+Shift+F'}
+ + ), + overlayProps: { placement: 'bottom' }, + }), + [t] + ) + + return ( + + + + ) +} + +export default FullProjectSearchButton diff --git a/services/web/modules/full-project-search/frontend/js/components/full-project-search-modifiers.tsx b/services/web/modules/full-project-search/frontend/js/components/full-project-search-modifiers.tsx new file mode 100644 index 0000000000..465bf1363a --- /dev/null +++ b/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(null) + const regexpRef = useRef(null) + const wholeWordRef = useRef(null) + + useImperativeHandle(ref, () => { + return { + setQuery(query: SearchQuery) { + caseSensitiveRef.current!.checked = query.caseSensitive + regexpRef.current!.checked = query.regexp + wholeWordRef.current!.checked = query.wholeWord + }, + } + }, []) + + return ( +
+ + + + + + + + + + + + + + + +
+ ) + } +) diff --git a/services/web/modules/full-project-search/frontend/js/components/full-project-search-results.tsx b/services/web/modules/full-project-search/frontend/js/components/full-project-search-results.tsx new file mode 100644 index 0000000000..33ae590254 --- /dev/null +++ b/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>(new Set()) + const [selectedHit, setSelectedHit] = useState() + + 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(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() + + 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 ( +
+ {matchedFiles.map(matchedFile => ( +
+ toggleCollapse(matchedFile.path)} + /> + {!collapsedFiles.has(matchedFile.path) && ( +
+ {matchedFile.hits.map(hit => { + return ( + + ) + })} +
+ )} +
+ ))} +
+ ) +} diff --git a/services/web/modules/full-project-search/frontend/js/components/full-project-search-ui.tsx b/services/web/modules/full-project-search/frontend/js/components/full-project-search-ui.tsx new file mode 100644 index 0000000000..db42a927a4 --- /dev/null +++ b/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() + const [matchedFiles, setMatchedFiles] = useState() + + const { userSettings } = useUserSettingsContext() + const { fontFamily, fontSize } = useMemo( + () => userStyles(userSettings), + [userSettings] + ) + + const abortControllerRef = useRef(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 = 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(null) + + const handleKeyDown: React.KeyboardEventHandler = 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) => { + 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 = 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 ( +
+ {newEditor ? ( + + ) : ( + setProjectSearchIsOpen(false)} + splitTestName="full-project-search" + /> + )} + +
+
+ + {error && } + +
+ +
+ + {matchedFiles && ( + + )} +
+ ) +} + +export default memo(FullProjectSearchUI) diff --git a/services/web/modules/full-project-search/frontend/js/components/full-project-search.tsx b/services/web/modules/full-project-search/frontend/js/components/full-project-search.tsx new file mode 100644 index 0000000000..7720b21589 --- /dev/null +++ b/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 ( + + + + ) +} + +export default FullProjectSearch diff --git a/services/web/modules/full-project-search/frontend/js/components/matched-hit-highlight.tsx b/services/web/modules/full-project-search/frontend/js/components/matched-hit-highlight.tsx new file mode 100644 index 0000000000..8f7122cb22 --- /dev/null +++ b/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 ( + + {parts.before} + {parts.match} + {parts.after} + + ) +} diff --git a/services/web/modules/full-project-search/frontend/js/components/matched-hit.tsx b/services/web/modules/full-project-search/frontend/js/components/matched-hit.tsx new file mode 100644 index 0000000000..2652e589b4 --- /dev/null +++ b/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(null) + + useLayoutEffect(() => { + if (selected) { + containerRef.current?.focus() + } + }, [selected]) + + const handleSelect: React.MouseEventHandler = useCallback( + event => { + event.preventDefault() + setSelectedHit(hit) + }, + [hit, setSelectedHit] + ) + + return ( + + ) +} diff --git a/services/web/modules/full-project-search/frontend/js/util/regexp.ts b/services/web/modules/full-project-search/frontend/js/util/regexp.ts new file mode 100644 index 0000000000..7795d05911 --- /dev/null +++ b/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) +} diff --git a/services/web/modules/full-project-search/frontend/js/util/search-snapshot.ts b/services/web/modules/full-project-search/frontend/js/util/search-snapshot.ts new file mode 100644 index 0000000000..2abb5626b9 --- /dev/null +++ b/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() + + 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 +} diff --git a/services/web/modules/full-project-search/frontend/js/util/search.ts b/services/web/modules/full-project-search/frontend/js/util/search.ts new file mode 100644 index 0000000000..69078f6323 --- /dev/null +++ b/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)) diff --git a/services/web/modules/full-project-search/frontend/stylesheets/full-project-search.scss b/services/web/modules/full-project-search/frontend/stylesheets/full-project-search.scss new file mode 100644 index 0000000000..4b19b7e894 --- /dev/null +++ b/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); + } + } + } */ +} diff --git a/services/web/modules/full-project-search/stories/full-project-search.stories.tsx b/services/web/modules/full-project-search/stories/full-project-search.stories.tsx new file mode 100644 index 0000000000..e599fabecd --- /dev/null +++ b/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 + }, + ], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const UI = {} satisfies Story