[Notebooks]: Remove notepad UI from notebooks feature (#58217)

* Remove notepad UI from notebooks feature

* Update CHANGELOG.md
This commit is contained in:
Vova Kulikov 2023-11-09 14:28:58 -03:00 committed by GitHub
parent 1d67b44f8d
commit a6debeb00a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 11 additions and 2358 deletions

View File

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

View File

@ -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<keyof TemporarySettings, null> = {
'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,

View File

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

View File

@ -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<LegacyLayoutProps> = 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<LegacyLayoutProps> = 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 <ApplicationRoutes routes={props.routes} />
@ -281,12 +279,6 @@ export const LegacyLayout: FC<LegacyLayoutProps> = props => {
extensionsController={props.extensionsController}
platformContext={props.platformContext}
/>
{(isSearchNotebookListPage || (isSearchRelatedPage && !isSearchHomepage)) && (
<NotepadContainer
userId={props.authenticatedUser?.id}
isRepositoryRelatedPage={isRepositoryRelatedPage}
/>
)}
{fuzzyFinder && (
<LazyFuzzyFinder
isVisible={isFuzzyFinderVisible}

View File

@ -174,34 +174,6 @@ export const NotebooksGettingStartedTab: React.FunctionComponent<
</Container>
</div>
</div>
<H3>Powerful creation features</H3>
<Container className="mb-4">
<div className={classNames(styles.row, 'row', 'mb-4')}>
<div className="col-12 col-md-6">
<strong>Enable the notepad for frictionless knowledge sharing</strong>
<Text className="mt-1">
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.
</Text>
<strong>Compose rich documentation with multiple block types</strong>
<Text className="mt-1">
Create text content with Markdown blocks, track symbols within files with symbol blocks, and
add whole files or line ranges with file blocks.
</Text>
</div>
<div className="col-12 col-md-6">
<video
className="w-100 h-auto shadow percy-hide"
muted={true}
playsInline={true}
controls={true}
src={`https://storage.googleapis.com/sourcegraph-assets/notebooks/notepad_overview_${
isLightTheme ? 'light' : 'dark'
}.mp4`}
/>
</div>
</div>
</Container>
<H3>Functionality</H3>
<div className={classNames(styles.row, 'row', 'mb-4')}>
{functionalityPanels.map(panel => (

View File

@ -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 (
<>
<ToggleNotepadButton telemetryService={telemetryService} className="mr-2 d-none d-md-inline" />
{/* The file upload input has to always be present in the DOM, otherwise the upload process
does not complete when the menu below closes. */}
<Input
@ -126,52 +120,3 @@ export const NotebooksListPageHeader: React.FunctionComponent<
</>
)
}
export const NOTEPAD_ENABLED_EVENT = 'SearchNotepadEnabled'
const NOTEPAD_DISABLED_EVENT = 'SearchNotepadDisabled'
const ToggleNotepadButton: React.FunctionComponent<
React.PropsWithChildren<TelemetryProps & { className?: string }>
> = ({ 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 (
<>
<Button variant="secondary" type="button" onClick={onClick} className={className}>
<NotepadIcon /> {notepadEnabled ? 'Disable' : 'Enable'} notepad
</Button>
{showCTA && (
<Modal aria-labelledby={NOTEPAD_CTA_ID}>
<NotepadCTA onEnable={onEnableFromCTA} onCancel={onCancelFromCTA} />
</Modal>
)}
</>
)
}

View File

@ -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<React.PropsWithChildren<NotepadCTAProps>> = ({
onEnable,
onCancel,
}) => {
const assetsRoot = window.context?.assetsRoot || ''
const isLightTheme = useIsLightTheme()
return (
<div>
<H3 id={NOTEPAD_CTA_ID} className="d-inline-block">
<NotepadIcon /> Enable notepad
</H3>{' '}
<ProductStatusBadge status="beta" />
<div className="d-flex align-items-center">
<img
className="flex-shrink-0 mr-3"
src={`${assetsRoot}/img/notepad-illustration-${isLightTheme ? 'light' : 'dark'}.svg`}
alt="notepad illustration"
/>
<Text>
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.
</Text>
</div>
<div className="float-right mt-2">
<Button className="mr-2" variant="secondary" size="sm" onClick={onCancel}>
Cancel
</Button>
<Button variant="primary" onClick={onEnable} size="sm">
Enable notepad
</Button>
</div>
</div>
)
}

View File

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

View File

@ -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<React.PropsWithChildren<Noteb
const [notebookTitle, setNotebookTitle] = useState('')
const [updateQueue, setUpdateQueue] = useState<Partial<NotebookInput>[]>([])
const outlineContainerElement = useRef<HTMLDivElement | null>(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<React.PropsWithChildren<Noteb
[notebookTitle, copyNotebook]
)
const showNotepadCTA = useMemo(
() =>
!notepadEnabled &&
!notepadCTASeen &&
isNotebookLoaded(latestNotebook) &&
latestNotebook.blocks.length === 0,
[latestNotebook, notepadCTASeen, notepadEnabled]
)
const stickyBox = useStickyBox()
return (
@ -321,65 +294,7 @@ export const NotebookPage: React.FunctionComponent<React.PropsWithChildren<Noteb
</>
)}
</div>
<div className={styles.spacer}>
{showNotepadCTA && (
<NotepadCTA
onEnable={() => {
telemetryService.log(NOTEPAD_ENABLED_EVENT)
setNotepadCTASeen(true)
setNotepadEnabled(true)
}}
onClose={() => setNotepadCTASeen(true)}
/>
)}
</div>
</div>
</div>
)
}
interface NotepadCTAProps {
onEnable: () => void
onClose: () => void
}
const NotepadCTA: React.FunctionComponent<React.PropsWithChildren<NotepadCTAProps>> = ({ onEnable, onClose }) => {
const assetsRoot = window.context?.assetsRoot || ''
const isLightTheme = useIsLightTheme()
return (
<MarketingBlock wrapperClassName={classNames(styles.notepadCta, 'd-none d-md-block')}>
<aside className={styles.notepadCtaContent}>
<Button
aria-label="Hide"
variant="icon"
onClick={onClose}
size="sm"
className={styles.notepadCtaCloseButton}
>
<Icon aria-hidden={true} svgPath={mdiClose} />
</Button>
<img
className="flex-shrink-0 mr-3"
src={`${assetsRoot}/img/notepad-illustration-${isLightTheme ? 'light' : 'dark'}.svg`}
alt=""
/>
<div>
<H3 className="d-inline-block">
<NotepadIcon /> Enable notepad
</H3>{' '}
<ProductStatusBadge status="beta" />
<Text>
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.
</Text>
<Text>
<Button variant="primary" onClick={onEnable} size="sm">
Enable notepad
</Button>
</Text>
</div>
</aside>
</MarketingBlock>
)
}

View File

@ -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<BlobPageProps> = ({ 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) {

View File

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

View File

@ -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 (
<MockTemporarySettings settings={{ 'search.notepad.enabled': enableNotepad }}>
<NotepadContainer initialOpen={open} />
</MockTemporarySettings>
)
}
const META: Meta<typeof NotepadWrapper> = {
title: 'web/search/Notepad',
component: NotepadWrapper,
}
export default META
type Story = StoryObj<typeof META>
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<typeof NotepadWrapper> = args => <WebStory>{() => <NotepadWrapper {...args} />}</WebStory>
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,
}

View File

@ -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<NotepadProps>, enabled = true): RenderWithBrandedContextResult =>
renderWithBrandedContext(
<MockedTestProvider mocks={[]}>
<MockTemporarySettings settings={{ 'search.notepad.enabled': enabled }}>
<NotepadContainer userId="testID" {...props} />
</MockTemporarySettings>
</MockedTestProvider>
)
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()
})
})
})

