From 146a4eebd8f9a986952dd5279d7a93941c3b407b Mon Sep 17 00:00:00 2001 From: lethemanh Date: Wed, 15 Oct 2025 15:54:04 +0700 Subject: [PATCH 01/17] refactor: Moved all logics that're related to shift selection out of SelectionProvider :recycle: --- src/hooks/useOnLongPress/helpers.js | 8 - src/hooks/useOnLongPress/index.js | 4 - src/hooks/useShiftArrowsSelection.jsx | 51 ------ src/modules/filelist/File.jsx | 17 +- src/modules/filelist/virtualized/GridFile.jsx | 20 +-- src/modules/selection/SelectionProvider.jsx | 164 +----------------- .../selection/SelectionProvider.spec.jsx | 72 +------- src/modules/views/Folder/FolderViewBody.jsx | 25 +-- src/modules/views/Folder/virtualized/Grid.jsx | 17 +- .../views/Folder/virtualized/Table.jsx | 63 ++----- .../views/Folder/virtualized/helpers.js | 6 +- 11 files changed, 37 insertions(+), 410 deletions(-) delete mode 100644 src/hooks/useShiftArrowsSelection.jsx diff --git a/src/hooks/useOnLongPress/helpers.js b/src/hooks/useOnLongPress/helpers.js index 9b66693cb7..a2030daa4d 100644 --- a/src/hooks/useOnLongPress/helpers.js +++ b/src/hooks/useOnLongPress/helpers.js @@ -13,8 +13,6 @@ export const handleClick = ({ lastClickTime, setLastClickTime, setSelectedItems, - setLastSelectedIndex, - setFocusedIndex, clearHighlightedItems }) => { // if default behavior is opening a file, it blocks that to force other bahavior @@ -43,8 +41,6 @@ export const handleClick = ({ // we should probablt not use index - 1 // we should use only one func to set things on click, and not 3 setters setSelectedItems({ [file._id]: file }) - setFocusedIndex(file.index - 1) - setLastSelectedIndex(file.index - 1) } setLastClickTime(currentTime) @@ -62,8 +58,6 @@ export const makeDesktopHandlers = ({ setLastClickTime, clearSelection, setSelectedItems, - setLastSelectedIndex, - setFocusedIndex, clearHighlightedItems }) => { return { @@ -85,8 +79,6 @@ export const makeDesktopHandlers = ({ setLastClickTime, clearSelection, setSelectedItems, - setLastSelectedIndex, - setFocusedIndex, clearHighlightedItems }) } diff --git a/src/hooks/useOnLongPress/index.js b/src/hooks/useOnLongPress/index.js index 8d85daa717..1642cd0675 100644 --- a/src/hooks/useOnLongPress/index.js +++ b/src/hooks/useOnLongPress/index.js @@ -20,8 +20,6 @@ export const useLongPress = ({ const { isDesktop } = useBreakpoints() const { setSelectedItems, - setFocusedIndex, - setLastSelectedIndex, clearSelection, isSelectionBarVisible: selectionModeActive } = useSelectionContext() @@ -40,8 +38,6 @@ export const useLongPress = ({ setLastClickTime, clearSelection, setSelectedItems, - setLastSelectedIndex, - setFocusedIndex, clearHighlightedItems }) } diff --git a/src/hooks/useShiftArrowsSelection.jsx b/src/hooks/useShiftArrowsSelection.jsx deleted file mode 100644 index b50aa0aa0c..0000000000 --- a/src/hooks/useShiftArrowsSelection.jsx +++ /dev/null @@ -1,51 +0,0 @@ -import { useEffect, useRef } from 'react' - -import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints' - -import { useSelectionContext } from '@/modules/selection/SelectionProvider' - -const useShiftArrowsSelection = ({ items, viewType = 'list' }, ref) => { - const { isMobile } = useBreakpoints() - - const { setItemsList, handleShiftArrow } = useSelectionContext() - const viewTypeRef = useRef() - - useEffect(() => { - viewTypeRef.current = viewType - }, [viewType]) - - useEffect(() => { - setItemsList(items) - }, [items, setItemsList]) - - useEffect(() => { - if (isMobile) return - - if (!ref?.current) return - - const table = ref.current - ref.current.focus() - - const handleKeyDown = event => { - if ( - event.shiftKey && - ((viewTypeRef.current === 'list' && - (event.key === 'ArrowUp' || event.key === 'ArrowDown')) || - (viewTypeRef.current === 'grid' && - (event.key === 'ArrowRight' || event.key === 'ArrowLeft'))) - ) { - event.preventDefault() - const direction = - event.key === 'ArrowUp' || event.key === 'ArrowLeft' ? -1 : 1 - handleShiftArrow(direction) - } - } - - table.addEventListener('keydown', handleKeyDown) - return () => { - table.removeEventListener('keydown', handleKeyDown) - } - }, [handleShiftArrow, isMobile, ref]) -} - -export { useShiftArrowsSelection } diff --git a/src/modules/filelist/File.jsx b/src/modules/filelist/File.jsx index 688862828d..f0f73f4d6f 100644 --- a/src/modules/filelist/File.jsx +++ b/src/modules/filelist/File.jsx @@ -69,15 +69,13 @@ const File = ({ breakpoints: { isExtraLarge, isMobile }, disableSelection = false, canInteractWith, - onContextMenu, - fileIndex = null + onContextMenu }) => { const { viewType } = useViewSwitcherContext() const [actionMenuVisible, setActionMenuVisible] = useState(false) const filerowMenuToggleRef = useRef() - const { toggleSelectedItem, isItemSelected, handleShiftClick } = - useSelectionContext() + const { toggleSelectedItem, isItemSelected } = useSelectionContext() const { isItemCut } = useClipboardContext() @@ -93,13 +91,8 @@ const File = ({ setActionMenuVisible(false) } - const toggle = e => { - e.stopPropagation() - if (e.shiftKey && fileIndex !== null) { - handleShiftClick(attributes, fileIndex) - } else { - toggleSelectedItem(attributes, fileIndex) - } + const toggle = () => { + toggleSelectedItem(attributes) } const isRowDisabledOrInSyncFromSharing = disabled || isInSyncFromSharing @@ -155,7 +148,7 @@ const File = ({ viewType={viewType} withSelectionCheckbox={withSelectionCheckbox && actions?.length > 0} selected={selected} - onClick={e => toggle(e)} + onClick={toggle} disabled={ !canInteractWithFile || isRowDisabledOrInSyncFromSharing || diff --git a/src/modules/filelist/virtualized/GridFile.jsx b/src/modules/filelist/virtualized/GridFile.jsx index 0dd6cfc8e6..8228d17841 100644 --- a/src/modules/filelist/virtualized/GridFile.jsx +++ b/src/modules/filelist/virtualized/GridFile.jsx @@ -47,17 +47,12 @@ const GridFile = ({ disableSelection = false, canInteractWith, onContextMenu, - isOver, - fileIndex = null + isOver }) => { const [actionMenuVisible, setActionMenuVisible] = useState(false) const filerowMenuToggleRef = useRef() - const { - toggleSelectedItem, - isItemSelected, - isSelectionBarVisible, - handleShiftClick - } = useSelectionContext() + const { toggleSelectedItem, isItemSelected, isSelectionBarVisible } = + useSelectionContext() const { isItemCut } = useClipboardContext() const { isNew } = useNewItemHighlightContext() @@ -73,13 +68,8 @@ const GridFile = ({ setActionMenuVisible(false) } - const toggle = e => { - e.stopPropagation() - if (e.shiftKey && fileIndex !== null) { - handleShiftClick(attributes, fileIndex) - } else { - toggleSelectedItem(attributes, fileIndex) - } + const toggle = () => { + toggleSelectedItem(attributes) } const isRowDisabledOrInSyncFromSharing = disabled || isInSyncFromSharing diff --git a/src/modules/selection/SelectionProvider.jsx b/src/modules/selection/SelectionProvider.jsx index d0187d02b0..056054d33b 100644 --- a/src/modules/selection/SelectionProvider.jsx +++ b/src/modules/selection/SelectionProvider.jsx @@ -4,12 +4,10 @@ import React, { useMemo, useState, useEffect, - useRef, useCallback } from 'react' import { useLocation } from 'react-router-dom' -import { SHARED_DRIVES_DIR_ID } from '@/constants/config' import { useNewItemHighlightContext } from '@/modules/upload/NewItemHighlightProvider' /** @@ -22,12 +20,6 @@ import { useNewItemHighlightContext } from '@/modules/upload/NewItemHighlightPro * @property {Function} isItemSelected Find out if an item is selected by its id * @property {boolean} isSelectAll Whether all the items are selected or not * @property {Function} toggleSelectAllItems Toggle selects all items - * @property {Function} selectRange Select a range of items between two indices - * @property {Function} setItemsList Set the current list of items for range selection - * @property {Function} handleShiftClick Handle shift+click selection - * @property {Function} handleShiftArrow Handle keyboard navigation selection - * @property {number} focusedIndex Current focused item index - * @property {Function} setFocusedIndex Set the focused item index * @property {Function} selectAll Select all items * @property {Function} clearSelection Clear all the selected items */ @@ -42,26 +34,15 @@ const SelectionProvider = ({ children }) => { const location = useLocation() const [selectedItems, setSelectedItems] = useState({}) const [isSelectionBarOpen, setSelectionBarOpen] = useState(false) - const [focusedIndex, setFocusedIndex] = useState(0) - const [lastSelectedIndex, setLastSelectedIndex] = useState(null) - const [isKeyboardNavigating, setIsKeyboardNavigating] = useState(false) - const itemsListRef = useRef([]) + const [isSelectAll, setIsSelectAll] = useState(false) const { highlightedItems, clearItems } = useNewItemHighlightContext() - const setItemsList = items => { - itemsListRef.current = items - } - const isItemSelected = id => { return selectedItems[id] !== undefined } - const toggleSelectedItem = (item, index = null) => { - if (item._id === SHARED_DRIVES_DIR_ID) { - return - } - + const toggleSelectedItem = item => { if (highlightedItems?.length > 0) { clearItems() } @@ -70,128 +51,9 @@ const SelectionProvider = ({ children }) => { // eslint-disable-next-line no-unused-vars const { [item._id]: _, ...stillSelected } = selectedItems setSelectedItems(stillSelected) - setLastSelectedIndex(null) } else { setSelectedItems({ ...selectedItems, [item._id]: item }) - if (index !== null) { - setLastSelectedIndex(index) - setFocusedIndex(index) - } - } - } - - const selectRange = (startIndex, endIndex) => { - const start = Math.min(startIndex, endIndex) - const end = Math.max(startIndex, endIndex) - - const newSelectedItems = { ...selectedItems } - - for (let i = start; i <= end; i++) { - const item = itemsListRef.current[i] - if (!item || item._id === SHARED_DRIVES_DIR_ID) continue - - if (newSelectedItems[item._id] && i !== start) { - delete newSelectedItems[item._id] - } else { - newSelectedItems[item._id] = item - } - } - - setSelectedItems(newSelectedItems) - } - - const handleShiftClick = (item, clickedIndex) => { - if (lastSelectedIndex !== null) { - selectRange(lastSelectedIndex, clickedIndex) - } else { - toggleSelectedItem(item, clickedIndex) - } - setLastSelectedIndex(clickedIndex) - setFocusedIndex(clickedIndex) - } - - const handleShiftArrow = (direction, items = []) => { - const allItems = items.length === 0 ? itemsListRef.current : items - if (!allItems || allItems.length === 0) return - - const clamp = i => Math.max(0, Math.min(allItems.length - 1, i)) - - if (lastSelectedIndex === null && Object.keys(selectedItems).length === 0) { - let firstValidIndex = 0 - if (allItems[0]?._id === SHARED_DRIVES_DIR_ID && allItems.length > 1) { - firstValidIndex = 1 - } - - const firstItem = allItems[firstValidIndex] - if (firstItem && firstItem._id !== SHARED_DRIVES_DIR_ID) { - const newSelectedItems = { [firstItem._id]: firstItem } - setSelectedItems(newSelectedItems) - setFocusedIndex(firstValidIndex) - setLastSelectedIndex(firstValidIndex) - setIsKeyboardNavigating(true) - } - return } - - const prevFocused = focusedIndex - const anchorIndex = - lastSelectedIndex !== null ? lastSelectedIndex : prevFocused - const startSelected = - !!allItems[anchorIndex] && isItemSelected(allItems[anchorIndex]?._id) - - let nextFocused = clamp(prevFocused + (direction < 0 ? -1 : 1)) - - const newSelectedItems = { ...selectedItems } - - const step = direction < 0 ? -1 : 1 - - if (startSelected) { - const movingAway = - (anchorIndex <= prevFocused && step === 1) || - (anchorIndex >= prevFocused && step === -1) - - if (movingAway) { - while ( - nextFocused >= 0 && - nextFocused < allItems.length && - isItemSelected(allItems[nextFocused]?._id) - ) { - const nf = nextFocused + step - if (nf < 0 || nf >= allItems.length) break - nextFocused = nf - } - - let i = prevFocused + step - while (i >= 0 && i < allItems.length) { - const it = allItems[i] - if (it && it._id !== SHARED_DRIVES_DIR_ID) { - newSelectedItems[it._id] = it - } - if (i === nextFocused) break - i += step - } - } else { - let i = prevFocused - while (i >= 0 && i < allItems.length && i !== nextFocused) { - const it = allItems[i] - if (it) delete newSelectedItems[it._id] - i -= step - } - } - } else { - let i = prevFocused + step - while (i >= 0 && i < allItems.length) { - const it = allItems[i] - if (it) delete newSelectedItems[it._id] - if (i === nextFocused) break - i += step - } - } - - setSelectedItems(newSelectedItems) - setFocusedIndex(nextFocused) - setIsKeyboardNavigating(true) - setLastSelectedIndex(anchorIndex) } const selectAll = items => { @@ -200,12 +62,12 @@ const SelectionProvider = ({ children }) => { return acc }, {}) setSelectedItems(newSelectedItems) + setIsSelectAll(true) } const clearSelection = useCallback(() => { + setIsSelectAll(false) setSelectedItems({}) - setLastSelectedIndex(null) - setFocusedIndex(0) }, []) const toggleSelectAllItems = items => { @@ -226,13 +88,6 @@ const SelectionProvider = ({ children }) => { return Object.keys(selectedItems).length !== 0 || isSelectionBarOpen }, [isSelectionBarOpen, selectedItems]) - const isSelectAll = useMemo(() => { - return ( - itemsListRef.current.length > 0 && - Object.keys(selectedItems).length === itemsListRef.current.length - ) - }, [selectedItems]) - useEffect(() => { hideSelectionBar() }, [location, hideSelectionBar]) @@ -250,15 +105,8 @@ const SelectionProvider = ({ children }) => { isItemSelected, isSelectAll, toggleSelectAllItems, - selectRange, - setItemsList, - handleShiftClick, - handleShiftArrow, - focusedIndex, - setFocusedIndex, - isKeyboardNavigating, - setLastSelectedIndex, - setSelectedItems + setSelectedItems, + setIsSelectAll }} > {children} diff --git a/src/modules/selection/SelectionProvider.spec.jsx b/src/modules/selection/SelectionProvider.spec.jsx index 0a8bb77d3a..06efd27b58 100644 --- a/src/modules/selection/SelectionProvider.spec.jsx +++ b/src/modules/selection/SelectionProvider.spec.jsx @@ -39,18 +39,10 @@ const SelectionConsumer = ({ items }) => { hideSelectionBar, isSelectionBarVisible, toggleSelectedItem, - isItemSelected, - handleShiftClick, - handleShiftArrow, - focusedIndex, - setItemsList + isItemSelected } = useSelectionContext() const { pathname } = useLocation() - React.useEffect(() => { - setItemsList(items) - }, [items, setItemsList]) - return ( <> {pathname === '/' && Change route} @@ -59,7 +51,7 @@ const SelectionConsumer = ({ items }) => { )} {items.map((item, index) => ( ))} - - - - -
{focusedIndex}
) } @@ -183,44 +155,4 @@ describe('SelectionProvider', () => { expect(screen.queryByText('Hide selection bar')).toBeNull() }) }) - - it('selects a range of items with handleShiftClick', () => { - setup() - - fireEvent.click(screen.getByTestId('item-1')) - expect(screen.getByText('Item file-foobar1 selected')).toBeInTheDocument() - - fireEvent.click(screen.getByTestId('shift-click')) - expect(screen.getByText('Item file-foobar1 selected')).toBeInTheDocument() - expect(screen.getByText('Item file-foobar2 selected')).toBeInTheDocument() - expect(screen.getByText('Item file-foobar3 selected')).toBeInTheDocument() - - // shift-click again should toggle/deselect item3 - fireEvent.click(screen.getByTestId('shift-click')) - - expect(screen.getByText('Item file-foobar1 selected')).toBeInTheDocument() - expect(screen.getByText('Item file-foobar2 selected')).toBeInTheDocument() - // item3 should now be unselected - expect(screen.getByTestId('item-3')).toHaveTextContent('Item file-foobar3') - }) - - it('selects range with handleShiftArrow', () => { - setup() - - fireEvent.click(screen.getByTestId('item-1')) - expect(screen.getByText('Item file-foobar1 selected')).toBeInTheDocument() - - // Shift + ArrowDown => should extend to item2 - fireEvent.click(screen.getByTestId('shift-arrow-down')) - expect(screen.getByText('Item file-foobar1 selected')).toBeInTheDocument() - expect(screen.getByText('Item file-foobar2 selected')).toBeInTheDocument() - - // Shift + ArrowDown again => should extend to item3 - fireEvent.click(screen.getByTestId('shift-arrow-down')) - expect(screen.getByText('Item file-foobar3 selected')).toBeInTheDocument() - - // Shift + ArrowUp => shrink back, item3 deselected - fireEvent.click(screen.getByTestId('shift-arrow-up')) - expect(screen.getByText('Item file-foobar3')).toBeInTheDocument() - }) }) diff --git a/src/modules/views/Folder/FolderViewBody.jsx b/src/modules/views/Folder/FolderViewBody.jsx index 0e0048de47..99f0cb2c9f 100644 --- a/src/modules/views/Folder/FolderViewBody.jsx +++ b/src/modules/views/Folder/FolderViewBody.jsx @@ -4,7 +4,6 @@ import React, { useContext, useState, useEffect, - useMemo, useRef } from 'react' import { useSelector } from 'react-redux' @@ -21,7 +20,6 @@ import styles from '@/styles/folder-view.styl' import { EmptyWrapper } from '@/components/Error/Empty' import Oops from '@/components/Error/Oops' import RightClickFileMenu from '@/components/RightClick/RightClickFileMenu' -import { useShiftArrowsSelection } from '@/hooks/useShiftArrowsSelection' import AcceptingSharingContext from '@/lib/AcceptingSharingContext' import { useThumbnailSizeContext } from '@/lib/ThumbnailSizeContext' import { useViewSwitcherContext } from '@/lib/ViewSwitcherContext' @@ -69,18 +67,6 @@ const FolderViewBody = ({ const folderViewRef = useRef() const IsAddingFolder = useSelector(isTypingNewFolderName) - const allFiles = useMemo(() => { - const files = [] - queryResults.forEach(query => { - if (query.data && query.data.length > 0) { - files.push(...query.data) - } - }) - return files - }, [queryResults]) - - useShiftArrowsSelection({ items: allFiles, viewType }, folderViewRef) - /** * Since we are not able to restore the scroll correctly, * and force the scroll to top every time we change the @@ -220,17 +206,9 @@ const FolderViewBody = ({ /> {queryResults.map((query, queryIndex) => { if (query.data !== null && query.data.length > 0) { - let fileIndex = 0 - for (let i = 0; i < queryIndex; i++) { - if (queryResults[i].data) { - fileIndex += queryResults[i].data.length - } - } - return ( - {query.data.map((file, localIndex) => { - const globalIndex = fileIndex + localIndex + {query.data.map(file => { return ( ) diff --git a/src/modules/views/Folder/virtualized/Grid.jsx b/src/modules/views/Folder/virtualized/Grid.jsx index 711e1c87ef..26da9a255b 100644 --- a/src/modules/views/Folder/virtualized/Grid.jsx +++ b/src/modules/views/Folder/virtualized/Grid.jsx @@ -1,5 +1,5 @@ import cx from 'classnames' -import React, { useRef } from 'react' +import React from 'react' import { useVaultClient } from 'cozy-keys-lib' import VirtualizedGridListDnd from 'cozy-ui/transpiled/react/GridList/Virtualized/Dnd' @@ -9,7 +9,6 @@ import GridWrapper from './GridWrapper' import styles from '@/styles/filelist.styl' import RightClickFileMenu from '@/components/RightClick/RightClickFileMenu' -import { useShiftArrowsSelection } from '@/hooks/useShiftArrowsSelection' import AddFolder from '@/modules/filelist/AddFolder' import { GridFileWithSelection as GridFile } from '@/modules/filelist/virtualized/GridFile' @@ -27,16 +26,9 @@ const Grid = ({ currentFolderId }) => { const vaultClient = useVaultClient() - const gridRef = useRef() - useShiftArrowsSelection({ items, viewType: 'grid' }, gridRef) return ( -
+
( + itemRenderer={(file, { isOver }) => ( <> {file.type != 'tempDirectory' ? ( ) : ( @@ -87,4 +78,6 @@ const Grid = ({ ) } +Grid.displayName = 'Grid' + export default React.memo(Grid) diff --git a/src/modules/views/Folder/virtualized/Table.jsx b/src/modules/views/Folder/virtualized/Table.jsx index adc38551fb..bbb51a0594 100644 --- a/src/modules/views/Folder/virtualized/Table.jsx +++ b/src/modules/views/Folder/virtualized/Table.jsx @@ -1,18 +1,13 @@ -import React, { useRef, forwardRef, useState, useMemo } from 'react' +import React, { forwardRef } from 'react' import VirtuosoTableDnd from 'cozy-ui/transpiled/react/Table/Virtualized/Dnd' import TableRowDnD from 'cozy-ui/transpiled/react/Table/Virtualized/Dnd/TableRow' import virtuosoComponentsDnd from 'cozy-ui/transpiled/react/Table/Virtualized/Dnd/virtuosoComponents' -import { - stableSort, - getComparator -} from 'cozy-ui/transpiled/react/Table/Virtualized/helpers' import { secondarySort } from '../helpers' import RightClickFileMenu from '@/components/RightClick/RightClickFileMenu' import { useClipboardContext } from '@/contexts/ClipboardProvider' -import { useShiftArrowsSelection } from '@/hooks/useShiftArrowsSelection' import Cell from '@/modules/filelist/virtualized/cells/Cell' import { useSelectionContext } from '@/modules/selection/SelectionProvider' import { useNewItemHighlightContext } from '@/modules/upload/NewItemHighlightProvider' @@ -57,63 +52,29 @@ const Table = ({ actions, sortOrder }) => { - const { - handleShiftClick, - focusedIndex, - toggleSelectedItem, - isKeyboardNavigating - } = useSelectionContext() - - const tableRef = useRef() - const [order, setOrder] = useState(sortOrder?.order || 'asc') - const [orderBy, setOrderBy] = useState( - sortOrder?.attribute || columns?.[0]?.id - ) + const { toggleSelectedItem } = useSelectionContext() const { isNew } = useNewItemHighlightContext() - const sortedRow = useMemo(() => { - const sortedData = stableSort(rows, getComparator(order, orderBy)) - return secondarySort(sortedData) - }, [rows, order, orderBy]) - - useShiftArrowsSelection({ items: sortedRow, viewType: 'list' }, tableRef) - - const handleRowSelect = (row, event, visualIndex) => { - event.stopPropagation() - if (event.shiftKey && visualIndex !== undefined) { - handleShiftClick(row, visualIndex) - } else { - toggleSelectedItem(row, visualIndex) - } - } - - const handleSort = ({ order, orderBy }) => { - setOrder(order) - setOrderBy(orderBy) - } return ( -
+
) } @@ -132,4 +91,6 @@ const Table = ({ ) } +Table.displayName = 'Table' + export default React.memo(Table) diff --git a/src/modules/views/Folder/virtualized/helpers.js b/src/modules/views/Folder/virtualized/helpers.js index 58bce856cf..03a631fbca 100644 --- a/src/modules/views/Folder/virtualized/helpers.js +++ b/src/modules/views/Folder/virtualized/helpers.js @@ -13,11 +13,7 @@ export const makeRows = ({ queryResults, IsAddingFolder, syncingFakeFile }) => { rows.push(syncingFakeFile) } - // TODO: we should not modify io.cozy.files for frontend purpose - return rows.map((row, index) => ({ - ...row, - index - })) + return rows } export const onDrop = From a346dcdf43f5e3622009a1315e07a2c5adb16963 Mon Sep 17 00:00:00 2001 From: lethemanh Date: Wed, 15 Oct 2025 15:56:46 +0700 Subject: [PATCH 02/17] refactor: Implemented new hook and helper functions for shift selection :recycle: --- src/hooks/useShiftSelection/helpers.ts | 262 +++++++++++++++++++ src/hooks/useShiftSelection/index.tsx | 198 ++++++++++++++ src/modules/selection/SelectionProvider.d.ts | 7 + src/modules/selection/types.ts | 49 ++++ 4 files changed, 516 insertions(+) create mode 100644 src/hooks/useShiftSelection/helpers.ts create mode 100644 src/hooks/useShiftSelection/index.tsx create mode 100644 src/modules/selection/SelectionProvider.d.ts create mode 100644 src/modules/selection/types.ts diff --git a/src/hooks/useShiftSelection/helpers.ts b/src/hooks/useShiftSelection/helpers.ts new file mode 100644 index 0000000000..6ea5498dcf --- /dev/null +++ b/src/hooks/useShiftSelection/helpers.ts @@ -0,0 +1,262 @@ +import { IOCozyFile } from 'cozy-client/types/types' + +import type { SelectedItems } from '@/modules/selection/types' + +export const FORWARD_DIRECTION = 1 as const +export const BACKWARD_DIRECTION = -1 as const + +interface HandleShiftSelectionResponse { + newSelectedItems: SelectedItems + lastInteractedItemId: string +} + +interface FindNextBoundaryIndexParams { + items: IOCozyFile[] + startIdx: number + direction: typeof FORWARD_DIRECTION | typeof BACKWARD_DIRECTION + isMovingToSelect: boolean + isReturnCurrent: boolean + isItemSelected: (id: string) => boolean +} + +interface ToggleSelectionParams { + items: IOCozyFile[] + selectedItems: SelectedItems + currentIdx: number + lastInteractedIdx: number + isMovingToSelect: boolean + isItemSelected: (id: string) => boolean +} + +export interface HandleShiftArrowParams { + direction: typeof FORWARD_DIRECTION | typeof BACKWARD_DIRECTION + items: IOCozyFile[] + selectedItems: SelectedItems + lastInteractedIdx: number + isItemSelected: (id: string) => boolean +} + +export interface HandleShiftClickParams { + startIdx: number + endIdx: number + selectedItems: SelectedItems + items: IOCozyFile[] +} + +/** + * Returns a new selectedItems object without the removed key + * @param {SelectedItems} selectedItems The current selected items object + * @param {string} removedKey The key/ID of the item to remove from selection + * + * @returns {SelectedItems} A new SelectedItems object without the removed key + */ +const returnValidSelections = ( + selectedItems: SelectedItems, + removedKey: string +): SelectedItems => { + const { [removedKey]: _, ...validSelections } = selectedItems + return validSelections +} + +/** + * Clamps an index value to be within valid array bounds. + * @param {number} maxLength The maximum length of the array + * @param {number} index The index to clamp + * + * @returns {number} The clamped index value between 0 and maxLength-1 + */ +const clamp = (maxLength: number, index: number): number => + Math.max(0, Math.min(maxLength - 1, index)) + +/** + * Find the next index (in given direction) where selection state flips. + * This defines the next "boundary" for select/deselect operations. + * Used to determine where to stop when selecting or deselecting. + * + * @param {FindNextBoundaryIndexParams} params The parameters object + * @param {IOCozyFile[]} params.items Array of all available items + * @param {number} params.startIdx Starting index to search from + * @param {number} params.direction Direction to search (1 for forward, -1 for backward) + * @param {boolean} params.isMovingToSelect Whether we're moving to select or deselect items + * @param {boolean} params.isReturnCurrent Determine if we have to find next index or not + * @param {function} params.isItemSelected Function to check if an item is selected + * + * @returns {number} The index of the next boundary where selection state changes + */ +const findNextBoundaryIndex = ({ + items, + startIdx, + direction, + isMovingToSelect, + isReturnCurrent, + isItemSelected +}: FindNextBoundaryIndexParams): number => { + if (isReturnCurrent) return startIdx + + let idx = startIdx + direction + + while ( + idx >= 0 && + idx < items.length && + isMovingToSelect === isItemSelected(items[idx]?._id) + ) { + idx += direction + } + + return clamp(items.length, idx - direction) +} + +/** + * Toggles the selection state of items based on keyboard navigation. + * Handles the complex logic of selection or deselecting selections during Shift+Arrow operations. + * + * @param {ToggleSelectionParams} params The parameters object + * @param {IOCozyFile[]} params.items Array of all available items + * @param {SelectedItems} params.selectedItems Current selected items object + * @param {number} params.currentIdx Current index being navigated to + * @param {number} params.lastInteractedIdx Index of the last interacted item + * @param {boolean} params.isMovingToSelect Whether we're moving to select or deselect items + * @param {function} params.isItemSelected Function to check if an item is selected + * + * @returns {SelectedItems} + */ +const toggleSelection = ({ + items, + selectedItems, + currentIdx, + lastInteractedIdx, + isMovingToSelect, + isItemSelected +}: ToggleSelectionParams): SelectedItems => { + let newSelected = { ...selectedItems } + + // Identify which item to modify (depends on selection direction) + const targetItem = isMovingToSelect + ? items[currentIdx] + : isItemSelected(items[lastInteractedIdx]._id) + ? items[lastInteractedIdx] + : items[currentIdx] + + if (isMovingToSelect) { + newSelected[targetItem._id] = targetItem + } else { + newSelected = returnValidSelections(newSelected, targetItem._id) + } + + return newSelected +} + +/** + * Handle Shift + Arrow keyboard selection. + * - If no items are selected, selects the first/last item based on direction + * - Return selected items based on selection state + * - Return focus position for continued navigation + * + * @param {HandleShiftArrowParams} params The parameters object + * @param {number} params.direction Direction of arrow key (FORWARD_DIRECTION or BACKWARD_DIRECTION) + * @param {IOCozyFile[]} [params.items] Array of all available items (defaults to empty array) + * @param {SelectedItems} [params.selectedItems] Current selected items object (defaults to empty object) + * @param {number} params.lastInteractedIdx Index of the last interacted item + * @param {function} params.isItemSelected Function to check if an item is selected by _id + * + * @returns {HandleShiftSelectionResponse} + */ +export const handleShiftArrow = ({ + direction, + items, + selectedItems = {}, + lastInteractedIdx, + isItemSelected +}: HandleShiftArrowParams): HandleShiftSelectionResponse => { + if (Object.keys(selectedItems).length === 0) { + const autoSelectedItem = + direction === FORWARD_DIRECTION ? items[0] : items[items.length - 1] + return { + newSelectedItems: { + [autoSelectedItem._id]: autoSelectedItem + }, + lastInteractedItemId: autoSelectedItem._id + } + } + + const nextIdx = lastInteractedIdx + direction + const currentIdx = clamp(items.length, nextIdx) + + // If we hit a boundary (can't move further), keep current selection unchanged + if (currentIdx === lastInteractedIdx) { + return { + newSelectedItems: selectedItems, + lastInteractedItemId: items[lastInteractedIdx]._id + } + } + + const prevSelected = isItemSelected(items[lastInteractedIdx]?._id) + const currSelected = isItemSelected(items[currentIdx]?._id) + const isMovingToSelect = prevSelected && !currSelected + + const newSelectedItems = toggleSelection({ + items, + selectedItems, + currentIdx, + lastInteractedIdx, + isMovingToSelect, + isItemSelected + }) + + // Updates focus position for continued navigation + const finalIdx = findNextBoundaryIndex({ + items, + startIdx: currentIdx, + direction, + isMovingToSelect, + isItemSelected, + isReturnCurrent: Object.keys(newSelectedItems).length < 1 + }) + + return { + newSelectedItems, + lastInteractedItemId: items[finalIdx]._id + } +} + +/** + * Handle Shift + Click range selection. + * - Selects all items in range if end item is not selected + * - Deselects all items in range if end item is already selected + * - Handles reverse ranges (endIdx < startIdx) automatically + * - Return the last interacted item to the clicked item and new selections + * + * @param {HandleShiftClickParams} params The parameters object + * @param {number} params.startIdx Starting index of the selection range (last interacted item) + * @param {number} params.endIdx Ending index of the selection range (last clicked item) + * @param {SelectedItems} params.selectedItems Current selected items object + * @param {IOCozyFile[]} params.items Array of all available items + * + * @returns {HandleShiftSelectionResponse} + */ +export const handleShiftClick = ({ + startIdx, + endIdx, + selectedItems, + items +}: HandleShiftClickParams): HandleShiftSelectionResponse => { + let newSelectedItems = { ...selectedItems } + const endItem = items[endIdx] + const isMovingToSelect = !Object.hasOwn(newSelectedItems, endItem._id) + const start = Math.min(startIdx, endIdx) + const end = Math.max(startIdx, endIdx) + + for (let i = start; i <= end; i++) { + const item = items[i] + if (isMovingToSelect) { + newSelectedItems[item._id] = item + } else { + newSelectedItems = returnValidSelections(newSelectedItems, item._id) + } + } + + return { + newSelectedItems, + lastInteractedItemId: items[endIdx]._id + } +} diff --git a/src/hooks/useShiftSelection/index.tsx b/src/hooks/useShiftSelection/index.tsx new file mode 100644 index 0000000000..8ff3a86cb1 --- /dev/null +++ b/src/hooks/useShiftSelection/index.tsx @@ -0,0 +1,198 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + RefObject +} from 'react' + +import { IOCozyFile } from 'cozy-client/types/types' +import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints' + +import { + handleShiftClick, + handleShiftArrow, + BACKWARD_DIRECTION, + FORWARD_DIRECTION +} from './helpers' + +import { useSelectionContext } from '@/modules/selection/SelectionProvider' +import { SelectedItems } from '@/modules/selection/types' + +type ViewType = 'list' | 'grid' + +interface UseShiftSelectionParams { + items: IOCozyFile[] + viewType?: ViewType +} + +interface UseShiftSelectionReturn { + setLastInteractedItem: (id: string | null) => void + onShiftClick: (clickedItemId: string, event: KeyboardEvent) => void +} + +/** + * Custom hook that provides shift-based range selection functionality for file/folder lists. + * + * This hook enables users to: + * - Select ranges of items using Shift+Click (from last interacted item to clicked item) + * - Navigate and extend selection using Shift+Arrow keys (direction depends on viewType) + * + * @param {UseShiftSelectionParams} params - Configuration object containing items and view type + * @param {IOCozyFile[]} params.items - Array of IOCozyFile objects to enable selection on + * @param {ViewType} params.viewType - View type ('list' or 'grid') that determines keyboard navigation behavior + * @param ref - React ref to the container element that should receive keyboard events + * + * @returns {UseShiftSelectionReturn} + */ +const useShiftSelection = ( + { items, viewType = 'list' }: UseShiftSelectionParams, + ref: RefObject +): UseShiftSelectionReturn => { + const { isMobile } = useBreakpoints() + + const itemsRef = useRef([]) + itemsRef.current = useMemo(() => items, [items]) + + const { selectedItems, setSelectedItems, isItemSelected, setIsSelectAll } = + useSelectionContext() + + const [lastInteractedItem, setLastInteractedItem] = useState( + null + ) + + const lastInteractedIdx = useMemo(() => { + return lastInteractedItem + ? itemsRef.current.findIndex(item => item._id === lastInteractedItem) + : 0 + }, [lastInteractedItem]) + + const selectedItemMap: SelectedItems = useMemo(() => { + return selectedItems.reduce( + (prev: SelectedItems, cur: IOCozyFile) => ({ + ...prev, + [cur._id]: cur + }), + {} + ) + }, [selectedItems]) + + /** + * Handles shift+click events for range selection. + * + * When shift key is held and an item is clicked, selects or deselects all items + * between the last interacted item and the clicked item (inclusive). + * + * @param {string} clickedItemId - ID of the item that was clicked + * @param {KeyboardEvent} event - The keyboard event (must have shiftKey = true) + */ + const onShiftClick = useCallback( + (clickedItemId: string, event: KeyboardEvent) => { + if (!event.shiftKey) return + + event.stopPropagation() + + const endIdx = items.findIndex(item => item._id === clickedItemId) + const { newSelectedItems, lastInteractedItemId } = handleShiftClick({ + startIdx: lastInteractedIdx, + endIdx, + selectedItems: selectedItemMap, + items + }) + + setSelectedItems(newSelectedItems) + setLastInteractedItem(lastInteractedItemId) + setIsSelectAll( + Object.keys(newSelectedItems).length === itemsRef.current.length + ) + }, + [ + items, + lastInteractedIdx, + selectedItemMap, + setSelectedItems, + setIsSelectAll, + setLastInteractedItem + ] + ) + + /** + * Handles keyboard events for shift+arrow navigation. + * + * Listens for shift+arrow key combinations and extends/contracts selection + * based on the navigation direction. The specific arrow keys depend on viewType: + * - List view: ArrowUp (backward) / ArrowDown (forward) + * - Grid view: ArrowLeft (backward) / ArrowRight (forward) + * + * @param {KeyboardEvent} event - The keyboard event to handle + */ + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (!event.shiftKey) return + + const key = event.key + const isListKey = + viewType === 'list' && ['ArrowUp', 'ArrowDown'].includes(key) + const isGridKey = + viewType === 'grid' && ['ArrowLeft', 'ArrowRight'].includes(key) + + if (!isListKey && !isGridKey) return + + event.preventDefault() + + const direction = + key === 'ArrowUp' || key === 'ArrowLeft' + ? BACKWARD_DIRECTION + : FORWARD_DIRECTION + + const { newSelectedItems, lastInteractedItemId } = handleShiftArrow({ + direction, + items: itemsRef.current, + selectedItems: selectedItemMap, + lastInteractedIdx, + isItemSelected + }) + + setSelectedItems(newSelectedItems) + setLastInteractedItem(lastInteractedItemId) + setIsSelectAll(selectedItems.length === itemsRef.current.length) + }, + [ + viewType, + selectedItemMap, + lastInteractedIdx, + selectedItems.length, + setSelectedItems, + isItemSelected, + setIsSelectAll, + setLastInteractedItem + ] + ) + + /** + * Sets up keyboard event listeners on the container element. + * + * - Focuses the container to ensure it can receive keyboard events + * - Adds keydown event listener for shift+arrow navigation + * - Skips setup on mobile devices or when no items/container available + */ + useEffect(() => { + if (isMobile || !itemsRef.current.length || !ref.current) return + + const container = ref.current + container.focus() + + container.addEventListener('keydown', handleKeyDown) + return () => { + container.removeEventListener('keydown', handleKeyDown) + } + }, [isMobile, ref, handleKeyDown]) + + return { + setLastInteractedItem, + onShiftClick + } +} + +export { useShiftSelection } diff --git a/src/modules/selection/SelectionProvider.d.ts b/src/modules/selection/SelectionProvider.d.ts new file mode 100644 index 0000000000..fe811a6ba7 --- /dev/null +++ b/src/modules/selection/SelectionProvider.d.ts @@ -0,0 +1,7 @@ +import { SelectionContextType, SelectionProviderProps } from './types' + +declare const SelectionProvider: React.FC +declare const useSelectionContext: () => SelectionContextType + +export { SelectionProvider, useSelectionContext } +export type { SelectionContextType, SelectionProviderProps } diff --git a/src/modules/selection/types.ts b/src/modules/selection/types.ts new file mode 100644 index 0000000000..78330ececd --- /dev/null +++ b/src/modules/selection/types.ts @@ -0,0 +1,49 @@ +import { ReactNode } from 'react' + +import { IOCozyFile } from 'cozy-client/types/types' + +export type SelectedItems = Record + +export interface SelectionContextType { + /** Show the SelectionBar */ + showSelectionBar: () => void + + /** Hide the SelectionBar */ + hideSelectionBar: () => void + + /** Clear all the selected items */ + clearSelection: () => void + + /** Whether the SelectionBar is visible or not */ + isSelectionBarVisible: boolean + + /** List of selected items as an array */ + selectedItems: IOCozyFile[] + + /** Select an item if it is not selected, otherwise deselect it */ + toggleSelectedItem: (item: IOCozyFile) => void + + /** Select all items */ + selectAll: (items: IOCozyFile[]) => void + + /** Find out if an item is selected by its id */ + isItemSelected: (id: string) => boolean + + /** Whether all the items are selected or not */ + isSelectAll: boolean + + /** Toggle selects all items */ + toggleSelectAllItems: (items: IOCozyFile[]) => void + + /** Set selected items directly (used internally) */ + setSelectedItems: ( + items: SelectedItems | ((prev: SelectedItems) => SelectedItems) + ) => void + + /** Set select all status */ + setIsSelectAll: (isSelectAll: boolean) => void +} + +export interface SelectionProviderProps { + children: ReactNode +} From 46c91d654c89ad9b97ce2fc07daa38171763e713 Mon Sep 17 00:00:00 2001 From: lethemanh Date: Wed, 15 Oct 2025 15:57:31 +0700 Subject: [PATCH 03/17] refactor: Implemented unit test for shift selection :recycle: --- src/hooks/useShiftSelection/helpers.spec.ts | 399 ++++++++++++++++++++ src/hooks/useShiftSelection/index.spec.tsx | 232 ++++++++++++ 2 files changed, 631 insertions(+) create mode 100644 src/hooks/useShiftSelection/helpers.spec.ts create mode 100644 src/hooks/useShiftSelection/index.spec.tsx diff --git a/src/hooks/useShiftSelection/helpers.spec.ts b/src/hooks/useShiftSelection/helpers.spec.ts new file mode 100644 index 0000000000..497fe7f1fa --- /dev/null +++ b/src/hooks/useShiftSelection/helpers.spec.ts @@ -0,0 +1,399 @@ +import { IOCozyFile } from 'cozy-client/types/types' + +import { + handleShiftArrow, + handleShiftClick, + FORWARD_DIRECTION, + BACKWARD_DIRECTION, + HandleShiftArrowParams, + HandleShiftClickParams +} from './helpers' + +import { SelectedItems } from '@/modules/selection/types' + +const createMockFile = (id: string, name = `file-${id}`): IOCozyFile => + ({ + _id: id, + _type: 'io.cozy.files', + name, + type: 'file', + dir_id: 'root', + created_at: '2023-01-01T00:00:00.000Z', + updated_at: '2023-01-01T00:00:00.000Z', + size: 1000, + mime: 'text/plain', + class: 'text', + executable: false + } as IOCozyFile) + +const mockFiles: IOCozyFile[] = [ + createMockFile('1', 'file1.txt'), + createMockFile('2', 'file2.txt'), + createMockFile('3', 'file3.txt'), + createMockFile('4', 'file4.txt'), + createMockFile('5', 'file5.txt') +] + +describe('handleShiftArrow', () => { + let mockIsItemSelected: jest.Mock + + beforeEach(() => { + mockIsItemSelected = jest.fn() + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + describe('when no items are selected', () => { + it('should select the first item when moving forward', () => { + const params: HandleShiftArrowParams = { + direction: FORWARD_DIRECTION, + items: mockFiles, + selectedItems: {}, + lastInteractedIdx: 0, + isItemSelected: mockIsItemSelected + } + + const result = handleShiftArrow(params) + + expect(result).toEqual({ + newSelectedItems: { '1': mockFiles[0] }, + lastInteractedItemId: '1' + }) + }) + + it('should select the last item when moving backward', () => { + const params: HandleShiftArrowParams = { + direction: BACKWARD_DIRECTION, + items: mockFiles, + selectedItems: {}, + lastInteractedIdx: 0, + isItemSelected: mockIsItemSelected + } + + const result = handleShiftArrow(params) + + expect(result).toEqual({ + newSelectedItems: { '5': mockFiles[4] }, + lastInteractedItemId: '5' + }) + }) + }) + + describe('when items are already selected', () => { + it('should extend selection forward when moving from selected to unselected item', () => { + const selectedItems: SelectedItems = { '2': mockFiles[1] } + mockIsItemSelected.mockImplementation((id: string) => { + if (id === '2') return true // Previous item is selected + if (id === '3') return false // Current item is not selected + return false + }) + + const params: HandleShiftArrowParams = { + direction: FORWARD_DIRECTION, + items: mockFiles, + selectedItems, + lastInteractedIdx: 1, + isItemSelected: mockIsItemSelected + } + + const result = handleShiftArrow(params) + + expect(result.newSelectedItems).toEqual({ + '2': mockFiles[1], + '3': mockFiles[2] + }) + expect(result.lastInteractedItemId).toBe('3') + }) + + it('should contract selection when moving from selected to selected item', () => { + const selectedItems: SelectedItems = { + '1': mockFiles[0], + '2': mockFiles[1], + '3': mockFiles[2] + } + mockIsItemSelected.mockImplementation((id: string) => { + return ['1', '2', '3'].includes(id) + }) + + const params: HandleShiftArrowParams = { + direction: BACKWARD_DIRECTION, + items: mockFiles, + selectedItems, + lastInteractedIdx: 2, + isItemSelected: mockIsItemSelected + } + + const result = handleShiftArrow(params) + + expect(result.newSelectedItems).toEqual({ + '1': mockFiles[0], + '2': mockFiles[1] + }) + expect(result.lastInteractedItemId).toBe('2') + }) + + it('should handle boundary conditions at the start of the list', () => { + const selectedItems: SelectedItems = { '1': mockFiles[0] } + mockIsItemSelected.mockImplementation((id: string) => id === '1') + + const params: HandleShiftArrowParams = { + direction: BACKWARD_DIRECTION, + items: mockFiles, + selectedItems, + lastInteractedIdx: 0, + isItemSelected: mockIsItemSelected + } + + const result = handleShiftArrow(params) + + expect(result.newSelectedItems).toEqual({ '1': mockFiles[0] }) + expect(result.lastInteractedItemId).toBe('1') + }) + + it('should handle boundary conditions at the end of the list', () => { + const selectedItems: SelectedItems = { '5': mockFiles[4] } + mockIsItemSelected.mockImplementation((id: string) => id === '5') + + const params: HandleShiftArrowParams = { + direction: FORWARD_DIRECTION, + items: mockFiles, + selectedItems, + lastInteractedIdx: 4, + isItemSelected: mockIsItemSelected + } + + const result = handleShiftArrow(params) + + expect(result.newSelectedItems).toEqual({ '5': mockFiles[4] }) + expect(result.lastInteractedItemId).toBe('5') + }) + }) +}) + +describe('handleShiftClick', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('range selection behavior', () => { + it('should select all items in range when end item is not selected', () => { + const selectedItems: SelectedItems = {} + + const params: HandleShiftClickParams = { + startIdx: 1, + endIdx: 3, + selectedItems, + items: mockFiles + } + + const result = handleShiftClick(params) + + expect(result).toEqual({ + newSelectedItems: { + '2': mockFiles[1], + '3': mockFiles[2], + '4': mockFiles[3] + }, + lastInteractedItemId: '4' + }) + }) + + it('should deselect all items in range when end item is selected', () => { + const selectedItems: SelectedItems = { + '1': mockFiles[0], + '2': mockFiles[1], + '3': mockFiles[2], + '4': mockFiles[3], + '5': mockFiles[4] + } + + const params: HandleShiftClickParams = { + startIdx: 1, + endIdx: 3, + selectedItems, + items: mockFiles + } + + const result = handleShiftClick(params) + + expect(result).toEqual({ + newSelectedItems: { + '1': mockFiles[0], + '5': mockFiles[4] + }, + lastInteractedItemId: '4' + }) + }) + + it('should handle reverse range selection (endIdx < startIdx)', () => { + const selectedItems: SelectedItems = {} + + const params: HandleShiftClickParams = { + startIdx: 3, + endIdx: 1, + selectedItems, + items: mockFiles + } + + const result = handleShiftClick(params) + + expect(result).toEqual({ + newSelectedItems: { + '2': mockFiles[1], + '3': mockFiles[2], + '4': mockFiles[3] + }, + lastInteractedItemId: '2' + }) + }) + + it('should handle single item selection (startIdx === endIdx)', () => { + const selectedItems: SelectedItems = {} + + const params: HandleShiftClickParams = { + startIdx: 2, + endIdx: 2, + selectedItems, + items: mockFiles + } + + const result = handleShiftClick(params) + + expect(result).toEqual({ + newSelectedItems: { + '3': mockFiles[2] + }, + lastInteractedItemId: '3' + }) + }) + }) + + describe('boundary conditions', () => { + it('should handle selection at the beginning of the list', () => { + const selectedItems: SelectedItems = {} + + const params: HandleShiftClickParams = { + startIdx: 0, + endIdx: 2, + selectedItems, + items: mockFiles + } + + const result = handleShiftClick(params) + + expect(result).toEqual({ + newSelectedItems: { + '1': mockFiles[0], + '2': mockFiles[1], + '3': mockFiles[2] + }, + lastInteractedItemId: '3' + }) + }) + + it('should handle selection at the end of the list', () => { + const selectedItems: SelectedItems = {} + + const params: HandleShiftClickParams = { + startIdx: 2, + endIdx: 4, + selectedItems, + items: mockFiles + } + + const result = handleShiftClick(params) + + expect(result).toEqual({ + newSelectedItems: { + '3': mockFiles[2], + '4': mockFiles[3], + '5': mockFiles[4] + }, + lastInteractedItemId: '5' + }) + }) + + it('should handle full list selection', () => { + const selectedItems: SelectedItems = {} + + const params: HandleShiftClickParams = { + startIdx: 0, + endIdx: 4, + selectedItems, + items: mockFiles + } + + const result = handleShiftClick(params) + + expect(result).toEqual({ + newSelectedItems: { + '1': mockFiles[0], + '2': mockFiles[1], + '3': mockFiles[2], + '4': mockFiles[3], + '5': mockFiles[4] + }, + lastInteractedItemId: '5' + }) + }) + }) + + describe('mixed selection scenarios', () => { + it('should handle partial existing selection', () => { + const selectedItems: SelectedItems = { + '1': mockFiles[0], + '5': mockFiles[4] + } + + const params: HandleShiftClickParams = { + startIdx: 1, + endIdx: 3, + selectedItems, + items: mockFiles + } + + const result = handleShiftClick(params) + + expect(result).toEqual({ + newSelectedItems: { + '1': mockFiles[0], + '2': mockFiles[1], + '3': mockFiles[2], + '4': mockFiles[3], + '5': mockFiles[4] + }, + lastInteractedItemId: '4' + }) + }) + + it('should preserve items outside the range when deselecting', () => { + const selectedItems: SelectedItems = { + '1': mockFiles[0], + '2': mockFiles[1], + '3': mockFiles[2], + '4': mockFiles[3], + '5': mockFiles[4] + } + + const params: HandleShiftClickParams = { + startIdx: 1, + endIdx: 2, + selectedItems, + items: mockFiles + } + + const result = handleShiftClick(params) + + expect(result).toEqual({ + newSelectedItems: { + '1': mockFiles[0], + '4': mockFiles[3], + '5': mockFiles[4] + }, + lastInteractedItemId: '3' + }) + }) + }) +}) diff --git a/src/hooks/useShiftSelection/index.spec.tsx b/src/hooks/useShiftSelection/index.spec.tsx new file mode 100644 index 0000000000..2cdc3379be --- /dev/null +++ b/src/hooks/useShiftSelection/index.spec.tsx @@ -0,0 +1,232 @@ +import { renderHook, act } from '@testing-library/react-hooks' +import { RefObject } from 'react' + +import { IOCozyFile } from 'cozy-client/types/types' + +import * as helpers from './helpers' +import { useShiftSelection } from './index' + +jest.mock('cozy-ui/transpiled/react/providers/Breakpoints', () => ({ + __esModule: true, + default: (): { isMobile: boolean } => ({ isMobile: false }) +})) + +jest.mock('@/modules/selection/SelectionProvider', () => ({ + useSelectionContext: jest.fn() +})) + +jest.mock('./helpers', () => ({ + handleShiftClick: jest.fn().mockReturnValue({ + newSelectedItems: {}, + lastInteractedItemId: '1' + }), + handleShiftArrow: jest.fn().mockReturnValue({ + newSelectedItems: {}, + lastInteractedItemId: '1' + }), + FORWARD_DIRECTION: 1, + BACKWARD_DIRECTION: -1 +})) + +import { useSelectionContext } from '@/modules/selection/SelectionProvider' +const mockUseSelectionContext = useSelectionContext as jest.Mock + +// Get references to mocked functions +const mockHandleShiftArrow = helpers.handleShiftArrow as jest.Mock +const mockHandleShiftClick = helpers.handleShiftClick as jest.Mock + +const createMockFile = (id: string): IOCozyFile => + ({ + _id: id, + name: `file-${id}`, + type: 'file' + } as IOCozyFile) + +const mockFiles = [ + createMockFile('1'), + createMockFile('2'), + createMockFile('3') +] + +describe('useShiftSelection', () => { + let mockSetSelectedItems: jest.Mock + let mockIsItemSelected: jest.Mock + let mockRef: RefObject + let mockElement: HTMLElement + + beforeEach(() => { + mockSetSelectedItems = jest.fn() + mockIsItemSelected = jest.fn() + + mockElement = { + focus: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn() + } as unknown as HTMLElement + + mockRef = { current: mockElement } + + mockUseSelectionContext.mockReturnValue({ + selectedItems: [], + setSelectedItems: mockSetSelectedItems, + isItemSelected: mockIsItemSelected, + setIsSelectAll: jest.fn() + }) + + jest.clearAllMocks() + }) + + describe('initialization', () => { + it('should return correct interface', () => { + const { result } = renderHook(() => + useShiftSelection({ items: mockFiles }, mockRef) + ) + + expect(result.current).toHaveProperty('setLastInteractedItem') + expect(result.current).toHaveProperty('onShiftClick') + expect(typeof result.current.onShiftClick).toBe('function') + expect(typeof result.current.setLastInteractedItem).toBe('function') + }) + }) + + describe('keyboard event handling - list view', () => { + it('should call handleShiftArrow on Shift+ArrowDown in list view', () => { + renderHook(() => + useShiftSelection({ items: mockFiles, viewType: 'list' }, mockRef) + ) + + const keydownHandler = ( + (mockElement.addEventListener as jest.Mock).mock.calls[0] as unknown[] + )[1] as (event: KeyboardEvent) => void + const mockEvent = { + shiftKey: true, + key: 'ArrowDown', + preventDefault: jest.fn() + } as unknown as KeyboardEvent + + act(() => { + keydownHandler(mockEvent) + }) + + expect(mockHandleShiftArrow).toHaveBeenCalledWith({ + direction: 1, + items: mockFiles, + selectedItems: {}, + lastInteractedIdx: 0, + isItemSelected: mockIsItemSelected + }) + }) + + it('should call handleShiftArrow on Shift+ArrowUp in list view', () => { + renderHook(() => + useShiftSelection({ items: mockFiles, viewType: 'list' }, mockRef) + ) + + const keydownHandler = ( + (mockElement.addEventListener as jest.Mock).mock.calls[0] as unknown[] + )[1] as (event: KeyboardEvent) => void + const mockEvent = { + shiftKey: true, + key: 'ArrowUp', + preventDefault: jest.fn() + } as unknown as KeyboardEvent + + act(() => { + keydownHandler(mockEvent) + }) + + expect(mockHandleShiftArrow).toHaveBeenCalledWith( + expect.objectContaining({ direction: -1 }) + ) + }) + }) + + describe('keyboard event handling - grid view', () => { + it('should call handleShiftArrow on Shift+ArrowRight in grid view', () => { + renderHook(() => + useShiftSelection({ items: mockFiles, viewType: 'grid' }, mockRef) + ) + + const keydownHandler = ( + (mockElement.addEventListener as jest.Mock).mock.calls[0] as unknown[] + )[1] as (event: KeyboardEvent) => void + const mockEvent = { + shiftKey: true, + key: 'ArrowRight', + preventDefault: jest.fn() + } as unknown as KeyboardEvent + + act(() => { + keydownHandler(mockEvent) + }) + + expect(mockHandleShiftArrow).toHaveBeenCalledWith( + expect.objectContaining({ direction: 1 }) + ) + }) + + it('should call handleShiftArrow on Shift+ArrowLeft in grid view', () => { + renderHook(() => + useShiftSelection({ items: mockFiles, viewType: 'grid' }, mockRef) + ) + + const keydownHandler = ( + (mockElement.addEventListener as jest.Mock).mock.calls[0] as unknown[] + )[1] as (event: KeyboardEvent) => void + const mockEvent = { + shiftKey: true, + key: 'ArrowLeft', + preventDefault: jest.fn() + } as unknown as KeyboardEvent + + act(() => { + keydownHandler(mockEvent) + }) + + expect(mockHandleShiftArrow).toHaveBeenCalledWith( + expect.objectContaining({ direction: -1 }) + ) + }) + }) + + describe('onShiftClick', () => { + it('should call handleShiftClick when shift key is pressed', () => { + const { result } = renderHook(() => + useShiftSelection({ items: mockFiles }, mockRef) + ) + + const mockEvent = { + shiftKey: true, + stopPropagation: jest.fn() + } as unknown as KeyboardEvent + + act(() => { + result.current.onShiftClick('2', mockEvent) + }) + + expect(mockHandleShiftClick).toHaveBeenCalledWith({ + startIdx: 0, + endIdx: 1, + selectedItems: {}, + items: mockFiles + }) + }) + + it('should not call handleShiftClick when shift key is not pressed', () => { + const { result } = renderHook(() => + useShiftSelection({ items: mockFiles }, mockRef) + ) + + const mockEvent = { + shiftKey: false, + stopPropagation: jest.fn() + } as unknown as KeyboardEvent + + act(() => { + result.current.onShiftClick('2', mockEvent) + }) + + expect(mockHandleShiftClick).not.toHaveBeenCalled() + }) + }) +}) From 5bc25debb132b4765b5cbebca9c29d4bc1d8e6e0 Mon Sep 17 00:00:00 2001 From: lethemanh Date: Wed, 15 Oct 2025 16:05:29 +0700 Subject: [PATCH 04/17] refactor: Modified folder view component to trigger shift selection :recycle: --- src/modules/filelist/File.jsx | 9 +- src/modules/filelist/virtualized/GridFile.jsx | 6 +- .../filelist/virtualized/cells/Cell.jsx | 14 +- src/modules/views/Folder/FolderViewBody.jsx | 25 +++ .../virtualized/FolderViewBodyContent.jsx | 44 +++++- src/modules/views/Folder/virtualized/Grid.jsx | 143 ++++++++++-------- .../views/Folder/virtualized/Table.jsx | 120 +++++++++------ 7 files changed, 231 insertions(+), 130 deletions(-) diff --git a/src/modules/filelist/File.jsx b/src/modules/filelist/File.jsx index f0f73f4d6f..2c042b45d5 100644 --- a/src/modules/filelist/File.jsx +++ b/src/modules/filelist/File.jsx @@ -69,7 +69,8 @@ const File = ({ breakpoints: { isExtraLarge, isMobile }, disableSelection = false, canInteractWith, - onContextMenu + onContextMenu, + onToggleSelect }) => { const { viewType } = useViewSwitcherContext() @@ -91,8 +92,9 @@ const File = ({ setActionMenuVisible(false) } - const toggle = () => { + const toggle = e => { toggleSelectedItem(attributes) + onToggleSelect?.(attributes?._id, e) } const isRowDisabledOrInSyncFromSharing = disabled || isInSyncFromSharing @@ -271,7 +273,8 @@ File.propTypes = { isInSyncFromSharing: PropTypes.bool, extraColumns: extraColumnsPropTypes, /** Disables the ability to select a file */ - disableSelection: PropTypes.bool + disableSelection: PropTypes.bool, + onToggleSelect: PropTypes.func } export const DumbFile = props => { diff --git a/src/modules/filelist/virtualized/GridFile.jsx b/src/modules/filelist/virtualized/GridFile.jsx index 8228d17841..7dff3341a5 100644 --- a/src/modules/filelist/virtualized/GridFile.jsx +++ b/src/modules/filelist/virtualized/GridFile.jsx @@ -47,7 +47,8 @@ const GridFile = ({ disableSelection = false, canInteractWith, onContextMenu, - isOver + isOver, + onToggleSelect }) => { const [actionMenuVisible, setActionMenuVisible] = useState(false) const filerowMenuToggleRef = useRef() @@ -68,8 +69,9 @@ const GridFile = ({ setActionMenuVisible(false) } - const toggle = () => { + const toggle = e => { toggleSelectedItem(attributes) + onToggleSelect?.(attributes?._id, e) } const isRowDisabledOrInSyncFromSharing = disabled || isInSyncFromSharing diff --git a/src/modules/filelist/virtualized/cells/Cell.jsx b/src/modules/filelist/virtualized/cells/Cell.jsx index 9e28890171..4b24e9db14 100644 --- a/src/modules/filelist/virtualized/cells/Cell.jsx +++ b/src/modules/filelist/virtualized/cells/Cell.jsx @@ -32,9 +32,7 @@ const Cell = ({ cell, currentFolderId, withFilePath, - actions, - focusedIndex, - isKeyboardNavigating + actions }) => { const { f, t } = useI18n() const vaultClient = useVaultClient() @@ -43,7 +41,8 @@ const Cell = ({ const { sharingsValue } = useContext(AcceptingSharingContext) const { byDocId } = useSharingContext() const filerowMenuToggleRef = useRef() - const { toggleSelectedItem, handleShiftClick } = useSelectionContext() + const { toggleSelectedItem } = useSelectionContext() + const [showActionMenu, toggleShowActionMenu] = useReducer( state => !state, false @@ -92,11 +91,7 @@ const Cell = ({ const toggle = e => { e.stopPropagation() - if (e.shiftKey && row.index !== undefined) { - handleShiftClick(row, row.index) - } else { - toggleSelectedItem(row, row.index) - } + toggleSelectedItem(row) } return ( @@ -114,7 +109,6 @@ const Cell = ({ formattedSize={formattedSize} formattedUpdatedAt={formattedUpdatedAt} isInSyncFromSharing={isInSyncFromSharing} - isFocused={isKeyboardNavigating && row.index === focusedIndex} /> ) diff --git a/src/modules/views/Folder/FolderViewBody.jsx b/src/modules/views/Folder/FolderViewBody.jsx index 99f0cb2c9f..aeadb74263 100644 --- a/src/modules/views/Folder/FolderViewBody.jsx +++ b/src/modules/views/Folder/FolderViewBody.jsx @@ -4,6 +4,7 @@ import React, { useContext, useState, useEffect, + useMemo, useRef } from 'react' import { useSelector } from 'react-redux' @@ -20,6 +21,7 @@ import styles from '@/styles/folder-view.styl' import { EmptyWrapper } from '@/components/Error/Empty' import Oops from '@/components/Error/Oops' import RightClickFileMenu from '@/components/RightClick/RightClickFileMenu' +import { useShiftSelection } from '@/hooks/useShiftSelection' import AcceptingSharingContext from '@/lib/AcceptingSharingContext' import { useThumbnailSizeContext } from '@/lib/ThumbnailSizeContext' import { useViewSwitcherContext } from '@/lib/ViewSwitcherContext' @@ -67,6 +69,21 @@ const FolderViewBody = ({ const folderViewRef = useRef() const IsAddingFolder = useSelector(isTypingNewFolderName) + const allFiles = useMemo(() => { + const files = [] + queryResults.forEach(query => { + if (query.data && query.data.length > 0) { + files.push(...query.data) + } + }) + return files + }, [queryResults]) + + const { setLastInteractedItem, onShiftClick } = useShiftSelection( + { items: allFiles, viewType }, + folderViewRef + ) + /** * Since we are not able to restore the scroll correctly, * and force the scroll to top every time we change the @@ -115,6 +132,11 @@ const FolderViewBody = ({ const { syncingFakeFile } = useSyncingFakeFile({ isEmpty, queryResults }) + const onToggleSelect = (fileId, e) => { + setLastInteractedItem(fileId) + onShiftClick(fileId, e) + } + /** * When we mount the component when we already have data in cache, * the mount is time consuming since we'll render at least 100 lines @@ -234,6 +256,9 @@ const FolderViewBody = ({ ) } extraColumns={extraColumns} + onToggleSelect={e => { + onToggleSelect(file?._id, e) + }} /> ) diff --git a/src/modules/views/Folder/virtualized/FolderViewBodyContent.jsx b/src/modules/views/Folder/virtualized/FolderViewBodyContent.jsx index a39fb7f318..2cf49e58e2 100644 --- a/src/modules/views/Folder/virtualized/FolderViewBodyContent.jsx +++ b/src/modules/views/Folder/virtualized/FolderViewBodyContent.jsx @@ -1,16 +1,22 @@ -import React, { useCallback, useMemo } from 'react' +import React, { useCallback, useMemo, useState, useRef } from 'react' import { useSelector } from 'react-redux' import { useNavigate } from 'react-router-dom' import { useClient } from 'cozy-client' import { useSharingContext } from 'cozy-sharing' +import { + stableSort, + getComparator +} from 'cozy-ui/transpiled/react/Table/Virtualized/helpers' import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert' import { useI18n } from 'cozy-ui/transpiled/react/providers/I18n' import Grid from './Grid' +import { secondarySort } from '../helpers' import { useSyncingFakeFile } from '../useSyncingFakeFile' import { SHARED_DRIVES_DIR_ID } from '@/constants/config' +import { useShiftSelection } from '@/hooks/useShiftSelection' import { useViewSwitcherContext } from '@/lib/ViewSwitcherContext' import { isTypingNewFolderName } from '@/modules/filelist/duck' import { FolderUnlocker } from '@/modules/folder/components/FolderUnlocker' @@ -31,6 +37,8 @@ const FolderViewBodyContent = ({ withFilePath, sortOrder }) => { + const folderViewRef = useRef() + const client = useClient() const navigate = useNavigate() @@ -43,6 +51,11 @@ const FolderViewBodyContent = ({ const { t } = useI18n() const IsAddingFolder = useSelector(isTypingNewFolderName) + const [order, setOrder] = useState(sortOrder?.order || 'asc') + const [orderBy, setOrderBy] = useState( + sortOrder?.attribute || columns?.[0]?.id + ) + const fetchMore = queryResults.find(query => query.hasMore)?.fetchMore const isSelectedItem = file => { @@ -59,6 +72,24 @@ const FolderViewBodyContent = ({ [queryResults, IsAddingFolder, syncingFakeFile] ) + const sortedRows = useMemo(() => { + const sortedData = stableSort(rows, getComparator(order, orderBy)) + return secondarySort(sortedData) + }, [rows, order, orderBy]) + + const { setLastInteractedItem, onShiftClick } = useShiftSelection( + { + items: sortedRows, + viewType + }, + folderViewRef + ) + + const onSelect = (itemId, event) => { + setLastInteractedItem(itemId) + onShiftClick(itemId, event) + } + const handleFolderUnlockerDismiss = useCallback(() => { navigate('/folder') }, [navigate]) @@ -92,7 +123,14 @@ const FolderViewBodyContent = ({ currentFolderId={currentFolderId} withFilePath={withFilePath} actions={actions} - sortOrder={sortOrder} + ref={folderViewRef} + onSelect={onSelect} + orderProps={{ + order, + orderBy, + setOrder, + setOrderBy + }} /> ) : ( )} diff --git a/src/modules/views/Folder/virtualized/Grid.jsx b/src/modules/views/Folder/virtualized/Grid.jsx index 26da9a255b..c2fcdda56d 100644 --- a/src/modules/views/Folder/virtualized/Grid.jsx +++ b/src/modules/views/Folder/virtualized/Grid.jsx @@ -1,5 +1,5 @@ import cx from 'classnames' -import React from 'react' +import React, { forwardRef } from 'react' import { useVaultClient } from 'cozy-keys-lib' import VirtualizedGridListDnd from 'cozy-ui/transpiled/react/GridList/Virtualized/Dnd' @@ -12,71 +12,88 @@ import RightClickFileMenu from '@/components/RightClick/RightClickFileMenu' import AddFolder from '@/modules/filelist/AddFolder' import { GridFileWithSelection as GridFile } from '@/modules/filelist/virtualized/GridFile' -const Grid = ({ - items, - actions, - withFilePath = false, - refreshFolderContent, - isSharingContextEmpty, - isSharingShortcut = null, - isReferencedByShareInSharingContext, - sharingsValue, - fetchMore, - dragProps, - currentFolderId -}) => { - const vaultClient = useVaultClient() +const Grid = forwardRef( + ( + { + items, + actions, + withFilePath = false, + refreshFolderContent, + isSharingContextEmpty, + isSharingShortcut = null, + isReferencedByShareInSharingContext, + sharingsValue, + fetchMore, + dragProps, + currentFolderId, + onSelect + }, + ref + ) => { + const vaultClient = useVaultClient() - return ( -
- ( - <> - {file.type != 'tempDirectory' ? ( - !action.selectAllItems)} - > - { + onSelect?.(fileId, event) + } + + return ( +
+ ( + <> + {file.type != 'tempDirectory' ? ( + !action.selectAllItems)} + > + + + ) : ( + - - ) : ( - - )} - - )} - endReached={fetchMore} - context={actions} - /> -
- ) -} + )} + + )} + endReached={fetchMore} + context={actions} + /> +
+ ) + } +) Grid.displayName = 'Grid' diff --git a/src/modules/views/Folder/virtualized/Table.jsx b/src/modules/views/Folder/virtualized/Table.jsx index bbb51a0594..d5983f1e5a 100644 --- a/src/modules/views/Folder/virtualized/Table.jsx +++ b/src/modules/views/Folder/virtualized/Table.jsx @@ -39,57 +39,77 @@ const components = { TableRow: TableRowMemo } -const Table = ({ - rows, - columns, - dragProps, - selectAll, - fetchMore, - isSelectedItem, - selectedItems, - currentFolderId, - withFilePath, - actions, - sortOrder -}) => { - const { toggleSelectedItem } = useSelectionContext() - const { isNew } = useNewItemHighlightContext() +const Table = forwardRef( + ( + { + rows, + columns, + dragProps, + selectAll, + fetchMore, + isSelectedItem, + selectedItems, + currentFolderId, + withFilePath, + actions, + orderProps, + onSelect + }, + ref + ) => { + const { toggleSelectedItem } = useSelectionContext() + const { isNew } = useNewItemHighlightContext() + const { order, orderBy, setOrder, setOrderBy } = orderProps - return ( -
- - ) - } - }} - /> -
- ) -} + const handleRowSelect = (row, event) => { + toggleSelectedItem(row) + onSelect?.(row?._id, event) + } + + const handleSort = ({ order, orderBy }) => { + setOrder(order) + setOrderBy(orderBy) + } + + return ( +
+ + ) + } + }} + /> +
+ ) + } +) Table.displayName = 'Table' From 98d5e16db29199ad69e205168dd7ac2b9626e07a Mon Sep 17 00:00:00 2001 From: lethemanh Date: Wed, 15 Oct 2025 16:06:04 +0700 Subject: [PATCH 05/17] refactor: Modified useLongPress to be available with shift selection :recycle: --- src/hooks/useOnLongPress/helpers.js | 8 ++++++-- src/hooks/useOnLongPress/helpers.spec.jsx | 3 ++- src/hooks/useOnLongPress/index.js | 4 +++- src/modules/filelist/File.jsx | 1 + src/modules/filelist/FileOpener.jsx | 12 ++++++++++-- src/modules/filelist/virtualized/GridFile.jsx | 1 + src/modules/filelist/virtualized/cells/Cell.jsx | 8 ++++++-- src/modules/views/Folder/virtualized/Table.jsx | 1 + 8 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/hooks/useOnLongPress/helpers.js b/src/hooks/useOnLongPress/helpers.js index a2030daa4d..75a73137c2 100644 --- a/src/hooks/useOnLongPress/helpers.js +++ b/src/hooks/useOnLongPress/helpers.js @@ -13,6 +13,7 @@ export const handleClick = ({ lastClickTime, setLastClickTime, setSelectedItems, + onInteractWithFile, clearHighlightedItems }) => { // if default behavior is opening a file, it blocks that to force other bahavior @@ -43,6 +44,7 @@ export const handleClick = ({ setSelectedItems({ [file._id]: file }) } + onInteractWithFile(file._id, event) setLastClickTime(currentTime) } @@ -58,7 +60,8 @@ export const makeDesktopHandlers = ({ setLastClickTime, clearSelection, setSelectedItems, - clearHighlightedItems + clearHighlightedItems, + onInteractWithFile }) => { return { // first event triggered on Desktop @@ -79,7 +82,8 @@ export const makeDesktopHandlers = ({ setLastClickTime, clearSelection, setSelectedItems, - clearHighlightedItems + clearHighlightedItems, + onInteractWithFile }) } } diff --git a/src/hooks/useOnLongPress/helpers.spec.jsx b/src/hooks/useOnLongPress/helpers.spec.jsx index 910cbcac24..8fc13c3ae5 100644 --- a/src/hooks/useOnLongPress/helpers.spec.jsx +++ b/src/hooks/useOnLongPress/helpers.spec.jsx @@ -89,7 +89,8 @@ describe('handleClick', () => { openLink: mockOpenLink, toggle: mockToggle, lastClickTime, - setLastClickTime: jest.fn() + setLastClickTime: jest.fn(), + onInteractWithFile: jest.fn() } } } diff --git a/src/hooks/useOnLongPress/index.js b/src/hooks/useOnLongPress/index.js index 1642cd0675..dd8b56b777 100644 --- a/src/hooks/useOnLongPress/index.js +++ b/src/hooks/useOnLongPress/index.js @@ -12,7 +12,8 @@ export const useLongPress = ({ disabled, isRenaming, openLink, - toggle + toggle, + onInteractWithFile }) => { const timerId = useRef() const isLongPress = useRef(false) @@ -38,6 +39,7 @@ export const useLongPress = ({ setLastClickTime, clearSelection, setSelectedItems, + onInteractWithFile, clearHighlightedItems }) } diff --git a/src/modules/filelist/File.jsx b/src/modules/filelist/File.jsx index 2c042b45d5..6f3421ebbe 100644 --- a/src/modules/filelist/File.jsx +++ b/src/modules/filelist/File.jsx @@ -165,6 +165,7 @@ const File = ({ } toggle={toggle} isRenaming={isRenaming} + onInteractWithFile={onToggleSelect} > { +const FileOpener = ({ + file, + toggle, + disabled, + isRenaming, + onInteractWithFile, + children +}) => { const rowRef = useRef() const { link, openLink } = useFileLink(file) const handlers = useLongPress({ @@ -14,7 +21,8 @@ const FileOpener = ({ file, toggle, disabled, isRenaming, children }) => { disabled, isRenaming, openLink, - toggle + toggle, + onInteractWithFile }) return ( diff --git a/src/modules/filelist/virtualized/GridFile.jsx b/src/modules/filelist/virtualized/GridFile.jsx index 7dff3341a5..97cdbfe422 100644 --- a/src/modules/filelist/virtualized/GridFile.jsx +++ b/src/modules/filelist/virtualized/GridFile.jsx @@ -134,6 +134,7 @@ const GridFile = ({ isRowDisabledOrInSyncFromSharing || isCut || actionMenuVisible } toggle={toggle} + onInteractWithFile={onToggleSelect} isRenaming={isRenaming} >
{ const { f, t } = useI18n() const vaultClient = useVaultClient() @@ -100,6 +101,7 @@ const Cell = ({ disabled={isInSyncFromSharing || showActionMenu} toggle={toggle} isRenaming={isRenaming} + onInteractWithFile={onInteractWithFile} > { return ( ) } diff --git a/src/modules/views/Folder/virtualized/Table.jsx b/src/modules/views/Folder/virtualized/Table.jsx index d5983f1e5a..d3169b15eb 100644 --- a/src/modules/views/Folder/virtualized/Table.jsx +++ b/src/modules/views/Folder/virtualized/Table.jsx @@ -101,6 +101,7 @@ const Table = forwardRef( currentFolderId={currentFolderId} withFilePath={withFilePath} actions={actions} + onInteractWithFile={onSelect} /> ) } From ff1f734f6f031d0c5275331fa2b911b8d7f9bea7 Mon Sep 17 00:00:00 2001 From: lethemanh Date: Wed, 15 Oct 2025 16:50:53 +0700 Subject: [PATCH 06/17] fix: Resolve problem when deselect last item by using shift arrow :bug: --- src/hooks/useShiftSelection/helpers.spec.ts | 4 ++-- src/hooks/useShiftSelection/helpers.ts | 10 +--------- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/src/hooks/useShiftSelection/helpers.spec.ts b/src/hooks/useShiftSelection/helpers.spec.ts index 497fe7f1fa..c189e17bd2 100644 --- a/src/hooks/useShiftSelection/helpers.spec.ts +++ b/src/hooks/useShiftSelection/helpers.spec.ts @@ -148,7 +148,7 @@ describe('handleShiftArrow', () => { const result = handleShiftArrow(params) - expect(result.newSelectedItems).toEqual({ '1': mockFiles[0] }) + expect(result.newSelectedItems).toEqual({}) expect(result.lastInteractedItemId).toBe('1') }) @@ -166,7 +166,7 @@ describe('handleShiftArrow', () => { const result = handleShiftArrow(params) - expect(result.newSelectedItems).toEqual({ '5': mockFiles[4] }) + expect(result.newSelectedItems).toEqual({}) expect(result.lastInteractedItemId).toBe('5') }) }) diff --git a/src/hooks/useShiftSelection/helpers.ts b/src/hooks/useShiftSelection/helpers.ts index 6ea5498dcf..288e4c99b5 100644 --- a/src/hooks/useShiftSelection/helpers.ts +++ b/src/hooks/useShiftSelection/helpers.ts @@ -182,14 +182,6 @@ export const handleShiftArrow = ({ const nextIdx = lastInteractedIdx + direction const currentIdx = clamp(items.length, nextIdx) - // If we hit a boundary (can't move further), keep current selection unchanged - if (currentIdx === lastInteractedIdx) { - return { - newSelectedItems: selectedItems, - lastInteractedItemId: items[lastInteractedIdx]._id - } - } - const prevSelected = isItemSelected(items[lastInteractedIdx]?._id) const currSelected = isItemSelected(items[currentIdx]?._id) const isMovingToSelect = prevSelected && !currSelected @@ -210,7 +202,7 @@ export const handleShiftArrow = ({ direction, isMovingToSelect, isItemSelected, - isReturnCurrent: Object.keys(newSelectedItems).length < 1 + isReturnCurrent: Object.keys(newSelectedItems).length <= 1 }) return { From 32f4f16d326deea34bec3dcf2a3418aedc3d0633 Mon Sep 17 00:00:00 2001 From: lethemanh Date: Thu, 16 Oct 2025 00:42:50 +0700 Subject: [PATCH 07/17] refactor: Rename props that're used to interact with file :recycle: --- src/modules/filelist/virtualized/GridFile.jsx | 6 +++--- .../Folder/virtualized/FolderViewBodyContent.jsx | 6 +++--- src/modules/views/Folder/virtualized/Grid.jsx | 11 +++-------- src/modules/views/Folder/virtualized/Table.jsx | 6 +++--- 4 files changed, 12 insertions(+), 17 deletions(-) diff --git a/src/modules/filelist/virtualized/GridFile.jsx b/src/modules/filelist/virtualized/GridFile.jsx index 97cdbfe422..892bad2be7 100644 --- a/src/modules/filelist/virtualized/GridFile.jsx +++ b/src/modules/filelist/virtualized/GridFile.jsx @@ -48,7 +48,7 @@ const GridFile = ({ canInteractWith, onContextMenu, isOver, - onToggleSelect + onInteractWithFile }) => { const [actionMenuVisible, setActionMenuVisible] = useState(false) const filerowMenuToggleRef = useRef() @@ -71,7 +71,7 @@ const GridFile = ({ const toggle = e => { toggleSelectedItem(attributes) - onToggleSelect?.(attributes?._id, e) + onInteractWithFile?.(attributes?._id, e) } const isRowDisabledOrInSyncFromSharing = disabled || isInSyncFromSharing @@ -134,7 +134,7 @@ const GridFile = ({ isRowDisabledOrInSyncFromSharing || isCut || actionMenuVisible } toggle={toggle} - onInteractWithFile={onToggleSelect} + onInteractWithFile={onInteractWithFile} isRenaming={isRenaming} >
{ + const onInteractWithFile = (itemId, event) => { setLastInteractedItem(itemId) onShiftClick(itemId, event) } @@ -124,7 +124,7 @@ const FolderViewBodyContent = ({ withFilePath={withFilePath} actions={actions} ref={folderViewRef} - onSelect={onSelect} + onInteractWithFile={onInteractWithFile} orderProps={{ order, orderBy, @@ -151,7 +151,7 @@ const FolderViewBodyContent = ({ t }) }} - onSelect={onSelect} + onInteractWithFile={onInteractWithFile} ref={folderViewRef} /> )} diff --git a/src/modules/views/Folder/virtualized/Grid.jsx b/src/modules/views/Folder/virtualized/Grid.jsx index c2fcdda56d..9ce25f814f 100644 --- a/src/modules/views/Folder/virtualized/Grid.jsx +++ b/src/modules/views/Folder/virtualized/Grid.jsx @@ -26,16 +26,12 @@ const Grid = forwardRef( fetchMore, dragProps, currentFolderId, - onSelect + onInteractWithFile }, ref ) => { const vaultClient = useVaultClient() - const onToggleSelect = (fileId, event) => { - onSelect?.(fileId, event) - } - return (
( + itemRenderer={(file, { isOver }) => ( <> {file.type != 'tempDirectory' ? ( ) : ( diff --git a/src/modules/views/Folder/virtualized/Table.jsx b/src/modules/views/Folder/virtualized/Table.jsx index d3169b15eb..48e725c5f6 100644 --- a/src/modules/views/Folder/virtualized/Table.jsx +++ b/src/modules/views/Folder/virtualized/Table.jsx @@ -53,7 +53,7 @@ const Table = forwardRef( withFilePath, actions, orderProps, - onSelect + onInteractWithFile }, ref ) => { @@ -63,7 +63,7 @@ const Table = forwardRef( const handleRowSelect = (row, event) => { toggleSelectedItem(row) - onSelect?.(row?._id, event) + onInteractWithFile?.(row?._id, event) } const handleSort = ({ order, orderBy }) => { @@ -101,7 +101,7 @@ const Table = forwardRef( currentFolderId={currentFolderId} withFilePath={withFilePath} actions={actions} - onInteractWithFile={onSelect} + onInteractWithFile={onInteractWithFile} /> ) } From ed2fa87c9c30fbc1110b78f85a43a111e6c4125e Mon Sep 17 00:00:00 2001 From: lethemanh Date: Thu, 16 Oct 2025 09:53:33 +0700 Subject: [PATCH 08/17] refactor: Change logic to update selected items by using reduce :recycle: --- src/hooks/useShiftSelection/helpers.ts | 56 ++++++++++++-------------- 1 file changed, 26 insertions(+), 30 deletions(-) diff --git a/src/hooks/useShiftSelection/helpers.ts b/src/hooks/useShiftSelection/helpers.ts index 288e4c99b5..2f43062e5f 100644 --- a/src/hooks/useShiftSelection/helpers.ts +++ b/src/hooks/useShiftSelection/helpers.ts @@ -1,3 +1,5 @@ +import cloneDeep from 'lodash/cloneDeep' + import { IOCozyFile } from 'cozy-client/types/types' import type { SelectedItems } from '@/modules/selection/types' @@ -43,21 +45,6 @@ export interface HandleShiftClickParams { items: IOCozyFile[] } -/** - * Returns a new selectedItems object without the removed key - * @param {SelectedItems} selectedItems The current selected items object - * @param {string} removedKey The key/ID of the item to remove from selection - * - * @returns {SelectedItems} A new SelectedItems object without the removed key - */ -const returnValidSelections = ( - selectedItems: SelectedItems, - removedKey: string -): SelectedItems => { - const { [removedKey]: _, ...validSelections } = selectedItems - return validSelections -} - /** * Clamps an index value to be within valid array bounds. * @param {number} maxLength The maximum length of the array @@ -128,8 +115,6 @@ const toggleSelection = ({ isMovingToSelect, isItemSelected }: ToggleSelectionParams): SelectedItems => { - let newSelected = { ...selectedItems } - // Identify which item to modify (depends on selection direction) const targetItem = isMovingToSelect ? items[currentIdx] @@ -137,13 +122,20 @@ const toggleSelection = ({ ? items[lastInteractedIdx] : items[currentIdx] - if (isMovingToSelect) { - newSelected[targetItem._id] = targetItem - } else { - newSelected = returnValidSelections(newSelected, targetItem._id) - } - - return newSelected + return Object.entries(selectedItems).reduce( + (acc, [key, value]) => { + if (isMovingToSelect) { + acc[key] = value + acc[targetItem._id] = targetItem + } else { + if (key !== targetItem._id) { + acc[key] = value + } + } + return acc + }, + {} + ) } /** @@ -232,20 +224,24 @@ export const handleShiftClick = ({ selectedItems, items }: HandleShiftClickParams): HandleShiftSelectionResponse => { - let newSelectedItems = { ...selectedItems } const endItem = items[endIdx] - const isMovingToSelect = !Object.hasOwn(newSelectedItems, endItem._id) + const isMovingToSelect = !Object.hasOwn(selectedItems, endItem._id) const start = Math.min(startIdx, endIdx) const end = Math.max(startIdx, endIdx) - for (let i = start; i <= end; i++) { + const newSelectedItems = Array.from( + { length: end - start + 1 }, + (_, i) => start + i + ).reduce((acc, i) => { const item = items[i] if (isMovingToSelect) { - newSelectedItems[item._id] = item + acc[item._id] = item } else { - newSelectedItems = returnValidSelections(newSelectedItems, item._id) + const { [item._id]: _, ...rest } = acc + return rest } - } + return acc + }, cloneDeep(selectedItems)) return { newSelectedItems, From c7086c68f206d383c3c2a26d07e3731b99d6047f Mon Sep 17 00:00:00 2001 From: lethemanh Date: Mon, 20 Oct 2025 15:20:32 +0700 Subject: [PATCH 09/17] fix: Resolve sharing link issue :bug: --- src/modules/public/PublicLayout.jsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/modules/public/PublicLayout.jsx b/src/modules/public/PublicLayout.jsx index fc0417f38a..7e48abce69 100644 --- a/src/modules/public/PublicLayout.jsx +++ b/src/modules/public/PublicLayout.jsx @@ -11,13 +11,13 @@ import { SelectionProvider } from '@/modules/selection/SelectionProvider' import { NewItemHighlightProvider } from '@/modules/upload/NewItemHighlightProvider' import UploadQueue from '@/modules/upload/UploadQueue' -const NewItemHighlightProviderWrapper = flag( - 'drive.highlight-new-items.enabled' -) - ? NewItemHighlightProvider - : Fragment - const PublicLayout = () => { + const NewItemHighlightProviderWrapper = flag( + 'drive.highlight-new-items.enabled' + ) + ? NewItemHighlightProvider + : Fragment + return ( From 27d317a2d027799ce0444d2525724e69c8982a02 Mon Sep 17 00:00:00 2001 From: Khaled FERJANI Date: Mon, 20 Oct 2025 10:37:22 +0200 Subject: [PATCH 10/17] =?UTF-8?q?chore:=20bump=20version=20=F0=9F=94=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- manifest.webapp | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/manifest.webapp b/manifest.webapp index fbd3906a0c..401ffe90a5 100644 --- a/manifest.webapp +++ b/manifest.webapp @@ -2,7 +2,7 @@ "name": "Drive", "name_prefix": "Twake", "slug": "drive", - "version": "1.84.0", + "version": "1.84.1", "type": "webapp", "licence": "AGPL-3.0", "icon": "assets/app-icon.svg", diff --git a/package.json b/package.json index 08a9f32db5..daaa0971e2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cozy-drive", - "version": "1.84.0", + "version": "1.84.1", "main": "src/main.jsx", "scripts": { "build": "rsbuild build", From 066b323c6aa28d93f45975b78371d0fe926c28d6 Mon Sep 17 00:00:00 2001 From: Vince Linise Date: Fri, 24 Oct 2025 09:39:29 +0200 Subject: [PATCH 11/17] feat(shell): Add useShell hook to get shell context --- src/hooks/useShell.jsx | 82 +++++++++++++++++++++++++++++++++++ src/targets/browser/index.jsx | 5 ++- 2 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 src/hooks/useShell.jsx diff --git a/src/hooks/useShell.jsx b/src/hooks/useShell.jsx new file mode 100644 index 0000000000..726791a546 --- /dev/null +++ b/src/hooks/useShell.jsx @@ -0,0 +1,82 @@ +import React, { createContext, useContext, useEffect, useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; + +import { BarLeft, BarCenter, BarRight, BarSearch } from 'cozy-bar' +import { navigateToModal } from '@/modules/actions/helpers'; + +const ShellContext = createContext(); + +export const ShellProvider = ({ children }) => { + const navigate = useNavigate(); + + const [runsInShell, setRunsInShell] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); + + useEffect(() => { + window.top.postMessage('loaded', '*') + + window.onmessage = function (e) { + if (e.data == undefined || e.data == null || typeof e.data !== "string") return; + if (e.data === "inShell:true") { + setRunsInShell(true) + console.log("Set runsInShell to true from parent") + } + if (e.data.startsWith("selectedFile:")) { + const fileId = e.data.split("selectedFile:")[1].trim(); + console.log("Set selectedFile to " + fileId + " from parent") + setSelectedFile(fileId) + } + if (e.data.startsWith("openFolder:")) { + const folderId = e.data.split("openFolder:")[1].trim(); + console.log("Set folderId to " + folderId + " from parent") + navigate(`/folder/${folderId}`); + } + }; + }, []) + + // if runs in shell, add global CSS + if (runsInShell) { + const CSS = ` + .coz-bar-container nav, .coz-bar-container a { + display: none !important; + } + + .coz-bar-container button[aria-label="Rechercher"] { + margin-right: -12px; + } + `; + + const style = document.createElement('style'); + style.type = 'text/css'; + style.appendChild(document.createTextNode(CSS)); + document.head.appendChild(style); + } + + const openFileInParent = (file) => { + window.top.postMessage('openFile:' + file.metadata.externalId, '*') + } + + if(!runsInShell) { + return children; + } + + return ( + + {runsInShell && ( + +
+
+ )} + + {children} +
+ ); +}; + +export const useShell = () => { + const context = useContext(ShellContext); + if (!context) { + throw new Error('useShell must be used within a ShellProvider'); + } + return context; +}; \ No newline at end of file diff --git a/src/targets/browser/index.jsx b/src/targets/browser/index.jsx index a8b3c63406..ff9e6b8e21 100644 --- a/src/targets/browser/index.jsx +++ b/src/targets/browser/index.jsx @@ -23,11 +23,14 @@ import AppRoute from '@/modules/navigation/AppRoute' // ambient styles import styles from '@/styles/main.styl' // eslint-disable-line no-unused-vars +import { ShellProvider } from '@/hooks/useShell' const AppComponent = props => ( - + + + ) From 126c534ace6221c6b4b0e9e33ebbed1033230623 Mon Sep 17 00:00:00 2001 From: Vince Linise Date: Fri, 24 Oct 2025 09:43:50 +0200 Subject: [PATCH 12/17] fix(shell): useShell when no provider --- src/hooks/useShell.jsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/hooks/useShell.jsx b/src/hooks/useShell.jsx index 726791a546..0c23c46cfd 100644 --- a/src/hooks/useShell.jsx +++ b/src/hooks/useShell.jsx @@ -56,10 +56,6 @@ export const ShellProvider = ({ children }) => { window.top.postMessage('openFile:' + file.metadata.externalId, '*') } - if(!runsInShell) { - return children; - } - return ( {runsInShell && ( From 10506acd0b4fde25baa0924401172293c809d2ad Mon Sep 17 00:00:00 2001 From: Vince Linise Date: Fri, 24 Oct 2025 09:47:17 +0200 Subject: [PATCH 13/17] feat(shell): Show file selection in shell --- src/modules/filelist/File.jsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/modules/filelist/File.jsx b/src/modules/filelist/File.jsx index 6f3421ebbe..cae938c873 100644 --- a/src/modules/filelist/File.jsx +++ b/src/modules/filelist/File.jsx @@ -34,6 +34,7 @@ import { import FileOpener from '@/modules/filelist/FileOpener' import FileThumbnail from '@/modules/filelist/icons/FileThumbnail' import { useSelectionContext } from '@/modules/selection/SelectionProvider' +import { useShell } from '@/hooks/useShell' const FileWrapper = ({ children, viewType, className, onContextMenu }) => viewType === 'list' ? ( @@ -73,6 +74,7 @@ const File = ({ onToggleSelect }) => { const { viewType } = useViewSwitcherContext() + const { runsInShell, selectedFile } = useShell() const [actionMenuVisible, setActionMenuVisible] = useState(false) const filerowMenuToggleRef = useRef() @@ -101,9 +103,10 @@ const File = ({ const isCut = isItemCut(attributes._id) const selected = isItemSelected(attributes._id) + const selectedInShell = runsInShell && selectedFile && selectedFile === attributes._id; const filContentRowSelected = cx(styles['fil-content-row'], { - [styles['fil-content-row-selected']]: selected, + [styles['fil-content-row-selected']]: selected || selectedInShell, [styles['fil-content-row-actioned']]: actionMenuVisible, [styles['fil-content-row-disabled']]: styleDisabled || isCut }) From 9e32377f29faf751b717bb6dcb40dc8340cd8fc8 Mon Sep 17 00:00:00 2001 From: Vince Linise Date: Fri, 24 Oct 2025 09:47:42 +0200 Subject: [PATCH 14/17] feat(shell): Use shell to open Docs files in Docs --- src/modules/navigation/hooks/useFileLink.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/modules/navigation/hooks/useFileLink.tsx b/src/modules/navigation/hooks/useFileLink.tsx index f56e0f3dea..c510641d10 100644 --- a/src/modules/navigation/hooks/useFileLink.tsx +++ b/src/modules/navigation/hooks/useFileLink.tsx @@ -15,6 +15,7 @@ import { import { usePublicContext } from '@/modules/public/PublicProvider' import { getFolderPath } from '@/modules/routeUtils' import { isOfficeEnabled as computeOfficeEnabled } from '@/modules/views/OnlyOffice/helpers' +import { useShell } from '@/hooks/useShell' export interface LinkResult { app: string @@ -55,6 +56,7 @@ const useFileLink = ( const { isDesktop } = useBreakpoints() const isOfficeEnabled = computeOfficeEnabled(isDesktop) const { isPublic } = usePublicContext() + const { runsInShell, selectedFile, openFileInParent } = useShell() // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment const cozyUrl = client?.getStackClient().uri as string @@ -128,6 +130,12 @@ const useFileLink = ( shouldBeOpenedInNewTab ) { window.open(href, '_blank') + } else if (runsInShell && file.type && file.type === "file") { + if (file.name && file.name.endsWith(".docs-note")) { + openFileInParent(file); + } else { + window.open(href, '_blank') + } } else if (app === 'drive') { navigate(to) } else { From 6013d0c7b2fac71d3ab09024e3368bce21c0a10a Mon Sep 17 00:00:00 2001 From: Vince Linise Date: Mon, 1 Dec 2025 12:20:03 +0100 Subject: [PATCH 15/17] chore: Fix lint --- src/hooks/useShell.jsx | 78 -------------- src/hooks/useShell.tsx | 104 +++++++++++++++++++ src/modules/filelist/File.jsx | 5 +- src/modules/navigation/hooks/useFileLink.tsx | 21 ++-- 4 files changed, 122 insertions(+), 86 deletions(-) delete mode 100644 src/hooks/useShell.jsx create mode 100644 src/hooks/useShell.tsx diff --git a/src/hooks/useShell.jsx b/src/hooks/useShell.jsx deleted file mode 100644 index 0c23c46cfd..0000000000 --- a/src/hooks/useShell.jsx +++ /dev/null @@ -1,78 +0,0 @@ -import React, { createContext, useContext, useEffect, useState } from 'react'; -import { useNavigate, useSearchParams } from 'react-router-dom'; - -import { BarLeft, BarCenter, BarRight, BarSearch } from 'cozy-bar' -import { navigateToModal } from '@/modules/actions/helpers'; - -const ShellContext = createContext(); - -export const ShellProvider = ({ children }) => { - const navigate = useNavigate(); - - const [runsInShell, setRunsInShell] = useState(false); - const [selectedFile, setSelectedFile] = useState(null); - - useEffect(() => { - window.top.postMessage('loaded', '*') - - window.onmessage = function (e) { - if (e.data == undefined || e.data == null || typeof e.data !== "string") return; - if (e.data === "inShell:true") { - setRunsInShell(true) - console.log("Set runsInShell to true from parent") - } - if (e.data.startsWith("selectedFile:")) { - const fileId = e.data.split("selectedFile:")[1].trim(); - console.log("Set selectedFile to " + fileId + " from parent") - setSelectedFile(fileId) - } - if (e.data.startsWith("openFolder:")) { - const folderId = e.data.split("openFolder:")[1].trim(); - console.log("Set folderId to " + folderId + " from parent") - navigate(`/folder/${folderId}`); - } - }; - }, []) - - // if runs in shell, add global CSS - if (runsInShell) { - const CSS = ` - .coz-bar-container nav, .coz-bar-container a { - display: none !important; - } - - .coz-bar-container button[aria-label="Rechercher"] { - margin-right: -12px; - } - `; - - const style = document.createElement('style'); - style.type = 'text/css'; - style.appendChild(document.createTextNode(CSS)); - document.head.appendChild(style); - } - - const openFileInParent = (file) => { - window.top.postMessage('openFile:' + file.metadata.externalId, '*') - } - - return ( - - {runsInShell && ( - -
-
- )} - - {children} -
- ); -}; - -export const useShell = () => { - const context = useContext(ShellContext); - if (!context) { - throw new Error('useShell must be used within a ShellProvider'); - } - return context; -}; \ No newline at end of file diff --git a/src/hooks/useShell.tsx b/src/hooks/useShell.tsx new file mode 100644 index 0000000000..618e6316c2 --- /dev/null +++ b/src/hooks/useShell.tsx @@ -0,0 +1,104 @@ +import React, { createContext, useContext, useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' + +import { BarLeft } from 'cozy-bar' +import type { File } from '@/components/FolderPicker/types' + +interface ShellContextType { + runsInShell: boolean + setRunsInShell: React.Dispatch> + selectedFile: string | null + setSelectedFile: React.Dispatch> + openFileInParent: (file: File) => void +} + +const ShellContext = createContext(undefined) + +export const ShellProvider = ({ + children +}: { + children: React.ReactNode +}): JSX.Element => { + const navigate = useNavigate() + + const [runsInShell, setRunsInShell] = useState(false) + const [selectedFile, setSelectedFile] = useState(null) + + useEffect(() => { + if (window.top) { + window.top.postMessage('loaded', '*') + } + + window.onmessage = function (e: MessageEvent): void { + if (e.data == undefined || e.data == null || typeof e.data !== 'string') + return + if (e.data === 'inShell:true') { + setRunsInShell(true) + // console.log('Set runsInShell to true from parent') + } + if (e.data.startsWith('selectedFile:')) { + const fileId = e.data.split('selectedFile:')[1].trim() + // console.log('Set selectedFile to ' + fileId + ' from parent') + setSelectedFile(fileId) + } + if (e.data.startsWith('openFolder:')) { + const folderId = e.data.split('openFolder:')[1].trim() + // console.log('Set folderId to ' + folderId + ' from parent') + navigate(`/folder/${folderId}`) + } + } + }, [navigate]) + + // if runs in shell, add global CSS + if (runsInShell) { + const CSS = ` + .coz-bar-container nav, .coz-bar-container a { + display: none !important; + } + + .coz-bar-container button[aria-label="Rechercher"] { + margin-right: -12px; + } + ` + + const style = document.createElement('style') + style.type = 'text/css' + style.appendChild(document.createTextNode(CSS)) + document.head.appendChild(style) + } + + const openFileInParent = (file: File): void => { + if ('metadata' in file && window.top) { + const id = file.metadata.externalId || '' + window.top.postMessage('openFile:' + id, '*') + } + } + + const contextValue: ShellContextType = { + runsInShell, + setRunsInShell, + selectedFile, + setSelectedFile, + openFileInParent + } + + return ( + + {runsInShell && ( + +
+
+ )} + + {children} +
+ ) +} + +export const useShell = (): ShellContextType => { + const context = useContext(ShellContext) + if (!context) { + throw new Error('useShell must be used within a ShellProvider') + } + return context +} diff --git a/src/modules/filelist/File.jsx b/src/modules/filelist/File.jsx index d2267fee11..1771e112ad 100644 --- a/src/modules/filelist/File.jsx +++ b/src/modules/filelist/File.jsx @@ -24,6 +24,7 @@ import { import styles from '@/styles/filelist.styl' import { useClipboardContext } from '@/contexts/ClipboardProvider' +import { useShell } from '@/hooks/useShell' import { useViewSwitcherContext } from '@/lib/ViewSwitcherContext' import { ActionMenuWithHeader } from '@/modules/actionmenu/ActionMenuWithHeader' import { getContextMenuActions } from '@/modules/actions/helpers' @@ -35,7 +36,6 @@ import { import FileOpener from '@/modules/filelist/FileOpener' import FileThumbnail from '@/modules/filelist/icons/FileThumbnail' import { useSelectionContext } from '@/modules/selection/SelectionProvider' -import { useShell } from '@/hooks/useShell' const FileWrapper = ({ children, viewType, className, onContextMenu }) => viewType === 'list' ? ( @@ -104,7 +104,8 @@ const File = ({ const isCut = isItemCut(attributes._id) const selected = isItemSelected(attributes._id) - const selectedInShell = runsInShell && selectedFile && selectedFile === attributes._id; + const selectedInShell = + runsInShell && selectedFile && selectedFile === attributes._id const filContentRowSelected = cx(styles['fil-content-row'], { [styles['fil-content-row-selected']]: selected || selectedInShell, diff --git a/src/modules/navigation/hooks/useFileLink.tsx b/src/modules/navigation/hooks/useFileLink.tsx index c510641d10..c485a128ce 100644 --- a/src/modules/navigation/hooks/useFileLink.tsx +++ b/src/modules/navigation/hooks/useFileLink.tsx @@ -56,7 +56,7 @@ const useFileLink = ( const { isDesktop } = useBreakpoints() const isOfficeEnabled = computeOfficeEnabled(isDesktop) const { isPublic } = usePublicContext() - const { runsInShell, selectedFile, openFileInParent } = useShell() + const { runsInShell, openFileInParent } = useShell() // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment const cozyUrl = client?.getStackClient().uri as string @@ -130,19 +130,28 @@ const useFileLink = ( shouldBeOpenedInNewTab ) { window.open(href, '_blank') - } else if (runsInShell && file.type && file.type === "file") { - if (file.name && file.name.endsWith(".docs-note")) { - openFileInParent(file); + } else if (runsInShell && file.type && file.type === 'file') { + if (file.name && file.name.endsWith('.docs-note')) { + openFileInParent(file) } else { window.open(href, '_blank') - } + } } else if (app === 'drive') { navigate(to) } else { window.location.href = href } }, - [app, href, navigate, to, shouldBeOpenedInNewTab] + [ + app, + href, + navigate, + to, + shouldBeOpenedInNewTab, + runsInShell, + file, + openFileInParent + ] ) return { From 6fbbf5ff7d631b7fb617db38478c6aad269a785c Mon Sep 17 00:00:00 2001 From: Vince Linise Date: Mon, 1 Dec 2025 12:44:53 +0100 Subject: [PATCH 16/17] fix: Error on sharings view --- src/hooks/useShell.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/hooks/useShell.tsx b/src/hooks/useShell.tsx index 618e6316c2..65023025f4 100644 --- a/src/hooks/useShell.tsx +++ b/src/hooks/useShell.tsx @@ -98,7 +98,13 @@ export const ShellProvider = ({ export const useShell = (): ShellContextType => { const context = useContext(ShellContext) if (!context) { - throw new Error('useShell must be used within a ShellProvider') + return { + runsInShell: false, + setRunsInShell: () => {}, + selectedFile: null, + setSelectedFile: () => {}, + openFileInParent: () => {} + } } return context } From f8cf12aef1d212df64cbbbc90a017b5db0e94a46 Mon Sep 17 00:00:00 2001 From: Vince Linise Date: Mon, 1 Dec 2025 12:56:12 +0100 Subject: [PATCH 17/17] chore: Fix lint issues and refactor code --- src/hooks/useShell.tsx | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/hooks/useShell.tsx b/src/hooks/useShell.tsx index 65023025f4..cedc36fb43 100644 --- a/src/hooks/useShell.tsx +++ b/src/hooks/useShell.tsx @@ -1,9 +1,11 @@ +// Imports import React, { createContext, useContext, useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' import { BarLeft } from 'cozy-bar' import type { File } from '@/components/FolderPicker/types' +// Types interface ShellContextType { runsInShell: boolean setRunsInShell: React.Dispatch> @@ -12,6 +14,7 @@ interface ShellContextType { openFileInParent: (file: File) => void } +// Context const ShellContext = createContext(undefined) export const ShellProvider = ({ @@ -34,22 +37,18 @@ export const ShellProvider = ({ return if (e.data === 'inShell:true') { setRunsInShell(true) - // console.log('Set runsInShell to true from parent') } if (e.data.startsWith('selectedFile:')) { const fileId = e.data.split('selectedFile:')[1].trim() - // console.log('Set selectedFile to ' + fileId + ' from parent') setSelectedFile(fileId) } if (e.data.startsWith('openFolder:')) { const folderId = e.data.split('openFolder:')[1].trim() - // console.log('Set folderId to ' + folderId + ' from parent') navigate(`/folder/${folderId}`) } } }, [navigate]) - // if runs in shell, add global CSS if (runsInShell) { const CSS = ` .coz-bar-container nav, .coz-bar-container a { @@ -95,15 +94,22 @@ export const ShellProvider = ({ ) } +// Hook export const useShell = (): ShellContextType => { const context = useContext(ShellContext) if (!context) { return { runsInShell: false, - setRunsInShell: () => {}, + setRunsInShell: (): void => { + return + }, selectedFile: null, - setSelectedFile: () => {}, - openFileInParent: () => {} + setSelectedFile: (): void => { + return + }, + openFileInParent: (): void => { + return + } } } return context