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