View File

@ -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<number>()
const previous = previousLength.current
previousLength.current = entries.length
return previous !== undefined && previous < entries.length
}
export const NotepadIcon: React.FunctionComponent<React.PropsWithChildren<unknown>> = () => (
<Icon aria-hidden={true} svgPath={mdiBookPlusOutline} />
)
export interface NotepadContainerProps {
initialOpen?: boolean
userId?: string
isRepositoryRelatedPage?: boolean
}
export const NotepadContainer: React.FunctionComponent<React.PropsWithChildren<NotepadContainerProps>> = ({
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 (
<Notepad
className={styles.fixed}
initialOpen={initialOpen}
newEntry={newEntry}
entries={entries}
userId={userId}
restorePreviousSession={canRestore ? restorePreviousSession : undefined}
addEntry={addNotepadEntry}
removeEntry={removeFromNotepad}
isRepositoryRelatedPage={isRepositoryRelatedPage}
/>
)
}
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<React.PropsWithChildren<NotepadProps>> = ({
className,
initialOpen = false,
entries,
restorePreviousSession,
addEntry,
removeEntry,
newEntry,
selectable = true,
userId,
isRepositoryRelatedPage,
}) => {
const navigate = useNavigate()
const [open, setOpen] = useState(initialOpen)
const [selectedEntries, setSelectedEntries] = useState<number[]>([])
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<HTMLButtonElement>(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<HTMLButtonElement>(null)
const rootButton = useRef<HTMLButtonElement>(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 (
<aside
className={classNames(styles.root, className, { [styles.open]: open })}
id={NOTEPAD_ID}
aria-labelledby={`${NOTEPAD_ID}-button`}
// eslint-disable-next-line react/forbid-dom-props
style={{
marginRight: isRepositoryRelatedPage ? codySidebarWidth : 0,
}}
>
<Button
variant="icon"
className={classNames(styles.header, 'p-2 d-flex align-items-center justify-content-between')}
onClick={toggleOpen}
aria-controls={NOTEPAD_ID}
aria-expanded="true"
id={`${NOTEPAD_ID}-button`}
ref={rootButton}
>
<span>
<NotepadIcon />
<H2 className="px-1 d-inline">Notepad</H2>
<small>
({reversedEntries.length} note{reversedEntries.length === 1 ? '' : 's'})
</small>
</span>
<span className={styles.toggleIcon}>
<Icon aria-label={(open ? 'Close' : 'Open') + ' Notepad'} svgPath={mdiChevronUp} />
</span>
</Button>
{open && (
<>
{newEntry && (
<div className={classNames(styles.newNote, 'p-2')}>
<H3>Create new note from current {newEntry.type === 'file' ? 'file' : 'search'}:</H3>
<AddEntryButton entry={newEntry} addEntry={addEntry} />
</div>
)}
<H3 className="p-2">
Notes <small>({reversedEntries.length})</small>
</H3>
{/* This should be a role="listbox" and the entries should be role="option", but that doesn't work with the
design because of the nested buttons. Leave this as a normal list with some interaction (arrow keys, etc)
so that screen readers can navigate the nested controls.
*/}
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
<ol
onKeyDown={handleKey}
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={0}
aria-label="Notepad entries. Use arrow keys to move selection. Use shift key to select multiple items."
>
{reversedEntries.map((entry, index) => {
const selected = selectedEntries.includes(index)
return (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<li
key={entry.id}
data-notepad-entry-index={index}
onClick={event => toggleSelectedEntry(index, event)}
onKeyDown={event => {
if (document.activeElement === event.currentTarget && event.key === ' ') {
event.stopPropagation()
toggleSelectedEntry(index, event)
}
}}
onMouseDown={preventTextSelection}
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={0}
aria-label={getLabel(entry, selected)}
>
<NotepadEntryComponent
entry={entry}
focus={hasNewEntry && index === 0}
selected={selected}
onDelete={deleteEntry}
index={index}
/>
</li>
)
})}
</ol>
{confirmRemoveAll && (
<div className="p-2" role="alert">
<Text>Are you sure you want to delete all entries?</Text>
<div className="d-flex justify-content-between">
<Button
variant="secondary"
onClick={() => onRemoveAllClosed(false)}
ref={cancelRemoveAll}
>
Cancel
</Button>
<Button variant="danger" onClick={() => onRemoveAllClosed(true)}>
Yes, delete
</Button>
</div>
</div>
)}
<div className="p-2 d-flex align-items-center">
<Button
onClick={handleCreateNotebook}
variant="primary"
size="sm"
disabled={entries.length === 0}
className="flex-1 mr-2"
>
Create Notebook
</Button>
{restorePreviousSession && (
<Button
className="mr-2"
onClick={restorePreviousSession}
outline={true}
variant="secondary"
size="sm"
>
Restore last session
</Button>
)}
<Button
aria-label="Remove all notes"
title="Remove all notes"
variant="icon"
className="text-muted"
disabled={entries.length === 0}
onClick={() => setConfirmRemoveAll(true)}
ref={removeAllButton}
>
<Icon aria-hidden={true} svgPath={mdiDelete} />
</Button>
</div>
</>
)}
</aside>
)
}
interface AddEntryButtonProps {
entry: NotepadEntryInput
addEntry: typeof addNotepadEntry
}
const AddEntryButton: React.FunctionComponent<React.PropsWithChildren<AddEntryButtonProps>> = ({ entry, addEntry }) => {
let button: React.ReactElement
switch (entry.type) {
case 'search': {
button = (
<Button
outline={true}
variant="primary"
size="sm"
title="Add search"
className="w-100"
onClick={event => {
event.stopPropagation()
addEntry(entry)
}}
>
<Icon aria-hidden={true} svgPath={mdiMagnify} /> Add search
</Button>
)
break
}
case 'file': {
button = (
<span className="d-flex mx-0">
<Button
outline={true}
variant="primary"
size="sm"
title="Add file"
className={classNames({ 'flex-1': true, 'mr-1': !!entry.lineRange })}
onClick={event => {
event.stopPropagation()
addEntry(entry, 'file')
}}
>
<Icon aria-hidden={true} svgPath={mdiFileDocumentOutline} /> Add as file
</Button>
{entry.lineRange && (
<Button
outline={true}
variant="primary"
size="sm"
title="Add line range"
className="flex-1 ml-1"
onClick={event => {
event.stopPropagation()
addEntry(entry, 'range')
}}
>
<Icon aria-hidden={true} svgPath={mdiCodeBrackets} /> Add as range{' '}
{formatLineRange(entry.lineRange)}
</Button>
)}
</span>
)
}
}
const { title } = getUIComponentsForEntry(entry)
return (
<>
<div className={classNames(styles.entry, 'p-0 py-2')}>{title}</div>
{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<React.PropsWithChildren<NotepadEntryComponentProps>> = ({
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<HTMLTextAreaElement | null>(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 (
<div className={classNames(styles.entry, { [styles.selected]: selected })}>
<div className="d-flex">
<span className="sr-only">{selected ? 'Selected, ' : ''}</span>
<span className="flex-shrink-0 text-muted mr-1">{icon}</span>
<span className="flex-1">
<Link
to={typeof location === 'string' ? location : createLinkUrl(location)}
className="text-monospace search-query-link"
>
{title}
</Link>
</span>
<span className="ml-1 d-flex">
<Button
aria-label="Add annotation"
title="Add annotation"
variant="icon"
className="text-muted"
onClick={event => {
event.stopPropagation()
toggleAnnotationInput(!showAnnotationInput)
}}
>
<Icon aria-hidden={true} svgPath={mdiTextBox} />
</Button>
<Button
aria-label="Remove entry"
title="Remove entry"
variant="icon"
className="ml-1 text-muted"
onClick={event => {
event.stopPropagation()
onDelete(entry)
}}
>
<Icon aria-hidden={true} svgPath={mdiDelete} />
</Button>
</span>
</div>
{showAnnotationInput && (
<TextArea
ref={textarea}
className="mt-1"
placeholder="Type to add annotation..."
value={annotation}
onBlur={() => setEntryAnnotation(entry, annotation)}
onChange={event => setAnnotation(event.currentTarget.value)}
onClick={stopPropagation}
onKeyDown={event => {
switch (event.key) {
case 'Escape': {
event.currentTarget.blur()
break
}
case 'Enter': {
if (isMetaKey(event, isMacPlatform())) {
toggleAnnotationInput(false)
}
break
}
}
}}
/>
)}
</div>
)
}
function getUIComponentsForEntry(entry: NotepadEntry | NotepadEntryInput): {
icon: React.ReactElement
title: React.ReactElement
location: LocationDescriptorObject | string
body?: React.ReactElement
} {
switch (entry.type) {
case 'search': {
return {
icon: <Icon aria-label="Search" svgPath={mdiMagnify} />,
title: <SyntaxHighlightedSearchQuery query={entry.query} />,
location: {
pathname: '/search',
search: buildSearchURLQuery(
entry.query,
entry.patternType,
entry.caseSensitive,
entry.searchContext
),
},
}
}
case 'file': {
return {
icon: (
<Icon
aria-label={entry.lineRange ? 'Line range' : 'File'}
svgPath={entry.lineRange ? mdiCodeBrackets : mdiFileDocumentOutline}
/>
),
title: (
<span title={entry.path}>
{fileName(entry.path)}
{entry.lineRange ? ` ${formatLineRange(entry.lineRange)}` : ''}
</span>
),
location: toPrettyBlobURL({
repoName: entry.repo,
revision: entry.revision,
filePath: entry.path,
range: entry.lineRange
? {
start: { line: entry.lineRange.startLine + 1, character: 0 },
end: { line: entry.lineRange.endLine + 1, character: 0 },
}
: undefined,
}),
}
}
}
}
function getLabel(entry: NotepadEntry, selected: boolean): string {
const selectedText = selected ? 'Selected, ' : ''
switch (entry.type) {
case 'search': {
return `${selectedText}search: ${toSearchQuery(entry)}`
}
case 'file': {
if (entry.lineRange) {
return `${selectedText}line range: ${fileName(entry.path)}${formatLineRange(entry.lineRange)}`
}
return `${selectedText}file: ${fileName(entry.path)}`
}
}
}
function toSearchQuery(entry: SearchEntry): string {
let { query } = entry
if (entry.patternType !== SearchPatternType.standard) {
query = updateFilter(entry.query, FilterType.patterntype, entry.patternType)
}
if (entry.caseSensitive) {
query = updateFilter(query, FilterType.case, 'yes')
}
if (entry.searchContext) {
query = appendContextFilter(query, entry.searchContext)
}
return query
}
function fileName(path: string): string {
const parts = path.split('/')
return parts.at(-1)!
}
function formatLineRange(lineRange: HighlightLineRange): string {
if (lineRange.startLine === lineRange.endLine) {
return `L${lineRange.startLine + 1}`
}
return `L${lineRange.startLine + 1}:${lineRange.endLine + 1}`
}
// Helper functions for working with "selections", an ordered list of indexes
type Selection = number[]
/**
* Adds or removes a position from a selection. If `multiple` is false (default)
* but the selection contains multiple elements the new position will always be
* added.
*
* @param selection The selection to operate on
* @param position The position to add or remove
* @param multiple Whether to allow multiple selected items or not.
*/
function toggleSelection(selection: Selection, position: number, multiple: boolean = false): Selection {
const index = selection.indexOf(position)
if (multiple) {
if (index === -1) {
return [...selection, position]
}
const newSelection = [...selection]
newSelection.splice(index, 1)
return newSelection
}
return index === -1 || selection.length > 1 ? [position] : []
}
/**
* Extends a given selection to contain all positions between the last one and
* the new newly added one. The new selection will always contain the new
* position. This will rearrange existing selected positions.
*
* ([1,2,3], 5) => [1,2,3,4,5]
* ([1,2,3], 3) => [1,2,3]
* ([1,2,3], 2) => [1,3,2]
* ([1,2,3], 0) => [3,2,1,0]
*
* @param selection The selection to operate on
* @param newPosition The position to extend the selection to
*/
function extendSelection(selection: Selection, newPosition: number): Selection {
if (selection.length === 0) {
return [newPosition]
}
const newSelection = [...selection]
const lastSelectedPosition = newSelection.at(-1)!
const direction = lastSelectedPosition > newPosition ? -1 : 1
for (let position = lastSelectedPosition; position !== newPosition + direction; position += direction) {
// Re-arrange selection as necessary
const existingSelectionIndex = newSelection.indexOf(position)
if (existingSelectionIndex > -1) {
newSelection.splice(existingSelectionIndex, 1)
}
newSelection.push(position)
}
return newSelection
}
/**
* This function is supposed to be used when reacting to shift+arrow_up/down
* events. In particular it
* - selects the next unselected position
* - deselects a previously selected position if the direction changes
*
* This behavior is different enough from shift+click to warrant its own
* function.
*
* @param selection The selection to operator on
* @param direction The direction in which to change the selection
* @param total The total number of entries in the list (to handle
* wrapping around)
*/
function growOrShrinkSelection(selection: Selection, direction: 'UP' | 'DOWN', total: number): Selection {
// Select top/bottom element if selection is empty
if (selection.length === 0) {
return [direction === 'UP' ? total - 1 : 0]
}
const delta = direction === 'UP' ? -1 : 1
let nextPosition = wrapPosition(selection.at(-1)! + delta, total)
// Did we change direction and "deselected" the last position?
// (it's enough to look at the penultimate selected position)
if (selection.length > 1 && selection.at(-2) === nextPosition) {
return selection.slice(0, -1)
}
// Otherwise select the next unselected position (and rearrange positions
// accordingly)
const selectionCopy = [...selection]
let index = selectionCopy.indexOf(nextPosition)
while (index !== -1) {
selectionCopy.splice(index, 1)
selectionCopy.push(nextPosition)
nextPosition += delta
index = selectionCopy.indexOf(nextPosition)
}
selectionCopy.push(nextPosition)
return selectionCopy
}
/**
* Adjusts all indexes in the selection which are above the removed position.
*/
function adjustSelection(selection: Selection, removedPosition: number): Selection {
const result: number[] = []
for (const position of selection) {
if (position === removedPosition) {
continue
} else if (position > removedPosition) {
result.push(position - 1)
continue
}
result.push(position)
}
return result
}
/**
* Helper function for properly wrapping a value between 0 and max (exclusive).
* Basically modulo without negative numbers.
*/
function wrapPosition(position: number, max: number): number {
if (position >= max) {
return position % max
}
if (position < 0) {
return max + position
}
return position
}

View File

@ -40,7 +40,6 @@ import {
setSearchMode,
useDeveloperSettings,
useNavbarQueryState,
useNotepad,
} from '../../stores'
import { GettingStartedTour } from '../../tour/GettingStartedTour'
import { useShowOnboardingTour } from '../../tour/hooks'
@ -256,22 +255,6 @@ export const StreamingSearchResults: FC<StreamingSearchResultsProps> = props =>
setAllExpanded(false)
}, [location.search])
useNotepad(
useMemo(
() =>
results?.state === 'complete'
? {
type: 'search',
query: submittedURLQuery,
caseSensitive,
patternType,
searchContext: props.selectedSearchContextSpec,
}
: null,
[results, submittedURLQuery, patternType, caseSensitive, props.selectedSearchContextSpec]
)
)
// Expand/contract all results
const [allExpanded, setAllExpanded] = useState(false)
const onExpandAllResultsToggle = useCallback(() => {

View File

@ -21,7 +21,6 @@ export {
buildSearchURLQueryFromQueryState,
} from './navbarSearchQueryState'
export { useNotepadState, useNotepad } from './notepad'
export * from './devSettings'
/**

View File

@ -1,82 +0,0 @@
import { act } from '@testing-library/react'
import { beforeAll, describe, expect, it } from 'vitest'
import { SearchPatternType } from '@sourcegraph/shared/src/graphql-operations'
import { setAct } from '../../__mocks__/zustand'
import {
addNotepadEntry,
removeAllNotepadEntries,
removeFromNotepad,
restorePreviousSession,
type NotepadEntry,
type NotepadEntryInput,
useNotepadState,
} from './notepad'
describe('notepad store', () => {
beforeAll(() => {
setAct(act)
})
const exampleEntryInput: NotepadEntryInput = {
type: 'search',
query: 'test',
patternType: SearchPatternType.standard,
caseSensitive: false,
}
const exampleEntry: NotepadEntry = {
...exampleEntryInput,
id: 0,
}
describe('adding entries', () => {
it('adds a new entry', () => {
addNotepadEntry(exampleEntry)
expect(useNotepadState.getState().entries).toEqual([exampleEntry])
})
it('adds a new entry as file', () => {
addNotepadEntry(
{ type: 'file', path: 'path/', lineRange: { startLine: 0, endLine: 1 }, repo: 'repo', revision: 'rev' },
'file'
)
expect(useNotepadState.getState().entries[0]).toHaveProperty('lineRange', null)
})
it('adds a new entry as line range', () => {
addNotepadEntry(
{ type: 'file', path: 'path/', lineRange: { startLine: 0, endLine: 1 }, repo: 'repo', revision: 'rev' },
'range'
)
expect(useNotepadState.getState().entries[0]).toHaveProperty('lineRange', { startLine: 0, endLine: 1 })
})
})
it('restores previous session entries', () => {
useNotepadState.setState({ entries: [], previousEntries: [exampleEntry], canRestoreSession: true })
restorePreviousSession()
const state = useNotepadState.getState()
expect(state.entries).toEqual([exampleEntry])
expect(state.canRestoreSession).toBe(false)
})
it('removes individual entries', () => {
useNotepadState.setState({ entries: [exampleEntry, { ...exampleEntry, id: 1 }] })
removeFromNotepad(exampleEntry.id)
const state = useNotepadState.getState()
expect(state.entries).toHaveLength(1)
})
it('removes all entries', () => {
useNotepadState.setState({ entries: [exampleEntry, { ...exampleEntry, id: 1 }] })
removeAllNotepadEntries()
const state = useNotepadState.getState()
expect(state.entries).toHaveLength(0)
})
})

View File

@ -1,207 +0,0 @@
import { useEffect } from 'react'
import create from 'zustand'
import type { SearchPatternType } from '@sourcegraph/shared/src/graphql-operations'
import { FilterType } from '@sourcegraph/shared/src/search/query/filters'
import { FilterKind, findFilter } from '@sourcegraph/shared/src/search/query/query'
import { omitFilter } from '@sourcegraph/shared/src/search/query/transformer'
import { useTemporarySetting } from '@sourcegraph/shared/src/settings/temporary/useTemporarySetting'
import type { HighlightLineRange } from '../graphql-operations'
export type NotepadEntryID = number
export interface SearchEntry {
type: 'search'
/**
* The ID is primarily used to let the UI uniquily identifiy each entry.
*/
id: NotepadEntryID
query: string
caseSensitive: boolean
searchContext?: string
patternType: SearchPatternType
annotation?: string
}
export interface FileEntry {
type: 'file'
/**
* The ID is primarily used to let the UI uniquily identifiy each entry.
*/
id: NotepadEntryID
path: string
repo: string
revision: string
lineRange: HighlightLineRange | null
annotation?: string
}
export type NotepadEntry = SearchEntry | FileEntry
export type NotepadEntryInput = Omit<SearchEntry, 'id'> | Omit<FileEntry, 'id'>
export interface NotepadStore {
/**
* If a page/component has information that can be added to the notepad, it
* should set this value.
*/
addableEntry: NotepadEntryInput | null
entries: NotepadEntry[]
previousEntries: NotepadEntry[]
canRestoreSession: boolean
}
const NOTEPAD_SESSION_KEY = 'search:notepad:session'
// TODO (@fkling): Remove fallback to old name after ~2 releases.
const SEARCH_STACK_SESSION_KEY = 'search:search-stack:session'
/**
* Uniquly identifies each entry.
*/
let nextEntryID = 0
/**
* Hook to get the notepad's current state. Used by the Notepad
* component itself and by internal functions to add a new entry to the notepad.
* The current entries persist in local and session storage. Currently this
* doesn't work well with multiple tabs.
*/
export const useNotepadState = create<NotepadStore>(() => {
// We have to get data for the current and previous session here (and retain
// them) because those entries might get overwritten immediately if a page
// is loaded that calls addNotepadEntry
const entriesFromSession = restoreSession(sessionStorage)
const entriesFromPreviousSession = restoreSession(localStorage)
return {
addableEntry: null,
entries: entriesFromSession,
previousEntries: entriesFromPreviousSession,
canRestoreSession: entriesFromSession.length === 0 && entriesFromPreviousSession.length > 0,
}
})
/**
* Hook to make a new entry available for adding to the notepad. Use
* `useMemo` to avoid unnecessary triggers and to properly remove the entry when
* the component gets unmounted.
*/
export function useNotepad(newEntry: NotepadEntryInput | null): void {
const [enableNotepad] = useTemporarySetting('search.notepad.enabled')
useEffect(() => {
if (!enableNotepad || !newEntry) {
return
}
let entry: NotepadEntryInput = newEntry
switch (entry.type) {
case 'search': {
// `query` most likely contains a 'context' filter that we don't
// want to show (this information is kept separately in
// `searchContext`).
let processedQuery = entry.query
const contextFilter = findFilter(entry.query, FilterType.context, FilterKind.Global)
if (contextFilter) {
processedQuery = omitFilter(entry.query, contextFilter)
}
entry = { ...entry, query: processedQuery }
break
}
}
useNotepadState.setState({ addableEntry: entry })
// We have to "remove" the entry if the component unmounts.
return () => {
const currentState = useNotepadState.getState()
if (currentState.addableEntry === entry) {
useNotepadState.setState({ addableEntry: null })
}
}
}, [newEntry, enableNotepad])
}
/**
* Adds the current value of addableEntry to the list of items.
* If that value is a file entry, then a hint can be provided to control whether
* the whole file or the line range should be added.
*/
export function addNotepadEntry(newEntry: NotepadEntryInput, hint?: 'file' | 'range'): void {
const { entries } = useNotepadState.getState()
let entry = newEntry
if (entry.type === 'file' && entry.lineRange && hint === 'file') {
entry = { ...entry, lineRange: null }
}
const newState = {
entries: [...entries, { ...entry, id: nextEntryID++ }],
canRestoreSession: entries.length === 0,
}
persistSession(newState.entries)
useNotepadState.setState(newState)
}
export function restorePreviousSession(): void {
if (useNotepadState.getState().canRestoreSession) {
useNotepadState.setState(state =>
// TODO (@fkling): Merge current and previous session?
({ entries: state.previousEntries, canRestoreSession: false })
)
}
}
export function removeFromNotepad(idsToDelete: NotepadEntryID | NotepadEntryID[]): void {
useNotepadState.setState(currentState => {
if (!Array.isArray(idsToDelete)) {
idsToDelete = [idsToDelete]
}
const entries = [...currentState.entries]
for (const id of idsToDelete) {
entries.splice(
entries.findIndex(entry => entry.id === id),
1
)
}
persistSession(entries)
return { entries }
})
}
export function removeAllNotepadEntries(): void {
persistSession([])
useNotepadState.setState({ entries: [] })
}
export function setEntryAnnotation(entry: NotepadEntry, annotation: string): void {
useNotepadState.setState(state => {
const index = state.entries.indexOf(entry)
if (index > -1) {
const entriesCopy = state.entries.slice()
entriesCopy.splice(index, 1, { ...state.entries[index], annotation })
return { entries: entriesCopy }
}
return state
})
}
function restoreSession(storage: Storage): NotepadEntry[] {
return (
JSON.parse(storage.getItem(NOTEPAD_SESSION_KEY) ?? storage.getItem(SEARCH_STACK_SESSION_KEY) ?? '[]')
// We always "re-id" restored entries. This makes things easier (no need
// to track which IDs have already been used)
.map((entry: NotepadEntry) => ({ ...entry, id: nextEntryID++ }))
)
}
function persistSession(entries: NotepadEntry[]): void {
// We store notepad data in both local and session storage: This feature
// should really be considered to be session related but at the same time we
// want to make it possible to restore information from the previous session
// (e.g. in case the page was accidentally closed).
// Storing the entries in local storage allows us to do that (see
// useNotepadState above).
const serializedEntries = JSON.stringify(entries)
localStorage.setItem(NOTEPAD_SESSION_KEY, serializedEntries)
sessionStorage.setItem(NOTEPAD_SESSION_KEY, serializedEntries)
}

View File

@ -26,7 +26,6 @@ import { CodySurveyToast, 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'
@ -74,7 +73,6 @@ export const Layout: React.FC<LegacyLayoutProps> = props => {
const isSearchNotebooksPage = routeMatches.some(routeMatch =>
routeMatch.pathname.startsWith(EnterprisePageRoutes.Notebooks)
)
const isSearchNotebookListPage = location.pathname === EnterprisePageRoutes.Notebooks
const isCodySearchPage = routeMatches.some(routeMatch =>
routeMatch.pathname.startsWith(EnterprisePageRoutes.CodySearch)
)
@ -249,12 +247,6 @@ export const Layout: React.FC<LegacyLayoutProps> = props => {
<div id="references-panel-react-portal" />
</Suspense>
</ErrorBoundary>
{(isSearchNotebookListPage || (isSearchRelatedPage && !isSearchHomepage)) && (
<NotepadContainer
userId={props.authenticatedUser?.id}
isRepositoryRelatedPage={isRepositoryRelatedPage}
/>
)}
{fuzzyFinder && (
<LazyFuzzyFinder
isVisible={isFuzzyFinderVisible}

View File

@ -38,13 +38,17 @@ Inspired by Jupyter Notebooks and powered by Markdown and Sourcegraph's code sea
![](https://storage.googleapis.com/sourcegraph-assets/docs/images/notebooks/notebooks_home.gif)
Notebooks have powerful content creation features, like a [notepad](../notebooks/notepad.md) and multiple block types, each with their own unique capabilities. If you're familiar with Jupyter Notebooks, then you already understand the blocks concept. You can add as many of each block as you want to a Sourcegraph notebook.
Notebooks have powerful content creation features, like multiple block types, each with their own unique capabilities.
If you're familiar with Jupyter Notebooks, then you already understand the blocks concept. You can add as many of each
block as you want to a Sourcegraph notebook.
## Notebook types
Notebooks can be created in two ways. Through the web interface or via special Markdown files with the special `.snb.md` extension. To view file-based notebooks you must view the files in the file view on sourcegraph.com or on your Sourcegraph instance.
### Web-based notebooks
The simplest way to get started with Notebooks is to create one using the web interface. Notebooks created this way have the advantage of being interactive, letting you see the content of your blocks in realtime as you create your notebook. You can also utilize the [notepad](../notebooks/notepad.md) to dramatically speed up notebook creation.
The simplest way to get started with Notebooks is to create one using the web interface. Notebooks created this way have
the advantage of being interactive, letting you see the content of your blocks in realtime as you create your notebook.
You can also create web-based notebooks by importing plain Markdown files and then augmenting them with Sourcegraph notebook block types in the web interface. A new notebook will automatically be created when you import a standard markdown file. From there, you can modify it however you like in the web interface.
@ -97,5 +101,4 @@ In versions older than 3.39 (beginning in 3.36) Notebooks are behind an experime
## Explanations
- [Sharing notebooks](../notebooks/notebook-sharing.md)
- [Embedding notebooks](../notebooks/notebook-embedding.md)
- [The notepad](../notebooks/notepad.md)
- [Block types](../notebooks/blocks.md)

View File

@ -1,31 +0,0 @@
# Notepad
The notepad feature lets you quickly create notebooks as you navigate through Sourcegraph and create a notebook from those notes with one click. It is disabled by default and can be enabled on the Notebooks home page by click the "Enable notepad" button. Once enabled, the notepad component will be visible in the bottom right corner of your browser window. You can disable the notepad at any time by clicking the "Disable notepad" button (located in the same place).
!["Enable notepad"](https://storage.googleapis.com/sourcegraph-assets/notebooks/enable_notepad.png)
## Creating a Notebook
### Adding notes
The notepad currently supports adding three kinds of notes: searches, files, and file ranges. It intelligently detects the type of available notes, so if you're on a search page, it makes a search note available. If you're on a file view with no lines selected, you can add the entire file, and if you're on a file view with one or more lines selected, you can add the entire file or selected range. To select a range of lines you can use Shift + right click on the file's line numbers.
!["Add search to notepad"](https://storage.googleapis.com/sourcegraph-assets/notebooks/notepad_add_search.png)
### Annotating notes
When you create a note, you can optionally add text to it, which will also be added to the Notebook when created. On note creation, the annotation box is automatically opened and focused for ease of annotation. You can remove focus at any time by pressing Escape. The text annotation is automatically converted into a Markdown block in the Notebook, which means you can write your annotations using Markdown.
!["Annotating notes"](https://storage.googleapis.com/sourcegraph-assets/notebooks/notepad_annotations.png)
You can close a note after you add an annotation by clicking Meta/Cmd + Enter. To reopen it, click the paper icon button in the top right of the note.
You can also delete individual notes by clicking trash can icon in the note, or delete all notes by clicking the trash can icon in the bottom of the notepad, to the right of the "Create Notebook" button.
## Creating a Notebook
When you've added all your notes, click the "Create Notebook" button to create a Notebook from them. When a Notebook is created from the notepad, the notes are added in reverse order, so the first note you added is the first block in the Notebook, preceded by any annotations you added to that note (annotations appear as Markdown blocks).
When you click "Create Notebook" you will be taken to the newly-created Notebook and can edit it like any other Notebook. Add additional blocks and remove or edit existing blocks at will.
# Restoring your previous notepad session
If you close your Sourcegraph window and return, the notepad will display no notes, but you you can restore your previous session by clicking the "restore last session" button that is now visible. Even if you have added new notes since you've returned, you can restore you previous session without losing the new notes.
# Deleting all notes
Click the trash can icon in the bottom right of the notepad to delete all notes. You will be prompted to confirm deletion of all the notes. You cannot undo this action.