diff --git a/client/web/BUILD.bazel b/client/web/BUILD.bazel index f70363cfe34..a886cfb783e 100644 --- a/client/web/BUILD.bazel +++ b/client/web/BUILD.bazel @@ -205,7 +205,6 @@ ts_project( "src/cody/chat/index.tsx", "src/cody/components/ChatUI/ChatUi.tsx", "src/cody/components/ChatUI/index.tsx", - "src/cody/components/ChatUI/useIsFileIgnored.ts", "src/cody/components/CodeMirrorEditor.ts", "src/cody/components/CodyIcon.tsx", "src/cody/components/CodyLogo.tsx", @@ -257,6 +256,7 @@ ts_project( "src/cody/upsell/MultilineCompletion.tsx", "src/cody/upsell/vs-code.tsx", "src/cody/useCodyChat.tsx", + "src/cody/useCodyIgnore.tsx", "src/cody/widgets/CodyRecipesWidget.tsx", "src/cody/widgets/components/Recipe.tsx", "src/cody/widgets/components/RecipeAction.tsx", @@ -1831,6 +1831,7 @@ ts_project( "//:node_modules/monaco-yaml", "//:node_modules/ordinal", "//:node_modules/pretty-bytes", + "//:node_modules/re2js", "//:node_modules/react", "//:node_modules/react-calendar", "//:node_modules/react-circular-progressbar", @@ -1876,6 +1877,7 @@ ts_project( "src/backend/persistenceMapper.test.ts", "src/codeintel/ReferencesPanel.mocks.ts", "src/codeintel/ReferencesPanel.test.tsx", + "src/cody/useCodyIgnore.test.ts", "src/components/ErrorBoundary.test.tsx", "src/components/FilteredConnection/FilteredConnection.test.tsx", "src/components/FilteredConnection/hooks/usePageSwitcherPagination.test.tsx", diff --git a/client/web/src/cody/components/ChatUI/ChatUi.tsx b/client/web/src/cody/components/ChatUI/ChatUi.tsx index 8534d7d7467..8b465b9e06a 100644 --- a/client/web/src/cody/components/ChatUI/ChatUi.tsx +++ b/client/web/src/cody/components/ChatUI/ChatUi.tsx @@ -35,8 +35,6 @@ import { GettingStarted } from '../GettingStarted' import { ScopeSelector } from '../ScopeSelector' import type { ScopeSelectorProps } from '../ScopeSelector/ScopeSelector' -import { useIsFileIgnored } from './useIsFileIgnored' - import styles from './ChatUi.module.scss' export const SCROLL_THRESHOLD = 100 @@ -99,8 +97,6 @@ export const ChatUI: React.FC = ({ const onSubmit = useCallback((text: string) => submitMessage(text), [submitMessage]) const onEdit = useCallback((text: string) => editMessage(text), [editMessage]) - const isFileIgnored = useIsFileIgnored() - const scopeSelectorProps: ScopeSelectorProps = useMemo( () => ({ scope, @@ -111,7 +107,6 @@ export const ChatUI: React.FC = ({ transcriptHistory, className: 'mt-2', authenticatedUser, - isFileIgnored, }), [ scope, @@ -121,7 +116,6 @@ export const ChatUI: React.FC = ({ logTranscriptEvent, transcriptHistory, authenticatedUser, - isFileIgnored, ] ) diff --git a/client/web/src/cody/components/ChatUI/useIsFileIgnored.ts b/client/web/src/cody/components/ChatUI/useIsFileIgnored.ts deleted file mode 100644 index f7eaffc66aa..00000000000 --- a/client/web/src/cody/components/ChatUI/useIsFileIgnored.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { useCallback, useEffect, useState } from 'react' - -import type { Ignore } from 'ignore' -import { useLocation } from 'react-router-dom' - -import { useQuery, gql } from '@sourcegraph/http-client' - -import type { CodyIgnoreContentResult, CodyIgnoreContentVariables } from '../../../graphql-operations' -import { parseBrowserRepoURL } from '../../../util/url' - -const CODY_IGNORE_CONTENT = gql` - query CodyIgnoreContent($repoName: String!, $repoRev: String!, $filePath: String!) { - repository(name: $repoName) { - commit(rev: $repoRev) { - blob(path: $filePath) { - content - } - } - } - } -` - -const CODY_IGNORE_PATH = '.cody/ignore' - -export const useIsFileIgnored = (): ((path: string) => boolean) => { - const location = useLocation() - const { repoName, revision } = parseBrowserRepoURL(location.pathname + location.search + location.hash) - const { data } = useQuery(CODY_IGNORE_CONTENT, { - skip: !window.context?.experimentalFeatures.codyContextIgnore, - variables: { repoName, repoRev: revision || '', filePath: CODY_IGNORE_PATH }, - }) - const [ignoreManager, setIgnoreManager] = useState() - - const content = data?.repository?.commit?.blob?.content - useEffect(() => { - const loadIgnore = async (): Promise => { - if (content) { - const ignore = (await import('ignore')).default - setIgnoreManager(ignore().add(content)) - } - } - - void loadIgnore() - }, [content]) - - const isFileIgnored = useCallback( - (path: string): boolean => { - if (ignoreManager) { - return ignoreManager.ignores(path) - } - return false - }, - [ignoreManager] - ) - - return isFileIgnored -} diff --git a/client/web/src/cody/components/ScopeSelector/RepositoriesSelectorPopover.tsx b/client/web/src/cody/components/ScopeSelector/RepositoriesSelectorPopover.tsx index d96d6fb1d10..e145323125c 100644 --- a/client/web/src/cody/components/ScopeSelector/RepositoriesSelectorPopover.tsx +++ b/client/web/src/cody/components/ScopeSelector/RepositoriesSelectorPopover.tsx @@ -24,6 +24,7 @@ import { import type { ReposSelectorSearchResult, ReposSelectorSearchVariables } from '../../../graphql-operations' import { ExternalRepositoryIcon } from '../../../site-admin/components/ExternalRepositoryIcon' +import { useCodyIgnore } from '../../useCodyIgnore' import { ReposSelectorSearchQuery } from './backend' import { Callout } from './Callout' @@ -85,7 +86,12 @@ export const RepositoriesSelectorPopover: React.FC<{ omitSuggestions: additionalRepositories, }) - const searchResults = useMemo(() => searchResultsData?.repositories.nodes || [], [searchResultsData]) + const { isRepoIgnored } = useCodyIgnore() + // Repo search results with ignored repositories filtered out. + const filteredSearchResults = useMemo( + () => searchResultsData?.repositories.nodes.filter(r => !isRepoIgnored(r.name)) || [], + [searchResultsData, isRepoIgnored] + ) const onSearch = useCallback( (event: React.ChangeEvent) => { @@ -352,8 +358,8 @@ export const RepositoriesSelectorPopover: React.FC<{
- {searchResults.length ? ( - searchResults.map(repository => ( + {filteredSearchResults.length ? ( + filteredSearchResults.map(repository => ( boolean } export const ScopeSelector: React.FC = React.memo(function ScopeSelectorComponent({ @@ -48,7 +48,6 @@ export const ScopeSelector: React.FC = React.memo(function S renderHint, encourageOverlap, authenticatedUser, - isFileIgnored, }) { const [loadReposStatus, { data: newReposStatusData, previousData: previousReposStatusData }] = useLazyQuery< ReposStatusResult, @@ -59,18 +58,49 @@ export const ScopeSelector: React.FC = React.memo(function S const activeEditor = useMemo(() => scope.editor.getActiveTextEditor(), [scope.editor]) - const isCurrentFileIgnored = activeEditor?.filePath ? isFileIgnored(activeEditor.filePath) : false - const inferredFilePath = (!isCurrentFileIgnored && activeEditor?.filePath) || null + const codyIgnoreFns = useCodyIgnore() + + const inferredFilePath = (() => { + if (!activeEditor?.repoName || !activeEditor?.filePath) { + return null + } + if (codyIgnoreFns.isFileIgnored(activeEditor.repoName, activeEditor.filePath)) { + return null + } + return activeEditor.filePath + })() + useEffect(() => { + if (!activeEditor?.repoName || !activeEditor?.filePath) { + return + } + + const isCurrentRepoIgnored = codyIgnoreFns.isRepoIgnored(activeEditor.repoName) + if (isCurrentRepoIgnored) { + setScope({ + ...scope, + includeInferredFile: false, + includeInferredRepository: false, + repositories: scope.repositories.filter(r => r !== activeEditor?.repoName), + }) + return + } + + const isCurrentFileIgnored = codyIgnoreFns.isFileIgnored(activeEditor.repoName, activeEditor.filePath) if (isCurrentFileIgnored && scope.includeInferredFile) { setScope({ ...scope, includeInferredFile: false, includeInferredRepository: true }) + return } - }, [isCurrentFileIgnored, scope, setScope]) + }, [activeEditor, codyIgnoreFns, scope, setScope]) useEffect(() => { const repoNames = [...scope.repositories] - if (activeEditor?.repoName && !repoNames.includes(activeEditor.repoName)) { + if ( + activeEditor?.repoName && + !repoNames.includes(activeEditor.repoName) && + !codyIgnoreFns.isRepoIgnored(activeEditor.repoName) + ) { repoNames.push(activeEditor.repoName) } @@ -81,17 +111,17 @@ export const ScopeSelector: React.FC = React.memo(function S loadReposStatus({ variables: { repoNames, first: repoNames.length }, }).catch(() => null) - }, [activeEditor, scope.repositories, loadReposStatus]) + }, [activeEditor, scope.repositories, codyIgnoreFns, loadReposStatus]) const allRepositories = useMemo(() => reposStatusData?.repositories.nodes || [], [reposStatusData]) const inferredRepository = useMemo(() => { - if (activeEditor?.repoName) { + if (activeEditor?.repoName && !codyIgnoreFns.isRepoIgnored(activeEditor.repoName)) { return allRepositories.find(repo => repo.name === activeEditor.repoName) || null } return null - }, [activeEditor, allRepositories]) + }, [activeEditor, codyIgnoreFns, allRepositories]) const additionalRepositories: IRepo[] = useMemo( () => @@ -126,8 +156,19 @@ export const ScopeSelector: React.FC = React.memo(function S const resetScope = useCallback((): void => { logTranscriptEvent(EventName.CODY_CHAT_SCOPE_RESET, 'cody.chat.scope.repo', 'reset') - setScope({ ...scope, repositories: [], includeInferredRepository: true, includeInferredFile: true }) - }, [scope, setScope, logTranscriptEvent]) + let isCurrentRepoIgnored = false + let isCurrentFileIgnored = false + if (activeEditor?.repoName && activeEditor?.filePath) { + isCurrentRepoIgnored = codyIgnoreFns.isRepoIgnored(activeEditor.repoName) + isCurrentFileIgnored = codyIgnoreFns.isFileIgnored(activeEditor.repoName, activeEditor.filePath) + } + setScope({ + ...scope, + repositories: [], + includeInferredRepository: !isCurrentRepoIgnored, + includeInferredFile: !isCurrentFileIgnored, + }) + }, [scope, setScope, logTranscriptEvent, activeEditor, codyIgnoreFns]) return ( <> diff --git a/client/web/src/cody/components/ScopeSelector/useRepoSuggestions.ts b/client/web/src/cody/components/ScopeSelector/useRepoSuggestions.ts index 5b2068bf5d5..dde94f48e14 100644 --- a/client/web/src/cody/components/ScopeSelector/useRepoSuggestions.ts +++ b/client/web/src/cody/components/ScopeSelector/useRepoSuggestions.ts @@ -10,6 +10,7 @@ import type { SuggestedReposResult, SuggestedReposVariables, } from '../../../graphql-operations' +import { useCodyIgnore } from '../../useCodyIgnore' import { SuggestedReposQuery } from './backend' @@ -53,6 +54,8 @@ const DEFAULT_OPTS: UseRepoSuggestionsOpts = { * browsing history, embedded repositories available on their instance, and any fallbacks * configured in the options. * + * If Cody Ignore are configured on the instance, repositories matching ignore rules are excluded from suggestions. + * * The number of suggestions can be configured with `opts.numSuggestions`. The default is 10. * * Fallback suggestions can be configured with `opts.fallbackSuggestions`. The default is @@ -72,6 +75,7 @@ export const useRepoSuggestions = ( ): ContextSelectorRepoFields[] => { const { numSuggestions, fallbackSuggestions, omitSuggestions } = { ...DEFAULT_OPTS, ...opts } + const { isRepoIgnored } = useCodyIgnore() const userHistory = useUserHistory(authenticatedUser?.id, false) const suggestedRepoNames: string[] = useMemo(() => { const flattenedTranscriptHistoryEntries = transcriptHistory @@ -89,6 +93,8 @@ export const useRepoSuggestions = ( .flat() // Remove duplicates. .filter(removeDupes) + // Remove ignored repositories. + .filter(r => !isRepoIgnored(r.name)) // We only need up to the first numSuggestions. .slice(0, numSuggestions) @@ -100,6 +106,10 @@ export const useRepoSuggestions = ( // Parse a date from the last acessed timestamp. lastAccessed: new Date(item.lastAccessed), })) + // Remove duplicates. + .filter(removeDupes) + // Remove ignored repositories. + .filter(r => !isRepoIgnored(r.name)) // We only need up to the first numSuggestions. .slice(0, numSuggestions) || [] @@ -111,6 +121,8 @@ export const useRepoSuggestions = ( // We order by most recently accessed; these should always be ranked last. lastAccessed: new Date(0), })) + // Remove ignored repositories. + .filter(r => !isRepoIgnored(r.name)) .slice(0, numSuggestions) // Merge the lists. @@ -124,7 +136,7 @@ export const useRepoSuggestions = ( // Return just the names. return merged.map(({ name }) => name) - }, [transcriptHistory, userHistory, numSuggestions, fallbackSuggestions]) + }, [transcriptHistory, userHistory, numSuggestions, fallbackSuggestions, isRepoIgnored]) // Query for the suggested repositories. const { data: suggestedReposData } = useQuery(SuggestedReposQuery, { @@ -157,12 +169,14 @@ export const useRepoSuggestions = ( [...sortedByNameNodes, ...suggestedReposData.firstN.nodes] // Remove any duplicates. .filter(removeDupes) + // Remove ignored repositories. Repositories we looked up by name are already filtered, but the first N repos are not. + .filter(r => !isRepoIgnored(r.name)) // Take the first numSuggestions. .slice(0, numSuggestions) // Finally, filter out repositories that are should be omitted. .filter(suggestion => !omitSuggestions.find(toOmit => toOmit.name === suggestion.name)) ) - }, [suggestedReposData, suggestedRepoNames, omitSuggestions, numSuggestions]) + }, [suggestedReposData, suggestedRepoNames, omitSuggestions, numSuggestions, isRepoIgnored]) return suggestions } diff --git a/client/web/src/cody/useCodyChat.tsx b/client/web/src/cody/useCodyChat.tsx index d600c3addc2..4fa97948e85 100644 --- a/client/web/src/cody/useCodyChat.tsx +++ b/client/web/src/cody/useCodyChat.tsx @@ -19,6 +19,7 @@ import { useLocalStorage } from '@sourcegraph/wildcard' import { EventName } from '../util/constants' import { isEmailVerificationNeededForCody } from './isCodyEnabled' +import { useCodyIgnore } from './useCodyIgnore' export interface CodyChatStore extends Pick< @@ -207,6 +208,17 @@ export const useCodyChat = ({ [transcript, telemetryRecorder] ) + const { isRepoIgnored } = useCodyIgnore() + const setScopeFromTranscript = useCallback( + (t: TranscriptJSON) => { + const newScope = { ...scope, ...t.scope } + // ensure ignored repositories are not added to scope + newScope.repositories = newScope.repositories.filter(repo => !isRepoIgnored(repo)) + setScopeInternal(newScope) + }, + [scope, setScopeInternal, isRepoIgnored] + ) + const loadTranscriptFromHistory = useCallback( async (id: string) => { if (transcript?.id === id) { @@ -218,11 +230,11 @@ export const useCodyChat = ({ await setTranscript(Transcript.fromJSON(transcriptToLoad)) if (transcriptToLoad.scope) { - setScopeInternal({ ...scope, ...transcriptToLoad.scope }) + setScopeFromTranscript(transcriptToLoad) } } }, - [transcriptHistory, transcript?.id, setTranscript, setScopeInternal, scope] + [transcriptHistory, transcript?.id, setTranscript, setScopeFromTranscript] ) const updateTranscriptInHistory = useCallback( @@ -291,7 +303,7 @@ export const useCodyChat = ({ setTranscript(Transcript.fromJSON(transcriptToLoad)).catch(() => null) if (transcriptToLoad.scope) { - setScopeInternal({ ...scope, ...transcriptToLoad.scope }) + setScopeFromTranscript(transcriptToLoad) } } } @@ -301,13 +313,12 @@ export const useCodyChat = ({ }, [ setTranscript, - setScopeInternal, client.config.needsEmailVerification, initializeNewChatInternal, transcript?.id, setTranscriptHistoryState, - scope, logTranscriptEvent, + setScopeFromTranscript, ] ) @@ -399,7 +410,7 @@ export const useCodyChat = ({ setTranscript(Transcript.fromJSON(transcriptToLoad)).catch(() => null) if (transcriptToLoad.scope) { - setScopeInternal({ ...scope, ...transcriptToLoad.scope }) + setScopeFromTranscript(transcriptToLoad) } } else { const newTranscript = new Transcript() @@ -425,8 +436,7 @@ export const useCodyChat = ({ setTranscriptHistoryState, loadTranscriptFromHistory, initializeNewChat, - scope, - setScopeInternal, + setScopeFromTranscript, ]) const setScope = useCallback( diff --git a/client/web/src/cody/useCodyIgnore.test.ts b/client/web/src/cody/useCodyIgnore.test.ts new file mode 100644 index 00000000000..5856e5cecc5 --- /dev/null +++ b/client/web/src/cody/useCodyIgnore.test.ts @@ -0,0 +1,751 @@ +import { describe, expect, it } from 'vitest' + +import { alwaysTrue, getFilterFnsFromCodyContextFilters } from './useCodyIgnore' + +describe('getFilterFnsFromCodyContextFilters', () => { + it('ignores everything if failed to parse filters from site config', async () => { + const regexWithLookahead = '\\d(?=\\D)' // not supported in RE2 + const { isRepoIgnored, isFileIgnored } = await getFilterFnsFromCodyContextFilters({ + exclude: [{ repoNamePattern: regexWithLookahead }], + }) + expect(isRepoIgnored).toBe(alwaysTrue) + expect(isFileIgnored).toBe(alwaysTrue) + }) + + // TODO: (taras-yemets) replace with shared Cody Ignore test dataset once it's published + const testCases = [ + { + name: 'Cody context filters are not defined', + description: 'Any repo should be included.', + includeByDefault: true, + includeUnknown: false, + 'cody.contextFilters': null, + repos: [ + { name: 'github.com/sourcegraph/about', id: 1 }, + { name: 'github.com/sourcegraph/annotate', id: 2 }, + { name: 'github.com/sourcegraph/sourcegraph', id: 3 }, + { name: 'github.com/docker/compose', id: 4 }, + ], + includedRepos: [ + { name: 'github.com/sourcegraph/about', id: 1 }, + { name: 'github.com/sourcegraph/annotate', id: 2 }, + { name: 'github.com/sourcegraph/sourcegraph', id: 3 }, + { name: 'github.com/docker/compose', id: 4 }, + ], + fileChunks: [ + { + repo: { + name: 'github.com/sourcegraph/about', + id: 1, + }, + Path: '/file1.go', + }, + { + repo: { + name: 'github.com/sourcegraph/annotate', + id: 2, + }, + Path: '/file2.go', + }, + { + repo: { + name: 'github.com/sourcegraph/sourcegraph', + id: 3, + }, + Path: '/file3.go', + }, + { + repo: { + name: 'github.com/docker/compose', + id: 4, + }, + Path: '/file4.go', + }, + ], + includedFileChunks: [ + { + repo: { + name: 'github.com/sourcegraph/about', + id: 1, + }, + Path: '/file1.go', + }, + { + repo: { + name: 'github.com/sourcegraph/annotate', + id: 2, + }, + Path: '/file2.go', + }, + { + repo: { + name: 'github.com/sourcegraph/sourcegraph', + id: 3, + }, + Path: '/file3.go', + }, + { + repo: { + name: 'github.com/docker/compose', + id: 4, + }, + Path: '/file4.go', + }, + ], + }, + { + name: 'Include and exclude rules are not defined', + description: + 'This scenario shouldn\'t happen. "cody.contextFilters" if defined in the site config, should have at least one property. Thus, either "include" or "exclude" should be defined. We rely on site config schema validation.', + includeByDefault: true, + includeUnknown: false, + 'cody.contextFilters': {}, + repos: [ + { name: 'github.com/sourcegraph/about', id: 1 }, + { name: 'github.com/sourcegraph/annotate', id: 2 }, + { name: 'github.com/sourcegraph/sourcegraph', id: 3 }, + { name: 'github.com/docker/compose', id: 4 }, + ], + includedRepos: [ + { name: 'github.com/sourcegraph/about', id: 1 }, + { name: 'github.com/sourcegraph/annotate', id: 2 }, + { name: 'github.com/sourcegraph/sourcegraph', id: 3 }, + { name: 'github.com/docker/compose', id: 4 }, + ], + fileChunks: [ + { + repo: { + name: 'github.com/sourcegraph/about', + id: 1, + }, + path: '/file1.go', + }, + { + repo: { + name: 'github.com/sourcegraph/annotate', + id: 2, + }, + path: '/file2.go', + }, + { + repo: { + name: 'github.com/sourcegraph/sourcegraph', + id: 3, + }, + path: '/file3.go', + }, + { + repo: { + name: 'github.com/docker/compose', + id: 4, + }, + path: '/file4.go', + }, + ], + includedFileChunks: [ + { + repo: { + name: 'github.com/sourcegraph/about', + id: 1, + }, + path: '/file1.go', + }, + { + repo: { + name: 'github.com/sourcegraph/annotate', + id: 2, + }, + path: '/file2.go', + }, + { + repo: { + name: 'github.com/sourcegraph/sourcegraph', + id: 3, + }, + path: '/file3.go', + }, + { + repo: { + name: 'github.com/docker/compose', + id: 4, + }, + path: '/file4.go', + }, + ], + }, + { + name: 'Include and exclude rules are empty lists', + description: + 'This scenario shouldn\'t happen. If either "include" or "exclude" field is defined, it should have at least one item. We rely on site config schema validation.', + includeByDefault: true, + includeUnknown: false, + 'cody.contextFilters': { + include: [], + exclude: [], + }, + repos: [ + { name: 'github.com/sourcegraph/about', id: 1 }, + { name: 'github.com/sourcegraph/annotate', id: 2 }, + { name: 'github.com/sourcegraph/sourcegraph', id: 3 }, + { name: 'github.com/docker/compose', id: 4 }, + ], + includedRepos: [ + { name: 'github.com/sourcegraph/about', id: 1 }, + { name: 'github.com/sourcegraph/annotate', id: 2 }, + { name: 'github.com/sourcegraph/sourcegraph', id: 3 }, + { name: 'github.com/docker/compose', id: 4 }, + ], + fileChunks: [ + { + repo: { + name: 'github.com/sourcegraph/about', + id: 1, + }, + path: '/file1.go', + }, + { + repo: { + name: 'github.com/sourcegraph/annotate', + id: 2, + }, + path: '/file2.go', + }, + { + repo: { + name: 'github.com/sourcegraph/sourcegraph', + id: 3, + }, + path: '/file3.go', + }, + { + repo: { + name: 'github.com/docker/compose', + id: 4, + }, + path: '/file4.go', + }, + ], + includedFileChunks: [ + { + repo: { + name: 'github.com/sourcegraph/about', + id: 1, + }, + path: '/file1.go', + }, + { + repo: { + name: 'github.com/sourcegraph/annotate', + id: 2, + }, + path: '/file2.go', + }, + { + repo: { + name: 'github.com/sourcegraph/sourcegraph', + id: 3, + }, + path: '/file3.go', + }, + { + repo: { + name: 'github.com/docker/compose', + id: 4, + }, + path: '/file4.go', + }, + ], + }, + { + name: 'Only include rules are defined', + description: 'Only repos matching "include" repo name patterns should be included.', + includeByDefault: true, + includeUnknown: false, + 'cody.contextFilters': { + include: [{ repoNamePattern: '^github\\.com\\/sourcegraph\\/.+' }], + }, + repos: [ + { name: 'github.com/sourcegraph/about', id: 1 }, + { name: 'github.com/sourcegraph/annotate', id: 2 }, + { name: 'github.com/sourcegraph/sourcegraph', id: 3 }, + { name: 'github.com/docker/compose', id: 4 }, + ], + includedRepos: [ + { name: 'github.com/sourcegraph/about', id: 1 }, + { name: 'github.com/sourcegraph/annotate', id: 2 }, + { name: 'github.com/sourcegraph/sourcegraph', id: 3 }, + ], + fileChunks: [ + { + repo: { + name: 'github.com/sourcegraph/about', + id: 1, + }, + path: '/file1.go', + }, + { + repo: { + name: 'github.com/sourcegraph/annotate', + id: 2, + }, + path: '/file2.go', + }, + { + repo: { + name: 'github.com/sourcegraph/sourcegraph', + id: 3, + }, + path: '/file3.go', + }, + { + repo: { + name: 'github.com/docker/compose', + id: 4, + }, + path: '/file4.go', + }, + ], + includedFileChunks: [ + { + repo: { + name: 'github.com/sourcegraph/about', + id: 1, + }, + path: '/file1.go', + }, + { + repo: { + name: 'github.com/sourcegraph/annotate', + id: 2, + }, + path: '/file2.go', + }, + { + repo: { + name: 'github.com/sourcegraph/sourcegraph', + id: 3, + }, + path: '/file3.go', + }, + ], + }, + { + name: 'Only exclude rules are defined', + description: 'Only repos not matching any of "include" repo name patterns should be included.', + includeByDefault: true, + includeUnknown: false, + 'cody.contextFilters': { + exclude: [{ repoNamePattern: '^github\\.com\\/sourcegraph\\/.+' }], + }, + repos: [ + { name: 'github.com/sourcegraph/about', id: 1 }, + { name: 'github.com/sourcegraph/annotate', id: 2 }, + { name: 'github.com/sourcegraph/sourcegraph', id: 3 }, + { name: 'github.com/docker/compose', id: 4 }, + ], + includedRepos: [{ name: 'github.com/docker/compose', id: 4 }], + fileChunks: [ + { + repo: { + name: 'github.com/sourcegraph/about', + id: 1, + }, + path: '/file1.go', + }, + { + repo: { + name: 'github.com/sourcegraph/annotate', + id: 2, + }, + path: '/file2.go', + }, + { + repo: { + name: 'github.com/sourcegraph/sourcegraph', + id: 3, + }, + path: '/file3.go', + }, + { + repo: { + name: 'github.com/docker/compose', + id: 4, + }, + path: '/file4.go', + }, + ], + includedFileChunks: [ + { + repo: { + name: 'github.com/docker/compose', + id: 4, + }, + path: '/file4.go', + }, + ], + }, + { + name: 'Include and exclude rules are defined', + description: + 'Only repos matching any of "include" and not matching any of "exclude" repo name patterns should be included.', + includeByDefault: true, + includeUnknown: false, + 'cody.contextFilters': { + include: [{ repoNamePattern: '^github\\.com\\/sourcegraph\\/.+' }], + exclude: [{ repoNamePattern: '.*cody.*' }], + }, + repos: [ + { name: 'github.com/sourcegraph/about', id: 1 }, + { name: 'github.com/sourcegraph/annotate', id: 2 }, + { name: 'github.com/sourcegraph/sourcegraph', id: 3 }, + { name: 'github.com/docker/compose', id: 4 }, + { name: 'github.com/sourcegraph/cody', id: 5 }, + ], + includedRepos: [ + { name: 'github.com/sourcegraph/about', id: 1 }, + { name: 'github.com/sourcegraph/annotate', id: 2 }, + { name: 'github.com/sourcegraph/sourcegraph', id: 3 }, + ], + fileChunks: [ + { + repo: { + name: 'github.com/sourcegraph/about', + id: 1, + }, + path: '/file1.go', + }, + { + repo: { + name: 'github.com/sourcegraph/annotate', + id: 2, + }, + path: '/file2.go', + }, + { + repo: { + name: 'github.com/sourcegraph/sourcegraph', + id: 3, + }, + path: '/file3.go', + }, + { + repo: { + name: 'github.com/docker/compose', + id: 4, + }, + path: '/file4.go', + }, + { + repo: { + name: 'github.com/sourcegraph/cody', + id: 5, + }, + path: '/index.ts', + }, + ], + includedFileChunks: [ + { + repo: { + name: 'github.com/sourcegraph/about', + id: 1, + }, + path: '/file1.go', + }, + { + repo: { + name: 'github.com/sourcegraph/annotate', + id: 2, + }, + path: '/file2.go', + }, + { + repo: { + name: 'github.com/sourcegraph/sourcegraph', + id: 3, + }, + path: '/file3.go', + }, + ], + }, + { + name: 'Multiple include and exclude rules are defined', + description: + 'Only repos matching any of "include" and not matching any of "exclude" repo name patterns should be included.', + includeByDefault: true, + includeUnknown: false, + 'cody.contextFilters': { + include: [ + { repoNamePattern: '^github\\.com\\/sourcegraph\\/.+' }, + { repoNamePattern: '^github\\.com\\/docker\\/compose$' }, + { repoNamePattern: '^github\\.com\\/.+\\/react' }, + ], + exclude: [{ repoNamePattern: '.*cody.*' }, { repoNamePattern: '.+\\/docker\\/.+' }], + }, + repos: [ + { name: 'github.com/sourcegraph/about', id: 1 }, + { name: 'github.com/sourcegraph/annotate', id: 2 }, + { name: 'github.com/sourcegraph/sourcegraph', id: 3 }, + { name: 'github.com/docker/compose', id: 4 }, + { name: 'github.com/sourcegraph/cody', id: 5 }, + { name: 'github.com/facebook/react', id: 6 }, + ], + includedRepos: [ + { name: 'github.com/sourcegraph/about', id: 1 }, + { name: 'github.com/sourcegraph/annotate', id: 2 }, + { name: 'github.com/sourcegraph/sourcegraph', id: 3 }, + { name: 'github.com/facebook/react', id: 6 }, + ], + fileChunks: [ + { + repo: { + name: 'github.com/sourcegraph/about', + id: 1, + }, + path: '/file1.go', + }, + { + repo: { + name: 'github.com/sourcegraph/annotate', + id: 2, + }, + path: '/file2.go', + }, + { + repo: { + name: 'github.com/sourcegraph/sourcegraph', + id: 3, + }, + path: '/file3.go', + }, + { + repo: { + name: 'github.com/docker/compose', + id: 4, + }, + path: '/file4.go', + }, + { + repo: { + name: 'github.com/sourcegraph/cody', + id: 5, + }, + path: '/index.ts', + }, + { + repo: { + name: 'github.com/facebook/react', + id: 6, + }, + path: '/hooks.ts', + }, + ], + includedFileChunks: [ + { + repo: { + name: 'github.com/sourcegraph/about', + id: 1, + }, + path: '/file1.go', + }, + { + repo: { + name: 'github.com/sourcegraph/annotate', + id: 2, + }, + path: '/file2.go', + }, + { + repo: { + name: 'github.com/sourcegraph/sourcegraph', + id: 3, + }, + path: '/file3.go', + }, + { + repo: { + name: 'github.com/facebook/react', + id: 6, + }, + path: '/hooks.ts', + }, + ], + }, + { + name: 'Include rules contain repo name pattern matching any repo', + description: 'Any repo should be included.', + includeByDefault: true, + includeUnknown: false, + 'cody.contextFilters': { + include: [{ repoNamePattern: '.*' }], + }, + repos: [ + { name: 'github.com/sourcegraph/about', id: 1 }, + { name: 'github.com/sourcegraph/annotate', id: 2 }, + { name: 'github.com/sourcegraph/sourcegraph', id: 3 }, + { name: 'github.com/docker/compose', id: 4 }, + { name: 'github.com/sourcegraph/cody', id: 5 }, + { name: 'github.com/facebook/react', id: 6 }, + ], + includedRepos: [ + { name: 'github.com/sourcegraph/about', id: 1 }, + { name: 'github.com/sourcegraph/annotate', id: 2 }, + { name: 'github.com/sourcegraph/sourcegraph', id: 3 }, + { name: 'github.com/docker/compose', id: 4 }, + { name: 'github.com/sourcegraph/cody', id: 5 }, + { name: 'github.com/facebook/react', id: 6 }, + ], + fileChunks: [ + { + repo: { + name: 'github.com/sourcegraph/about', + id: 1, + }, + path: '/file1.go', + }, + { + repo: { + name: 'github.com/sourcegraph/annotate', + id: 2, + }, + path: '/file2.go', + }, + { + repo: { + name: 'github.com/sourcegraph/sourcegraph', + id: 3, + }, + path: '/file3.go', + }, + { + repo: { + name: 'github.com/docker/compose', + id: 4, + }, + path: '/file4.go', + }, + { + repo: { + name: 'github.com/sourcegraph/cody', + id: 5, + }, + path: '/index.ts', + }, + { + repo: { + name: 'github.com/facebook/react', + id: 6, + }, + path: '/hooks.ts', + }, + ], + includedFileChunks: [ + { + repo: { + name: 'github.com/sourcegraph/about', + id: 1, + }, + path: '/file1.go', + }, + { + repo: { + name: 'github.com/sourcegraph/annotate', + id: 2, + }, + path: '/file2.go', + }, + { + repo: { + name: 'github.com/sourcegraph/sourcegraph', + id: 3, + }, + path: '/file3.go', + }, + { + repo: { + name: 'github.com/docker/compose', + id: 4, + }, + path: '/file4.go', + }, + { + repo: { + name: 'github.com/sourcegraph/cody', + id: 5, + }, + path: '/index.ts', + }, + { + repo: { + name: 'github.com/facebook/react', + id: 6, + }, + path: '/hooks.ts', + }, + ], + }, + { + name: 'Exclude rules contain repo name pattern matching any repo', + description: 'Neither repo should be included.', + includeByDefault: true, + includeUnknown: false, + 'cody.contextFilters': { + include: [{ repoNamePattern: '^github\\.com\\/sourcegraph\\/.+' }], + exclude: [{ repoNamePattern: '.*' }], + }, + repos: [ + { name: 'github.com/sourcegraph/about', id: 1 }, + { name: 'github.com/sourcegraph/annotate', id: 2 }, + { name: 'github.com/sourcegraph/sourcegraph', id: 3 }, + { name: 'github.com/docker/compose', id: 4 }, + ], + includedRepos: [], + fileChunks: [ + { + repo: { + name: 'github.com/sourcegraph/about', + id: 1, + }, + path: '/file1.go', + }, + { + repo: { + name: 'github.com/sourcegraph/annotate', + id: 2, + }, + path: '/file2.go', + }, + { + repo: { + name: 'github.com/sourcegraph/sourcegraph', + id: 3, + }, + path: '/file3.go', + }, + { + repo: { + name: 'github.com/docker/compose', + id: 4, + }, + path: '/file4.go', + }, + ], + includedFileChunks: [], + }, + ] + + for (const testCase of testCases) { + it(testCase.name, async () => { + const ccf = testCase['cody.contextFilters'] + if (!ccf) { + return + } + const { isRepoIgnored, isFileIgnored } = await getFilterFnsFromCodyContextFilters(ccf) + + const gotRepos = testCase.repos.filter(r => !isRepoIgnored(r.name)) + expect(gotRepos).toEqual(testCase.includedRepos) + + const gotFileChunks = testCase.fileChunks.filter(fc => !isFileIgnored(fc.repo.name, fc.path)) + expect(gotFileChunks).toEqual(testCase.includedFileChunks) + }) + } +}) diff --git a/client/web/src/cody/useCodyIgnore.tsx b/client/web/src/cody/useCodyIgnore.tsx new file mode 100644 index 00000000000..a22556fa2ca --- /dev/null +++ b/client/web/src/cody/useCodyIgnore.tsx @@ -0,0 +1,261 @@ +import React, { createContext, useCallback, useMemo, useContext, useEffect, useState } from 'react' + +import { type ApolloClient, type ApolloQueryResult, useApolloClient } from '@apollo/client' + +import { useQuery, gql, getDocumentNode } from '@sourcegraph/http-client' + +import type { + CodyIgnoreContentResult, + CodyIgnoreContentVariables, + ContextFiltersResult, + ContextFiltersVariables, +} from '../graphql-operations' + +import { isCodyEnabled } from './isCodyEnabled' + +interface CodyIgnoreFns { + isRepoIgnored(repoName: string): boolean + isFileIgnored(repoName: string, filePath: string): boolean +} + +// alwaysTrue is exported only for testing purposes. +export const alwaysTrue = (): true => true +// alwaysFalse is exported only for testing purposes. +export const alwaysFalse = (): false => false + +/** + * defaultCodyIgnoreFns is used to set the default `CodyIgnoreContext` value. + * + * Every repo and file is allowed by default. + */ +const defaultCodyIgnoreFns = { isRepoIgnored: alwaysFalse, isFileIgnored: alwaysFalse } +const CodyIgnoreContext = createContext(defaultCodyIgnoreFns) + +export function useCodyIgnore(): CodyIgnoreFns { + return useContext(CodyIgnoreContext) +} + +/** + * CodyIgnoreProvider provides {@link CodyIgnoreFns} based on the {@link isSourcegraphDotCom} prop. + * + * If Cody is enabled, the {@link CodyIgnoreFns} are defined based on: + * - {@link CODY_IGNORE_FILE_PATH} file content for dotcom users; + * - {@link CodyContextFilters} from the site config for enterprise users. + * + * If Cody is not enabled, {@link defaultCodyIgnoreFns} are used. + */ +export const CodyIgnoreProvider: React.FC> = ({ + isSourcegraphDotCom, + children, +}) => { + // Cody is not enabled, return default ignore fns. + if (!isCodyEnabled()) { + return {children} + } + + if (isSourcegraphDotCom) { + // Cody Ignore is an experimental feature on dotcom. If the feature is not enabled, return default ignore fns. + if (!window.context?.experimentalFeatures.codyContextIgnore) { + return {children} + } + + return {children} + } + + return {children} +} + +/** + * DotcomProvider provides {@link CodyIgnoreFns} as {@link CodyIgnoreContext} value + * based on the {@link CODY_IGNORE_FILE_PATH} content for the given repo. + * + * Rules defined in {@link CODY_IGNORE_FILE_PATH} apply only to a given repo, thus: + * - {@link CodyIgnoreFns.isRepoIgnored} always returns `false` + * - {@link CodyIgnoreFns.isFileIgnored} returns whether the file path matches ignore rules if + * {@link CODY_IGNORE_FILE_PATH} exists in the repo, and `false` if it doesn't exist. + */ +const DotcomProvider: React.FC> = ({ children }) => { + const client = useApolloClient() + const [isFileIgnoredByRepo, setIsFileIgnoredByRepo] = useState>({}) + + const fetchCodyIgnoreContent = useMemo(() => createCodyIgnoreContentFetcher(client), [client]) + + const isFileIgnored: CodyIgnoreFns['isFileIgnored'] = useCallback( + (repoName, filePath) => { + // If ignore fn is defined for the repo, use it. + const fn = isFileIgnoredByRepo[repoName] + if (fn) { + return fn(repoName, filePath) + } + + // If ignore fn is not defined for the repo, fetch the ignore file content and update the state. + void fetchCodyIgnoreContent(repoName).then(fn => + setIsFileIgnoredByRepo(state => ({ ...state, [repoName]: fn })) + ) + + // Ignore file as we don't have the ignore file content yet. + return true + }, + [isFileIgnoredByRepo, setIsFileIgnoredByRepo, fetchCodyIgnoreContent] + ) + + const fns = useMemo(() => ({ isRepoIgnored: alwaysFalse, isFileIgnored }), [isFileIgnored]) + + return {children} +} + +const CODY_IGNORE_CONTENT = gql` + query CodyIgnoreContent($repoName: String!, $repoRev: String!, $filePath: String!) { + repository(name: $repoName) { + id + commit(rev: $repoRev) { + id + blob(path: $filePath) { + content + } + } + } + } +` + +const CODY_IGNORE_FILE_PATH = '.cody/ignore' + +/** + * createCodyIgnoreContentFetcher creates a function that fetches {@link CODY_IGNORE_FILE_PATH} content for a given repo. + * + * If an error occurs when fetching the content, the function returns {@link alwaysTrue} function. + * + * If the content is fetched successfully, the function returns: + * - if the ignore file exists - {@link CodyIgnoreFns.isFileIgnored} function based on the ignore rules from the content + * - if the ignore file doesn't exist - {@link alwaysFalse} function. + * + * Function caches promises to avoid fetching the content for the same repo multiple times. + */ +function createCodyIgnoreContentFetcher( + client: ApolloClient +): (repoName: string) => Promise { + const cache = new Map>>() + + return async (repoName: string) => { + let promise = cache.get(repoName) + if (!promise) { + promise = client.query({ + query: getDocumentNode(CODY_IGNORE_CONTENT), + variables: { repoName, repoRev: 'HEAD', filePath: CODY_IGNORE_FILE_PATH }, + }) + cache.set(repoName, promise) + } + + const { error, data } = await promise + + if (error) { + // Error when fetching ignore file, ignore everything. + return alwaysTrue + } + + const content = data?.repository?.commit?.blob?.content + if (content) { + const ignore = (await import('ignore')).default + return (_repoName: string, filePath: string) => ignore().add(content).ignores(filePath) + } + + // Ignore file doesn't exist for a given repo, allow everything. + return alwaysFalse + } +} + +interface CodyContextFilters { + include?: CodyContextFilterItem[] + exclude?: CodyContextFilterItem[] +} + +interface CodyContextFilterItem { + // Regex following RE2 syntax + repoNamePattern: string +} + +const CODY_CONTEXT_FILTERS_QUERY = gql` + query ContextFilters { + site { + codyContextFilters(version: V1) { + raw + } + } + } +` + +/** + * EnterpriseProvider fetches {@link CodyContextFilters} from the site config and provides {@link CodyIgnoreFns} as + * {@link CodyIgnoreContext} value based on the filters from the site config: + * - If the query hasn't yet started, filters are {@link CodyIgnoreFns} are set to ignore everything. + * - If the site config query is loading or returned an error, filters are {@link CodyIgnoreFns} are set to ignore everything. + * - If {@link CodyContextFilters} are not defined in the site config, {@link CodyIgnoreFns} are set to allow everything. + * - If {@link CodyContextFilters} are defined, {@link CodyIgnoreFns} are set based on the filters value. + */ +const EnterpriseProvider: React.FC> = ({ children }) => { + const { data, error, loading } = useQuery( + CODY_CONTEXT_FILTERS_QUERY, + {} + ) + const [fns, setFns] = useState({ isRepoIgnored: alwaysTrue, isFileIgnored: alwaysTrue }) + + useEffect(() => { + // To dynamically import RE2 regex parsing library, we need to call this function in `useEffect`. + void (async () => { + // Cody context filters are not available, ignore everything + if (loading || error) { + setFns({ isRepoIgnored: alwaysTrue, isFileIgnored: alwaysTrue }) + return + } + + const filters = data?.site.codyContextFilters.raw as CodyContextFilters + + // Cody context filters are not defined, allow everything + if (!filters) { + setFns({ isRepoIgnored: alwaysFalse, isFileIgnored: alwaysFalse }) + return + } + + setFns(await getFilterFnsFromCodyContextFilters(filters)) + })() + }, [loading, error, data]) + + return {children} +} + +/** + * getFilterFnsFromCodyContextFilters imports RE2 regexes parsing library and returns {@link CodyIgnoreFns}. + * + * `isRepoIgnored` function returns true if repo doesn't match any of the `include` or matches any of `exclude` repo name patterns. + * + * Currently, only repo-level ignore filters are supported. Thus, `isFileIgnored` function returns the same result as `isRepoIgnored`. + * If repo is allowed, every file in it is allowed too. + * + * If filters include repo name patterns that are not valid regexes, both `isRepoIgnored` and `isFileIgnored` functions return false. + * + * This function is exported only for testing purposes. + */ +export async function getFilterFnsFromCodyContextFilters(filters: CodyContextFilters): Promise { + const { RE2JS } = await import('re2js') + let include: InstanceType[] = [] + let exclude: InstanceType[] = [] + try { + include = filters.include?.map(({ repoNamePattern }) => RE2JS.compile(repoNamePattern)) || [] + exclude = filters.exclude?.map(({ repoNamePattern }) => RE2JS.compile(repoNamePattern)) || [] + } catch (error) { + // eslint-disable-next-line no-console + console.error('Failed to parse Cody context filters', error) + // If we fail to parse the filters, ignore everything + return { isRepoIgnored: alwaysTrue, isFileIgnored: alwaysTrue } + } + + const isRepoIgnored = (repoName: string): boolean => { + const isIncluded = !include.length || include.some(re => re.matches(repoName)) + const isExcluded = exclude.some(re => re.matches(repoName)) + return !isIncluded || isExcluded + } + + // We don't support file-level ignore filters yet, so we just use the repo-level filters + const isFileIgnored = (repoName: string, _filePath: string): boolean => isRepoIgnored(repoName) + return { isRepoIgnored, isFileIgnored } +} diff --git a/client/web/src/integration/commit-page.test.ts b/client/web/src/integration/commit-page.test.ts index 1ff7ad7fa84..033863d8563 100644 --- a/client/web/src/integration/commit-page.test.ts +++ b/client/web/src/integration/commit-page.test.ts @@ -14,6 +14,7 @@ import { afterEachSaveScreenshotIfFailed } from '@sourcegraph/shared/src/testing import type { WebGraphQlOperations } from '../graphql-operations' import { createWebIntegrationTestContext, type WebIntegrationTestContext } from './context' +import { createCodyContextFiltersResult } from './graphQlResponseHelpers' import { commonWebGraphQlResults } from './graphQlResults' import { percySnapshotWithVariants } from './utils' @@ -23,6 +24,7 @@ describe('RepositoryCommitPage', () => { const commitDate = subDays(new Date(), 7).toISOString() const commonBlobGraphQlResults: Partial = { ...commonWebGraphQlResults, + ContextFilters: () => createCodyContextFiltersResult(), RepositoryCommit: () => ({ node: { __typename: 'Repository', diff --git a/client/web/src/integration/graphQlResponseHelpers.ts b/client/web/src/integration/graphQlResponseHelpers.ts index ad59690d0e0..d6c2cdc5460 100644 --- a/client/web/src/integration/graphQlResponseHelpers.ts +++ b/client/web/src/integration/graphQlResponseHelpers.ts @@ -4,6 +4,7 @@ import { RepositoryType, type TreeEntriesResult } from '@sourcegraph/shared/src/ import { type BlobResult, + ContextFiltersResult, ExternalServiceKind, type FileExternalLinksResult, type FileNamesResult, @@ -154,3 +155,13 @@ export const createFileNamesResult = (): FileNamesResult => ({ commit: { id: 'c0ff33', __typename: 'GitCommit', fileNames: ['README.md'] }, }, }) + +export const createCodyContextFiltersResult = (): ContextFiltersResult => ({ + site: { + codyContextFilters: { + raw: null, + __typename: 'CodyContextFilters', + }, + __typename: 'Site', + }, +}) diff --git a/client/web/src/integration/repository.test.ts b/client/web/src/integration/repository.test.ts index 9c575676921..de197c6186f 100644 --- a/client/web/src/integration/repository.test.ts +++ b/client/web/src/integration/repository.test.ts @@ -31,11 +31,12 @@ import { createFileNamesResult, createResolveCloningRepoRevisionResult, createFileTreeEntriesResult, + createCodyContextFiltersResult, } from './graphQlResponseHelpers' import { commonWebGraphQlResults } from './graphQlResults' import { createEditorAPI, percySnapshotWithVariants, removeContextFromQuery } from './utils' -export const getCommonRepositoryGraphQlResults = ( +const getCommonRepositoryGraphQlResults = ( repositoryName: string, repositoryUrl: string, fileEntries: string[] = [] @@ -48,6 +49,7 @@ export const getCommonRepositoryGraphQlResults = ( TreeEntries: () => createTreeEntriesResult(repositoryUrl, fileEntries), FileTreeEntries: () => createFileTreeEntriesResult(repositoryUrl, fileEntries), Blob: ({ filePath }) => createBlobContentResult(`content for: ${filePath}\nsecond line\nthird line`), + ContextFilters: () => createCodyContextFiltersResult(), }) const now = new Date() @@ -733,6 +735,7 @@ describe('Repository', () => { }, }, }), + ContextFilters: () => createCodyContextFiltersResult(), }) await driver.page.goto(driver.sourcegraphBaseUrl + '/github.com/sourcegraph/sourcegraph/-/commits') await driver.page.waitForSelector('[data-testid="commits-page"]', { visible: true }) diff --git a/client/web/src/repo/RepoContainer.tsx b/client/web/src/repo/RepoContainer.tsx index ad59bb010a3..ae56e7245b8 100644 --- a/client/web/src/repo/RepoContainer.tsx +++ b/client/web/src/repo/RepoContainer.tsx @@ -43,6 +43,7 @@ import type { CodeIntelligenceProps } from '../codeintel' import { RepoContainerEditor } from '../cody/components/RepoContainerEditor' import { CodySidebar } from '../cody/sidebar' import { useCodySidebar, useSidebarSize, CODY_SIDEBAR_SIZES } from '../cody/sidebar/Provider' +import { useCodyIgnore } from '../cody/useCodyIgnore' import type { BreadcrumbSetters, BreadcrumbsProps } from '../components/Breadcrumbs' import { RouteError } from '../components/ErrorBoundary' import { HeroPage } from '../components/HeroPage' @@ -344,6 +345,8 @@ const RepoUserContainer: FC = ({ const { sidebarSize, setSidebarSize: setCodySidebarSize } = useSidebarSize() + const { isRepoIgnored } = useCodyIgnore() + /* eslint-disable react-hooks/exhaustive-deps */ const codySidebarSize = useMemo(() => sidebarSize, [isCodySidebarOpen]) /* eslint-enable react-hooks/exhaustive-deps */ @@ -434,7 +437,7 @@ const RepoUserContainer: FC = ({ // must exactly match how the revision was encoded in the URL const repoNameAndRevision = `${repoName}${typeof rawRevision === 'string' ? `@${rawRevision}` : ''}` const licenseFeatures = getLicenseFeatures() - const showAskCodyBtn = licenseFeatures.isCodyEnabled && !isCodySidebarOpen + const showAskCodyBtn = licenseFeatures.isCodyEnabled && !isRepoIgnored(repoName) && !isCodySidebarOpen return ( <> @@ -557,7 +560,7 @@ const RepoUserContainer: FC = ({ - {isCodySidebarOpen && ( + {!isRepoIgnored(repoName) && isCodySidebarOpen && ( = props => { useMemo(() => EditorView.darkTheme.of(!isLightTheme), [isLightTheme]) ) + const { isFileIgnored } = useCodyIgnore() + const isCodyEnabledForFile = isCodyEnabled() && !isFileIgnored(blobInfo.repoName, blobInfo.filePath) + const extensions = useMemo( () => [ staticExtensions, @@ -348,7 +352,7 @@ export const CodeMirrorBlob: React.FunctionComponent = props => { scipSnapshot(blobInfo.content, blobInfo.snapshotData), openCodeGraphExtension, codeFoldingExtension(), - isCodyEnabled() + isCodyEnabledForFile ? codyWidgetExtension( // TODO: replace with real telemetryRecorder noOpTelemetryRecorder, @@ -390,7 +394,7 @@ export const CodeMirrorBlob: React.FunctionComponent = props => { staticHighlightRanges, navigate, blobInfo, - isCodyEnabled, + isCodyEnabledForFile, openCodeGraphExtension, codeIntelExtension, editorRef.current, diff --git a/client/web/src/routes.tsx b/client/web/src/routes.tsx index f2d5ea543d7..868dd8ca165 100644 --- a/client/web/src/routes.tsx +++ b/client/web/src/routes.tsx @@ -30,6 +30,7 @@ const SurveyPage = lazyComponent(() => import('./marketing/page/SurveyPage'), 'S const RepoContainer = lazyComponent(() => import('./repo/RepoContainer'), 'RepoContainer') const TeamsArea = lazyComponent(() => import('./team/TeamsArea'), 'TeamsArea') const CodySidebarStoreProvider = lazyComponent(() => import('./cody/sidebar/Provider'), 'CodySidebarStoreProvider') +const CodyIgnoreProvider = lazyComponent(() => import('./cody/useCodyIgnore'), 'CodyIgnoreProvider') const GetCodyPage = lazyComponent(() => import('./get-cody/GetCodyPage'), 'GetCodyPage') const PostSignUpPage = lazyComponent(() => import('./auth/PostSignUpPage'), 'PostSignUpPage') @@ -384,11 +385,13 @@ export const routes: RouteObject[] = [ element: ( ( - + + + )} condition={({ licenseFeatures }) => licenseFeatures.isCodyEnabled} /> @@ -439,12 +442,14 @@ export const routes: RouteObject[] = [ element: ( ( - - - + + + + + )} condition={({ licenseFeatures }) => licenseFeatures.isCodeSearchEnabled} /> diff --git a/package.json b/package.json index cb9d3c39ffb..f67fb9a3c8b 100644 --- a/package.json +++ b/package.json @@ -387,6 +387,7 @@ "pretty-bytes": "^5.3.0", "process": "^0.11.10", "prop-types": "^15.7.2", + "re2js": "^0.4.1", "react": "18.1.0", "react-calendar": "^3.7.0", "react-circular-progressbar": "^2.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7d977a59e34..486b7d47a82 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -367,6 +367,9 @@ importers: prop-types: specifier: ^15.7.2 version: 15.8.1 + re2js: + specifier: ^0.4.1 + version: 0.4.1 react: specifier: 18.1.0 version: 18.1.0 @@ -21820,6 +21823,10 @@ packages: strip-json-comments: 2.0.1 dev: true + /re2js@0.4.1: + resolution: {integrity: sha512-Kxb+OKXrEPowP4bXAF07NDXtgYX07S8HeVGgadx5/D/R41LzWg1kgTD2szIv2iHJM3vrAPnDKaBzfUE/7QWX9w==} + dev: false + /react-calendar@3.7.0(react-dom@18.1.0)(react@18.1.0): resolution: {integrity: sha512-zkK95zWLWLC6w3O7p3SHx/FJXEyyD2UMd4jr3CrKD+G73N+G5vEwrXxYQCNivIPoFNBjqoyYYGlkHA+TBDPLCw==} peerDependencies: