mirror of
https://github.com/sourcegraph/sourcegraph.git
synced 2026-02-06 12:51:55 +00:00
fix(svelte): Respect cody ignore settings (#63677)
This commit adds support for cody ignore settings to the SvelteKit app. When cody is disabled on the instance or for the user, the cody button and sidebar are not shown anymore. Likewise if the repository is excluded from cody, the buttons and sidebar are not shown. The implementation was inspired by `useCodyIngore.ts`, but simplified for use in the SvelteKit app. It seems that the file exclusion logic is not actually used for the sidebar and thus was omitted. It also seems that for the sidebar itself we are only checking whether the current repository is excluded or not, which lets us simplify the whole setup and simply pass a boolean (store) from the data loader, indicating whether cody is enabled or not. Furthermore I introduced zod to validate that the value of `codyContextFilters.raw`, which is typed as `JSONValue`, has the expected shape. We've run into issues in the past where such values have just been cast to the expected Typescript type. zod adds runtime validation. Note that we use JSON schema base validation (with `ajv`) in some places, but that requires importing and sending the whole JSON schema to the client, which is something I'd like to avoid. The advantage JSON schema is that we also use it for generating Go code. We should find a way to use JSON schema but generate specific validators at build time. There are also other libraries that do runtime validation and are smaller but they don't necessarily allow asynchronous validation (which we want to do because we only want to import the `re2js` library when necessary; of course we could organize the code differently but it's nice to be able to encapsulate this logic) ## Test plan Manual testing and new integration tests.
This commit is contained in:
parent
e3d112b01c
commit
fb1106be15
@ -62,6 +62,7 @@ BUILD_DEPS = [
|
||||
"//:node_modules/@mdi/js",
|
||||
"//:node_modules/@reach/combobox",
|
||||
"//:node_modules/@reach/menu-button",
|
||||
"//:node_modules/@sourcegraph/cody-context-filters-test-dataset",
|
||||
"//:node_modules/@types/lodash",
|
||||
"//:node_modules/@types/node",
|
||||
"//:node_modules/@types/react",
|
||||
@ -115,6 +116,7 @@ BUILD_DEPS = [
|
||||
":node_modules/hotkeys-js",
|
||||
":node_modules/mermaid",
|
||||
":node_modules/prismjs",
|
||||
":node_modules/re2js",
|
||||
":node_modules/sass",
|
||||
":node_modules/signale",
|
||||
":node_modules/svelte",
|
||||
@ -124,6 +126,7 @@ BUILD_DEPS = [
|
||||
":node_modules/vite",
|
||||
":node_modules/vite-plugin-inspect",
|
||||
":node_modules/wonka",
|
||||
":node_modules/zod",
|
||||
] + glob([
|
||||
"dev/**/*.cjs",
|
||||
"dev/**/*.ts",
|
||||
|
||||
@ -99,8 +99,10 @@
|
||||
"hotkeys-js": "^3.13.7",
|
||||
"mermaid": "^10.9.1",
|
||||
"prismjs": "^1.29.0",
|
||||
"re2js": "^0.4.1",
|
||||
"ts-key-enum": "^2.0.12",
|
||||
"wonka": "^6.3.4"
|
||||
"wonka": "^6.3.4",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"msw": {
|
||||
"workerDirectory": "static"
|
||||
|
||||
@ -1,3 +1,9 @@
|
||||
<script context="module" lang="ts">
|
||||
import { uniqueID } from '$lib/dom'
|
||||
|
||||
export const CODY_SIDEBAR_ID = uniqueID('cody-sidebar')
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
@ -12,17 +18,24 @@
|
||||
export let repository: CodySidebar_ResolvedRevision
|
||||
export let filePath: string
|
||||
|
||||
const headingID = uniqueID('cody-sidebar-heading')
|
||||
const dispatch = createEventDispatcher<{ close: void }>()
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
<aside id={CODY_SIDEBAR_ID} aria-labelledby={headingID}>
|
||||
<div class="header">
|
||||
<h3>
|
||||
<h3 id={headingID}>
|
||||
<Icon icon={ISgCody} /> Cody
|
||||
<Badge variant="warning">Experimental</Badge>
|
||||
</h3>
|
||||
<Tooltip tooltip="Close Cody chat">
|
||||
<Button variant="icon" aria-label="Close Cody" on:click={() => dispatch('close')}>
|
||||
<Button
|
||||
variant="icon"
|
||||
aria-label="Close Cody"
|
||||
on:click={() => dispatch('close')}
|
||||
aria-controls={CODY_SIDEBAR_ID}
|
||||
aria-expanded="true"
|
||||
>
|
||||
<Icon icon={ILucideX} inline aria-hidden />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
@ -39,10 +52,10 @@
|
||||
<a href="/sign-in">Sign in</a> to use Cody.
|
||||
</Alert>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<style lang="scss">
|
||||
.root {
|
||||
aside {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
31
client/web-sveltekit/src/lib/cody/config.test.ts
Normal file
31
client/web-sveltekit/src/lib/cody/config.test.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { describe, test, expect } from 'vitest'
|
||||
|
||||
import { testCases } from '@sourcegraph/cody-context-filters-test-dataset/dataset.json'
|
||||
|
||||
import { CodyContextFiltersSchema, getFiltersFromCodyContextFilters } from './config'
|
||||
|
||||
describe('CodyContextFilters', () => {
|
||||
test('invalid re2 regex', async () => {
|
||||
const regexWithLookahead = '\\d(?=\\D)' // not supported in RE2
|
||||
const result = await CodyContextFiltersSchema.safeParseAsync({
|
||||
exclude: [{ repoNamePattern: regexWithLookahead }],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFiltersFromCodyContextFilters', () => {
|
||||
for (const testCase of testCases) {
|
||||
test(testCase.name, async () => {
|
||||
const filters = testCase['cody.contextFilters']
|
||||
if (!filters) {
|
||||
return
|
||||
}
|
||||
|
||||
const filter = getFiltersFromCodyContextFilters(await CodyContextFiltersSchema.parseAsync(filters))
|
||||
|
||||
const gotRepos = testCase.repos.filter(repo => filter(repo.name))
|
||||
expect(gotRepos).toEqual(testCase.includedRepos)
|
||||
})
|
||||
}
|
||||
})
|
||||
58
client/web-sveltekit/src/lib/cody/config.ts
Normal file
58
client/web-sveltekit/src/lib/cody/config.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { memoize } from 'lodash'
|
||||
import { z } from 'zod'
|
||||
|
||||
// This is used in the site config to enable/disable cody for specific repositories.
|
||||
|
||||
/**
|
||||
* Re2Expression is a zod schema for a RE2 regular expression.
|
||||
*/
|
||||
const Re2Expression = z.string().transform(async (val, ctx) => {
|
||||
try {
|
||||
const { RE2JS } = await import('re2js')
|
||||
return RE2JS.compile(val)
|
||||
} catch (error) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Failed to parse r2 regex',
|
||||
})
|
||||
return z.NEVER
|
||||
}
|
||||
})
|
||||
|
||||
const CodyContextFilterItem = z.object({
|
||||
repoNamePattern: Re2Expression,
|
||||
})
|
||||
|
||||
/**
|
||||
* CodyContextFilters is a zod schema for the filters used to enable/disable cody for specific repositories.
|
||||
* It imports the RE2 parsing library and therefore needs to be
|
||||
* called with parseSync or safeParseAsync.
|
||||
*/
|
||||
export const CodyContextFiltersSchema = z
|
||||
.object({
|
||||
include: z.array(CodyContextFilterItem),
|
||||
exclude: z.array(CodyContextFilterItem),
|
||||
})
|
||||
.partial()
|
||||
|
||||
/**
|
||||
* CodyContextFilters describes the filters used to enable/disable cody for specific repositories.
|
||||
*/
|
||||
export type CodyContextFilters = z.infer<typeof CodyContextFiltersSchema>
|
||||
|
||||
/**
|
||||
* getFiltersFromCodyContextFilters imports the RE2 parsing library and returns a validation function.
|
||||
* That function returns true if a repo matches any of the include filters and none of the exclude filters.
|
||||
*
|
||||
* If filters include repo name patterns that are not valid regexes the function
|
||||
* always returns false.
|
||||
*/
|
||||
export const getFiltersFromCodyContextFilters = memoize(
|
||||
({ include, exclude }: CodyContextFilters): ((repoName: string) => boolean) =>
|
||||
(repoName: string): boolean => {
|
||||
const isIncluded = !include?.length || include.some(filter => filter.repoNamePattern.matches(repoName))
|
||||
const isExcluded = exclude?.some(filter => filter.repoNamePattern.matches(repoName))
|
||||
return isIncluded && !isExcluded
|
||||
},
|
||||
filters => filters
|
||||
)
|
||||
@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { CODY_SIDEBAR_ID } from '$lib/cody/CodySidebar.svelte'
|
||||
import Icon from '$lib/Icon.svelte'
|
||||
import { rightPanelOpen } from '$lib/repo/stores'
|
||||
import Tooltip from '$lib/Tooltip.svelte'
|
||||
@ -9,7 +10,7 @@
|
||||
</script>
|
||||
|
||||
<Tooltip tooltip="Open Cody chat">
|
||||
<button on:click={handleClick}>
|
||||
<button on:click={handleClick} aria-controls={CODY_SIDEBAR_ID} aria-expanded={$rightPanelOpen}>
|
||||
<Icon icon={ISgCody} />
|
||||
<span data-action-label>Cody</span>
|
||||
</button>
|
||||
|
||||
@ -97,7 +97,7 @@
|
||||
let references: RepoPage_ReferencesLocationConnection | null
|
||||
const fileTreeStore = createFileTreeStore({ fetchFileTreeData: fetchSidebarFileTree })
|
||||
|
||||
$: ({ revision = '', parentPath, repoName, resolvedRevision } = data)
|
||||
$: ({ revision = '', parentPath, repoName, resolvedRevision, isCodyAvailable } = data)
|
||||
$: fileTreeStore.set({ repoName, revision: resolvedRevision.commitID, path: parentPath })
|
||||
$: commitHistoryQuery = data.commitHistory
|
||||
$: lastCommitQuery = data.lastCommit
|
||||
@ -256,7 +256,7 @@
|
||||
<Panel order={1} id="main-content-panel">
|
||||
<slot />
|
||||
</Panel>
|
||||
{#if $rightPanelOpen}
|
||||
{#if $isCodyAvailable && $rightPanelOpen}
|
||||
<PanelResizeHandle id="right-sidebar-resize-handle" />
|
||||
<Panel id="right-sidebar-panel" order={2} minSize={20} maxSize={70}>
|
||||
<CodySidebar
|
||||
|
||||
@ -1,15 +1,17 @@
|
||||
import { dirname } from 'path'
|
||||
|
||||
import { from } from 'rxjs'
|
||||
import { readable, derived, type Readable } from 'svelte/store'
|
||||
|
||||
import { CodyContextFiltersSchema, getFiltersFromCodyContextFilters } from '$lib/cody/config'
|
||||
import type { LineOrPositionOrRange } from '$lib/common'
|
||||
import { getGraphQLClient, infinityQuery } from '$lib/graphql'
|
||||
import { getGraphQLClient, infinityQuery, type GraphQLClient } from '$lib/graphql'
|
||||
import { ROOT_PATH, fetchSidebarFileTree } from '$lib/repo/api/tree'
|
||||
import { resolveRevision } from '$lib/repo/utils'
|
||||
import { parseRepoRevision } from '$lib/shared'
|
||||
|
||||
import type { LayoutLoad } from './$types'
|
||||
import { GitHistoryQuery, LastCommitQuery, RepoPage_PreciseCodeIntel } from './layout.gql'
|
||||
import { CodyContextFiltersQuery, GitHistoryQuery, LastCommitQuery, RepoPage_PreciseCodeIntel } from './layout.gql'
|
||||
|
||||
const HISTORY_COMMITS_PER_PAGE = 20
|
||||
const REFERENCES_PER_PAGE = 20
|
||||
@ -38,6 +40,7 @@ export const load: LayoutLoad = async ({ parent, params }) => {
|
||||
fileTree,
|
||||
filePath,
|
||||
parentPath,
|
||||
isCodyAvailable: createCodyAvailableStore(client, repoName),
|
||||
lastCommit: client.query(LastCommitQuery, {
|
||||
repoName,
|
||||
revspec: revision,
|
||||
@ -147,3 +150,62 @@ export const load: LayoutLoad = async ({ parent, params }) => {
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a store that indicates whether Cody is available for the current user and repository.
|
||||
* If cody is not enabled on the instance or for the current user, the store will always return false.
|
||||
* If this is sourcegraph.com, the store will always return true.
|
||||
* Otherwise we'll check the site configuration to see if Cody is disabled for the current repository.
|
||||
* Initially the store will return false until the site configuration is loaded. If there is an
|
||||
* error loading the site configuration or processing it, the store will return false.
|
||||
*/
|
||||
function createCodyAvailableStore(client: GraphQLClient, repoName: string): Readable<boolean> {
|
||||
if (!window.context.codyEnabledOnInstance || !window.context.codyEnabledForCurrentUser) {
|
||||
return readable(false)
|
||||
}
|
||||
|
||||
// Cody is always enabled on sourcegraph.com
|
||||
if (window.context.sourcegraphDotComMode) {
|
||||
return readable(true)
|
||||
}
|
||||
|
||||
// On enterprise instances, we check whether the site config disables
|
||||
// cody for specific repos.
|
||||
|
||||
const queryResult = readable(
|
||||
// First check the cache to see if Cody is disabled for the current repo.
|
||||
client.readQuery(CodyContextFiltersQuery, {}),
|
||||
set => {
|
||||
// Then update the store with the latest data.
|
||||
client.query(CodyContextFiltersQuery, {}, { requestPolicy: 'network-only' }).then(set)
|
||||
}
|
||||
)
|
||||
|
||||
// NOTE: The way this is implemented won't trigger a GraphQL on data prefetching. This is intentional
|
||||
// (for now) because we don't want to refetch the data for every data preload.
|
||||
return derived(queryResult, ($codyContextFilters, set) => {
|
||||
if (!$codyContextFilters || $codyContextFilters.error) {
|
||||
// Cody context filters are not available, disable Cody
|
||||
set(false)
|
||||
return
|
||||
}
|
||||
const filters = $codyContextFilters.data?.site.codyContextFilters.raw
|
||||
if (!filters) {
|
||||
// Cody context filters are not defined, enable Cody
|
||||
set(true)
|
||||
return
|
||||
}
|
||||
|
||||
CodyContextFiltersSchema.safeParseAsync(filters).then(result => {
|
||||
if (!result.success) {
|
||||
// codyContextFilters cannot be parsed properly, disable Cody
|
||||
// TODO: log error with sentry
|
||||
set(false)
|
||||
return
|
||||
}
|
||||
if (result.data) {
|
||||
set(getFiltersFromCodyContextFilters(result.data)(repoName))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@ -71,6 +71,7 @@
|
||||
revision,
|
||||
resolvedRevision: { commitID },
|
||||
revisionOverride,
|
||||
isCodyAvailable,
|
||||
} = data)
|
||||
$: blobLoader.set(data.blob)
|
||||
$: highlightsLoader.set(data.highlights)
|
||||
@ -185,7 +186,9 @@
|
||||
<OpenInCodeHostAction data={blob} lineOrPosition={data.lineOrPosition} />
|
||||
{/if}
|
||||
<Permalink {commitID} />
|
||||
<OpenCodyAction />
|
||||
{#if $isCodyAvailable}
|
||||
<OpenCodyAction />
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="actionmenu">
|
||||
<MenuLink href={rawURL} target="_blank">
|
||||
|
||||
@ -22,6 +22,7 @@
|
||||
const treeEntriesWithCommitInfo = createPromiseStore<TreeEntryWithCommitInfo[]>()
|
||||
|
||||
$: treeEntriesWithCommitInfo.set(data.treeEntriesWithCommitInfo)
|
||||
$: isCodyAvailable = data.isCodyAvailable
|
||||
|
||||
afterNavigate(() => {
|
||||
repositoryContext.set({ directoryPath: data.filePath })
|
||||
@ -38,7 +39,9 @@
|
||||
<FileHeader type="tree" repoName={data.repoName} revision={data.revision} path={data.filePath}>
|
||||
<svelte:fragment slot="actions">
|
||||
<Permalink commitID={data.resolvedRevision.commitID} />
|
||||
<OpenCodyAction />
|
||||
{#if isCodyAvailable}
|
||||
<OpenCodyAction />
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
</FileHeader>
|
||||
|
||||
|
||||
@ -1,3 +1,11 @@
|
||||
query CodyContextFiltersQuery {
|
||||
site {
|
||||
codyContextFilters(version: V1) {
|
||||
raw
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
query GitHistoryQuery($repoName: String!, $revspec: String!, $first: Int, $afterCursor: String, $filePath: String) {
|
||||
repository(name: $repoName) {
|
||||
id
|
||||
|
||||
@ -312,3 +312,140 @@ test('file popover', async ({ page, sg }, testInfo) => {
|
||||
await page.locator('span').filter({ hasText: /^src$/ }).getByRole('link').click()
|
||||
await page.waitForURL(/src$/)
|
||||
})
|
||||
|
||||
test.describe('cody sidebar', () => {
|
||||
const path = `/${repoName}/-/blob/index.js`
|
||||
|
||||
async function hasCody(page: Page): Promise<void> {
|
||||
const codyButton = page.getByLabel('Open Cody chat')
|
||||
await expect(codyButton).toBeVisible()
|
||||
await codyButton.click()
|
||||
await expect(page.getByRole('complementary', { name: 'Cody' })).toBeVisible()
|
||||
}
|
||||
|
||||
async function doesNotHaveCody(page: Page): Promise<void> {
|
||||
const codyButton = page.getByLabel('Open Cody chat')
|
||||
await expect(page.getByRole('link', { name: 'index.js' })).toBeVisible()
|
||||
await expect(codyButton).not.toBeAttached()
|
||||
}
|
||||
|
||||
test.describe('dotcom', () => {
|
||||
test.beforeEach(({ sg }) => {
|
||||
sg.dotcomMode()
|
||||
})
|
||||
|
||||
test('enabled when signed out', async ({ page, sg }) => {
|
||||
await page.goto(path)
|
||||
await hasCody(page)
|
||||
await expect(
|
||||
page.getByText('Cody is only available to signed-in users. Sign in to use Cody.')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('enabled when signed in', async ({ page, sg }) => {
|
||||
sg.signIn()
|
||||
|
||||
await page.goto(path)
|
||||
await hasCody(page)
|
||||
})
|
||||
|
||||
test('ignores context filters', async ({ page, sg }) => {
|
||||
sg.mockTypes({
|
||||
Site: () => ({
|
||||
codyContextFilters: {
|
||||
raw: {
|
||||
include: [String.raw`source.*`],
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
await page.goto(path)
|
||||
await hasCody(page)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('enterprise', () => {
|
||||
test.beforeEach(({ sg }) => {
|
||||
sg.signIn()
|
||||
|
||||
sg.mockTypes({
|
||||
Site: () => ({
|
||||
codyContextFilters: {
|
||||
raw: null,
|
||||
},
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
test('disabled when disabled on instance', async ({ page, sg }) => {
|
||||
sg.setWindowContext({
|
||||
codyEnabledOnInstance: false,
|
||||
})
|
||||
|
||||
await page.goto(path)
|
||||
await doesNotHaveCody(page)
|
||||
})
|
||||
|
||||
test('disabled when disabled for user', async ({ page, sg }) => {
|
||||
sg.setWindowContext({
|
||||
codyEnabledOnInstance: true,
|
||||
codyEnabledForCurrentUser: false,
|
||||
})
|
||||
|
||||
await page.goto(path)
|
||||
await doesNotHaveCody(page)
|
||||
})
|
||||
|
||||
test('enabled for user', async ({ page, sg }) => {
|
||||
// teardown takes longer than default timeout
|
||||
test.setTimeout(10000)
|
||||
|
||||
sg.setWindowContext({
|
||||
codyEnabledOnInstance: true,
|
||||
codyEnabledForCurrentUser: true,
|
||||
})
|
||||
|
||||
await page.goto(path)
|
||||
await hasCody(page)
|
||||
})
|
||||
|
||||
test('disabled for excluded repo', async ({ page, sg }) => {
|
||||
sg.setWindowContext({
|
||||
codyEnabledOnInstance: true,
|
||||
codyEnabledForCurrentUser: true,
|
||||
})
|
||||
sg.mockTypes({
|
||||
Site: () => ({
|
||||
codyContextFilters: {
|
||||
raw: {
|
||||
include: [String.raw`source.*`],
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
await page.goto(path)
|
||||
await doesNotHaveCody(page)
|
||||
})
|
||||
|
||||
test('disabled with invalid context filter', async ({ page, sg }) => {
|
||||
sg.setWindowContext({
|
||||
codyEnabledOnInstance: true,
|
||||
codyEnabledForCurrentUser: true,
|
||||
})
|
||||
sg.mockTypes({
|
||||
Site: () => ({
|
||||
codyContextFilters: {
|
||||
raw: {
|
||||
include: [String.raw`*`],
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
await page.goto(path)
|
||||
await doesNotHaveCody(page)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -260,6 +260,13 @@ class Sourcegraph {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock the current window context to be in "dotcom mode" (sourcegraph.com).
|
||||
*/
|
||||
public dotcomMode(): void {
|
||||
this.setWindowContext({ sourcegraphDotComMode: true })
|
||||
}
|
||||
|
||||
public teardown(): void {
|
||||
this.graphqlMock.reset()
|
||||
}
|
||||
|
||||
@ -1614,12 +1614,18 @@ importers:
|
||||
prismjs:
|
||||
specifier: ^1.29.0
|
||||
version: 1.29.0
|
||||
re2js:
|
||||
specifier: ^0.4.1
|
||||
version: 0.4.1
|
||||
ts-key-enum:
|
||||
specifier: ^2.0.12
|
||||
version: 2.0.12
|
||||
wonka:
|
||||
specifier: ^6.3.4
|
||||
version: 6.3.4
|
||||
zod:
|
||||
specifier: ^3.23.8
|
||||
version: 3.23.8
|
||||
devDependencies:
|
||||
'@faker-js/faker':
|
||||
specifier: ^8.0.2
|
||||
@ -27883,6 +27889,10 @@ packages:
|
||||
/zen-observable@0.8.15:
|
||||
resolution: {integrity: sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==}
|
||||
|
||||
/zod@3.23.8:
|
||||
resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==}
|
||||
dev: false
|
||||
|
||||
/zone.js@0.11.6:
|
||||
resolution: {integrity: sha512-umJqFtKyZlPli669gB1gOrRE9hxUUGkZr7mo878z+NEBJZZixJkKeVYfnoLa7g25SseUDc92OZrMKKHySyJrFg==}
|
||||
dependencies:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user