@parca/profile 0.19.138 → 0.19.140
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/dist/GraphTooltipArrow/useGraphTooltipMetaInfo/index.d.ts.map +1 -1
- package/dist/GraphTooltipArrow/useGraphTooltipMetaInfo/index.js +11 -13
- package/dist/ProfileExplorer/ProfileExplorerCompare.d.ts.map +1 -1
- package/dist/ProfileExplorer/ProfileExplorerCompare.js +4 -9
- package/dist/ProfileFlameChart/SamplesStrips/index.d.ts +2 -2
- package/dist/ProfileFlameChart/SamplesStrips/index.d.ts.map +1 -1
- package/dist/ProfileFlameChart/index.d.ts.map +1 -1
- package/dist/ProfileFlameChart/index.js +13 -19
- package/dist/ProfileFlameGraph/FlameGraphArrow/ContextMenu.d.ts.map +1 -1
- package/dist/ProfileFlameGraph/FlameGraphArrow/ContextMenu.js +8 -8
- package/dist/ProfileFlameGraph/FlameGraphArrow/TextWithEllipsis.d.ts.map +1 -1
- package/dist/ProfileFlameGraph/FlameGraphArrow/TextWithEllipsis.js +4 -3
- package/dist/ProfileFlameGraph/index.d.ts.map +1 -1
- package/dist/ProfileFlameGraph/index.js +6 -4
- package/dist/ProfileMetricsGraph/index.d.ts.map +1 -1
- package/dist/ProfileMetricsGraph/index.js +4 -6
- package/dist/ProfileSelector/MetricsGraphSection.d.ts.map +1 -1
- package/dist/ProfileSelector/MetricsGraphSection.js +5 -10
- package/dist/ProfileSelector/index.d.ts.map +1 -1
- package/dist/ProfileSelector/index.js +27 -25
- package/dist/ProfileSelector/useAutoQuerySelector.d.ts.map +1 -1
- package/dist/ProfileSelector/useAutoQuerySelector.js +3 -0
- package/dist/ProfileTypeSelector/index.d.ts.map +1 -1
- package/dist/ProfileTypeSelector/index.js +4 -0
- package/dist/ProfileView/components/ActionButtons/SortByDropdown.d.ts.map +1 -1
- package/dist/ProfileView/components/ActionButtons/SortByDropdown.js +5 -5
- package/dist/ProfileView/components/ColorStackLegend.d.ts.map +1 -1
- package/dist/ProfileView/components/ColorStackLegend.js +2 -3
- package/dist/ProfileView/components/InvertCallStack/index.d.ts.map +1 -1
- package/dist/ProfileView/components/InvertCallStack/index.js +5 -4
- package/dist/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.d.ts +1 -2
- package/dist/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.d.ts.map +1 -1
- package/dist/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.js +14 -16
- package/dist/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.test.js +84 -170
- package/dist/ProfileView/components/Toolbars/MultiLevelDropdown.d.ts.map +1 -1
- package/dist/ProfileView/components/Toolbars/MultiLevelDropdown.js +16 -20
- package/dist/ProfileView/components/Toolbars/TableColumnsDropdown.d.ts.map +1 -1
- package/dist/ProfileView/components/Toolbars/TableColumnsDropdown.js +4 -5
- package/dist/ProfileView/components/Toolbars/index.d.ts +2 -2
- package/dist/ProfileView/components/Toolbars/index.d.ts.map +1 -1
- package/dist/ProfileView/components/Toolbars/index.js +1 -1
- package/dist/ProfileView/components/ViewSelector/index.d.ts.map +1 -1
- package/dist/ProfileView/components/ViewSelector/index.js +8 -14
- package/dist/ProfileView/context/DashboardContext.d.ts.map +1 -1
- package/dist/ProfileView/context/DashboardContext.js +6 -6
- package/dist/ProfileView/hooks/useResetFlameGraphState.d.ts.map +1 -1
- package/dist/ProfileView/hooks/useResetFlameGraphState.js +5 -4
- package/dist/ProfileView/hooks/useResetStateOnProfileTypeChange.d.ts.map +1 -1
- package/dist/ProfileView/hooks/useResetStateOnProfileTypeChange.js +25 -26
- package/dist/ProfileView/hooks/useResetStateOnSeriesChange.d.ts.map +1 -1
- package/dist/ProfileView/hooks/useResetStateOnSeriesChange.js +13 -8
- package/dist/ProfileView/hooks/useVisualizationState.d.ts +3 -3
- package/dist/ProfileView/hooks/useVisualizationState.d.ts.map +1 -1
- package/dist/ProfileView/hooks/useVisualizationState.js +35 -51
- package/dist/ProfileViewWithData.d.ts.map +1 -1
- package/dist/ProfileViewWithData.js +19 -28
- package/dist/Sandwich/index.d.ts.map +1 -1
- package/dist/Sandwich/index.js +4 -3
- package/dist/SourceView/index.d.ts.map +1 -1
- package/dist/SourceView/index.js +4 -2
- package/dist/SourceView/useSelectedLineRange.d.ts.map +1 -1
- package/dist/SourceView/useSelectedLineRange.js +21 -16
- package/dist/Table/MoreDropdown.d.ts.map +1 -1
- package/dist/Table/MoreDropdown.js +8 -11
- package/dist/Table/TableContextMenu.d.ts.map +1 -1
- package/dist/Table/TableContextMenu.js +10 -13
- package/dist/Table/hooks/useTableConfiguration.d.ts.map +1 -1
- package/dist/Table/hooks/useTableConfiguration.js +3 -4
- package/dist/Table/index.d.ts.map +1 -1
- package/dist/Table/index.js +11 -9
- package/dist/TopTable/index.d.ts.map +1 -1
- package/dist/TopTable/index.js +3 -4
- package/dist/hooks/urlParsers.d.ts +18 -0
- package/dist/hooks/urlParsers.d.ts.map +1 -0
- package/dist/hooks/urlParsers.js +32 -0
- package/dist/hooks/useColorBy.d.ts +5 -0
- package/dist/hooks/useColorBy.d.ts.map +1 -0
- package/dist/hooks/useColorBy.js +26 -0
- package/dist/hooks/useCompareModeMeta.d.ts.map +1 -1
- package/dist/hooks/useCompareModeMeta.js +55 -86
- package/dist/hooks/useDashboardItems.d.ts +5 -0
- package/dist/hooks/useDashboardItems.d.ts.map +1 -0
- package/dist/hooks/useDashboardItems.js +27 -0
- package/dist/hooks/useQueryState.d.ts +3 -3
- package/dist/hooks/useQueryState.d.ts.map +1 -1
- package/dist/hooks/useQueryState.js +105 -105
- package/dist/hooks/useQueryState.test.js +186 -302
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -12
- package/dist/useSumBy.d.ts +1 -1
- package/dist/useSumBy.d.ts.map +1 -1
- package/dist/useSumBy.js +2 -2
- package/package.json +12 -11
- package/src/GraphTooltipArrow/useGraphTooltipMetaInfo/index.ts +11 -13
- package/src/ProfileExplorer/ProfileExplorerCompare.tsx +4 -9
- package/src/ProfileFlameChart/SamplesStrips/index.tsx +2 -2
- package/src/ProfileFlameChart/index.tsx +21 -28
- package/src/ProfileFlameGraph/FlameGraphArrow/ContextMenu.tsx +10 -9
- package/src/ProfileFlameGraph/FlameGraphArrow/TextWithEllipsis.tsx +5 -3
- package/src/ProfileFlameGraph/index.tsx +6 -9
- package/src/ProfileMetricsGraph/index.tsx +6 -8
- package/src/ProfileSelector/MetricsGraphSection.tsx +5 -10
- package/src/ProfileSelector/index.tsx +32 -31
- package/src/ProfileSelector/useAutoQuerySelector.ts +5 -0
- package/src/ProfileTypeSelector/index.tsx +4 -0
- package/src/ProfileView/components/ActionButtons/SortByDropdown.tsx +10 -6
- package/src/ProfileView/components/ColorStackLegend.tsx +2 -4
- package/src/ProfileView/components/InvertCallStack/index.tsx +5 -4
- package/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.test.tsx +94 -192
- package/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.ts +21 -21
- package/src/ProfileView/components/Toolbars/MultiLevelDropdown.tsx +24 -25
- package/src/ProfileView/components/Toolbars/TableColumnsDropdown.tsx +4 -5
- package/src/ProfileView/components/Toolbars/index.tsx +3 -3
- package/src/ProfileView/components/ViewSelector/index.tsx +9 -16
- package/src/ProfileView/context/DashboardContext.tsx +6 -6
- package/src/ProfileView/hooks/useResetFlameGraphState.ts +6 -4
- package/src/ProfileView/hooks/useResetStateOnProfileTypeChange.ts +24 -26
- package/src/ProfileView/hooks/useResetStateOnSeriesChange.ts +16 -8
- package/src/ProfileView/hooks/useVisualizationState.ts +61 -69
- package/src/ProfileViewWithData.tsx +29 -35
- package/src/Sandwich/index.tsx +4 -3
- package/src/SourceView/index.tsx +4 -2
- package/src/SourceView/useSelectedLineRange.ts +34 -19
- package/src/Table/MoreDropdown.tsx +9 -11
- package/src/Table/TableContextMenu.tsx +10 -13
- package/src/Table/hooks/useTableConfiguration.tsx +3 -4
- package/src/Table/index.tsx +12 -21
- package/src/TopTable/index.tsx +3 -4
- package/src/hooks/urlParsers.ts +38 -0
- package/src/hooks/useColorBy.ts +42 -0
- package/src/hooks/useCompareModeMeta.ts +61 -91
- package/src/hooks/useDashboardItems.ts +46 -0
- package/src/hooks/useQueryState.test.tsx +275 -345
- package/src/hooks/useQueryState.ts +136 -118
- package/src/index.tsx +16 -15
- package/src/useSumBy.ts +3 -3
|
@@ -11,13 +11,13 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
11
11
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
12
|
// See the License for the specific language governing permissions and
|
|
13
13
|
// limitations under the License.
|
|
14
|
-
import {
|
|
14
|
+
import { useQueryState } from 'nuqs';
|
|
15
|
+
import { Select } from '@parca/components';
|
|
15
16
|
import { FIELD_CUMULATIVE, FIELD_DIFF, FIELD_FUNCTION_NAME, } from '../../../ProfileFlameGraph/FlameGraphArrow';
|
|
17
|
+
import { stringParam } from '../../../hooks/urlParsers';
|
|
16
18
|
import { useProfileViewContext } from '../../context/ProfileViewContext';
|
|
17
19
|
const SortByDropdown = () => {
|
|
18
|
-
const [storeSortBy, setStoreSortBy] =
|
|
19
|
-
defaultValue: FIELD_FUNCTION_NAME,
|
|
20
|
-
});
|
|
20
|
+
const [storeSortBy, setStoreSortBy] = useQueryState('sort_by', stringParam.withDefault(FIELD_FUNCTION_NAME));
|
|
21
21
|
const { compareMode } = useProfileViewContext();
|
|
22
22
|
return (_jsxs("div", { children: [_jsx("label", { className: "text-sm", children: "Sort by" }), _jsx(Select, { className: "!px-3", items: [
|
|
23
23
|
{
|
|
@@ -44,6 +44,6 @@ const SortByDropdown = () => {
|
|
|
44
44
|
expanded: (_jsx(_Fragment, { children: _jsx("span", { children: "Diff" }) })),
|
|
45
45
|
},
|
|
46
46
|
},
|
|
47
|
-
], selectedKey: storeSortBy, onSelection: key => setStoreSortBy(key), placeholder: 'Sort By', primary: false, disabled: false, id: "h-sort-by-filter" })] }));
|
|
47
|
+
], selectedKey: storeSortBy, onSelection: key => void setStoreSortBy(key), placeholder: 'Sort By', primary: false, disabled: false, id: "h-sort-by-filter" })] }));
|
|
48
48
|
};
|
|
49
49
|
export default SortByDropdown;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ColorStackLegend.d.ts","sourceRoot":"","sources":["../../../src/ProfileView/components/ColorStackLegend.tsx"],"names":[],"mappings":"AAaA,OAAO,KAAgB,MAAM,OAAO,CAAC;AAarC,UAAU,KAAK;IACb,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED,QAAA,MAAM,gBAAgB,GAAI,oCAA0C,KAAK,KAAG,KAAK,CAAC,GAAG,CAAC,
|
|
1
|
+
{"version":3,"file":"ColorStackLegend.d.ts","sourceRoot":"","sources":["../../../src/ProfileView/components/ColorStackLegend.tsx"],"names":[],"mappings":"AAaA,OAAO,KAAgB,MAAM,OAAO,CAAC;AAarC,UAAU,KAAK;IACb,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED,QAAA,MAAM,gBAAgB,GAAI,oCAA0C,KAAK,KAAG,KAAK,CAAC,GAAG,CAAC,OAqGrF,CAAC;AAEF,eAAe,gBAAgB,CAAC"}
|
|
@@ -14,18 +14,17 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
14
14
|
import { useMemo } from 'react';
|
|
15
15
|
import { Icon } from '@iconify/react';
|
|
16
16
|
import cx from 'classnames';
|
|
17
|
-
import { useURLState } from '@parca/components';
|
|
18
17
|
import { USER_PREFERENCES, useCurrentColorProfile, useUserPreference } from '@parca/hooks';
|
|
19
18
|
import { EVERYTHING_ELSE, selectDarkMode, useAppSelector } from '@parca/store';
|
|
20
19
|
import { getMappingColors } from '../../ProfileFlameGraph/FlameGraphArrow';
|
|
21
20
|
import useMappingList from '../../ProfileFlameGraph/FlameGraphArrow/useMappingList';
|
|
21
|
+
import { useColorBy } from '../../hooks/useColorBy';
|
|
22
22
|
import { useProfileFilters } from './ProfileFilters/useProfileFilters';
|
|
23
23
|
const ColorStackLegend = ({ mappings, compareMode = false, loading }) => {
|
|
24
24
|
const isDarkMode = useAppSelector(selectDarkMode);
|
|
25
25
|
const currentColorProfile = useCurrentColorProfile();
|
|
26
26
|
const [colorProfileName] = useUserPreference(USER_PREFERENCES.FLAMEGRAPH_COLOR_PROFILE.key);
|
|
27
|
-
const
|
|
28
|
-
const colorBy = colorByValue === 'binary' || colorByValue === undefined ? 'binary' : 'filename';
|
|
27
|
+
const { colorBy } = useColorBy();
|
|
29
28
|
const { appliedFilters, removeExcludeBinary, excludeBinary } = useProfileFilters();
|
|
30
29
|
// Get current binary filters from the new ProfileFilters system
|
|
31
30
|
const currentBinaryFilters = useMemo(() => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/ProfileView/components/InvertCallStack/index.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/ProfileView/components/InvertCallStack/index.tsx"],"names":[],"mappings":"AAsBA,QAAA,MAAM,eAAe,QAAO,GAAG,CAAC,OAyB/B,CAAC;AAEF,eAAe,eAAe,CAAC"}
|
|
@@ -12,15 +12,16 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
12
12
|
// See the License for the specific language governing permissions and
|
|
13
13
|
// limitations under the License.
|
|
14
14
|
import { Icon } from '@iconify/react';
|
|
15
|
-
import {
|
|
15
|
+
import { useQueryState } from 'nuqs';
|
|
16
|
+
import { Button } from '@parca/components';
|
|
16
17
|
import { TEST_IDS, testId } from '@parca/test-utils';
|
|
18
|
+
import { invertCallStackParser } from '../../../hooks/urlParsers';
|
|
17
19
|
import { useResetFlameGraphState } from '../../hooks/useResetFlameGraphState';
|
|
18
20
|
const InvertCallStack = () => {
|
|
19
|
-
const [
|
|
20
|
-
const isInvert = invertStack === 'true';
|
|
21
|
+
const [isInvert, setInvertStack] = useQueryState('invert_call_stack', invertCallStackParser);
|
|
21
22
|
const resetFlameGraphState = useResetFlameGraphState();
|
|
22
23
|
const handleSetInvert = (value) => {
|
|
23
|
-
setInvertStack(value
|
|
24
|
+
void setInvertStack(value);
|
|
24
25
|
resetFlameGraphState();
|
|
25
26
|
};
|
|
26
27
|
return (_jsxs("div", { className: "flex flex-col", children: [_jsx("label", { className: "text-sm", children: "\u00A0" }), _jsxs(Button, { variant: "neutral", className: "flex items-center gap-2 whitespace-nowrap", onClick: () => handleSetInvert(!isInvert), id: "h-invert-call-stack", ...testId(TEST_IDS.INVERT_CALL_STACK_BUTTON), children: [_jsx(Icon, { icon: isInvert ? 'ph:sort-ascending' : 'ph:sort-descending', className: "h-4 w-4" }), isInvert ? 'Original' : 'Invert', " Call Stack"] })] }));
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import { type ParamValueSetterCustom } from '@parca/components';
|
|
2
1
|
import { type ProfileFilter } from './useProfileFilters';
|
|
3
2
|
export declare const decodeProfileFilters: (encoded: string) => ProfileFilter[];
|
|
4
3
|
export declare const useProfileFiltersUrlState: () => {
|
|
5
4
|
appliedFilters: ProfileFilter[];
|
|
6
|
-
setAppliedFilters:
|
|
5
|
+
setAppliedFilters: (filters: ProfileFilter[]) => void;
|
|
7
6
|
forceApplyFilters: (filters: ProfileFilter[]) => void;
|
|
8
7
|
};
|
|
9
8
|
//# sourceMappingURL=useProfileFiltersUrlState.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useProfileFiltersUrlState.d.ts","sourceRoot":"","sources":["../../../../src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"useProfileFiltersUrlState.d.ts","sourceRoot":"","sources":["../../../../src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.ts"],"names":[],"mappings":"AAoBA,OAAO,EAAC,KAAK,aAAa,EAAC,MAAM,qBAAqB,CAAC;AAsEvD,eAAO,MAAM,oBAAoB,GAAI,SAAS,MAAM,KAAG,aAAa,EAgDnE,CAAC;AAUF,eAAO,MAAM,yBAAyB,QAAO;IAC3C,cAAc,EAAE,aAAa,EAAE,CAAC;IAChC,iBAAiB,EAAE,CAAC,OAAO,EAAE,aAAa,EAAE,KAAK,IAAI,CAAC;IACtD,iBAAiB,EAAE,CAAC,OAAO,EAAE,aAAa,EAAE,KAAK,IAAI,CAAC;CAmCvD,CAAC"}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// See the License for the specific language governing permissions and
|
|
12
12
|
// limitations under the License.
|
|
13
13
|
import { useCallback, useMemo } from 'react';
|
|
14
|
-
import {
|
|
14
|
+
import { createParser, useQueryState } from 'nuqs';
|
|
15
15
|
import { safeDecode } from '@parca/utilities';
|
|
16
16
|
import { isPresetKey } from './filterPresets';
|
|
17
17
|
// Compact encoding mappings
|
|
@@ -113,21 +113,21 @@ export const decodeProfileFilters = (encoded) => {
|
|
|
113
113
|
return [];
|
|
114
114
|
}
|
|
115
115
|
};
|
|
116
|
+
const profileFiltersParser = createParser({
|
|
117
|
+
parse: (value) => decodeProfileFilters(value),
|
|
118
|
+
serialize: (value) => encodeProfileFilters(value),
|
|
119
|
+
eq: (a, b) => encodeProfileFilters(a) === encodeProfileFilters(b),
|
|
120
|
+
})
|
|
121
|
+
.withDefault([])
|
|
122
|
+
.withOptions({ history: 'replace' });
|
|
116
123
|
export const useProfileFiltersUrlState = () => {
|
|
117
|
-
const
|
|
118
|
-
// Store applied filters in URL state for persistence using compact encoding
|
|
119
|
-
const [appliedFilters, setAppliedFilters] = useURLStateCustom(`profile_filters`, {
|
|
120
|
-
parse: value => {
|
|
121
|
-
return decodeProfileFilters(value);
|
|
122
|
-
},
|
|
123
|
-
stringify: value => {
|
|
124
|
-
return encodeProfileFilters(value);
|
|
125
|
-
},
|
|
126
|
-
defaultValue: [],
|
|
127
|
-
});
|
|
124
|
+
const [appliedFilters, setRawFilters] = useQueryState('profile_filters', profileFiltersParser);
|
|
128
125
|
const memoizedAppliedFilters = useMemo(() => {
|
|
129
126
|
return appliedFilters ?? [];
|
|
130
127
|
}, [appliedFilters]);
|
|
128
|
+
const setAppliedFilters = useCallback((filters) => {
|
|
129
|
+
void setRawFilters(filters);
|
|
130
|
+
}, [setRawFilters]);
|
|
131
131
|
// Force apply filters (bypasses preserve-existing strategy)
|
|
132
132
|
const forceApplyFilters = useCallback((filters) => {
|
|
133
133
|
const validFilters = filters.filter(f => {
|
|
@@ -136,10 +136,8 @@ export const useProfileFiltersUrlState = () => {
|
|
|
136
136
|
}
|
|
137
137
|
return f.value !== '' && f.type != null && f.field != null && f.matchType != null;
|
|
138
138
|
});
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
});
|
|
142
|
-
}, [batchUpdates, setAppliedFilters]);
|
|
139
|
+
setAppliedFilters(validFilters);
|
|
140
|
+
}, [setAppliedFilters]);
|
|
143
141
|
return {
|
|
144
142
|
appliedFilters: memoizedAppliedFilters,
|
|
145
143
|
setAppliedFilters,
|
|
@@ -1,70 +1,17 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
// eslint-disable-next-line import/named
|
|
3
3
|
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
4
|
-
|
|
5
|
-
import {
|
|
4
|
+
// eslint-disable-next-line import/no-unresolved
|
|
5
|
+
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
|
6
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
6
7
|
import { decodeProfileFilters, useProfileFiltersUrlState } from './useProfileFiltersUrlState';
|
|
7
|
-
//
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
};
|
|
12
|
-
// Mock the navigate function
|
|
13
|
-
const mockNavigateTo = vi.fn((path, params) => {
|
|
14
|
-
const searchParams = new URLSearchParams();
|
|
15
|
-
Object.entries(params).forEach(([key, value]) => {
|
|
16
|
-
if (value !== undefined && value !== null) {
|
|
17
|
-
if (Array.isArray(value)) {
|
|
18
|
-
searchParams.set(key, value.join(','));
|
|
19
|
-
}
|
|
20
|
-
else {
|
|
21
|
-
searchParams.set(key, String(value));
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
});
|
|
25
|
-
mockLocation.search = `?${searchParams.toString()}`;
|
|
26
|
-
});
|
|
27
|
-
// Mock getQueryParamsFromURL
|
|
28
|
-
vi.mock('@parca/components/src/hooks/URLState/utils', async () => {
|
|
29
|
-
const actual = await vi.importActual('@parca/components/src/hooks/URLState/utils');
|
|
30
|
-
return {
|
|
31
|
-
...actual,
|
|
32
|
-
getQueryParamsFromURL: () => {
|
|
33
|
-
if (mockLocation.search === '')
|
|
34
|
-
return {};
|
|
35
|
-
const params = new URLSearchParams(mockLocation.search);
|
|
36
|
-
const result = {};
|
|
37
|
-
for (const [key, value] of params.entries()) {
|
|
38
|
-
const decodedValue = decodeURIComponent(value);
|
|
39
|
-
const existing = result[key];
|
|
40
|
-
if (existing !== undefined) {
|
|
41
|
-
result[key] = Array.isArray(existing)
|
|
42
|
-
? [...existing, decodedValue]
|
|
43
|
-
: [existing, decodedValue];
|
|
44
|
-
}
|
|
45
|
-
else {
|
|
46
|
-
result[key] = decodedValue;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
return result;
|
|
50
|
-
},
|
|
51
|
-
};
|
|
52
|
-
});
|
|
53
|
-
// Helper to create wrapper with URLStateProvider
|
|
54
|
-
const createWrapper = () => {
|
|
55
|
-
const Wrapper = ({ children }) => (_jsx(URLStateProvider, { navigateTo: mockNavigateTo, children: children }));
|
|
56
|
-
Wrapper.displayName = 'URLStateProviderWrapper';
|
|
8
|
+
// Helper to create wrapper with NuqsTestingAdapter
|
|
9
|
+
const createWrapper = (searchParams = {}, onUrlUpdate) => {
|
|
10
|
+
const Wrapper = ({ children }) => (_jsx(NuqsTestingAdapter, { searchParams: searchParams, onUrlUpdate: onUrlUpdate, hasMemory: true, children: children }));
|
|
11
|
+
Wrapper.displayName = 'NuqsTestingWrapper';
|
|
57
12
|
return Wrapper;
|
|
58
13
|
};
|
|
59
14
|
describe('useProfileFiltersUrlState', () => {
|
|
60
|
-
beforeEach(() => {
|
|
61
|
-
mockNavigateTo.mockClear();
|
|
62
|
-
Object.defineProperty(window, 'location', {
|
|
63
|
-
value: mockLocation,
|
|
64
|
-
writable: true,
|
|
65
|
-
});
|
|
66
|
-
mockLocation.search = '';
|
|
67
|
-
});
|
|
68
15
|
describe('decodeProfileFilters', () => {
|
|
69
16
|
it('should return empty array for empty string', () => {
|
|
70
17
|
expect(decodeProfileFilters('')).toEqual([]);
|
|
@@ -206,8 +153,9 @@ describe('useProfileFiltersUrlState', () => {
|
|
|
206
153
|
expect(result.current.appliedFilters).toEqual([]);
|
|
207
154
|
});
|
|
208
155
|
it('should read filters from URL', async () => {
|
|
209
|
-
|
|
210
|
-
|
|
156
|
+
const { result } = renderHook(() => useProfileFiltersUrlState(), {
|
|
157
|
+
wrapper: createWrapper({ profile_filters: 's:fn:=:testFunc' }),
|
|
158
|
+
});
|
|
211
159
|
await waitFor(() => {
|
|
212
160
|
expect(result.current.appliedFilters).toHaveLength(1);
|
|
213
161
|
expect(result.current.appliedFilters[0]).toMatchObject({
|
|
@@ -219,7 +167,10 @@ describe('useProfileFiltersUrlState', () => {
|
|
|
219
167
|
});
|
|
220
168
|
});
|
|
221
169
|
it('should update URL when setting filters', async () => {
|
|
222
|
-
const
|
|
170
|
+
const onUrlUpdate = vi.fn();
|
|
171
|
+
const { result } = renderHook(() => useProfileFiltersUrlState(), {
|
|
172
|
+
wrapper: createWrapper({}, onUrlUpdate),
|
|
173
|
+
});
|
|
223
174
|
const newFilters = [
|
|
224
175
|
{
|
|
225
176
|
id: 'test-1',
|
|
@@ -233,22 +184,23 @@ describe('useProfileFiltersUrlState', () => {
|
|
|
233
184
|
result.current.setAppliedFilters(newFilters);
|
|
234
185
|
});
|
|
235
186
|
await waitFor(() => {
|
|
236
|
-
expect(
|
|
237
|
-
const
|
|
238
|
-
expect(
|
|
187
|
+
expect(onUrlUpdate).toHaveBeenCalled();
|
|
188
|
+
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0];
|
|
189
|
+
expect(lastCall.searchParams.get('profile_filters')).toBe('f:b:!~:libc.so');
|
|
239
190
|
});
|
|
240
191
|
});
|
|
241
192
|
it('should clear URL param when setting empty filters', async () => {
|
|
242
|
-
|
|
243
|
-
const { result } = renderHook(() => useProfileFiltersUrlState(), {
|
|
193
|
+
const onUrlUpdate = vi.fn();
|
|
194
|
+
const { result } = renderHook(() => useProfileFiltersUrlState(), {
|
|
195
|
+
wrapper: createWrapper({ profile_filters: 's:fn:=:testFunc' }, onUrlUpdate),
|
|
196
|
+
});
|
|
244
197
|
act(() => {
|
|
245
198
|
result.current.setAppliedFilters([]);
|
|
246
199
|
});
|
|
247
200
|
await waitFor(() => {
|
|
248
|
-
expect(
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
expect(params.profile_filters === '' || params.profile_filters === undefined).toBe(true);
|
|
201
|
+
expect(onUrlUpdate).toHaveBeenCalled();
|
|
202
|
+
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0];
|
|
203
|
+
expect(lastCall.searchParams.has('profile_filters')).toBe(false);
|
|
252
204
|
});
|
|
253
205
|
});
|
|
254
206
|
});
|
|
@@ -258,8 +210,10 @@ describe('useProfileFiltersUrlState', () => {
|
|
|
258
210
|
expect(typeof result.current.forceApplyFilters).toBe('function');
|
|
259
211
|
});
|
|
260
212
|
it('should force apply filters overwriting existing', async () => {
|
|
261
|
-
|
|
262
|
-
const { result } = renderHook(() => useProfileFiltersUrlState(), {
|
|
213
|
+
const onUrlUpdate = vi.fn();
|
|
214
|
+
const { result } = renderHook(() => useProfileFiltersUrlState(), {
|
|
215
|
+
wrapper: createWrapper({ profile_filters: 's:fn:=:existingFunc' }, onUrlUpdate),
|
|
216
|
+
});
|
|
263
217
|
// Verify existing filter is loaded
|
|
264
218
|
await waitFor(() => {
|
|
265
219
|
expect(result.current.appliedFilters).toHaveLength(1);
|
|
@@ -277,28 +231,32 @@ describe('useProfileFiltersUrlState', () => {
|
|
|
277
231
|
result.current.forceApplyFilters(newFilters);
|
|
278
232
|
});
|
|
279
233
|
await waitFor(() => {
|
|
280
|
-
expect(
|
|
281
|
-
const
|
|
282
|
-
expect(
|
|
234
|
+
expect(onUrlUpdate).toHaveBeenCalled();
|
|
235
|
+
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0];
|
|
236
|
+
expect(lastCall.searchParams.get('profile_filters')).toBe('f:b:!~:forcedValue');
|
|
283
237
|
});
|
|
284
238
|
});
|
|
285
239
|
it('should clear filters when force applying empty array', async () => {
|
|
286
|
-
|
|
287
|
-
const { result } = renderHook(() => useProfileFiltersUrlState(), {
|
|
240
|
+
const onUrlUpdate = vi.fn();
|
|
241
|
+
const { result } = renderHook(() => useProfileFiltersUrlState(), {
|
|
242
|
+
wrapper: createWrapper({ profile_filters: 's:fn:=:existingFunc' }, onUrlUpdate),
|
|
243
|
+
});
|
|
288
244
|
act(() => {
|
|
289
245
|
result.current.forceApplyFilters([]);
|
|
290
246
|
});
|
|
291
247
|
await waitFor(() => {
|
|
292
|
-
expect(
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
expect(params.profile_filters === '' || params.profile_filters === undefined).toBe(true);
|
|
248
|
+
expect(onUrlUpdate).toHaveBeenCalled();
|
|
249
|
+
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0];
|
|
250
|
+
expect(lastCall.searchParams.has('profile_filters')).toBe(false);
|
|
296
251
|
});
|
|
297
252
|
});
|
|
298
253
|
});
|
|
299
254
|
describe('Preset filter encoding', () => {
|
|
300
255
|
it('should encode preset filters correctly', async () => {
|
|
301
|
-
const
|
|
256
|
+
const onUrlUpdate = vi.fn();
|
|
257
|
+
const { result } = renderHook(() => useProfileFiltersUrlState(), {
|
|
258
|
+
wrapper: createWrapper({}, onUrlUpdate),
|
|
259
|
+
});
|
|
302
260
|
const presetFilters = [
|
|
303
261
|
{
|
|
304
262
|
id: 'preset-1',
|
|
@@ -310,13 +268,16 @@ describe('useProfileFiltersUrlState', () => {
|
|
|
310
268
|
result.current.setAppliedFilters(presetFilters);
|
|
311
269
|
});
|
|
312
270
|
await waitFor(() => {
|
|
313
|
-
expect(
|
|
314
|
-
const
|
|
315
|
-
expect(
|
|
271
|
+
expect(onUrlUpdate).toHaveBeenCalled();
|
|
272
|
+
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0];
|
|
273
|
+
expect(lastCall.searchParams.get('profile_filters')).toBe('p:hide_libc:enabled');
|
|
316
274
|
});
|
|
317
275
|
});
|
|
318
276
|
it('should handle mixed preset and regular filters', async () => {
|
|
319
|
-
const
|
|
277
|
+
const onUrlUpdate = vi.fn();
|
|
278
|
+
const { result } = renderHook(() => useProfileFiltersUrlState(), {
|
|
279
|
+
wrapper: createWrapper({}, onUrlUpdate),
|
|
280
|
+
});
|
|
320
281
|
const mixedFilters = [
|
|
321
282
|
{
|
|
322
283
|
id: 'preset-1',
|
|
@@ -335,15 +296,18 @@ describe('useProfileFiltersUrlState', () => {
|
|
|
335
296
|
result.current.setAppliedFilters(mixedFilters);
|
|
336
297
|
});
|
|
337
298
|
await waitFor(() => {
|
|
338
|
-
expect(
|
|
339
|
-
const
|
|
340
|
-
expect(
|
|
299
|
+
expect(onUrlUpdate).toHaveBeenCalled();
|
|
300
|
+
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0];
|
|
301
|
+
expect(lastCall.searchParams.get('profile_filters')).toBe('p:hide_libc:enabled,f:b:!~:node');
|
|
341
302
|
});
|
|
342
303
|
});
|
|
343
304
|
});
|
|
344
305
|
describe('URL encoding edge cases', () => {
|
|
345
306
|
it('should handle special characters in filter values', async () => {
|
|
346
|
-
const
|
|
307
|
+
const onUrlUpdate = vi.fn();
|
|
308
|
+
const { result } = renderHook(() => useProfileFiltersUrlState(), {
|
|
309
|
+
wrapper: createWrapper({}, onUrlUpdate),
|
|
310
|
+
});
|
|
347
311
|
const filtersWithSpecialChars = [
|
|
348
312
|
{
|
|
349
313
|
id: 'special-1',
|
|
@@ -357,14 +321,18 @@ describe('useProfileFiltersUrlState', () => {
|
|
|
357
321
|
result.current.setAppliedFilters(filtersWithSpecialChars);
|
|
358
322
|
});
|
|
359
323
|
await waitFor(() => {
|
|
360
|
-
expect(
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
|
|
324
|
+
expect(onUrlUpdate).toHaveBeenCalled();
|
|
325
|
+
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0];
|
|
326
|
+
const filterValue = lastCall.searchParams.get('profile_filters');
|
|
327
|
+
// The value should contain the encoded special characters
|
|
328
|
+
expect(filterValue).toContain('std%3A%3Avector%3Cint%3E');
|
|
364
329
|
});
|
|
365
330
|
});
|
|
366
331
|
it('should filter out incomplete filters when encoding', async () => {
|
|
367
|
-
const
|
|
332
|
+
const onUrlUpdate = vi.fn();
|
|
333
|
+
const { result } = renderHook(() => useProfileFiltersUrlState(), {
|
|
334
|
+
wrapper: createWrapper({}, onUrlUpdate),
|
|
335
|
+
});
|
|
368
336
|
const incompleteFilters = [
|
|
369
337
|
{
|
|
370
338
|
id: 'complete-1',
|
|
@@ -389,10 +357,10 @@ describe('useProfileFiltersUrlState', () => {
|
|
|
389
357
|
result.current.setAppliedFilters(incompleteFilters);
|
|
390
358
|
});
|
|
391
359
|
await waitFor(() => {
|
|
392
|
-
expect(
|
|
393
|
-
const
|
|
360
|
+
expect(onUrlUpdate).toHaveBeenCalled();
|
|
361
|
+
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0];
|
|
394
362
|
// Only the complete filter should be encoded
|
|
395
|
-
expect(
|
|
363
|
+
expect(lastCall.searchParams.get('profile_filters')).toBe('f:b:!~:valid');
|
|
396
364
|
});
|
|
397
365
|
});
|
|
398
366
|
});
|
|
@@ -409,8 +377,9 @@ describe('useProfileFiltersUrlState', () => {
|
|
|
409
377
|
expect(result.current.appliedFilters).toEqual([]);
|
|
410
378
|
});
|
|
411
379
|
it('should return correctly structured filters from URL', async () => {
|
|
412
|
-
|
|
413
|
-
|
|
380
|
+
const { result } = renderHook(() => useProfileFiltersUrlState(), {
|
|
381
|
+
wrapper: createWrapper({ profile_filters: 's:fn:=:testFunc' }),
|
|
382
|
+
});
|
|
414
383
|
await waitFor(() => {
|
|
415
384
|
expect(result.current.appliedFilters).toHaveLength(1);
|
|
416
385
|
});
|
|
@@ -426,13 +395,12 @@ describe('useProfileFiltersUrlState', () => {
|
|
|
426
395
|
});
|
|
427
396
|
describe('View switching scenarios', () => {
|
|
428
397
|
it('should completely replace filters when switching views using forceApplyFilters', async () => {
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
398
|
+
const onUrlUpdate = vi.fn();
|
|
399
|
+
const { result } = renderHook(() => useProfileFiltersUrlState(), {
|
|
400
|
+
wrapper: createWrapper({ profile_filters: 's:fn:=:viewAFunc,f:b:!=:viewABinary' }, onUrlUpdate),
|
|
401
|
+
});
|
|
432
402
|
await waitFor(() => {
|
|
433
403
|
expect(result.current.appliedFilters).toHaveLength(2);
|
|
434
|
-
expect(result.current.appliedFilters[0].value).toBe('viewAFunc');
|
|
435
|
-
expect(result.current.appliedFilters[1].value).toBe('viewABinary');
|
|
436
404
|
});
|
|
437
405
|
// Switch to View B (completely different filter)
|
|
438
406
|
const viewBFilters = [
|
|
@@ -448,77 +416,23 @@ describe('useProfileFiltersUrlState', () => {
|
|
|
448
416
|
result.current.forceApplyFilters(viewBFilters);
|
|
449
417
|
});
|
|
450
418
|
await waitFor(() => {
|
|
451
|
-
expect(
|
|
452
|
-
const
|
|
419
|
+
expect(onUrlUpdate).toHaveBeenCalled();
|
|
420
|
+
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0];
|
|
421
|
+
const filterValue = lastCall.searchParams.get('profile_filters');
|
|
453
422
|
// View A's filters should be completely gone
|
|
454
|
-
expect(
|
|
455
|
-
expect(
|
|
423
|
+
expect(filterValue).not.toContain('viewAFunc');
|
|
424
|
+
expect(filterValue).not.toContain('viewABinary');
|
|
456
425
|
// Only View B's filter should be present
|
|
457
|
-
expect(
|
|
458
|
-
});
|
|
459
|
-
});
|
|
460
|
-
it('should handle sequential view switches correctly', async () => {
|
|
461
|
-
// Simulate: [default] -> [storage] -> [testing-view]
|
|
462
|
-
const { result } = renderHook(() => useProfileFiltersUrlState(), { wrapper: createWrapper() });
|
|
463
|
-
// View 1: default view (1 filter)
|
|
464
|
-
const defaultFilters = [{ id: 'd-1', type: 'hide_libc', value: 'enabled' }];
|
|
465
|
-
act(() => {
|
|
466
|
-
result.current.forceApplyFilters(defaultFilters);
|
|
467
|
-
});
|
|
468
|
-
await waitFor(() => {
|
|
469
|
-
expect(mockNavigateTo).toHaveBeenCalled();
|
|
470
|
-
const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
|
|
471
|
-
expect(params.profile_filters).toBe('p:hide_libc:enabled');
|
|
472
|
-
});
|
|
473
|
-
mockNavigateTo.mockClear();
|
|
474
|
-
// View 2: storage view (3 filters)
|
|
475
|
-
const storageFilters = [
|
|
476
|
-
{ id: 's-1', type: 'stack', field: 'function_name', matchType: 'not_contains', value: 'io' },
|
|
477
|
-
{ id: 's-2', type: 'frame', field: 'binary', matchType: 'not_contains', value: 'disk' },
|
|
478
|
-
{ id: 's-3', type: 'frame', field: 'function_name', matchType: 'contains', value: 'storage' },
|
|
479
|
-
];
|
|
480
|
-
act(() => {
|
|
481
|
-
result.current.forceApplyFilters(storageFilters);
|
|
482
|
-
});
|
|
483
|
-
await waitFor(() => {
|
|
484
|
-
expect(mockNavigateTo).toHaveBeenCalled();
|
|
485
|
-
const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
|
|
486
|
-
// Default view's filter should be gone
|
|
487
|
-
expect(params.profile_filters).not.toContain('hide_libc');
|
|
488
|
-
// Storage view should have 3 filters
|
|
489
|
-
expect(params.profile_filters).toContain('io');
|
|
490
|
-
expect(params.profile_filters).toContain('disk');
|
|
491
|
-
expect(params.profile_filters).toContain('storage');
|
|
492
|
-
});
|
|
493
|
-
mockNavigateTo.mockClear();
|
|
494
|
-
// View 3: testing-view (2 filters)
|
|
495
|
-
const testingFilters = [
|
|
496
|
-
{ id: 't-1', type: 'stack', field: 'function_name', matchType: 'equal', value: 'test_main' },
|
|
497
|
-
{ id: 't-2', type: 'frame', field: 'binary', matchType: 'contains', value: 'test' },
|
|
498
|
-
];
|
|
499
|
-
act(() => {
|
|
500
|
-
result.current.forceApplyFilters(testingFilters);
|
|
501
|
-
});
|
|
502
|
-
await waitFor(() => {
|
|
503
|
-
expect(mockNavigateTo).toHaveBeenCalled();
|
|
504
|
-
const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
|
|
505
|
-
// Storage view's filters should be gone
|
|
506
|
-
expect(params.profile_filters).not.toContain('io');
|
|
507
|
-
expect(params.profile_filters).not.toContain('disk');
|
|
508
|
-
expect(params.profile_filters).not.toContain('storage');
|
|
509
|
-
// Testing view should have its 2 filters
|
|
510
|
-
expect(params.profile_filters).toContain('test_main');
|
|
511
|
-
expect(params.profile_filters).toContain('test');
|
|
426
|
+
expect(filterValue).toBe('f:fn:~:viewBOnly');
|
|
512
427
|
});
|
|
513
428
|
});
|
|
514
429
|
it('should not change filters when clicking the same view tab', async () => {
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
430
|
+
const { result } = renderHook(() => useProfileFiltersUrlState(), {
|
|
431
|
+
wrapper: createWrapper({ profile_filters: 's:fn:=:existingFilter' }),
|
|
432
|
+
});
|
|
518
433
|
await waitFor(() => {
|
|
519
434
|
expect(result.current.appliedFilters).toHaveLength(1);
|
|
520
435
|
});
|
|
521
|
-
mockNavigateTo.mockClear();
|
|
522
436
|
// Apply the same filters (simulating clicking the same view tab)
|
|
523
437
|
const sameFilters = [
|
|
524
438
|
{
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"MultiLevelDropdown.d.ts","sourceRoot":"","sources":["../../../../src/ProfileView/components/Toolbars/MultiLevelDropdown.tsx"],"names":[],"mappings":"AAaA,OAAO,KAAiD,MAAM,OAAO,CAAC;AAQtE,OAAO,EAAC,WAAW,EAAC,MAAM,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"MultiLevelDropdown.d.ts","sourceRoot":"","sources":["../../../../src/ProfileView/components/Toolbars/MultiLevelDropdown.tsx"],"names":[],"mappings":"AAaA,OAAO,KAAiD,MAAM,OAAO,CAAC;AAQtE,OAAO,EAAC,WAAW,EAAC,MAAM,eAAe,CAAC;AAmK1C,UAAU,uBAAuB;IAC/B,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,IAAI,CAAC;IACnC,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,aAAa,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,cAAc,EAAE,OAAO,CAAC;IACxB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,oBAAoB,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAC9C,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CACvC;AAED,QAAA,MAAM,kBAAkB,EAAE,KAAK,CAAC,EAAE,CAAC,uBAAuB,CAoPzD,CAAC;AAEF,eAAe,kBAAkB,CAAC"}
|