From a6debeb00a9cb1839a9a8629e57d56a9c1150d5b Mon Sep 17 00:00:00 2001 From: Vova Kulikov Date: Thu, 9 Nov 2023 14:28:58 -0300 Subject: [PATCH] [Notebooks]: Remove notepad UI from notebooks feature (#58217) * Remove notepad UI from notebooks feature * Update CHANGELOG.md --- CHANGELOG.md | 1 + .../settings/temporary/TemporarySettings.ts | 4 - client/web/BUILD.bazel | 5 - client/web/src/LegacyLayout.tsx | 10 +- .../listPage/NotebooksGettingStartedTab.tsx | 28 - .../listPage/NotebooksListPageHeader.tsx | 57 +- .../web/src/notebooks/listPage/NotepadCta.tsx | 49 - .../notebookPage/NotebookPage.module.scss | 42 - .../notebooks/notebookPage/NotebookPage.tsx | 89 +- client/web/src/repo/blob/BlobPage.tsx | 18 - client/web/src/search/Notepad.module.scss | 101 -- client/web/src/search/Notepad.story.tsx | 95 -- client/web/src/search/Notepad.test.tsx | 563 ----------- client/web/src/search/Notepad.tsx | 952 ------------------ .../search/results/StreamingSearchResults.tsx | 17 - client/web/src/stores/index.ts | 1 - client/web/src/stores/notepad.test.ts | 82 -- client/web/src/stores/notepad.ts | 207 ---- .../src/storm/pages/LayoutPage/LayoutPage.tsx | 8 - doc/notebooks/index.md | 9 +- doc/notebooks/notepad.md | 31 - 21 files changed, 11 insertions(+), 2358 deletions(-) delete mode 100644 client/web/src/notebooks/listPage/NotepadCta.tsx delete mode 100644 client/web/src/search/Notepad.module.scss delete mode 100644 client/web/src/search/Notepad.story.tsx delete mode 100644 client/web/src/search/Notepad.test.tsx delete mode 100644 client/web/src/search/Notepad.tsx delete mode 100644 client/web/src/stores/notepad.test.ts delete mode 100644 client/web/src/stores/notepad.ts delete mode 100644 doc/notebooks/notepad.md diff --git a/CHANGELOG.md b/CHANGELOG.md index eb5399ee41e..18034cf0fd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ All notable changes to Sourcegraph are documented in this file. - The GitHub Proxy service is no longer required and has been removed from deployment options. [#55290](https://github.com/sourcegraph/sourcegraph/issues/55290) - The VSCode search extension "Sourcegraph for VS Code" has been sunset and removed from Sourcegraph repository. [#58023](https://github.com/sourcegraph/sourcegraph/pull/58023) +- The notepad UI, notebook creation feature. [#58217](https://github.com/sourcegraph/sourcegraph/pull/58217) ## Unreleased 5.2.3 diff --git a/client/shared/src/settings/temporary/TemporarySettings.ts b/client/shared/src/settings/temporary/TemporarySettings.ts index e16c8fd99f0..bfe48ac135c 100644 --- a/client/shared/src/settings/temporary/TemporarySettings.ts +++ b/client/shared/src/settings/temporary/TemporarySettings.ts @@ -32,8 +32,6 @@ export interface TemporarySettingsSchema { 'search.hiddenNoResultsSections': NoResultsSectionID[] 'search.sidebar.revisions.tab': number 'search.sidebar.collapsed': boolean // Used only on non-mobile sizes and when coreWorkflowImprovements.enabled is set - 'search.notepad.enabled': boolean - 'search.notepad.ctaSeen': boolean 'search.notebooks.gettingStartedTabSeen': boolean 'insights.freeGaAccepted': boolean 'insights.freeGaExpiredAccepted': boolean @@ -102,8 +100,6 @@ const TEMPORARY_SETTINGS: Record = { 'search.hiddenNoResultsSections': null, 'search.sidebar.revisions.tab': null, 'search.sidebar.collapsed': null, - 'search.notepad.enabled': null, - 'search.notepad.ctaSeen': null, 'search.notebooks.gettingStartedTabSeen': null, 'insights.freeGaAccepted': null, 'insights.freeGaExpiredAccepted': null, diff --git a/client/web/BUILD.bazel b/client/web/BUILD.bazel index b72145b22a0..90d8d7278c1 100644 --- a/client/web/BUILD.bazel +++ b/client/web/BUILD.bazel @@ -1183,7 +1183,6 @@ ts_project( "src/notebooks/listPage/NotebooksList.tsx", "src/notebooks/listPage/NotebooksListPage.tsx", "src/notebooks/listPage/NotebooksListPageHeader.tsx", - "src/notebooks/listPage/NotepadCta.tsx", "src/notebooks/notebook/NotebookAddBlockButtons.tsx", "src/notebooks/notebook/NotebookCommandPaletteInput.tsx", "src/notebooks/notebook/NotebookComponent.tsx", @@ -1405,7 +1404,6 @@ ts_project( "src/savedSearches/SavedSearchModal.tsx", "src/savedSearches/SavedSearchUpdateForm.tsx", "src/search-jobs/utility.ts", - "src/search/Notepad.tsx", "src/search/QuickLinks.tsx", "src/search/SearchConsolePage.tsx", "src/search/SearchPageWrapper.tsx", @@ -1622,7 +1620,6 @@ ts_project( "src/stores/devSettings.ts", "src/stores/index.ts", "src/stores/navbarSearchQueryState.ts", - "src/stores/notepad.ts", "src/storm/app-shell-init.ts", "src/storm/backend/route-loader.ts", "src/storm/pages/LayoutPage/LayoutPage.loader.ts", @@ -1984,7 +1981,6 @@ ts_project( "src/repo/releases/RepositoryReleasesTagsPage.test.tsx", "src/repo/tree/TreePage.test.tsx", "src/repo/utils.test.ts", - "src/search/Notepad.test.tsx", "src/search/helpers.test.tsx", "src/search/index.test.ts", "src/search/input/useRecentSearches.test.tsx", @@ -1999,7 +1995,6 @@ ts_project( "src/site-admin/outbound-webhooks/mocks.ts", "src/site/LicenseExpirationAlert.test.tsx", "src/stores/navbarSearchQueryState.test.ts", - "src/stores/notepad.test.ts", "src/team/area/testContext.mock.ts", "src/testSetup.test.ts", "src/tour/components/Tour/Tour.test.tsx", diff --git a/client/web/src/LegacyLayout.tsx b/client/web/src/LegacyLayout.tsx index 3089b347986..7afd4f2ecd1 100644 --- a/client/web/src/LegacyLayout.tsx +++ b/client/web/src/LegacyLayout.tsx @@ -27,7 +27,6 @@ import { SurveyToast } from './marketing/toast' import { GlobalNavbar } from './nav/GlobalNavbar' import { EnterprisePageRoutes, PageRoutes } from './routes.constants' import { parseSearchURLQuery } from './search' -import { NotepadContainer } from './search/Notepad' import { SearchQueryStateObserver } from './SearchQueryStateObserver' import { isSourcegraphDev, useDeveloperSettings } from './stores' import { isAccessTokenCallbackPage } from './user/settings/accessTokens/UserSettingsCreateAccessTokenCallbackPage' @@ -67,7 +66,6 @@ export const LegacyLayout: FC = props => { const isSearchJobsPage = routeMatch?.startsWith(EnterprisePageRoutes.SearchJobs) const isAppAuthCallbackPage = routeMatch?.startsWith(EnterprisePageRoutes.AppAuthCallback) const isSearchNotebooksPage = routeMatch?.startsWith(EnterprisePageRoutes.Notebooks) - const isSearchNotebookListPage = location.pathname === EnterprisePageRoutes.Notebooks const isCodySearchPage = routeMatch === EnterprisePageRoutes.CodySearch const isRepositoryRelatedPage = routeMatch === PageRoutes.RepoContainer ?? false @@ -197,7 +195,7 @@ export const LegacyLayout: FC = props => { // Some routes by their design require rendering on a blank page // without the UI chrome that Layout component renders by default. // If route has handle: { fullPage: true } we render just the route content - // and its container block without rendering global nav, notepad + // and its container block without rendering global nav // and other standard UI chrome elements. if (isFullPageRoute) { return @@ -281,12 +279,6 @@ export const LegacyLayout: FC = props => { extensionsController={props.extensionsController} platformContext={props.platformContext} /> - {(isSearchNotebookListPage || (isSearchRelatedPage && !isSearchHomepage)) && ( - - )} {fuzzyFinder && ( -

Powerful creation features

- -
-
- Enable the notepad for frictionless knowledge sharing - - With the notepad, create notebooks while you browse. Add searches, files, and file ranges - without leaving the page you're on, then create a notebook of it all with one click. - - Compose rich documentation with multiple block types - - Create text content with Markdown blocks, track symbols within files with symbol blocks, and - add whole files or line ranges with file blocks. - -
-
-
-
-

Functionality

{functionalityPanels.map(panel => ( diff --git a/client/web/src/notebooks/listPage/NotebooksListPageHeader.tsx b/client/web/src/notebooks/listPage/NotebooksListPageHeader.tsx index 8fe1786d487..2a9e6911695 100644 --- a/client/web/src/notebooks/listPage/NotebooksListPageHeader.tsx +++ b/client/web/src/notebooks/listPage/NotebooksListPageHeader.tsx @@ -1,11 +1,10 @@ -import React, { useCallback, useRef, useState } from 'react' +import React, { useCallback, useRef } from 'react' import { mdiChevronDown } from '@mdi/js' import { VisuallyHidden } from '@reach/visually-hidden' import * as uuid from 'uuid' import type { ErrorLike } from '@sourcegraph/common' -import { useTemporarySetting } from '@sourcegraph/shared/src/settings/temporary/useTemporarySetting' import type { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService' import { Link, @@ -17,19 +16,15 @@ import { MenuList, MenuItem, Input, - Modal, Icon, } from '@sourcegraph/wildcard' import type { AuthenticatedUser } from '../../auth' import type { CreateNotebookVariables } from '../../graphql-operations' import { EnterprisePageRoutes } from '../../routes.constants' -import { NotepadIcon } from '../../search/Notepad' import { blockToGQLInput } from '../serialize' import { convertMarkdownToBlocks } from '../serialize/convertMarkdownToBlocks' -import { NOTEPAD_CTA_ID, NotepadCTA } from './NotepadCta' - import styles from './NotebooksListPageHeader.module.scss' const LOADING = 'loading' as const @@ -96,7 +91,6 @@ export const NotebooksListPageHeader: React.FunctionComponent< return ( <> - {/* The file upload input has to always be present in the DOM, otherwise the upload process does not complete when the menu below closes. */} ) } - -export const NOTEPAD_ENABLED_EVENT = 'SearchNotepadEnabled' -const NOTEPAD_DISABLED_EVENT = 'SearchNotepadDisabled' - -const ToggleNotepadButton: React.FunctionComponent< - React.PropsWithChildren -> = ({ telemetryService, className }) => { - const [notepadEnabled, setNotepadEnabled] = useTemporarySetting('search.notepad.enabled') - const [ctaSeen, setCTASeen] = useTemporarySetting('search.notepad.ctaSeen') - const [showCTA, setShowCTA] = useState(false) - - function onClick(): void { - if (!notepadEnabled && !ctaSeen) { - setShowCTA(true) - } else { - setNotepadEnabled(enabled => { - // `enabled` is the old state so we have to log the "opposite" - // event - telemetryService.log(enabled ? NOTEPAD_DISABLED_EVENT : NOTEPAD_ENABLED_EVENT) - return !enabled - }) - } - } - - function onEnableFromCTA(): void { - telemetryService.log(NOTEPAD_ENABLED_EVENT) - setNotepadEnabled(true) - setShowCTA(false) - setCTASeen(true) - } - - function onCancelFromCTA(): void { - // We only mark the CTA as "seen" when the user enables the notepad from it - setShowCTA(false) - } - - return ( - <> - - {showCTA && ( - - - - )} - - ) -} diff --git a/client/web/src/notebooks/listPage/NotepadCta.tsx b/client/web/src/notebooks/listPage/NotepadCta.tsx deleted file mode 100644 index 7ad06f77271..00000000000 --- a/client/web/src/notebooks/listPage/NotepadCta.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import React from 'react' - -import { useIsLightTheme } from '@sourcegraph/shared/src/theme' -import { ProductStatusBadge, Button, H3, Text } from '@sourcegraph/wildcard' - -import { NotepadIcon } from '../../search/Notepad' - -export const NOTEPAD_CTA_ID = 'notepad-cta' - -interface NotepadCTAProps { - onEnable: () => void - onCancel: () => void -} - -export const NotepadCTA: React.FunctionComponent> = ({ - onEnable, - onCancel, -}) => { - const assetsRoot = window.context?.assetsRoot || '' - const isLightTheme = useIsLightTheme() - - return ( -
-

- Enable notepad -

{' '} - -
- notepad illustration - - The notepad adds a toolbar to the bottom right of search results and file pages to help you create - notebooks from your code navigation activities. - -
-
- - -
-
- ) -} diff --git a/client/web/src/notebooks/notebookPage/NotebookPage.module.scss b/client/web/src/notebooks/notebookPage/NotebookPage.module.scss index 8a33eda49ca..644a95ed5b0 100644 --- a/client/web/src/notebooks/notebookPage/NotebookPage.module.scss +++ b/client/web/src/notebooks/notebookPage/NotebookPage.module.scss @@ -41,46 +41,4 @@ // remaining space on the page. flex: none; } - - .spacer { - // Allows scrolling past last blocks in the notebook - // for easier editing. It will also ensure that there is enough space - // between the notepad cta and the content - margin-top: 10rem; - // Spacer should never shrink. This makes sure that - // (1) there is always space between the bottom of the notebookpage and the screen and - // (2) the notepad CTA doesn't overlap with notebook content. - flex: 1 0 auto; - display: flex; - // Aligns notepad CTA at the bottom of the page - align-items: flex-end; - } -} - -.notepad-cta { - position: relative; - bottom: 0; - max-width: var(--viewport-md); - margin: auto; - margin-bottom: 1rem; - - &--close-button { - position: absolute; - right: 0; - top: 0; - margin: 0.5rem; - } - - &--content { - display: flex; - align-items: center; - - @media (--xs-breakpoint-down) { - flex-direction: column; - } - - p:last-child { - margin-bottom: 0; - } - } } diff --git a/client/web/src/notebooks/notebookPage/NotebookPage.tsx b/client/web/src/notebooks/notebookPage/NotebookPage.tsx index c6a80089181..5e9673e35ba 100644 --- a/client/web/src/notebooks/notebookPage/NotebookPage.tsx +++ b/client/web/src/notebooks/notebookPage/NotebookPage.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { mdiClose, mdiCheckCircle, mdiBookOutline } from '@mdi/js' +import { mdiCheckCircle, mdiBookOutline } from '@mdi/js' import classNames from 'classnames' import { useParams } from 'react-router-dom' import { useStickyBox } from 'react-sticky-box' @@ -11,30 +11,15 @@ import type { StreamingSearchResultsListProps } from '@sourcegraph/branded' import { Timestamp } from '@sourcegraph/branded/src/components/Timestamp' import { asError, isErrorLike } from '@sourcegraph/common' import type { PlatformContextProps } from '@sourcegraph/shared/src/platform/context' -import { useTemporarySetting } from '@sourcegraph/shared/src/settings/temporary/useTemporarySetting' import type { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService' -import { useIsLightTheme } from '@sourcegraph/shared/src/theme' -import { - LoadingSpinner, - PageHeader, - useEventObservable, - useObservable, - Alert, - ProductStatusBadge, - Button, - Icon, - H3, - Text, -} from '@sourcegraph/wildcard' +import { LoadingSpinner, PageHeader, useEventObservable, useObservable, Alert, Icon } from '@sourcegraph/wildcard' import type { Block } from '..' import type { AuthenticatedUser } from '../../auth' -import { MarketingBlock } from '../../components/MarketingBlock' import { PageTitle } from '../../components/PageTitle' import type { NotebookFields, NotebookInput } from '../../graphql-operations' import type { OwnConfigProps } from '../../own/OwnConfigProps' import type { SearchStreamingProps } from '../../search' -import { NotepadIcon } from '../../search/Notepad' import { fetchNotebook as _fetchNotebook, updateNotebook as _updateNotebook, @@ -42,7 +27,6 @@ import { createNotebookStar as _createNotebookStar, deleteNotebookStar as _deleteNotebookStar, } from '../backend' -import { NOTEPAD_ENABLED_EVENT } from '../listPage/NotebooksListPageHeader' import { copyNotebook as _copyNotebook, type CopyNotebookProps } from '../notebook' import { blockToGQLInput, convertNotebookTitleToFileName, GQLBlockToGQLInput } from '../serialize' @@ -97,8 +81,6 @@ export const NotebookPage: React.FunctionComponent[]>([]) const outlineContainerElement = useRef(null) - const [notepadCTASeen, setNotepadCTASeen] = useTemporarySetting('search.notepad.ctaSeen') - const [notepadEnabled, setNotepadEnabled] = useTemporarySetting('search.notepad.enabled') const exportedFileName = useMemo( () => `${notebookTitle ? convertNotebookTitleToFileName(notebookTitle) : 'notebook'}.snb.md`, @@ -181,15 +163,6 @@ export const NotebookPage: React.FunctionComponent - !notepadEnabled && - !notepadCTASeen && - isNotebookLoaded(latestNotebook) && - latestNotebook.blocks.length === 0, - [latestNotebook, notepadCTASeen, notepadEnabled] - ) - const stickyBox = useStickyBox() return ( @@ -321,65 +294,7 @@ export const NotebookPage: React.FunctionComponent )}
-
- {showNotepadCTA && ( - { - telemetryService.log(NOTEPAD_ENABLED_EVENT) - setNotepadCTASeen(true) - setNotepadEnabled(true) - }} - onClose={() => setNotepadCTASeen(true)} - /> - )} -
) } - -interface NotepadCTAProps { - onEnable: () => void - onClose: () => void -} - -const NotepadCTA: React.FunctionComponent> = ({ onEnable, onClose }) => { - const assetsRoot = window.context?.assetsRoot || '' - const isLightTheme = useIsLightTheme() - - return ( - - - - ) -} diff --git a/client/web/src/repo/blob/BlobPage.tsx b/client/web/src/repo/blob/BlobPage.tsx index 94e87886ac0..b806457b750 100644 --- a/client/web/src/repo/blob/BlobPage.tsx +++ b/client/web/src/repo/blob/BlobPage.tsx @@ -59,7 +59,6 @@ import { copyNotebook, type CopyNotebookProps } from '../../notebooks/notebook' import { OpenInEditorActionItem } from '../../open-in-editor/OpenInEditorActionItem' import type { OwnConfigProps } from '../../own/OwnConfigProps' import type { SearchStreamingProps } from '../../search' -import { useNotepad } from '../../stores' import { parseBrowserRepoURL, toTreeURL } from '../../util/url' import { serviceKindDisplayNameAndIcon } from '../actions/GoToCodeHostAction' import { ToggleBlameAction } from '../actions/ToggleBlameAction' @@ -151,23 +150,6 @@ export const BlobPage: React.FunctionComponent = ({ className, co props.telemetryService.logViewEvent('Blob', { repoName, filePath }) }, [repoName, commitID, filePath, renderMode, props.telemetryService]) - useNotepad( - useMemo( - () => ({ - type: 'file', - path: filePath, - repo: repoName, - revision, - // Need to subtract 1 because IHighlightLineRange is 0-based but - // line information in the URL is 1-based. - lineRange: lineOrRange.line - ? { startLine: lineOrRange.line - 1, endLine: (lineOrRange.endLine ?? lineOrRange.line) - 1 } - : null, - }), - [filePath, repoName, revision, lineOrRange.line, lineOrRange.endLine] - ) - ) - useBreadcrumb( useMemo(() => { if (!filePath) { diff --git a/client/web/src/search/Notepad.module.scss b/client/web/src/search/Notepad.module.scss deleted file mode 100644 index 9a1c8a02eca..00000000000 --- a/client/web/src/search/Notepad.module.scss +++ /dev/null @@ -1,101 +0,0 @@ -.root { - z-index: 100; - display: flex; - flex-direction: column; - overflow: hidden; - - color: var(--body-color); - background-color: var(--color-bg-1); - - border: 1px solid var(--border-color-2); - border-radius: var(--border-radius); - - box-shadow: var(--dropdown-shadow); - - // This is in a separate class so that we can render the widget in a - // different context with a different layout - &.fixed { - position: fixed; - right: 0.75rem; - bottom: 0.75rem; - - &.open { - width: 20rem; - } - } - - h2 { - font-size: 0.75rem; - margin: 0; - font-weight: 600; - } - - h3 { - font-size: 0.75rem; - margin: 0; - } - - ol { - padding: 0; - margin: 0; - list-style: none; - overflow-y: auto; - flex: 1; - - li:first-child { - border-top: 1px solid var(--border-color); - } - - li { - border-bottom: 1px solid var(--border-color); - } - } - - .entry { - display: block; - padding: 0.75rem 0.5rem; - font-size: 0.75rem; - overflow-wrap: anywhere; - background-color: var(--color-bg-0); - - &.selected { - background-color: var(--color-bg-2); - - :global(.theme-dark) & { - background-color: var(--color-bg-3); - } - } - } - - &.open { - max-height: 95vh; - } - - small { - color: var(--text-muted); - } -} - -.new-note { - background-color: var(--color-bg-2); - - :global(.theme-dark) & { - background-color: var(--color-bg-3); - } -} - -.header { - .open & { - border-radius: 0; - border-bottom: 1px solid var(--border-color-2); - - // Hides the item count in the header - small { - display: none; - } - - .toggle-icon { - transform: rotate(180deg); - } - } -} diff --git a/client/web/src/search/Notepad.story.tsx b/client/web/src/search/Notepad.story.tsx deleted file mode 100644 index 5ec7067d1e4..00000000000 --- a/client/web/src/search/Notepad.story.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import React, { useEffect } from 'react' - -import type { Meta, StoryFn, StoryObj } from '@storybook/react' - -import { SearchPatternType } from '@sourcegraph/shared/src/graphql-operations' -import { MockTemporarySettings } from '@sourcegraph/shared/src/settings/temporary/testUtils' - -import { WebStory } from '../components/WebStory' -import { useNotepadState } from '../stores' -import type { NotepadEntry, NotepadStore } from '../stores/notepad' - -import { NotepadContainer } from './Notepad' - -const NotepadWrapper: React.FunctionComponent<{ open?: boolean; enableNotepad?: boolean } & NotepadStore> = ({ - entries = [], - previousEntries = [], - canRestoreSession = false, - open = false, - enableNotepad = true, -}): React.ReactElement => { - useEffect(() => { - useNotepadState.setState({ entries, previousEntries, canRestoreSession }, true) - }, [entries, previousEntries, canRestoreSession]) - - return ( - - - - ) -} - -const META: Meta = { - title: 'web/search/Notepad', - component: NotepadWrapper, -} -export default META - -type Story = StoryObj - -const mockEntries: NotepadEntry[] = [ - { id: 0, type: 'search', query: 'TODO', caseSensitive: false, patternType: SearchPatternType.standard }, - { id: 1, type: 'file', path: 'path/to/file1', repo: 'my/repo', revision: 'master', lineRange: null }, - { - id: 2, - type: 'file', - path: 'path/to/a/really/deeply/nested/file/that/should/be/abbreviated/somehow', - repo: 'github.com/sourcegraph/sourcegraph', - revision: 'master', - lineRange: { startLine: 10, endLine: 11 }, - }, - { - id: 3, - type: 'search', - query: 'file:ts$ a really long search query that should wrap', - caseSensitive: false, - patternType: SearchPatternType.standard, - }, -] - -const Template: StoryFn = args => {() => } - -export const NotepadClosed: Story = Template.bind({}) -NotepadClosed.args = { - entries: mockEntries, -} - -export const NotepadClosedEmpty: Story = Template.bind({}) -NotepadClosedEmpty.args = { - entries: [], -} - -export const NotepadOpen: Story = Template.bind({}) -NotepadOpen.args = { - entries: mockEntries, - open: true, -} - -export const NotepadRestorePreviousSession: Story = Template.bind({}) -NotepadRestorePreviousSession.args = { - entries: mockEntries, - open: true, - canRestoreSession: true, -} - -export const NotepadManyEntries: Story = Template.bind({}) -NotepadManyEntries.args = { - entries: Array.from({ length: 50 }, (_element, index) => ({ - id: index, - type: 'search', - query: `TODO${index}`, - caseSensitive: false, - patternType: SearchPatternType.standard, - })), - open: true, -} diff --git a/client/web/src/search/Notepad.test.tsx b/client/web/src/search/Notepad.test.tsx deleted file mode 100644 index 5462b92d1ae..00000000000 --- a/client/web/src/search/Notepad.test.tsx +++ /dev/null @@ -1,563 +0,0 @@ -import { act, cleanup, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { of } from 'rxjs' -import { spy } from 'sinon' -import { vi, afterEach, beforeEach, describe, expect, it } from 'vitest' - -import { SearchPatternType } from '@sourcegraph/shared/src/graphql-operations' -import { MockTemporarySettings } from '@sourcegraph/shared/src/settings/temporary/testUtils' -import { MockedTestProvider } from '@sourcegraph/shared/src/testing/apollo' -import { type RenderWithBrandedContextResult, renderWithBrandedContext } from '@sourcegraph/wildcard/src/testing' - -import type { NotebookFields } from '../graphql-operations' -import * as backend from '../notebooks/backend' -import { useNotepadState } from '../stores' -import { addNotepadEntry, type NotepadEntry } from '../stores/notepad' - -import { NotepadContainer, type NotepadProps } from './Notepad' - -describe('Notepad', () => { - const renderNotepad = (props?: Partial, enabled = true): RenderWithBrandedContextResult => - renderWithBrandedContext( - - - - - - ) - - function open() { - userEvent.click(screen.getByRole('button', { name: /Open Notepad/ })) - } - - // Notepad is hidden by default on small screens. jest-dom has no concept of - // screen size, so this needs to be explicitly overriden to ensure that the - // component renders. - window.matchMedia = spy( - (media: string): MediaQueryList => ({ - matches: true, - media, - addListener: () => {}, - removeListener: () => {}, - addEventListener: () => {}, - removeEventListener: () => {}, - dispatchEvent: () => false, - onchange: null, - }) - ) - - afterEach(cleanup) - - const mockEntries: NotepadEntry[] = [ - { id: 0, type: 'search', query: 'TODO', caseSensitive: false, patternType: SearchPatternType.standard }, - { - id: 1, - type: 'file', - path: 'path/to/file', - repo: 'test', - revision: 'master', - lineRange: null, - annotation: '', - }, - ] - - describe('closed state', () => { - it('does not render anything if feature is disabled dand there are no notes', () => { - renderNotepad({}, false) - - expect(screen.queryByRole('complementary')).not.toBeInTheDocument() - }) - - it('shows button to open notepad', () => { - useNotepadState.setState({ entries: mockEntries }) - - renderNotepad() - - expect(screen.queryByRole('button', { name: /Open Notepad/ })).toBeInTheDocument() - }) - }) - - describe('restore previous session', () => { - it('restores the previous session', () => { - useNotepadState.setState({ - entries: [], - previousEntries: mockEntries, - canRestoreSession: true, - addableEntry: mockEntries[0], - }) - renderNotepad() - userEvent.click(screen.getByRole('button', { name: /Open Notepad/ })) - - userEvent.click(screen.getByRole('button', { name: 'Restore last session' })) - expect(useNotepadState.getState().entries).toEqual(mockEntries) - }) - }) - - describe('with notes', () => { - beforeEach(() => { - useNotepadState.setState({ - entries: [ - { - id: 0, - type: 'search', - query: 'TODO', - caseSensitive: false, - patternType: SearchPatternType.standard, - }, - { id: 1, type: 'file', path: 'path/to/file', repo: 'test', revision: 'master', lineRange: null }, - ], - }) - }) - - it('opens and closes', () => { - renderNotepad() - - userEvent.click(screen.getByRole('button', { name: /Open Notepad/ })) - userEvent.click(screen.getByRole('button', { name: /Close Notepad/ })) - - expect(screen.queryByRole('button', { name: /Open Notepad/ })).toBeInTheDocument() - }) - - it('redirects to notes', () => { - renderNotepad() - open() - - const entryLinks = screen.queryAllByRole('link') - - // Entries are in reverse order - expect(entryLinks[0]).toHaveAttribute('href', '/test@master/-/blob/path/to/file') - expect(entryLinks[1]).toHaveAttribute('href', '/search?q=TODO&patternType=standard&sm=0') - }) - - it('creates notebooks', () => { - const createNotebookSpy = vi - .spyOn(backend, 'createNotebook') - .mockImplementation(() => of({} as unknown as NotebookFields)) - - renderNotepad() - open() - - act(() => { - userEvent.click(screen.getByRole('button', { name: 'Create Notebook' })) - }) - - expect(createNotebookSpy).toBeCalledTimes(1) - }) - - it('allows to delete entries', () => { - renderNotepad() - open() - - userEvent.click(screen.getAllByRole('button', { name: 'Remove entry' })[0]) - const entryLinks = screen.queryByRole('link') - expect(entryLinks).toBeInTheDocument() - }) - - it('opens the text annotation input', () => { - renderNotepad() - open() - - userEvent.click(screen.getAllByRole('button', { name: 'Add annotation' })[0]) - expect(screen.queryByPlaceholderText('Type to add annotation...')).toBeInTheDocument() - }) - - it('closes annotation input on Meta+Enter', () => { - renderNotepad() - open() - - userEvent.click(screen.getAllByRole('button', { name: 'Add annotation' })[0]) - userEvent.type(screen.getByPlaceholderText('Type to add annotation...'), 'test') - userEvent.keyboard('{ctrl}{enter}') - - expect(screen.queryByPlaceholderText('Type to add annotation...')).not.toBeInTheDocument() - }) - }) - - describe('selection', () => { - beforeEach(() => { - useNotepadState.setState({ - entries: [ - { - id: 1, - type: 'search', - query: 'TODO', - caseSensitive: false, - patternType: SearchPatternType.standard, - }, - { id: 2, type: 'file', path: 'path/to/file', repo: 'test', revision: 'master', lineRange: null }, - { - id: 3, - type: 'search', - query: 'another query', - caseSensitive: true, - patternType: SearchPatternType.standard, - }, - { - id: 4, - type: 'search', - query: 'yet another query', - caseSensitive: true, - patternType: SearchPatternType.standard, - }, - ], - }) - }) - - it('selects an item on click or space', () => { - renderNotepad() - open() - - const item = screen.getAllByRole('listitem') - // item1 <- - // item2 - // item3 - // item4 - userEvent.click(item[0]) - // item1 - // item2 <- - // item3 - // item4 - userEvent.click(item[1]) - expect(screen.queryAllByRole('listitem', { name: /^Selected/ })).toEqual([item[1]]) - - item[2].focus() - // item1 - // item2 - // item3 <- - // item4 - userEvent.keyboard('{space}') - - expect(screen.getByRole('listitem', { name: /^Selected/ })).toBe(item[2]) - }) - - it('selects multiple items on ctrl/meta+click/space', () => { - renderNotepad() - open() - - const item = screen.getAllByRole('listitem') - // item1 <- - // item2 - // item3 - // item4 - userEvent.click(item[0]) - // item1 <- - // item2 <- - // item3 - // item4 - userEvent.click(item[1], { ctrlKey: true }) - expect(screen.queryAllByRole('listitem', { name: /^Selected/ })).toEqual([item[0], item[1]]) - - item[3].focus() - // item1 <- - // item2 <- - // item3 - // item4 <- - userEvent.keyboard('{ctrl}{space}') - - expect(screen.queryAllByRole('listitem', { name: /^Selected/ })).toEqual([item[0], item[1], item[3]]) - }) - - it('selects a range of items on shift+click', () => { - renderNotepad() - open() - - const item = screen.getAllByRole('listitem') - // item1 <- - // item2 - // item3 - // item4 - userEvent.click(item[0]) - // item1 <- (last) - // item2 <- - // item3 <- - // item4 - userEvent.click(item[2], { shiftKey: true }) - - expect(screen.queryAllByRole('listitem', { name: /^Selected/ })).toEqual([item[0], item[1], item[2]]) - }) - - it('extends the range of items on shift+click', () => { - renderNotepad() - open() - - const item = screen.getAllByRole('listitem') - // item1 - // item2 <- - // item3 - // item4 - userEvent.click(item[1]) - // item1 - // item2 <- (last) - // item3 <- - // item4 <- - userEvent.click(item[3], { shiftKey: true }) - // Shift click always adds! - // item1 <- - // item2 <- - // item3 <- - // item4 <- (last) - userEvent.click(item[0], { shiftKey: true }) - - expect(screen.queryAllByRole('listitem', { name: /^Selected/ })).toEqual(item) - }) - - it('selects a range of items on shift+space', () => { - renderNotepad() - open() - - const item = screen.getAllByRole('listitem') - // item1 <- - // item2 - // item3 - // item4 - item[0].focus() - userEvent.keyboard('{space}') - // item1 <- (last) - // item2 <- - // item3 <- - // item4 - item[2].focus() - userEvent.keyboard('{shift}{space}') - - expect(screen.queryAllByRole('listitem', { name: /^Selected/ })).toEqual([item[0], item[1], item[2]]) - }) - - it('extends the range of items on shift+space', () => { - renderNotepad() - open() - - const item = screen.getAllByRole('listitem') - // item1 - // item2 <- - // item3 - // item4 - item[1].focus() - userEvent.keyboard('{space}') - // item1 - // item2 <- (last) - // item3 <- - // item4 <- - item[3].focus() - userEvent.keyboard('{shift}{space}') - // Shift click always adds! - // item1 <- - // item2 <- - // item3 <- - // item4 <- (last) - item[0].focus() - userEvent.keyboard('{shift}{space}') - - expect(screen.queryAllByRole('listitem', { name: /^Selected/ })).toEqual(item) - }) - - it('selects all items on ctrl+a', () => { - renderNotepad() - open() - - const list = screen.getByRole('list') - const items = screen.getAllByRole('listitem') - - list.focus() - userEvent.keyboard('{ctrl}{a}') - expect(screen.queryAllByRole('listitem', { name: /^Selected/ })).toEqual(items) - }) - - it('selects the next item on arrow-down', () => { - renderNotepad() - open() - - const list = screen.getByRole('list') - const items = screen.getAllByRole('listitem') - - list.focus() - userEvent.keyboard('{arrowdown}') - expect(screen.queryAllByRole('listitem', { name: /^Selected/ })).toEqual([items[0]]) - expect(items[0]).toHaveFocus() - - userEvent.keyboard('{arrowdown}') - expect(screen.queryAllByRole('listitem', { name: /^Selected/ })).toEqual([items[1]]) - expect(items[1]).toHaveFocus() - }) - - it('selects the previous item on arrow-up', () => { - renderNotepad() - open() - - const list = screen.getByRole('list') - const items = screen.getAllByRole('listitem') - - list.focus() - userEvent.keyboard('{arrowup}') - expect(screen.queryAllByRole('listitem', { name: /^Selected/ })).toEqual([items[3]]) - expect(items[3]).toHaveFocus() - - userEvent.keyboard('{arrowup}') - expect(screen.queryAllByRole('listitem', { name: /^Selected/ })).toEqual([items[2]]) - expect(items[2]).toHaveFocus() - }) - - it('extends/shrinks selection on shift+arrow-down/up', () => { - renderNotepad() - open() - - const list = screen.getByRole('list') - const items = screen.getAllByRole('listitem') - - list.focus() - userEvent.keyboard('{arrowdown}') - userEvent.keyboard('{shift}{arrowdown}') - expect(screen.queryAllByRole('listitem', { name: /^Selected/ })).toEqual([items[0], items[1]]) - - userEvent.keyboard('{shift}{arrowup}') - expect(screen.queryAllByRole('listitem', { name: /^Selected/ })).toEqual([items[0]]) - }) - - it('skips over selected notes using shift+arrow-down', () => { - renderNotepad() - open() - - const items = screen.getAllByRole('listitem') - - userEvent.click(items[2], { ctrlKey: true }) // select 3. item - userEvent.click(items[0], { ctrlKey: true }) // select 1. item - - userEvent.keyboard('{shift}{arrowdown}') // selects 2. item - userEvent.keyboard('{shift}{arrowdown}') // selects 4. item - - expect(screen.queryAllByRole('listitem', { name: /^Selected/ })).toEqual([ - items[0], - items[1], - items[2], - items[3], - ]) - }) - - it('skips over selected notes using shift+arrow-up', () => { - renderNotepad() - open() - - const items = screen.getAllByRole('listitem') - - userEvent.click(items[1], { ctrlKey: true }) // select 2. item - userEvent.click(items[3], { ctrlKey: true }) // select 4. item - - userEvent.keyboard('{shift}{arrowdown}') // selects 3. item - userEvent.keyboard('{shift}{arrowdown}') // selects 1. item - - expect(screen.queryAllByRole('listitem', { name: /^Selected/ })).toEqual([ - items[0], - items[1], - items[2], - items[3], - ]) - }) - - it('extends/shrinks selection on shift+arrow-up/down', () => { - renderNotepad() - open() - - const list = screen.getByRole('list') - const items = screen.getAllByRole('listitem') - - list.focus() - userEvent.keyboard('{arrowup}') - userEvent.keyboard('{shift}{arrowup}') - expect(screen.queryAllByRole('listitem', { name: /^Selected/ })).toEqual([items[2], items[3]]) - - userEvent.keyboard('{shift}{arrowdown}') - expect(screen.queryAllByRole('listitem', { name: /^Selected/ })).toEqual([items[3]]) - }) - - it('maintains the right selected items when non-selected items get removed', () => { - renderNotepad() - open() - - const items = screen.getAllByRole('listitem') - userEvent.click(items[1]) - userEvent.click(screen.getAllByTitle('Remove entry')[0]) - - // Verifies that the item is still the selected one (if not it would - // item[2] which is now the second item). - expect(screen.queryAllByRole('listitem', { name: /^Selected/ })).toEqual([items[1]]) - }) - - it('selectes the newly added item', () => { - renderNotepad() - open() - - let items = screen.getAllByRole('listitem') - - // Selected 2. item - userEvent.click(items[1]) - - act(() => { - addNotepadEntry({ - type: 'search', - patternType: SearchPatternType.standard, - query: 'new TODO', - caseSensitive: false, - }) - }) - - // Referesh items - items = screen.getAllByRole('listitem') - - expect(screen.queryAllByRole('listitem', { name: /^Selected/ })).toEqual([items[0]]) - }) - - it('deletes a single entry even if others are selected', () => { - renderNotepad() - open() - - const item = screen.getAllByRole('listitem') - userEvent.click(item[0]) - userEvent.click(item[2], { shiftKey: true }) - userEvent.click(screen.queryAllByRole('button', { name: 'Remove entry' })[0]) - - expect(screen.queryAllByRole('listitem').length).toBe(3) - }) - - it('deletes all selected notes when Delete is pressed', () => { - renderNotepad() - open() - - const item = screen.getAllByRole('listitem') - userEvent.click(item[0]) - userEvent.click(item[2], { shiftKey: true }) - userEvent.keyboard('{delete}') - - expect(screen.queryAllByRole('listitem').length).toBe(1) - }) - - it('clears selection on ESC', () => { - renderNotepad() - open() - - const item = screen.getAllByRole('listitem') - userEvent.click(item[0]) - expect(screen.queryAllByRole('listitem', { name: /^Selected/ }).length).toBe(1) - - userEvent.keyboard('{escape}') - expect(screen.queryAllByRole('listitem', { name: /^Selected/ }).length).toBe(0) - }) - - it('does not select entry on toggle annotion click', () => { - renderNotepad() - open() - - userEvent.click(screen.queryAllByRole('button', { name: 'Add annotation' })[0]) - - expect(screen.queryByRole('listitem', { name: /^Selected/ })).not.toBeInTheDocument() - }) - - it('does not select note on typing space into the annotation area', () => { - renderNotepad() - open() - - userEvent.click(screen.queryAllByRole('button', { name: 'Add annotation' })[0]) - userEvent.type(screen.getByPlaceholderText('Type to add annotation...'), '{space}') - - expect(screen.queryByRole('listitem', { name: /^Selected/ })).not.toBeInTheDocument() - }) - }) -}) diff --git a/client/web/src/search/Notepad.tsx b/client/web/src/search/Notepad.tsx deleted file mode 100644 index 6c3c124b908..00000000000 --- a/client/web/src/search/Notepad.tsx +++ /dev/null @@ -1,952 +0,0 @@ -import React, { - useCallback, - useState, - useMemo, - type KeyboardEvent, - type SyntheticEvent, - type MouseEvent, - useRef, - useEffect, - useLayoutEffect, -} from 'react' - -import { - mdiBookPlusOutline, - mdiChevronUp, - mdiDelete, - mdiMagnify, - mdiFileDocumentOutline, - mdiCodeBrackets, - mdiTextBox, -} from '@mdi/js' -import classNames from 'classnames' -import type { LocationDescriptorObject } from 'history' -import { useNavigate } from 'react-router-dom' -import * as uuid from 'uuid' - -import { SyntaxHighlightedSearchQuery } from '@sourcegraph/branded' -import { isMacPlatform, logger } from '@sourcegraph/common' -import { type HighlightLineRange, SearchPatternType } from '@sourcegraph/shared/src/graphql-operations' -import { FilterType } from '@sourcegraph/shared/src/search/query/filters' -import { appendContextFilter, updateFilter } from '@sourcegraph/shared/src/search/query/transformer' -import { useTemporarySetting } from '@sourcegraph/shared/src/settings/temporary/useTemporarySetting' -import { buildSearchURLQuery, toPrettyBlobURL } from '@sourcegraph/shared/src/util/url' -import { Button, Link, TextArea, Icon, H2, H3, Text, createLinkUrl, useMatchMedia } from '@sourcegraph/wildcard' - -import { useSidebarSize } from '../cody/sidebar/useSidebarSize' -import type { BlockInput } from '../notebooks' -import { createNotebook } from '../notebooks/backend' -import { blockToGQLInput } from '../notebooks/serialize' -import { EnterprisePageRoutes } from '../routes.constants' -import { - addNotepadEntry, - type NotepadEntry, - type NotepadEntryID, - type NotepadEntryInput, - removeAllNotepadEntries, - removeFromNotepad, - restorePreviousSession, - type SearchEntry, - setEntryAnnotation, - useNotepadState, -} from '../stores/notepad' - -import styles from './Notepad.module.scss' - -const NOTEPAD_ID = 'search:notepad' - -function isMacMetaKey(event: KeyboardEvent, isMacPlatform: boolean): boolean { - return isMacPlatform && event.metaKey -} - -function isMetaKey(event: KeyboardEvent, isMacPlatform: boolean): boolean { - return isMacMetaKey(event, isMacPlatform) || (!isMacPlatform && event.ctrlKey) -} - -/** - * This handler is used on mousedown to prevent text selection when multiple - * entries are selected with Shift+click. - * (tested in Firefox and Chromium) - */ -function preventTextSelection(event: MouseEvent | KeyboardEvent): void { - if (event.shiftKey) { - event.preventDefault() - } -} - -/** - * Helper hook to determine whether a new entry has been added to the notepad. - * Whenever the number of entries increases we have a new entry. It assumes that - * the newest entry is the first element in the input array. - */ -function useHasNewEntry(entries: NotepadEntry[]): boolean { - const previousLength = useRef() - - const previous = previousLength.current - previousLength.current = entries.length - - return previous !== undefined && previous < entries.length -} - -export const NotepadIcon: React.FunctionComponent> = () => ( - -) - -export interface NotepadContainerProps { - initialOpen?: boolean - userId?: string - isRepositoryRelatedPage?: boolean -} - -export const NotepadContainer: React.FunctionComponent> = ({ - initialOpen, - userId, - isRepositoryRelatedPage, -}) => { - const newEntry = useNotepadState(state => state.addableEntry) - const entries = useNotepadState(state => state.entries) - const canRestore = useNotepadState(state => state.canRestoreSession) - const [enableNotepad] = useTemporarySetting('search.notepad.enabled') - // Taken from global-styles/breakpoints.css , $viewport-md - const isWideScreen = useMatchMedia('(min-width: 768px)') - - if (enableNotepad && isWideScreen) { - return ( - - ) - } - - return null -} -export interface NotepadProps { - className?: string - initialOpen?: boolean - newEntry?: NotepadEntryInput | null - entries: NotepadEntry[] - addEntry: typeof addNotepadEntry - removeEntry: (ids: NotepadEntryID[] | NotepadEntryID) => void - restorePreviousSession?: () => void - // This is only used in our CTA to prevent notes from being rendered as - // selected - selectable?: boolean - userId?: string - isRepositoryRelatedPage?: boolean -} - -export const Notepad: React.FunctionComponent> = ({ - className, - initialOpen = false, - entries, - restorePreviousSession, - addEntry, - removeEntry, - newEntry, - selectable = true, - userId, - isRepositoryRelatedPage, -}) => { - const navigate = useNavigate() - - const [open, setOpen] = useState(initialOpen) - const [selectedEntries, setSelectedEntries] = useState([]) - const isMacPlatform_ = useMemo(isMacPlatform, []) - - const reversedEntries = useMemo(() => [...entries].reverse(), [entries]) - const hasNewEntry = useHasNewEntry(reversedEntries) - - useLayoutEffect(() => { - if (hasNewEntry && selectable) { - // Always select the new entry. This is also avoids problems with - // getting the selection index out of sync. - setSelectedEntries([0]) - } - }, [hasNewEntry, selectable]) - - const toggleSelectedEntry = useCallback( - (position: number, event: MouseEvent | KeyboardEvent) => { - const { ctrlKey, metaKey, shiftKey } = event - - setSelectedEntries(selectedEntries => { - if (shiftKey) { - // Select range. The range of entries is always computed - // from the last selected entry. - return extendSelection(selectedEntries, position) - } - - // Normal (de)selection, taking into account modifier keys for - // multiple selection. - // If multiple entries are selected then selecting - // (without ctrl/cmd/shift) an already selected entry will - // just select that entry. - return toggleSelection( - selectedEntries, - position, - (isMacPlatform_ && metaKey) || (!isMacPlatform_ && ctrlKey) - ) - }) - }, - [setSelectedEntries, isMacPlatform_] - ) - - const deleteSelectedEntries = useCallback(() => { - if (selectedEntries.length > 0) { - const entryIDs = selectedEntries.map(index => reversedEntries[index].id) - removeFromNotepad(entryIDs) - // Clear selection for now. - setSelectedEntries([]) - } - }, [reversedEntries, selectedEntries, setSelectedEntries]) - - const deleteEntry = useCallback( - (toDelete: NotepadEntry) => { - if (selectedEntries.length > 0) { - const entryPosition = reversedEntries.findIndex(entry => entry.id === toDelete.id) - setSelectedEntries(selection => adjustSelection(selection, entryPosition)) - } - removeEntry([toDelete.id]) - }, - [reversedEntries, selectedEntries, setSelectedEntries, removeEntry] - ) - - const handleCreateNotebook = useCallback(() => { - if (!userId) { - return - } - - const blocks: BlockInput[] = [] - for (const entry of entries) { - if (entry.annotation) { - blocks.push({ type: 'md', input: { text: entry.annotation } }) - } - switch (entry.type) { - case 'search': { - blocks.push({ type: 'query', input: { query: toSearchQuery(entry) } }) - break - } - case 'file': { - blocks.push({ - type: 'file', - input: { - repositoryName: entry.repo, - revision: entry.revision, - filePath: entry.path, - // Notebooks expect the end line to be exclusive - lineRange: entry.lineRange - ? { ...entry.lineRange, endLine: entry.lineRange?.endLine + 1 } - : null, - }, - }) - break - } - } - } - - createNotebook({ - notebook: { - title: 'New Notebook', - blocks: blocks.map(block => blockToGQLInput({ id: uuid.v4(), ...block })), - public: false, - namespace: userId, - }, - }) - .toPromise() - .then(createdNotebook => { - navigate(EnterprisePageRoutes.Notebook.replace(':id', createdNotebook.id)) - }) - .catch(logger.error) - }, [entries, userId, navigate]) - - const toggleOpen = useCallback(() => { - setOpen(open => { - if (open) { - // clear selected entries on close - setSelectedEntries([]) - } - return !open - }) - }, [setSelectedEntries, setOpen]) - - // Handles key events on the whole list - const handleKey = useCallback( - (event: KeyboardEvent): void => { - const hasMacMeta = isMacMetaKey(event, isMacPlatform_) - const hasMeta = isMetaKey(event, isMacPlatform_) - - if (document.activeElement && document.activeElement.tagName === 'TEXTAREA') { - // Ignore any events originating from an annotations input - return - } - - switch (event.key) { - // Select all entries - case 'a': { - if (hasMeta) { - // This prevents text selection - event.preventDefault() - setSelectedEntries(reversedEntries.map((_value, index) => index)) - } - break - } - // Clear selection - case 'Escape': { - if (selectedEntries.length > 0) { - setSelectedEntries([]) - } - break - } - // Delete selected entries - case 'Delete': { - if (selectedEntries.length > 0) { - deleteSelectedEntries() - } - break - } - // On macOS we also support CMD+Backpace for deletion - case 'Backspace': { - if (hasMacMeta && selectedEntries.length > 0) { - deleteSelectedEntries() - } - break - } - // Select "next" entry - case 'ArrowUp': - case 'ArrowDown': { - const { shiftKey, key } = event - - if (shiftKey) { - // This prevents text selection - event.preventDefault() - } - - setSelectedEntries(selection => { - if (shiftKey || hasMeta) { - // Extend (or shrink) selected entries range - // Shift and ctrl modifier are equivalent in this scenario - return growOrShrinkSelection( - selection, - key === 'ArrowDown' ? 'DOWN' : 'UP', - reversedEntries.length - ) - } - if (selection.length > 0) { - // Select next entry - return toggleSelection( - selection, - wrapPosition(selection.at(-1)! + (key === 'ArrowDown' ? 1 : -1), reversedEntries.length) - ) - } - if (reversedEntries.length > 0) { - // Select default (bottom or top) entry - return [key === 'ArrowDown' ? 0 : reversedEntries.length - 1] - } - - return selection - }) - break - } - } - }, - [reversedEntries, selectedEntries, deleteSelectedEntries, isMacPlatform_] - ) - - // Focus the cancel button when the remove all confirmation box is shown - const [confirmRemoveAll, setConfirmRemoveAll] = useState(false) - const cancelRemoveAll = useRef(null) - useEffect(() => { - if (confirmRemoveAll) { - cancelRemoveAll.current?.focus() - } - }, [confirmRemoveAll]) - - // Focus the remove all button when the remove all confirmation box is hidden. - // If the remove all button is now disabled (because there are no entries left), - // focus the top-level notepad button. - const removeAllButton = useRef(null) - const rootButton = useRef(null) - const onRemoveAllClosed = useCallback((removeAll: boolean) => { - setConfirmRemoveAll(false) - - if (removeAll) { - removeAllNotepadEntries() - rootButton.current?.focus() - } else { - removeAllButton.current?.focus() - } - }, []) - - // HACK: This is temporary fix for the overlapping Notepad icon until we either disable notepad - // or move Cody to the top level and mount the Notepad entrypoint inside it - const { sidebarSize: codySidebarWidth } = useSidebarSize() - - return ( - - ) -} - -interface AddEntryButtonProps { - entry: NotepadEntryInput - addEntry: typeof addNotepadEntry -} - -const AddEntryButton: React.FunctionComponent> = ({ entry, addEntry }) => { - let button: React.ReactElement - switch (entry.type) { - case 'search': { - button = ( - - ) - break - } - case 'file': { - button = ( - - - {entry.lineRange && ( - - )} - - ) - } - } - - const { title } = getUIComponentsForEntry(entry) - - return ( - <> -
{title}
- {button} - - ) -} - -function stopPropagation(event: SyntheticEvent): void { - event.stopPropagation() -} - -interface NotepadEntryComponentProps { - entry: NotepadEntry - /** - * If set to true, show and focus the annotations input. - */ - focus: boolean - selected: boolean - onDelete: (entry: NotepadEntry) => void - index: number -} - -const NotepadEntryComponent: React.FunctionComponent> = ({ - entry, - focus = false, - selected, - onDelete, - index, -}) => { - const { icon, title, location } = getUIComponentsForEntry(entry) - const [annotation, setAnnotation] = useState(entry.annotation ?? '') - const [showAnnotationInput, setShowAnnotationInput] = useState(focus) - const textarea = useRef(null) - - // Focus annotation input when the whenever it is opened. - useEffect(() => { - if (showAnnotationInput) { - textarea.current?.focus() - } - }, [showAnnotationInput]) - - // Focus entry when selected. - useEffect(() => { - if (selected) { - const element = document.querySelector(`[data-notepad-entry-index="${index}"]`) as HTMLElement - element?.focus() - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selected]) - - const toggleAnnotationInput = useCallback( - (show: boolean) => { - setShowAnnotationInput(show) - if (!show) { - // Persist the entry annotation when hiding the annotation input. - setEntryAnnotation(entry, annotation) - } - }, - [entry, annotation, setShowAnnotationInput] - ) - - return ( -
-
- {selected ? 'Selected, ' : ''} - {icon} - - - {title} - - - - - - -
- {showAnnotationInput && ( -