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 };
}