use-files-list-hotkeys.ts5.4 KBView on GitHub import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useHotkeysContext } from 'react-hotkeys-hook';
import { keyboardShortcuts } from '@/config/shortcuts';
import { useShortcuts } from '@/lib/hotkeys/use-hotkey-utils';
/**
* Keyboard for a Files list.
*
* Modelled on `use-task-list-hotkeys` — the surface owns its scope, enabling it on mount
* and disabling it on unmount, so keys only reach the list that is actually on screen.
* Three Files surfaces exist and at most one is ever mounted, so they can share one scope.
*
* ## What a key acts on
*
* Hover first, then selection — the ladder the mail list established
* (`mail-list-hotkeys.tsx` → `getTargetIds`). Hovering a row and pressing `w` should delete
* THAT row even if three others are ticked, because the cursor is the more specific
* statement of intent; with nothing hovered, the ticked set is the intent.
*
* Hover arrives as a window CustomEvent rather than a prop, and lands in a ref rather than
* state, for the same reason the thread list does it: a file tree that re-rendered every
* row on mouse-move would be unusable at a few hundred rows.
*/
export const FILES_ROW_HOVER_EVENT = 'filesRowHover';
/** Rows announce themselves; the hook is the only listener. See `FileListRow`. */
export function emitFilesRowHover(id: string | null): void {
if (typeof window === 'undefined') return;
window.dispatchEvent(new CustomEvent(FILES_ROW_HOVER_EVENT, { detail: id }));
}
export interface FilesListHotkeysOptions {
/**
* Every selectable row, in the order they are on screen. Range selection walks THIS,
* not the underlying tree order — a range has to mean what the eye sees between two
* rows, and a tree flattened depth-first is not that once folders are collapsed.
*/
selectableIds: readonly string[];
selectedIds: readonly string[];
setSelectedIds: (ids: string[]) => void;
/**
* Asked to delete these. The surface is expected to CONFIRM before destroying anything —
* `w` is a single keystroke with no undo behind it on every surface. See the scope's
* comment in `config/shortcuts.ts`.
*/
onRequestDelete?: (ids: string[]) => void;
/** Set false where the list is not the active surface (a file is open over it). */
enabled?: boolean;
}
export function useFilesListHotkeys({
selectableIds,
selectedIds,
setSelectedIds,
onRequestDelete,
enabled = true,
}: FilesListHotkeysOptions) {
const { enableScope, disableScope } = useHotkeysContext();
const hoveredId = useRef<string | null>(null);
const [anchorId, setAnchorId] = useState<string | null>(null);
useEffect(() => {
if (!enabled) return;
enableScope('files-list');
return () => disableScope('files-list');
}, [enabled, enableScope, disableScope]);
useEffect(() => {
const onHover = (event: Event) => {
if (!(event instanceof CustomEvent)) return;
hoveredId.current = typeof event.detail === 'string' ? event.detail : null;
};
window.addEventListener(FILES_ROW_HOVER_EVENT, onHover);
return () => window.removeEventListener(FILES_ROW_HOVER_EVENT, onHover);
}, []);
/** Hover wins over selection; selection is the fallback. Empty means "do nothing". */
const targetIds = useCallback((): string[] => {
const hovered = hoveredId.current;
if (hovered && selectableIds.includes(hovered)) return [hovered];
return [...selectedIds];
}, [selectableIds, selectedIds]);
const handlers = useMemo(
() => ({
deleteFiles: () => {
const ids = targetIds();
if (ids.length === 0) return;
onRequestDelete?.(ids);
},
selectFile: () => {
const id = hoveredId.current;
if (!id || !selectableIds.includes(id)) return;
setAnchorId(id);
setSelectedIds(
selectedIds.includes(id)
? selectedIds.filter((selected) => selected !== id)
: [...selectedIds, id],
);
},
rangeSelectFile: () => {
const id = hoveredId.current;
if (!id) return;
const to = selectableIds.indexOf(id);
if (to === -1) return;
const from = anchorId ? selectableIds.indexOf(anchorId) : -1;
if (from === -1) {
setAnchorId(id);
setSelectedIds([...new Set([...selectedIds, id])]);
return;
}
const range = selectableIds.slice(Math.min(from, to), Math.max(from, to) + 1);
setAnchorId(id);
// Additive, never subtractive — the mail list's rule. A range that un-ticked what
// it crossed would make a mis-aimed second press destroy the first selection.
setSelectedIds([...new Set([...selectedIds, ...range])]);
},
selectAllFiles: () => {
// Toggle: with anything selected, clear. Otherwise take everything. Same as `mod+a`
// in the mail list, so the key means one thing across the app.
setSelectedIds(selectedIds.length > 0 ? [] : [...selectableIds]);
setAnchorId(null);
},
exitFileSelection: () => {
if (selectedIds.length === 0) return;
setSelectedIds([]);
setAnchorId(null);
},
}),
[anchorId, onRequestDelete, selectableIds, selectedIds, setSelectedIds, targetIds],
);
const shortcuts = useMemo(
() => keyboardShortcuts.filter((shortcut) => shortcut.scope === 'files-list'),
[],
);
useShortcuts(shortcuts, handlers, { scope: 'files-list' });
return { anchorId, setAnchorId };
}