mirror of
https://github.com/sourcegraph/sourcegraph.git
synced 2026-02-06 17:11:49 +00:00
[Notebooks]: Remove notepad UI from notebooks feature (#58217)
* Remove notepad UI from notebooks feature * Update CHANGELOG.md
This commit is contained in:
parent
1d67b44f8d
commit
a6debeb00a
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 => (
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
@ -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(() => {
|
||||
|
||||
@ -21,7 +21,6 @@ export {
|
||||
buildSearchURLQueryFromQueryState,
|
||||
} from './navbarSearchQueryState'
|
||||
|
||||
export { useNotepadState, useNotepad } from './notepad'
|
||||
export * from './devSettings'
|
||||
|
||||
/**
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -38,13 +38,17 @@ Inspired by Jupyter Notebooks and powered by Markdown and Sourcegraph's code sea
|
||||
|
||||

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

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
Loading…
Reference in New Issue
Block a user