LCOV - code coverage report
Current view: top level - dashql-app/src/session - session_folder_row.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 99.2 % 127 126
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as React from 'react';
       2              : import { useSortable } from '@dnd-kit/sortable';
       3              : import { CSS } from '@dnd-kit/utilities';
       4              : 
       5              : import { AnchorAlignment, AnchorSide } from '../ui/foundations/anchored_position.js';
       6              : import { AnchoredOverlay } from '../ui/foundations/anchored_overlay.js';
       7              : import * as ActionList from '../ui/foundations/action_list.js';
       8              : import { ButtonSize, ButtonVariant, IconButton } from '../ui/foundations/button.js';
       9              : import {
      10              :     ChevronDownIcon,
      11              :     ChevronRightIcon,
      12              :     FileDirectoryFillIcon,
      13              :     FileDirectoryOpenFillIcon,
      14              :     PencilIcon,
      15              :     PlusIcon,
      16              :     SymbolIcon,
      17              :     TrashIcon,
      18              : } from '../ui/foundations/symbol_icon.js';
      19              : import { NameInput } from '../ui/foundations/name_input.js';
      20              : import { type RowContextMenu, useRowContextMenu, useRowContextMenuOpener } from '../ui/foundations/row_context_menu.js';
      21              : import { classNames } from '../utils/classnames.js';
      22              : import type { SessionTreeFolder } from '../storage/session_tree.js';
      23              : import * as actionMenuStyles from '../app/action_menu.module.css';
      24              : import * as styles from './session_workbench_sidebar.module.css';
      25              : 
      26            1 : const MoreIcon = SymbolIcon('kebab_horizontal');
      27              : 
      28              : /// The left padding of a folder row of the session list at a depth of the session tree
      29            1 : export function sessionListRowPadding(depth: number): number {
      30            1 :     return 8 + depth * 16;
      31            1 : }
      32              : 
      33              : /// The width of the caret column of folder and session rows with its gap, a session without scripts leaves it
      34              : /// empty so that its icon still lines up with the icons of its level
      35              : export const SESSION_LIST_CARET_COLUMN = 12 + 8;
      36              : 
      37              : interface SessionFolderRowProps {
      38              :     /// The sortable id of the row
      39              :     rowId: string;
      40              :     /// The folder
      41              :     folder: SessionTreeFolder;
      42              :     /// The depth of the folder, the folders of the root are at depth 0
      43              :     depth: number;
      44              :     /// Is the folder expanded?
      45              :     expanded: boolean;
      46              :     /// Is the name being edited?
      47              :     renaming: boolean;
      48              :     /// Expand or collapse the folder
      49              :     onToggle: () => void;
      50              :     /// Start editing the name, from the menu
      51              :     onStartRename: () => void;
      52              :     /// Commit an edited name
      53              :     onRename: (name: string) => void;
      54              :     /// Abandon the edit of the name
      55              :     onCancelRename: () => void;
      56              :     /// Create a session in the folder
      57              :     onNewSession: () => void;
      58              :     /// Move the folder to the root, null for a folder of the root
      59              :     onMoveToTopLevel: (() => void) | null;
      60              :     /// Delete the folder, its sessions move to the parent
      61              :     onDelete: () => void;
      62              :     /// Would a dragged row land in the folder?
      63              :     dropTarget: boolean;
      64              : }
      65              : 
      66              : /// A folder of the session list: caret, folder icon, name and a menu
      67            1 : export const SessionFolderRow: React.FC<SessionFolderRowProps> = (props) => {
      68            1 :     const renameInputRef = React.useRef<HTMLInputElement>(null);
      69            1 :     React.useEffect(() => {
      70            1 :         if (!props.renaming) return;
      71            1 :         renameInputRef.current?.focus();
      72            1 :         renameInputRef.current?.select();
      73            1 :     }, [props.renaming]);
      74            1 :     const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = useSortable({ id: props.rowId });
      75            1 :     const rowRef = React.useCallback((node: HTMLLIElement | null) => {
      76            1 :         setNodeRef(node);
      77            1 :         setActivatorNodeRef(node);
      78            1 :     }, [setNodeRef, setActivatorNodeRef]);
      79            1 :     const rowListeners = React.useMemo(() => listeners == null ? undefined : Object.fromEntries(
      80            1 :         Object.entries(listeners).map(([eventName, handler]) => [eventName, (event: React.SyntheticEvent<HTMLElement>) => {
      81            0 :             if (startsRowDrag(event)) handler(event);
      82            1 :         }]),
      83            1 :     ), [listeners]);
      84            1 :     const Caret = props.expanded ? ChevronDownIcon : ChevronRightIcon;
      85            1 :     const FolderIcon = props.expanded ? FileDirectoryOpenFillIcon : FileDirectoryFillIcon;
      86            1 :     const contextMenu = useRowContextMenu();
      87            1 :     return (
      88            1 :         <li
      89            1 :             ref={rowRef}
      90            1 :             className={classNames(styles.entry_surface, styles.entry_interactive, styles.session_row, styles.folder_row, {
      91            1 :                 [styles.session_row_dragging]: isDragging,
      92            1 :                 [styles.drop_into]: props.dropTarget,
      93            1 :             })}
      94            1 :             style={{ transform: CSS.Translate.toString(transform), transition, paddingLeft: sessionListRowPadding(props.depth) }}
      95            1 :             data-testid="session-folder-row"
      96            1 :             {...attributes}
      97            1 :             {...rowListeners}
      98            1 :             onContextMenu={contextMenu.onContextMenu}
      99              :         >
     100            1 :             {props.renaming ? (
     101            1 :                 <div className={styles.tree_rename}>
     102            1 :                     <span className={styles.tree_caret} aria-hidden="true"><Caret size={12} /></span>
     103            1 :                     <FolderIcon size={14} aria-hidden="true" />
     104            1 :                     <NameInput
     105            1 :                         ref={renameInputRef}
     106            1 :                         className={styles.session_rename_input}
     107            1 :                         name={props.folder.name}
     108            1 :                         onCommit={props.onRename}
     109            1 :                         onCancel={props.onCancelRename}
     110            1 :                         ariaLabel="Folder name"
     111            1 :                         placeholder="Name this folder"
     112            1 :                     />
     113            1 :                 </div>
     114              :             ) : (
     115            1 :                 <button
     116            1 :                     type="button"
     117            1 :                     className={styles.tree_button}
     118            1 :                     aria-expanded={props.expanded}
     119            1 :                     title={props.folder.path.join('/')}
     120            1 :                     onClick={props.onToggle}
     121              :                 >
     122            1 :                     <span className={styles.tree_caret} aria-hidden="true"><Caret size={12} /></span>
     123            1 :                     <FolderIcon size={14} aria-hidden="true" />
     124            1 :                     <span className={styles.session_name}>{props.folder.name}</span>
     125            1 :                 </button>
     126              :             )}
     127            1 :             <SessionFolderMenu {...props} contextMenu={contextMenu} />
     128            1 :         </li>
     129              :     );
     130            1 : };
     131              : 
     132            1 : const SessionFolderMenu: React.FC<SessionFolderRowProps & { contextMenu: RowContextMenu }> = (props) => {
     133            1 :     const [open, setOpen] = React.useState(false);
     134            1 :     useRowContextMenuOpener(props.contextMenu, () => setOpen(true));
     135            1 :     const triggerRef = React.useRef<HTMLButtonElement>(null);
     136            1 :     const run = (action: () => void) => (event: React.SyntheticEvent) => {
     137            1 :         event.stopPropagation();
     138            1 :         setOpen(false);
     139            1 :         action();
     140            1 :     };
     141            1 :     const label = props.folder.name;
     142            1 :     return (
     143            1 :         <AnchoredOverlay
     144            1 :             open={open}
     145            1 :             onOpen={() => setOpen(true)}
     146            1 :             onClose={() => setOpen(false)}
     147            1 :             anchorRef={triggerRef}
     148            1 :             returnFocusRef={triggerRef}
     149            1 :             side={AnchorSide.OutsideRight}
     150            1 :             align={AnchorAlignment.Start}
     151            1 :             anchorOffset={4}
     152            1 :             renderAnchor={(anchorProps) => (
     153            1 :                 <IconButton
     154            1 :                     {...anchorProps}
     155            1 :                     ref={triggerRef}
     156            1 :                     size={ButtonSize.Small}
     157            1 :                     variant={ButtonVariant.Invisible}
     158            1 :                     className={styles.session_menu_button}
     159            1 :                     data-testid="session-folder-menu"
     160            1 :                     aria-label={`More actions for folder ${label}`}
     161            1 :                     onClick={(event) => {
     162            1 :                         event.stopPropagation();
     163            1 :                         anchorProps.onClick?.(event);
     164            1 :                     }}
     165              :                 >
     166            1 :                     <MoreIcon size={14} />
     167            1 :                 </IconButton>
     168              :             )}
     169              :         >
     170            1 :             <div className={actionMenuStyles.menu} role="dialog" aria-label={`Actions for folder ${label}`}>
     171            1 :                 <ActionList.List aria-label={`Actions for folder ${label}`}>
     172            1 :                     <ActionList.ListItem data-testid="session-folder-menu-item" data-action="new-session" onClick={run(props.onNewSession)}>
     173            1 :                         <ActionList.Leading><PlusIcon size={16} /></ActionList.Leading>
     174            1 :                         <ActionList.ItemText>New session</ActionList.ItemText>
     175            1 :                     </ActionList.ListItem>
     176            1 :                     <ActionList.ListItem data-testid="session-folder-menu-item" data-action="rename" onClick={run(props.onStartRename)}>
     177            1 :                         <ActionList.Leading><PencilIcon size={16} /></ActionList.Leading>
     178            1 :                         <ActionList.ItemText>Rename</ActionList.ItemText>
     179            1 :                     </ActionList.ListItem>
     180            1 :                     <>{props.onMoveToTopLevel != null && (
     181            1 :                         <ActionList.ListItem data-testid="session-folder-menu-item" data-action="move-to-top-level" onClick={run(props.onMoveToTopLevel)}>
     182            1 :                             <ActionList.Leading><FileDirectoryOpenFillIcon size={16} /></ActionList.Leading>
     183            1 :                             <ActionList.ItemText>Move to top level</ActionList.ItemText>
     184            1 :                         </ActionList.ListItem>
     185            1 :                     )}</>
     186            1 :                     <ActionList.ListItem data-testid="session-folder-menu-item" data-action="delete" className={actionMenuStyles.delete_action} onClick={run(props.onDelete)}>
     187            1 :                         <ActionList.Leading><TrashIcon size={16} /></ActionList.Leading>
     188            1 :                         <ActionList.ItemText>Delete folder</ActionList.ItemText>
     189            1 :                     </ActionList.ListItem>
     190            1 :                 </ActionList.List>
     191            1 :             </div>
     192            1 :         </AnchoredOverlay>
     193              :     );
     194            1 : };
     195              : 
     196              : // React bubbles the events of the portaled row menu overlays through the row.
     197              : // A row drag starts only from the row itself, never from those overlays or from a text field.
     198            1 : export function startsRowDrag(event: React.SyntheticEvent<HTMLElement>): boolean {
     199            1 :     const target = event.target;
     200            1 :     if (!(target instanceof Element) || !event.currentTarget.contains(target)) return false;
     201            1 :     return target.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])') == null;
     202            1 : }
        

Generated by: LCOV version 2.0-1