Add context menu support and Vim keybindings in Sidebar and Tree components

This commit is contained in:
Gregory Schier
2025-10-28 08:45:36 -07:00
parent 68637d24c7
commit 5b8114f6f3
4 changed files with 35 additions and 7 deletions

View File

@@ -104,7 +104,7 @@ function Sidebar({ className }: { className?: string }) {
} }
// Select the 0th index on focus if none selected // Select the 0th index on focus if none selected
focusActiveItem(); setTimeout(focusActiveItem, 100);
}); });
const handleDragEnd = useCallback(async function handleDragEnd({ const handleDragEnd = useCallback(async function handleDragEnd({
@@ -360,6 +360,12 @@ const sidebarTreeAtom = atom<TreeNode<SidebarModel> | null>((get) => {
}); });
const actions = { const actions = {
'sidebar.context_menu': {
enable: isSidebarFocused,
cb: async function (tree: TreeHandle) {
tree.showContextMenu();
},
},
'sidebar.selected.delete': { 'sidebar.selected.delete': {
enable: isSidebarFocused, enable: isSidebarFocused,
cb: async function (_: TreeHandle, items: SidebarModel[]) { cb: async function (_: TreeHandle, items: SidebarModel[]) {

View File

@@ -76,9 +76,11 @@ export interface TreeProps<T extends { id: string }> {
} }
export interface TreeHandle { export interface TreeHandle {
treeId: string;
focus: () => void; focus: () => void;
selectItem: (id: string) => void; selectItem: (id: string) => void;
renameItem: (id: string) => void; renameItem: (id: string) => void;
showContextMenu: () => void;
} }
function TreeInner<T extends { id: string }>( function TreeInner<T extends { id: string }>(
@@ -132,14 +134,24 @@ function TreeInner<T extends { id: string }>(
const treeHandle = useMemo<TreeHandle>( const treeHandle = useMemo<TreeHandle>(
() => ({ () => ({
treeId,
focus: tryFocus, focus: tryFocus,
renameItem: (id) => treeItemRefs.current[id]?.rename(), renameItem: (id) => treeItemRefs.current[id]?.rename(),
selectItem: (id) => { selectItem: (id) => {
setSelected([id], false); setSelected([id], false);
jotaiStore.set(focusIdsFamily(treeId), { anchorId: id, lastId: id }); jotaiStore.set(focusIdsFamily(treeId), { anchorId: id, lastId: id });
}, },
showContextMenu: async () => {
if (getContextMenu == null) return;
const items = getSelectedItems(treeId, selectableItems);
const menuItems = await getContextMenu(treeHandle, items);
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
const rect = lastSelectedId ? treeItemRefs.current[lastSelectedId]?.rect() : null;
if (rect == null) return;
setShowContextMenu({ items: menuItems, x: rect.x, y: rect.y });
},
}), }),
[setSelected, treeId, tryFocus], [getContextMenu, selectableItems, setSelected, treeId, tryFocus],
); );
useImperativeHandle(ref, (): TreeHandle => treeHandle, [treeHandle]); useImperativeHandle(ref, (): TreeHandle => treeHandle, [treeHandle]);
@@ -268,7 +280,7 @@ function TreeInner<T extends { id: string }>(
); );
useKey( useKey(
'ArrowUp', (e) => e.key === 'ArrowUp' || e.key.toLowerCase() === 'k',
(e) => { (e) => {
if (!isSidebarFocused()) return; if (!isSidebarFocused()) return;
e.preventDefault(); e.preventDefault();
@@ -279,7 +291,7 @@ function TreeInner<T extends { id: string }>(
); );
useKey( useKey(
'ArrowDown', (e) => e.key === 'ArrowDown' || e.key.toLowerCase() === 'j',
(e) => { (e) => {
if (!isSidebarFocused()) return; if (!isSidebarFocused()) return;
e.preventDefault(); e.preventDefault();
@@ -291,7 +303,7 @@ function TreeInner<T extends { id: string }>(
// If the selected item is a collapsed folder, expand it. Otherwise, select next item // If the selected item is a collapsed folder, expand it. Otherwise, select next item
useKey( useKey(
'ArrowRight', (e) => e.key === 'ArrowRight' || e.key.toLowerCase() === 'l',
(e) => { (e) => {
if (!isSidebarFocused()) return; if (!isSidebarFocused()) return;
e.preventDefault(); e.preventDefault();
@@ -317,7 +329,7 @@ function TreeInner<T extends { id: string }>(
// If the selected item is in a folder, select its parent. // If the selected item is in a folder, select its parent.
// If the selected item is an expanded folder, collapse it. // If the selected item is an expanded folder, collapse it.
useKey( useKey(
'ArrowLeft', (e) => e.key === 'ArrowLeft' || e.key.toLowerCase() === 'h',
(e) => { (e) => {
if (!isSidebarFocused()) return; if (!isSidebarFocused()) return;
e.preventDefault(); e.preventDefault();

View File

@@ -37,6 +37,7 @@ export type TreeItemProps<T extends { id: string }> = Pick<
export interface TreeItemHandle { export interface TreeItemHandle {
rename: () => void; rename: () => void;
isRenaming: boolean; isRenaming: boolean;
rect: () => DOMRect;
} }
const HOVER_CLOSED_FOLDER_DELAY = 800; const HOVER_CLOSED_FOLDER_DELAY = 800;
@@ -70,6 +71,12 @@ function TreeItem_<T extends { id: string }>({
} }
}, },
isRenaming: editing, isRenaming: editing,
rect: () => {
if (listItemRef.current == null) {
return new DOMRect(0, 0, 0, 0);
}
return listItemRef.current.getBoundingClientRect();
},
}); });
}, [addRef, editing, getEditOptions, node.item]); }, [addRef, editing, getEditOptions, node.item]);
@@ -225,7 +232,7 @@ function TreeItem_<T extends { id: string }>({
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const items = await getContextMenu(node.item); const items = await getContextMenu(node.item);
setShowContextMenu({ items, x: e.clientX, y: e.clientY }); setShowContextMenu({ items, x: e.clientX ?? 100, y: e.clientY ?? 100 });
}, },
[getContextMenu, node.item], [getContextMenu, node.item],
); );

View File

@@ -28,6 +28,7 @@ export type HotkeyAction =
| 'sidebar.selected.duplicate' | 'sidebar.selected.duplicate'
| 'sidebar.selected.rename' | 'sidebar.selected.rename'
| 'sidebar.focus' | 'sidebar.focus'
| 'sidebar.context_menu'
| 'url_bar.focus' | 'url_bar.focus'
| 'workspace_settings.show'; | 'workspace_settings.show';
@@ -51,6 +52,7 @@ const hotkeys: Record<HotkeyAction, string[]> = {
'sidebar.selected.duplicate': ['CmdCtrl+d'], 'sidebar.selected.duplicate': ['CmdCtrl+d'],
'sidebar.selected.rename': ['Enter'], 'sidebar.selected.rename': ['Enter'],
'sidebar.focus': ['CmdCtrl+b'], 'sidebar.focus': ['CmdCtrl+b'],
'sidebar.context_menu': type() === 'macos' ? ['Control+Enter'] : ['Alt+Insert'],
'url_bar.focus': ['CmdCtrl+l'], 'url_bar.focus': ['CmdCtrl+l'],
'workspace_settings.show': ['CmdCtrl+;'], 'workspace_settings.show': ['CmdCtrl+;'],
}; };
@@ -75,6 +77,7 @@ const hotkeyLabels: Record<HotkeyAction, string> = {
'sidebar.selected.duplicate': 'Duplicate Selected Sidebar Item', 'sidebar.selected.duplicate': 'Duplicate Selected Sidebar Item',
'sidebar.selected.rename': 'Rename Selected Sidebar Item', 'sidebar.selected.rename': 'Rename Selected Sidebar Item',
'sidebar.focus': 'Focus or Toggle Sidebar', 'sidebar.focus': 'Focus or Toggle Sidebar',
'sidebar.context_menu': 'Show Context Menu',
'url_bar.focus': 'Focus URL', 'url_bar.focus': 'Focus URL',
'workspace_settings.show': 'Open Workspace Settings', 'workspace_settings.show': 'Open Workspace Settings',
}; };