From 11dba3414d9d85b60c4fa82cbac706b025ac3301 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Sat, 3 Sep 2022 23:21:04 -0700 Subject: [PATCH] add tanstack virtual --- apps/desktop/src/index.html | 1 + .../client/src/stores/useExplorerStore.ts | 8 +- packages/interface/package.json | 1 + .../src/components/explorer/Explorer.tsx | 203 ++-------- .../explorer/ExplorerContextMenu.tsx | 178 +++++++++ .../src/components/explorer/FileItem.tsx | 22 +- .../src/components/explorer/FileList.tsx | 347 ++++++++---------- .../src/components/explorer/FileRow.tsx | 104 ++++++ .../src/components/explorer/FileThumb.tsx | 2 +- .../src/components/explorer/Inspector.tsx | 14 +- .../src/components/layout/Sidebar.tsx | 5 +- .../src/screens/LocationExplorer.tsx | 2 +- pnpm-lock.yaml | Bin 674326 -> 675031 bytes 13 files changed, 498 insertions(+), 389 deletions(-) create mode 100644 packages/interface/src/components/explorer/ExplorerContextMenu.tsx create mode 100644 packages/interface/src/components/explorer/FileRow.tsx diff --git a/apps/desktop/src/index.html b/apps/desktop/src/index.html index b51fcedeb..324b0663a 100644 --- a/apps/desktop/src/index.html +++ b/apps/desktop/src/index.html @@ -8,6 +8,7 @@
+ diff --git a/packages/client/src/stores/useExplorerStore.ts b/packages/client/src/stores/useExplorerStore.ts index 5cb66ac2f..2c956216b 100644 --- a/packages/client/src/stores/useExplorerStore.ts +++ b/packages/client/src/stores/useExplorerStore.ts @@ -1,7 +1,7 @@ import produce from 'immer'; import create from 'zustand'; -type LayoutMode = 'list' | 'grid'; +export type ExplorerLayoutMode = 'list' | 'grid'; export enum ExplorerKind { Location, @@ -10,8 +10,10 @@ export enum ExplorerKind { } type ExplorerStore = { - layoutMode: LayoutMode; + layoutMode: ExplorerLayoutMode; locationId: number | null; // used by top bar + gridItemSize: number; + listItemSize: number; showInspector: boolean; selectedRowIndex: number; multiSelectIndexes: number[]; @@ -26,6 +28,8 @@ type ExplorerStore = { export const useExplorerStore = create((set) => ({ layoutMode: 'grid', locationId: null, + gridItemSize: 100, + listItemSize: 40, showInspector: true, selectedRowIndex: 1, multiSelectIndexes: [], diff --git a/packages/interface/package.json b/packages/interface/package.json index 2ea606988..3df99a7eb 100644 --- a/packages/interface/package.json +++ b/packages/interface/package.json @@ -32,6 +32,7 @@ "@tailwindcss/forms": "^0.5.2", "@tanstack/react-query": "^4.0.10", "@tanstack/react-query-devtools": "^4.0.10", + "@tanstack/react-virtual": "3.0.0-beta.18", "@types/styled-components": "^5.1.25", "@vitejs/plugin-react": "^2.0.0", "autoprefixer": "^10.4.7", diff --git a/packages/interface/src/components/explorer/Explorer.tsx b/packages/interface/src/components/explorer/Explorer.tsx index cb80c25b3..b3f40e3c4 100644 --- a/packages/interface/src/components/explorer/Explorer.tsx +++ b/packages/interface/src/components/explorer/Explorer.tsx @@ -16,28 +16,22 @@ import { Trash, TrashSimple } from 'phosphor-react'; -import React from 'react'; +import React, { memo, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { FileList } from '../explorer/FileList'; import { Inspector } from '../explorer/Inspector'; import { WithContextMenu } from '../layout/MenuOverlay'; import { TopBar } from '../layout/TopBar'; +import ExplorerContextMenu from './ExplorerContextMenu'; interface Props { data: ExplorerData; } export default function Explorer(props: Props) { - const { selectedRowIndex, addNewThumbnail, contextMenuObjectId, showInspector } = - useExplorerStore(); + const addNewThumbnail = useExplorerStore((store) => store.addNewThumbnail); - const { currentLibraryUuid } = useLibraryStore(); - - const { data: tags } = useLibraryQuery(['tags.getAll'], {}); - - const { mutate: assignTag } = useLibraryMutation('tags.assign'); - - const { data: tagsForFile } = useLibraryQuery(['tags.getForFile', contextMenuObjectId || -1]); + const currentLibraryUuid = useLibraryStore((store) => store.currentLibraryUuid); rspc.useSubscription(['jobs.newThumbnail', { library_id: currentLibraryUuid!, arg: null }], { onNext: (cas_id) => { @@ -47,166 +41,35 @@ export default function Explorer(props: Props) { return (
- { - const active = !!tagsForFile?.find((t) => t.id === tag.id); - return { - label: tag.name || '', - - // leftItem: t.id === tag.id)} />, - leftItem: ( -
-
-
- ), - onClick(e) { - e.preventDefault(); - if (contextMenuObjectId != null) - assignTag({ - tag_id: tag.id, - file_id: contextMenuObjectId, - unassign: active - }); - } - }; - }) || [] - ] - } - ], - [ - { - label: 'More actions...', - icon: Plus, - - children: [ - // [ - // { - // label: 'Move to library', - // icon: FilePlus, - // children: [libraries?.map((library) => ({ label: library.config.name })) || []] - // }, - // { - // label: 'Remove from library', - // icon: FileX - // } - // ], - [ - { - label: 'Encrypt', - icon: LockSimple - }, - { - label: 'Compress', - icon: Package - }, - { - label: 'Convert to', - icon: ArrowBendUpRight, - - children: [ - [ - { - label: 'PNG' - }, - { - label: 'WebP' - } - ] - ] - } - // { - // label: 'Mint NFT', - // icon: TrashIcon - // } - ], - [ - { - label: 'Secure delete', - icon: TrashSimple - } - ] - ] - } - ], - [ - { - label: 'Delete', - icon: Trash, - danger: true - } - ] - ]} - > -
- -
- - {showInspector && ( -
- {props.data.items[selectedRowIndex]?.id && ( - - )} -
- )} -
-
- + + +
); } + +const ExplorerContent = (props: Props) => { + const { selectedRowIndex, showInspector } = useExplorerStore((store) => ({ + selectedRowIndex: store.selectedRowIndex, + showInspector: store.showInspector + })); + + return ( +
+ +
+ + {showInspector && ( +
+ {props.data.items[selectedRowIndex]?.id && ( + + )} +
+ )} +
+
+ ); +}; diff --git a/packages/interface/src/components/explorer/ExplorerContextMenu.tsx b/packages/interface/src/components/explorer/ExplorerContextMenu.tsx new file mode 100644 index 000000000..942ede4c0 --- /dev/null +++ b/packages/interface/src/components/explorer/ExplorerContextMenu.tsx @@ -0,0 +1,178 @@ +import { useExplorerStore, useLibraryMutation, useLibraryQuery } from '@sd/client'; +import { ExplorerData } from '@sd/core'; +import { + ArrowBendUpRight, + LockSimple, + Package, + Plus, + Share, + TagSimple, + Trash, + TrashSimple +} from 'phosphor-react'; +import React from 'react'; + +import { WithContextMenu } from '../layout/MenuOverlay'; + +interface Props { + children: React.ReactNode; +} + +export default function ExplorerContextMenu(props: Props) { + const contextMenuObjectId = useExplorerStore((store) => store.contextMenuObjectId); + + const { data: tags } = useLibraryQuery(['tags.getAll'], {}); + + const { mutate: assignTag } = useLibraryMutation('tags.assign'); + + const { data: tagsForFile } = useLibraryQuery(['tags.getForFile', contextMenuObjectId || -1]); + return ( +
+ { + const active = !!tagsForFile?.find((t) => t.id === tag.id); + return { + label: tag.name || '', + + // leftItem: t.id === tag.id)} />, + leftItem: ( +
+
+
+ ), + onClick(e) { + e.preventDefault(); + if (contextMenuObjectId != null) + assignTag({ + tag_id: tag.id, + file_id: contextMenuObjectId, + unassign: active + }); + } + }; + }) || [] + ] + } + ], + [ + { + label: 'More actions...', + icon: Plus, + + children: [ + // [ + // { + // label: 'Move to library', + // icon: FilePlus, + // children: [libraries?.map((library) => ({ label: library.config.name })) || []] + // }, + // { + // label: 'Remove from library', + // icon: FileX + // } + // ], + [ + { + label: 'Encrypt', + icon: LockSimple + }, + { + label: 'Compress', + icon: Package + }, + { + label: 'Convert to', + icon: ArrowBendUpRight, + + children: [ + [ + { + label: 'PNG' + }, + { + label: 'WebP' + } + ] + ] + } + // { + // label: 'Mint NFT', + // icon: TrashIcon + // } + ], + [ + { + label: 'Secure delete', + icon: TrashSimple + } + ] + ] + } + ], + [ + { + label: 'Delete', + icon: Trash, + danger: true + } + ] + ]} + > + {props.children} + +
+ ); +} diff --git a/packages/interface/src/components/explorer/FileItem.tsx b/packages/interface/src/components/explorer/FileItem.tsx index b31c6c287..7ed656f28 100644 --- a/packages/interface/src/components/explorer/FileItem.tsx +++ b/packages/interface/src/components/explorer/FileItem.tsx @@ -1,23 +1,21 @@ -import { ReactComponent as Folder } from '@sd/assets/svgs/folder.svg'; -import { LocationContext, useExplorerStore } from '@sd/client'; -import { ExplorerData, ExplorerItem, File, FilePath } from '@sd/core'; +import { useExplorerStore } from '@sd/client'; +import { ExplorerItem } from '@sd/core'; import clsx from 'clsx'; -import React, { useContext } from 'react'; +import React from 'react'; -import icons from '../../assets/icons'; import FileThumb from './FileThumb'; -import { isObject, isPath } from './utils'; +import { isObject } from './utils'; interface Props extends React.HTMLAttributes { data: ExplorerItem; selected: boolean; - size: number; index: number; } -export default function FileItem(props: Props) { - const { set } = useExplorerStore(); - const size = props.size || 100; +function FileItem(props: Props) { + const set = useExplorerStore.getState().set; + + const size = useExplorerStore((state) => state.gridItemSize) || 100; return (
@@ -70,3 +68,5 @@ export default function FileItem(props: Props) {
); } + +export default FileItem; diff --git a/packages/interface/src/components/explorer/FileList.tsx b/packages/interface/src/components/explorer/FileList.tsx index 32fd8108c..cfd811f07 100644 --- a/packages/interface/src/components/explorer/FileList.tsx +++ b/packages/interface/src/components/explorer/FileList.tsx @@ -1,48 +1,15 @@ -import { EllipsisHorizontalIcon } from '@heroicons/react/24/solid'; -import { LocationContext, useBridgeQuery, useExplorerStore, useLibraryQuery } from '@sd/client'; +import { ExplorerLayoutMode, useExplorerStore } from '@sd/client'; import { ExplorerContext, ExplorerItem, FilePath } from '@sd/core'; -import clsx from 'clsx'; -import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import React, { memo, useCallback, useLayoutEffect, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; -import { Virtuoso, VirtuosoGrid, VirtuosoHandle } from 'react-virtuoso'; import { useKey, useWindowSize } from 'rooks'; -import styled from 'styled-components'; import FileItem from './FileItem'; -import FileThumb from './FileThumb'; +import FileRow from './FileRow'; import { isPath } from './utils'; -interface IColumn { - column: string; - key: string; - width: number; -} - -// Function ensure no types are lost, but guarantees that they are Column[] -function ensureIsColumns(data: T) { - return data; -} - -const columns = ensureIsColumns([ - { column: 'Name', key: 'name', width: 280 } as const, - // { column: 'Size', key: 'size_in_bytes', width: 120 } as const, - { column: 'Type', key: 'extension', width: 100 } as const -]); - -type ColumnKey = typeof columns[number]['key']; - -// these styled components are out of place, but are here to follow the virtuoso docs. could probably be translated to tailwind somehow, since the `components` prop only accepts a styled div, not a react component. -const GridContainer = styled.div` - display: flex; - margin-top: 60px; - margin-left: 10px; - width: 100%; - flex-wrap: wrap; -`; -const GridItemContainer = styled.div` - display: flex; - flex-wrap: wrap; -`; +const TOP_BAR_HEIGHT = 50; interface Props { context: ExplorerContext; @@ -50,27 +17,15 @@ interface Props { } export const FileList: React.FC = (props) => { - const size = useWindowSize(); - const tableContainer = useRef(null); - const VList = useRef(null); - - const { data: client } = useBridgeQuery(['getNode'], { - refetchOnWindowFocus: false - }); - - const { selectedRowIndex, set, layoutMode } = useExplorerStore(); + // const size = useWindowSize(); const [goingUp, setGoingUp] = useState(false); - useEffect(() => { - if (selectedRowIndex === 0 && goingUp) { - VList.current?.scrollTo({ top: 0, behavior: 'smooth' }); - } - if (selectedRowIndex !== -1 && typeof VList.current?.scrollIntoView === 'function') { - VList.current?.scrollIntoView({ - index: goingUp ? selectedRowIndex - 1 : selectedRowIndex - }); - } - }, [goingUp, selectedRowIndex]); + const { selectedRowIndex, layoutMode } = useExplorerStore((state) => ({ + selectedRowIndex: state.selectedRowIndex, + layoutMode: state.layoutMode + })); + + const set = useExplorerStore.getState().set; useKey('ArrowUp', (e) => { e.preventDefault(); @@ -86,170 +41,158 @@ export const FileList: React.FC = (props) => { set({ selectedRowIndex: selectedRowIndex + 1 }); }); - const createRenderItem = (RenderItem: React.FC) => { - return (index: number) => { - const row = props.data[index]; - if (!row) return null; - return ; - }; - }; - - const Header = () => ( -
- {props.context.name && ( -

{props.context.name}

- )} -
-
- {columns.map((col) => ( -
- - {col.column} -
- ))} -
-
-
- ); + // const Header = () => ( + //
+ // {props.context.name && ( + //

{props.context.name}

+ // )} + //
+ //
+ // {columns.map((col) => ( + //
+ // + // {col.column} + //
+ // ))} + //
+ //
+ //
+ // ); return ( -
- {layoutMode === 'grid' && ( - - )} - {layoutMode === 'list' && ( -
- }} - increaseViewportBy={{ top: 400, bottom: 200 }} - className="outline-none explorer-scroll" - /> - )} +
+
); }; -interface RenderItemProps { - item: ExplorerItem; - index: number; -} +function Virtualizer({ items }: { items: ExplorerItem[] }) { + const parentRef = useRef(null); + const innerRef = useRef(null); -const RenderGridItem: React.FC = ({ item, index }) => { - const { selectedRowIndex, set } = useExplorerStore(); - const [_, setSearchParams] = useSearchParams(); + const { gridItemSize, layoutMode, listItemSize, selectedRowIndex } = useExplorerStore( + (state) => ({ + selectedRowIndex: state.selectedRowIndex, + gridItemSize: state.gridItemSize, + layoutMode: state.layoutMode, + listItemSize: state.listItemSize + }) + ); + + const [width, setWidth] = useState(0); + + useLayoutEffect(() => { + setWidth(innerRef.current?.offsetWidth || 0); + }, []); + + const amountOfColumns = Math.floor(width / gridItemSize) || 8, + amountOfRows = layoutMode === 'grid' ? Math.ceil(items.length / amountOfColumns) : items.length, + itemSize = layoutMode === 'grid' ? gridItemSize + 25 : listItemSize; + + const rowVirtualizer = useVirtualizer({ + count: amountOfRows, + getScrollElement: () => parentRef.current, + overscan: 500, + estimateSize: () => itemSize, + measureElement: (index) => itemSize + }); return ( - { - if (item.type === 'Path' && item.is_dir) { - setSearchParams({ path: item.materialized_path }); - } - }} - index={index} - data={item} - selected={selectedRowIndex === index} - onClick={() => { - set({ selectedRowIndex: selectedRowIndex == index ? -1 : index }); - }} - size={100} - /> - ); -}; - -const RenderRow: React.FC = ({ item, index }) => { - const { selectedRowIndex, set } = useExplorerStore(); - const isActive = selectedRowIndex === index; - const [_, setSearchParams] = useSearchParams(); - - return useMemo( - () => ( +
set({ selectedRowIndex: selectedRowIndex == index ? -1 : index })} - onDoubleClick={() => { - if (isPath(item) && item.is_dir) { - setSearchParams({ path: item.materialized_path }); - } + ref={innerRef} + style={{ + height: `${rowVirtualizer.getTotalSize()}px`, + marginTop: `${TOP_BAR_HEIGHT}px` }} - className={clsx( - 'table-body-row mr-2 flex flex-row rounded-lg border-2', - isActive ? 'border-primary-500' : 'border-transparent', - index % 2 == 0 && 'bg-[#00000006] dark:bg-[#00000030]' - )} + className="relative w-full" > - {columns.map((col) => ( + {rowVirtualizer.getVirtualItems().map((virtualRow) => (
- + {layoutMode === 'list' ? ( + + ) : ( + [...Array(amountOfColumns)].map((_, i) => { + const index = virtualRow.index * amountOfColumns + i; + const item = items[index]; + return ( +
+
+ {item && ( + + )} +
+
+ ); + }) + )}
))}
- ), - // eslint-disable-next-line react-hooks/exhaustive-deps - [item.id, isActive] +
); -}; +} -const RenderCell: React.FC<{ - colKey: ColumnKey; - data: ExplorerItem; -}> = ({ colKey, data }) => { - switch (colKey) { - case 'name': - return ( -
-
- -
- {/* {colKey == 'name' && - (() => { - switch (row.extension.toLowerCase()) { - case 'mov' || 'mp4': - return ; +interface WrappedItemProps { + item: ExplorerItem; + index: number; + isSelected: boolean; + kind: ExplorerLayoutMode; +} - default: - if (row.is_dir) - return ; - return ; - } - })()} */} - {data[colKey]} -
- ); - // case 'size_in_bytes': - // return {byteSize(Number(value || 0))}; - case 'extension': - return {data[colKey]}; - // case 'meta_integrity_hash': - // return {value}; - // case 'tags': - // return renderCellWithIcon(MusicNoteIcon); +// Wrap either list item or grid item with click logic as it is the same for both +const WrappedItem: React.FC = memo(({ item, index, isSelected, kind }) => { + const [_, setSearchParams] = useSearchParams(); - default: - return <>; + const onDoubleClick = useCallback(() => { + if (isPath(item) && item.is_dir) setSearchParams({ path: item.materialized_path }); + }, [item, setSearchParams]); + + const onClick = useCallback(() => { + useExplorerStore.getState().set({ selectedRowIndex: isSelected ? -1 : index }); + }, [isSelected, index]); + + if (kind === 'list') { + return ( + + ); } -}; + + return ( + + ); +}); diff --git a/packages/interface/src/components/explorer/FileRow.tsx b/packages/interface/src/components/explorer/FileRow.tsx new file mode 100644 index 000000000..8a4df7411 --- /dev/null +++ b/packages/interface/src/components/explorer/FileRow.tsx @@ -0,0 +1,104 @@ +import { EllipsisHorizontalIcon } from '@heroicons/react/24/solid'; +import { LocationContext, useBridgeQuery, useExplorerStore, useLibraryQuery } from '@sd/client'; +import { ExplorerContext, ExplorerItem, FilePath } from '@sd/core'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import clsx from 'clsx'; +import React, { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { Virtuoso, VirtuosoGrid, VirtuosoHandle } from 'react-virtuoso'; +import { useKey, useWindowSize } from 'rooks'; +import styled from 'styled-components'; + +import FileItem from './FileItem'; +import FileThumb from './FileThumb'; +import { isPath } from './utils'; + +interface Props extends React.HTMLAttributes { + data: ExplorerItem; + index: number; + selected: boolean; +} + +function FileRow({ data, index, selected, ...props }: Props) { + return ( +
+ {columns.map((col) => ( +
+ +
+ ))} +
+ ); +} + +const RenderCell: React.FC<{ + colKey: ColumnKey; + data: ExplorerItem; +}> = ({ colKey, data }) => { + switch (colKey) { + case 'name': + return ( +
+
+ +
+ {/* {colKey == 'name' && + (() => { + switch (row.extension.toLowerCase()) { + case 'mov' || 'mp4': + return ; + + default: + if (row.is_dir) + return ; + return ; + } + })()} */} + {data[colKey]} +
+ ); + // case 'size_in_bytes': + // return {byteSize(Number(value || 0))}; + case 'extension': + return {data[colKey]}; + // case 'meta_integrity_hash': + // return {value}; + // case 'tags': + // return renderCellWithIcon(MusicNoteIcon); + + default: + return <>; + } +}; + +interface IColumn { + column: string; + key: string; + width: number; +} + +// Function ensure no types are lost, but guarantees that they are Column[] +function ensureIsColumns(data: T) { + return data; +} + +const columns = ensureIsColumns([ + { column: 'Name', key: 'name', width: 280 } as const, + // { column: 'Size', key: 'size_in_bytes', width: 120 } as const, + { column: 'Type', key: 'extension', width: 100 } as const +]); + +type ColumnKey = typeof columns[number]['key']; + +export default FileRow; diff --git a/packages/interface/src/components/explorer/FileThumb.tsx b/packages/interface/src/components/explorer/FileThumb.tsx index 9b9e35c8b..63487d301 100644 --- a/packages/interface/src/components/explorer/FileThumb.tsx +++ b/packages/interface/src/components/explorer/FileThumb.tsx @@ -16,7 +16,7 @@ interface Props { export default function FileThumb({ data, ...props }: Props) { const appProps = useContext(AppPropsContext); - const { newThumbnails } = useExplorerStore(); + const newThumbnails = useExplorerStore((store) => store.newThumbnails); if (isPath(data) && data.is_dir) return ; diff --git a/packages/interface/src/components/explorer/Inspector.tsx b/packages/interface/src/components/explorer/Inspector.tsx index 3971d1dbf..cdacc6a69 100644 --- a/packages/interface/src/components/explorer/Inspector.tsx +++ b/packages/interface/src/components/explorer/Inspector.tsx @@ -26,7 +26,19 @@ export const Inspector = (props: Props) => { const objectData = isObject(props.data) ? props.data : props.data.file; - const { data: tags } = useLibraryQuery(['tags.getForFile', objectData?.id || -1]); + // this prevents the inspector from fetching data when the user is navigating quickly + const [readyToFetch, setReadyToFetch] = useState(false); + useEffect(() => { + const timeout = setTimeout(() => { + setReadyToFetch(true); + }, 350); + return () => clearTimeout(timeout); + }, [props.data.id]); + + // this is causing LAG + const { data: tags } = useLibraryQuery(['tags.getForFile', objectData?.id || -1], { + enabled: readyToFetch + }); return (
diff --git a/packages/interface/src/components/layout/Sidebar.tsx b/packages/interface/src/components/layout/Sidebar.tsx index 1685de2d3..e2fbcbed9 100644 --- a/packages/interface/src/components/layout/Sidebar.tsx +++ b/packages/interface/src/components/layout/Sidebar.tsx @@ -85,7 +85,10 @@ export const Sidebar: React.FC = (props) => { const { data: locations } = useLibraryQuery(['locations.get']); // initialize libraries - const { init: initLibraries, switchLibrary } = useLibraryStore(); + const { init: initLibraries, switchLibrary } = useLibraryStore((store) => ({ + init: store.init, + switchLibrary: store.switchLibrary + })); const { currentLibrary, libraries, currentLibraryUuid } = useCurrentLibrary(); useEffect(() => { diff --git a/packages/interface/src/screens/LocationExplorer.tsx b/packages/interface/src/screens/LocationExplorer.tsx index ad8179446..05f2033d8 100644 --- a/packages/interface/src/screens/LocationExplorer.tsx +++ b/packages/interface/src/screens/LocationExplorer.tsx @@ -21,7 +21,7 @@ export const LocationExplorer: React.FC = () => { const { location_id, path } = useExplorerParams(); // for top bar location context, could be replaced with react context as it is child component - const { set } = useExplorerStore(); + const set = useExplorerStore((state) => state.set); useEffect(() => { set({ locationId: location_id }); }, [location_id]); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f41c6c66d85bdebcdb34f299459ac6fd0ee2439e..cad990b964c6834c3acc08f9231cf5dee6b523ea 100644 GIT binary patch delta 483 zcmbO>Mf3Vh%?+Z;lTWeniI-&-m6Rsts9Px*>lx@7=q9C>BvU<#F{&G+Bo z9OPGSm=%_!9bTE{8|s^sVi=km5uxuD9#HC=X6BTeWohK%q#tJPpPp@+XIhk0X&Pyo zSX`27o@}0*Q|y%L>t^9mQc&(16jbIq{oqMvx#V&IS>_nYotvsexJguI5Qu5&1p_g;6H1MJDCtd1anSW%?OW zS~=Vr|bV<=bZlk9V6HD=R7RJ plf@+Yr}lHGZkOj}$zW{v7hnNmRv=~rVs;?r0AkMV{sLUbEde@ooGJhS delta 85 zcmcaUQ*+uB%?+Z;ljn=FPH*sKX4~wqoW(hLwu)pkpKd#!E+Y^#ZRgWvwqcrHXT;38 heU>5fmiz6of-FGH3dC$c%nrmHK+L&4R*