diff --git a/src-tauri/yaak-models/guest-js/store.ts b/src-tauri/yaak-models/guest-js/store.ts index 2db25ead..72215f37 100644 --- a/src-tauri/yaak-models/guest-js/store.ts +++ b/src-tauri/yaak-models/guest-js/store.ts @@ -1,5 +1,6 @@ import { invoke } from '@tauri-apps/api/core'; import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'; +import { resolvedModelName } from '@yaakapp/app/lib/resolvedModelName'; import { AnyModel, ModelPayload } from '../bindings/gen_models'; import { modelStoreDataAtom } from './atoms'; import { ExtractModel, JotaiStore, ModelStoreData } from './types'; @@ -69,15 +70,12 @@ export async function changeModelStoreWorkspace(workspaceId: string | null) { _activeWorkspaceId = workspaceId; } -export function getAnyModel(id: string): AnyModel | null { +export function listModels>( + modelType: M | ReadonlyArray, +): T[] { let data = mustStore().get(modelStoreDataAtom); - for (const modelData of Object.values(data)) { - let model = modelData[id]; - if (model != null) { - return model; - } - } - return null; + const types: ReadonlyArray = Array.isArray(modelType) ? modelType : [modelType]; + return types.flatMap((t) => Object.values(data[t]) as T[]); } export function getModel>( @@ -137,23 +135,43 @@ export async function deleteModel('plugin:yaak-models|delete', { model }); } -export function duplicateModelById< - M extends AnyModel['model'], - T extends ExtractModel, ->(modelType: M | ReadonlyArray, id: string) { - let model = getModel(modelType, id); - return duplicateModel(model); -} - export function duplicateModel>( model: T | null, ) { if (model == null) { - throw new Error('Failed to delete null model'); + throw new Error('Failed to duplicate null model'); } - if ('sortPriority' in model) model.sortPriority = model.sortPriority + 0.0001; - return invoke('plugin:yaak-models|duplicate', { model }); + // If the model has a name, try to duplicate it with a name that doesn't conflict + let name = 'name' in model ? resolvedModelName(model) : undefined; + if (name != null) { + const existingModels = listModels(model.model); + for (let i = 0; i < 100; i++) { + const hasConflict = existingModels.some((m) => { + if ('folderId' in m && 'folderId' in model && model.folderId !== m.folderId) { + return false; + } else if (resolvedModelName(m) !== name) { + return false; + } + return true; + }); + if (!hasConflict) { + break; + } + + // Name conflict. Try another one + const m: RegExpMatchArray | null = name.match(/ Copy( (?\d+))?$/); + if (m != null && m.groups?.n == null) { + name = name.substring(0, m.index) + ' Copy 2'; + } else if (m != null && m.groups?.n != null) { + name = name.substring(0, m.index) + ` Copy ${parseInt(m.groups.n) + 1}`; + } else { + name = `${name} Copy`; + } + } + } + + return invoke('plugin:yaak-models|duplicate', { model: { ...model, name } }); } export async function createGlobalModel>( diff --git a/src-web/commands/commands.tsx b/src-web/commands/commands.tsx index 67082d6c..ca6b2bc6 100644 --- a/src-web/commands/commands.tsx +++ b/src-web/commands/commands.tsx @@ -34,7 +34,7 @@ export const createFolder = createFastMutation< confirmText: 'Create', placeholder: 'Name', }); - if (name == null) throw new Error('No name provided to create folder'); + if (name == null) return; patch.name = name; } diff --git a/src-web/components/CommandPaletteDialog.tsx b/src-web/components/CommandPaletteDialog.tsx index 8a6bfa06..48237530 100644 --- a/src-web/components/CommandPaletteDialog.tsx +++ b/src-web/components/CommandPaletteDialog.tsx @@ -85,11 +85,6 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) { action: 'settings.show', onSelect: () => openSettings.mutate(null), }, - { - key: 'folder.create', - label: 'Create Folder', - onSelect: () => createFolder.mutate({}), - }, { key: 'app.create', label: 'Create Workspace', diff --git a/src-web/components/ResizeHandle.tsx b/src-web/components/ResizeHandle.tsx index 081da6e5..bce16d0e 100644 --- a/src-web/components/ResizeHandle.tsx +++ b/src-web/components/ResizeHandle.tsx @@ -1,12 +1,22 @@ import classNames from 'classnames'; import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react'; -import React from 'react'; +import React, { useCallback, useRef, useState } from 'react'; -interface ResizeBarProps { +const START_DISTANCE = 7; + +export interface ResizeHandleEvent { + x: number; + y: number; + xStart: number; + yStart: number; +} + +interface Props { style?: CSSProperties; className?: string; - isResizing: boolean; - onResizeStart: (e: ReactMouseEvent) => void; + onResizeStart?: () => void; + onResizeEnd?: () => void; + onResizeMove?: (e: ResizeHandleEvent) => void; onReset?: () => void; side: 'left' | 'right' | 'top'; justify: 'center' | 'end' | 'start'; @@ -17,17 +27,65 @@ export function ResizeHandle({ justify, className, onResizeStart, + onResizeEnd, + onResizeMove, onReset, - isResizing, side, -}: ResizeBarProps) { +}: Props) { const vertical = side === 'top'; + const [isResizing, setIsResizing] = useState(false); + const moveState = useRef<{ + move: (e: MouseEvent) => void; + up: (e: MouseEvent) => void; + calledStart: boolean; + xStart: number; + yStart: number; + } | null>(null); + + const handlePointerDown = useCallback( + (e: ReactMouseEvent) => { + function move(e: MouseEvent) { + if (moveState.current == null) return; + + const xDistance = moveState.current.xStart - e.clientX; + const yDistance = moveState.current.yStart - e.clientY; + const distance = Math.abs(vertical ? yDistance : xDistance); + if (moveState.current.calledStart) { + onResizeMove?.({ + x: e.clientX, + y: e.clientY, + xStart: moveState.current.xStart, + yStart: moveState.current.yStart, + }); + } else if (distance > START_DISTANCE) { + onResizeStart?.(); + moveState.current.calledStart = true; + setIsResizing(true); + } + } + + function up() { + setIsResizing(false); + moveState.current = null; + document.documentElement.removeEventListener('mousemove', move); + document.documentElement.removeEventListener('mouseup', up); + onResizeEnd?.(); + } + + moveState.current = { calledStart: false, xStart: e.clientX, yStart: e.clientY, move, up }; + + document.documentElement.addEventListener('mousemove', move); + document.documentElement.addEventListener('mouseup', up); + }, + [moveState, onResizeEnd, onResizeMove, onResizeStart, vertical], + ); + return (
(null); const treeRef = useRef(null); + const filterRef = useRef(null); + const allHidden = useMemo(() => { + if (tree?.children?.length === 0) return false; + else if (filter) return false; + else return tree?.children?.every((c) => c.hidden); + }, [filter, tree?.children]); const focusActiveItem = useCallback(() => { treeRef.current?.focus(); }, []); + useHotKey( + 'sidebar.filter', + () => { + filterRef.current?.focus(); + }, + { + enable: isSidebarFocused, + }, + ); + useHotKey('sidebar.focus', async function focusHotkey() { // Hide the sidebar if it's already focused if (!hidden && isSidebarFocused()) { @@ -142,6 +164,31 @@ function NewSidebar({ className }: { className?: string }) { }); }, []); + const clearFilterText = useCallback(() => { + jotaiStore.set(sidebarFilterAtom, { text: '', key: `${Math.random()}` }); + requestAnimationFrame(() => { + filterRef.current?.focus(); + }); + }, []); + + const handleFilterKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + clearFilterText(); + } + }, + [clearFilterText], + ); + + const handleFilterChange = useMemo( + () => + debounce((text: string) => { + jotaiStore.set(sidebarFilterAtom, (prev) => ({ ...prev, text })); + }, 200), + [], + ); + if (tree == null || hidden) { return null; } @@ -150,28 +197,63 @@ function NewSidebar({ className }: { className?: string }) { ); } -export default NewSidebar; +export default Sidebar; const activeIdAtom = atom((get) => { return get(activeRequestIdAtom) || get(activeFolderIdAtom); @@ -206,9 +288,12 @@ const allPotentialChildrenAtom = atom((get) => { const memoAllPotentialChildrenAtom = deepEqualAtom(allPotentialChildrenAtom); -const sidebarTreeAtom = atom((get) => { +const sidebarFilterAtom = atom<{ text: string; key: string }>({ text: '', key: '' }); + +const sidebarTreeAtom = atom | null>((get) => { const allModels = get(memoAllPotentialChildrenAtom); const activeWorkspace = get(activeWorkspaceAtom); + const filter = get(sidebarFilterAtom); const childrenMap: Record[]> = {}; for (const item of allModels) { @@ -221,38 +306,57 @@ const sidebarTreeAtom = atom((get) => { } } - const treeParentMap: Record> = {}; - if (activeWorkspace == null) { return null; } - // Put requests and folders into a tree structure - const next = (node: TreeNode, depth: number): TreeNode => { + // returns true if this node OR any child matches the filter + const build = (node: TreeNode, depth: number): boolean => { const childItems = childrenMap[node.item.id] ?? []; + const matchesSelf = !filter || fuzzyMatch(resolvedModelName(node.item), filter.text) != null; + + let matchesChild = false; // Recurse to children - childItems.sort((a, b) => a.sortPriority - b.sortPriority); - if (node.item.model === 'folder' || node.item.model === 'workspace') { - node.children = node.children ?? []; + const m = node.item.model; + node.children = m === 'folder' || m === 'workspace' ? [] : undefined; + + if (node.children != null) { + childItems.sort((a, b) => { + if (a.sortPriority === b.sortPriority) { + return a.updatedAt > b.updatedAt ? 1 : -1; + } + return a.sortPriority - b.sortPriority; + }); + for (const item of childItems) { - treeParentMap[item.id] = node; - node.children.push(next({ item, parent: node, depth }, depth + 1)); + const childNode = { item, parent: node, depth }; + const childMatches = build(childNode, depth + 1); + if (childMatches) { + matchesChild = true; + } + node.children.push(childNode); } } - return node; + // hide node IFF nothing in its subtree matches + const anyMatch = matchesSelf || matchesChild; + node.hidden = !anyMatch; + + return anyMatch; }; - return next( - { - item: activeWorkspace, - children: [], - parent: null, - depth: 0, - }, - 1, - ); + const root: TreeNode = { + item: activeWorkspace, + parent: null, + children: [], + depth: 0, + }; + + // Build tree and mark visibility in one pass + build(root, 1); + + return root; }); const actions = { @@ -379,12 +483,8 @@ async function getContextMenu(tree: TreeHandle, items: SidebarModel[]): Promise< hidden: items.length > 1, hotKeyAction: 'sidebar.selected.rename', hotKeyLabelOnly: true, - onSelect: async () => { - const request = getModel( - ['folder', 'http_request', 'grpc_request', 'websocket_request'], - child.id, - ); - await renameModelWithPrompt(request); + onSelect: () => { + tree.renameItem(child.id); }, }, { diff --git a/src-web/components/Workspace.tsx b/src-web/components/Workspace.tsx index 44a0091f..20dc9929 100644 --- a/src-web/components/Workspace.tsx +++ b/src-web/components/Workspace.tsx @@ -2,7 +2,7 @@ import { workspacesAtom } from '@yaakapp-internal/models'; import classNames from 'classnames'; import { useAtomValue } from 'jotai'; import * as m from 'motion/react-m'; -import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react'; +import type { CSSProperties } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react'; import { useEnsureActiveCookieJar, @@ -27,7 +27,6 @@ import { useShouldFloatSidebar } from '../hooks/useShouldFloatSidebar'; import { useSidebarHidden } from '../hooks/useSidebarHidden'; import { useSidebarWidth } from '../hooks/useSidebarWidth'; import { useSyncWorkspaceRequestTitle } from '../hooks/useSyncWorkspaceRequestTitle'; -import { useToggleCommandPalette } from '../hooks/useToggleCommandPalette'; import { duplicateRequestOrFolderAndNavigate } from '../lib/duplicateRequestOrFolderAndNavigate'; import { importData } from '../lib/importData'; import { jotaiStore } from '../lib/jotai'; @@ -42,9 +41,10 @@ import { FolderLayout } from './FolderLayout'; import { GrpcConnectionLayout } from './GrpcConnectionLayout'; import { HeaderSize } from './HeaderSize'; import { HttpRequestLayout } from './HttpRequestLayout'; -import NewSidebar from './NewSidebar'; import { Overlay } from './Overlay'; +import type { ResizeHandleEvent } from './ResizeHandle'; import { ResizeHandle } from './ResizeHandle'; +import Sidebar from './Sidebar'; import { SidebarActions } from './SidebarActions'; import { WebsocketRequestLayout } from './WebsocketRequestLayout'; import { WorkspaceHeader } from './WorkspaceHeader'; @@ -59,55 +59,40 @@ export function Workspace() { useGlobalWorkspaceHooks(); const workspaces = useAtomValue(workspacesAtom); - const { setWidth, width, resetWidth } = useSidebarWidth(); + const [width, setWidth, resetWidth] = useSidebarWidth(); const [sidebarHidden, setSidebarHidden] = useSidebarHidden(); const [floatingSidebarHidden, setFloatingSidebarHidden] = useFloatingSidebarHidden(); const activeEnvironment = useAtomValue(activeEnvironmentAtom); const floating = useShouldFloatSidebar(); const [isResizing, setIsResizing] = useState(false); - const moveState = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>( - null, - ); + const startWidth = useRef(null); - const unsub = () => { - if (moveState.current !== null) { - document.documentElement.removeEventListener('mousemove', moveState.current.move); - document.documentElement.removeEventListener('mouseup', moveState.current.up); - } - }; + const handleResizeMove = useCallback( + async ({ x, xStart }: ResizeHandleEvent) => { + if (width == null || startWidth.current == null) return; - const handleResizeStart = useCallback( - (e: ReactMouseEvent) => { - if (width === undefined) return; - - unsub(); - const mouseStartX = e.clientX; - const startWidth = width; - moveState.current = { - move: async (e: MouseEvent) => { - e.preventDefault(); // Prevent text selection and things - const newWidth = startWidth + (e.clientX - mouseStartX); - if (newWidth < 50) { - await setSidebarHidden(true); - resetWidth(); - } else { - await setSidebarHidden(false); - setWidth(newWidth); - } - }, - up: (e: MouseEvent) => { - e.preventDefault(); - unsub(); - setIsResizing(false); - }, - }; - document.documentElement.addEventListener('mousemove', moveState.current.move); - document.documentElement.addEventListener('mouseup', moveState.current.up); - setIsResizing(true); + const newWidth = startWidth.current + (x - xStart); + if (newWidth < 50) { + await setSidebarHidden(true); + resetWidth(); + } else { + await setSidebarHidden(false); + setWidth(newWidth); + } }, [width, setSidebarHidden, resetWidth, setWidth], ); + const handleResizeStart = useCallback(() => { + startWidth.current = width ?? null; + setIsResizing(true); + }, [width]); + + const handleResizeEnd = useCallback(() => { + setIsResizing(false); + startWidth.current = null; + }, []); + const sideWidth = sidebarHidden ? 0 : width; const styles = useMemo( () => ({ @@ -164,7 +149,7 @@ export function Workspace() { - + @@ -172,15 +157,17 @@ export function Workspace() { <>
- +
@@ -276,9 +263,6 @@ function useGlobalWorkspaceHooks() { useSyncWorkspaceRequestTitle(); - const toggleCommandPalette = useToggleCommandPalette(); - useHotKey('command_palette.toggle', toggleCommandPalette); - useHotKey('model.duplicate', () => duplicateRequestOrFolderAndNavigate(jotaiStore.get(activeRequestAtom)), ); diff --git a/src-web/components/WorkspaceHeader.tsx b/src-web/components/WorkspaceHeader.tsx index 66bdc1f7..e3a3bc35 100644 --- a/src-web/components/WorkspaceHeader.tsx +++ b/src-web/components/WorkspaceHeader.tsx @@ -1,4 +1,3 @@ -import { type } from '@tauri-apps/plugin-os'; import classNames from 'classnames'; import { useAtom, useAtomValue } from 'jotai'; import React, { memo } from 'react'; @@ -75,9 +74,10 @@ export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Prop } /> diff --git a/src-web/components/core/SplitLayout.tsx b/src-web/components/core/SplitLayout.tsx index dcaf6927..70959dec 100644 --- a/src-web/components/core/SplitLayout.tsx +++ b/src-web/components/core/SplitLayout.tsx @@ -1,11 +1,12 @@ import classNames from 'classnames'; import { useAtomValue } from 'jotai'; -import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactNode } from 'react'; -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import type { CSSProperties, ReactNode } from 'react'; +import React, { useCallback, useMemo, useRef } from 'react'; import { useLocalStorage } from 'react-use'; import { activeWorkspaceAtom } from '../../hooks/useActiveWorkspace'; import { useContainerSize } from '../../hooks/useContainerQuery'; import { clamp } from '../../lib/clamp'; +import type { ResizeHandleEvent } from '../ResizeHandle'; import { ResizeHandle } from '../ResizeHandle'; export type SplitLayoutLayout = 'responsive' | 'horizontal' | 'vertical'; @@ -55,10 +56,6 @@ export function SplitLayout({ ); const width = widthRaw ?? defaultRatio; let height = heightRaw ?? defaultRatio; - const [isResizing, setIsResizing] = useState(false); - const moveState = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>( - null, - ); if (!secondSlot) { height = 0; @@ -86,60 +83,37 @@ export function SplitLayout({ }; }, [style, vertical, height, minHeightPx, width]); - const unsub = () => { - if (moveState.current !== null) { - document.documentElement.removeEventListener('pointermove', moveState.current.move); - document.documentElement.removeEventListener('pointerup', moveState.current.up); - } - }; - const handleReset = useCallback(() => { if (vertical) setHeight(defaultRatio); else setWidth(defaultRatio); }, [vertical, setHeight, defaultRatio, setWidth]); - const handleResizeStart = useCallback( - (e: ReactMouseEvent) => { + const handleResizeMove = useCallback( + (e: ResizeHandleEvent) => { if (containerRef.current === null) return; - unsub(); - const containerRect = containerRef.current.getBoundingClientRect(); + // const containerRect = containerRef.current.getBoundingClientRect(); + const { paddingLeft, paddingRight, paddingTop, paddingBottom } = getComputedStyle( + containerRef.current, + ); + const $c = containerRef.current; + const containerWidth = $c.clientWidth - parseFloat(paddingLeft) - parseFloat(paddingRight); + const containerHeight = $c.clientHeight - parseFloat(paddingTop) - parseFloat(paddingBottom); - const mouseStartX = e.clientX; - const mouseStartY = e.clientY; - const startWidth = containerRect.width * width; - const startHeight = containerRect.height * height; + const mouseStartX = e.xStart; + const mouseStartY = e.yStart; + const startWidth = containerWidth * width; + const startHeight = containerHeight * height; - moveState.current = { - move: (e: MouseEvent) => { - setIsResizing(true); // Set this here so we don't block double-clicks - e.preventDefault(); // Prevent text selection and things - if (vertical) { - const maxHeightPx = containerRect.height - minHeightPx; - const newHeightPx = clamp( - startHeight - (e.clientY - mouseStartY), - minHeightPx, - maxHeightPx, - ); - setHeight(newHeightPx / containerRect.height); - } else { - const maxWidthPx = containerRect.width - minWidthPx; - const newWidthPx = clamp( - startWidth - (e.clientX - mouseStartX), - minWidthPx, - maxWidthPx, - ); - setWidth(newWidthPx / containerRect.width); - } - }, - up: (e: MouseEvent) => { - e.preventDefault(); - unsub(); - setIsResizing(false); - }, - }; - document.documentElement.addEventListener('pointermove', moveState.current.move); - document.documentElement.addEventListener('pointerup', moveState.current.up); + if (vertical) { + const maxHeightPx = containerHeight - minHeightPx; + const newHeightPx = clamp(startHeight - (e.y - mouseStartY), minHeightPx, maxHeightPx); + setHeight(newHeightPx / containerHeight); + } else { + const maxWidthPx = containerWidth - minWidthPx; + const newWidthPx = clamp(startWidth - (e.x - mouseStartX), minWidthPx, maxWidthPx); + setWidth(newWidthPx / containerWidth); + } }, [width, height, vertical, minHeightPx, setHeight, minWidthPx, setWidth], ); @@ -155,9 +129,8 @@ export function SplitLayout({ <> { root: TreeNode; treeId: string; @@ -93,7 +105,6 @@ function TreeInner( y: number; } | null>(null); const treeItemRefs = useRef>({}); - const handleAddTreeItemRef = useCallback((item: T, r: TreeItemHandle | null) => { if (r == null) { delete treeItemRefs.current[item.id]; @@ -170,16 +181,17 @@ function TreeInner( return; } + const validSelectableItems = getValidSelectableItems(treeId, selectableItems); if (currIndex > anchorIndex) { // Selecting down - const itemsToSelect = selectableItems.slice(anchorIndex, currIndex + 1); + const itemsToSelect = validSelectableItems.slice(anchorIndex, currIndex + 1); setSelected( itemsToSelect.map((v) => v.node.item.id), true, ); } else if (currIndex < anchorIndex) { // Selecting up - const itemsToSelect = selectableItems.slice(currIndex, anchorIndex + 1); + const itemsToSelect = validSelectableItems.slice(currIndex, anchorIndex + 1); setSelected( itemsToSelect.map((v) => v.node.item.id), true, @@ -217,15 +229,50 @@ function TreeInner( [handleSelect, onActivate], ); + const selectPrevItem = useCallback( + (e: TreeItemClickEvent) => { + const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId; + const validSelectableItems = getValidSelectableItems(treeId, selectableItems); + const index = validSelectableItems.findIndex((i) => i.node.item.id === lastSelectedId); + const item = validSelectableItems[index - 1]; + if (item != null) { + handleSelect(item.node.item, e); + } + }, + [handleSelect, selectableItems, treeId], + ); + + const selectNextItem = useCallback( + (e: TreeItemClickEvent) => { + const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId; + const validSelectableItems = getValidSelectableItems(treeId, selectableItems); + const index = validSelectableItems.findIndex((i) => i.node.item.id === lastSelectedId); + const item = validSelectableItems[index + 1]; + if (item != null) { + handleSelect(item.node.item, e); + } + }, + [handleSelect, selectableItems, treeId], + ); + + const selectParentItem = useCallback( + (e: TreeItemClickEvent) => { + const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId; + const lastSelectedItem = + selectableItems.find((i) => i.node.item.id === lastSelectedId)?.node ?? null; + if (lastSelectedItem?.parent != null) { + handleSelect(lastSelectedItem.parent.item, e); + } + }, + [handleSelect, selectableItems, treeId], + ); + useKey( 'ArrowUp', (e) => { - if (!treeRef.current?.contains(document.activeElement)) return; + if (!isSidebarFocused()) return; e.preventDefault(); - const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId; - const index = selectableItems.findIndex((i) => i.node.item.id === lastSelectedId); - const item = selectableItems[index - 1]; - if (item != null) handleSelect(item.node.item, e); + selectPrevItem(e); }, undefined, [selectableItems, handleSelect], @@ -234,12 +281,60 @@ function TreeInner( useKey( 'ArrowDown', (e) => { - if (!treeRef.current?.contains(document.activeElement)) return; + if (!isSidebarFocused()) return; e.preventDefault(); + selectNextItem(e); + }, + undefined, + [selectableItems, handleSelect], + ); + + // If the selected item is a collapsed folder, expand it. Otherwise, select next item + useKey( + 'ArrowRight', + (e) => { + if (!isSidebarFocused()) return; + e.preventDefault(); + + const collapsed = jotaiStore.get(collapsedFamily(treeId)); const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId; - const index = selectableItems.findIndex((i) => i.node.item.id === lastSelectedId); - const item = selectableItems[index + 1]; - if (item != null) handleSelect(item.node.item, e); + const lastSelectedItem = selectableItems.find((i) => i.node.item.id === lastSelectedId); + + if ( + lastSelectedId && + lastSelectedItem?.node.children != null && + collapsed[lastSelectedItem.node.item.id] === true + ) { + jotaiStore.set(isCollapsedFamily({ treeId, itemId: lastSelectedId }), false); + } else { + selectNextItem(e); + } + }, + undefined, + [selectableItems, handleSelect], + ); + + // If the selected item is in a folder, select its parent. + // If the selected item is an expanded folder, collapse it. + useKey( + 'ArrowLeft', + (e) => { + if (!isSidebarFocused()) return; + e.preventDefault(); + + const collapsed = jotaiStore.get(collapsedFamily(treeId)); + const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId; + const lastSelectedItem = selectableItems.find((i) => i.node.item.id === lastSelectedId); + + if ( + lastSelectedId && + lastSelectedItem?.node.children != null && + collapsed[lastSelectedItem.node.item.id] !== true + ) { + jotaiStore.set(isCollapsedFamily({ treeId, itemId: lastSelectedId }), true); + } else { + selectParentItem(e); + } }, undefined, [selectableItems, handleSelect], @@ -467,6 +562,7 @@ function TreeInner( onDragEnd={handleDragEnd} onDragCancel={clearDragState} onDragAbort={clearDragState} + measuring={measuring} onDragMove={handleDragMove} autoScroll > @@ -608,3 +704,19 @@ function TreeHotKeys({ ); } + +function getValidSelectableItems( + treeId: string, + selectableItems: SelectableTreeNode[], +) { + const collapsed = jotaiStore.get(collapsedFamily(treeId)); + return selectableItems.filter((i) => { + if (i.node.hidden) return false; + let p = i.node.parent; + while (p) { + if (collapsed[p.item.id]) return false; + p = p.parent; + } + return true; + }); +} diff --git a/src-web/components/core/tree/TreeIndentGuide.tsx b/src-web/components/core/tree/TreeIndentGuide.tsx index 57962af7..346f0894 100644 --- a/src-web/components/core/tree/TreeIndentGuide.tsx +++ b/src-web/components/core/tree/TreeIndentGuide.tsx @@ -21,7 +21,7 @@ export const TreeIndentGuide = memo(function TreeIndentGuide({
diff --git a/src-web/components/core/tree/TreeItem.tsx b/src-web/components/core/tree/TreeItem.tsx index 69186c2f..56ce5698 100644 --- a/src-web/components/core/tree/TreeItem.tsx +++ b/src-web/components/core/tree/TreeItem.tsx @@ -12,10 +12,11 @@ import { ContextMenu } from '../Dropdown'; import { Icon } from '../Icon'; import { collapsedFamily, isCollapsedFamily, isLastFocusedFamily, isSelectedFamily } from './atoms'; import type { TreeNode } from './common'; +import { getNodeKey } from './common'; import type { TreeProps } from './Tree'; import { TreeIndentGuide } from './TreeIndentGuide'; -interface OnClickEvent { +export interface TreeItemClickEvent { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean; @@ -27,7 +28,7 @@ export type TreeItemProps = Pick< > & { node: TreeNode; className?: string; - onClick?: (item: T, e: OnClickEvent) => void; + onClick?: (item: T, e: TreeItemClickEvent) => void; getContextMenu?: (item: T) => Promise; depth: number; addRef?: (item: T, n: TreeItemHandle | null) => void; @@ -157,8 +158,10 @@ function TreeItem_({ } break; case 'Escape': - e.preventDefault(); - setEditing(false); + if (editing) { + e.preventDefault(); + setEditing(false); + } break; } }, @@ -253,6 +256,8 @@ function TreeItem_({ [setDraggableRef, setDroppableRef], ); + if (node.hidden || isAncestorCollapsed) return null; + return (
  • ({ 'tree-item', 'h-sm', 'grid grid-cols-[auto_minmax(0,1fr)]', - isAncestorCollapsed && 'hidden', editing && 'ring-1 focus-within:ring-focus', dropHover != null && 'relative z-10 ring-2 ring-primary', dropHover === 'animate' && 'animate-blinkRing', @@ -350,6 +354,9 @@ export const TreeItem = memo( if (nonEqualKeys.length > 0) { return false; } - return nextProps.getItemKey(prevNode.item) === nextProps.getItemKey(nextNode.item); + + return ( + getNodeKey(prevNode, prevProps.getItemKey) === getNodeKey(nextNode, nextProps.getItemKey) + ); }, ) as typeof TreeItem_; diff --git a/src-web/components/core/tree/TreeItemList.tsx b/src-web/components/core/tree/TreeItemList.tsx index 46a85e2a..ed1cca02 100644 --- a/src-web/components/core/tree/TreeItemList.tsx +++ b/src-web/components/core/tree/TreeItemList.tsx @@ -1,5 +1,5 @@ -import type { CSSProperties } from 'react'; -import { Fragment, memo } from 'react'; +import type { CSSProperties} from 'react'; +import { Fragment } from 'react'; import type { SelectableTreeNode } from './common'; import type { TreeProps } from './Tree'; import { TreeDropMarker } from './TreeDropMarker'; @@ -18,7 +18,7 @@ export type TreeItemListProps = Pick< addTreeItemRef?: (item: T, n: TreeItemHandle | null) => void; }; -function TreeItemList_({ +export function TreeItemList({ className, getContextMenu, getEditOptions, @@ -55,33 +55,3 @@ function TreeItemList_({ ); } - -export const TreeItemList = memo( - TreeItemList_, - ( - { nodes: prevNodes, getItemKey: prevGetItemKey, ...prevProps }, - { nodes: nextNodes, getItemKey: nextGetItemKey, ...nextProps }, - ) => { - const nonEqualKeys = []; - for (const key of Object.keys(prevProps)) { - if (prevProps[key as keyof typeof prevProps] !== nextProps[key as keyof typeof nextProps]) { - nonEqualKeys.push(key); - } - } - if (nonEqualKeys.length > 0) { - // console.log('TreeItemList: ', nonEqualKeys); - return false; - } - if (prevNodes.length !== nextNodes.length) return false; - - for (let i = 0; i < prevNodes.length; i++) { - const prev = prevNodes[i]!; - const next = nextNodes[i]!; - if (prevGetItemKey(prev.node.item) !== nextGetItemKey(next.node.item)) { - return false; - } - } - - return true; - }, -) as typeof TreeItemList_; diff --git a/src-web/components/core/tree/common.ts b/src-web/components/core/tree/common.ts index 9cf6569d..8b494c9f 100644 --- a/src-web/components/core/tree/common.ts +++ b/src-web/components/core/tree/common.ts @@ -4,6 +4,7 @@ import { selectedIdsFamily } from './atoms'; export interface TreeNode { children?: TreeNode[]; item: T; + hidden?: boolean; parent: TreeNode | null; depth: number; } @@ -27,19 +28,23 @@ export function getSelectedItems( export function equalSubtree( a: TreeNode, b: TreeNode, - getKey: (t: T) => string, + getItemKey: (t: T) => string, ): boolean { - if (getKey(a.item) !== getKey(b.item)) return false; + if (getNodeKey(a, getItemKey) !== getNodeKey(b, getItemKey)) return false; const ak = a.children ?? []; const bk = b.children ?? []; if (ak.length !== bk.length) return false; for (let i = 0; i < ak.length; i++) { - if (!equalSubtree(ak[i]!, bk[i]!, getKey)) return false; + if (!equalSubtree(ak[i]!, bk[i]!, getItemKey)) return false; } return true; } +export function getNodeKey(a: TreeNode, getItemKey: (i: T) => string) { + return getItemKey(a.item) + a.hidden; +} + export function hasAncestor(node: TreeNode, ancestorId: string) { if (node.parent == null) return false; if (node.parent.item.id === ancestorId) return true; diff --git a/src-web/hooks/useHotKey.ts b/src-web/hooks/useHotKey.ts index 11a69fa5..31cddaa4 100644 --- a/src-web/hooks/useHotKey.ts +++ b/src-web/hooks/useHotKey.ts @@ -23,6 +23,7 @@ export type HotkeyAction = | 'switcher.prev' | 'switcher.toggle' | 'settings.show' + | 'sidebar.filter' | 'sidebar.selected.delete' | 'sidebar.selected.duplicate' | 'sidebar.selected.rename' @@ -45,6 +46,7 @@ const hotkeys: Record = { 'switcher.prev': ['Control+Tab'], 'switcher.toggle': ['CmdCtrl+p'], 'settings.show': ['CmdCtrl+,'], + 'sidebar.filter': ['CmdCtrl+f'], 'sidebar.selected.delete': ['Delete', 'CmdCtrl+Backspace'], 'sidebar.selected.duplicate': ['CmdCtrl+d'], 'sidebar.selected.rename': ['Enter'], @@ -62,15 +64,16 @@ const hotkeyLabels: Record = { 'hotkeys.showHelp': 'Show Keyboard Shortcuts', 'model.create': 'New Request', 'model.duplicate': 'Duplicate Request', - 'request.rename': 'Rename', - 'request.send': 'Send', + 'request.rename': 'Rename Active Request', + 'request.send': 'Send Active Request', 'switcher.next': 'Go To Previous Request', 'switcher.prev': 'Go To Next Request', 'switcher.toggle': 'Toggle Request Switcher', 'settings.show': 'Open Settings', - 'sidebar.selected.delete': 'Delete', - 'sidebar.selected.duplicate': 'Duplicate', - 'sidebar.selected.rename': 'Rename', + 'sidebar.filter': 'Filter Sidebar', + 'sidebar.selected.delete': 'Delete Selected Sidebar Item', + 'sidebar.selected.duplicate': 'Duplicate Selected Sidebar Item', + 'sidebar.selected.rename': 'Rename Selected Sidebar Item', 'sidebar.focus': 'Focus or Toggle Sidebar', 'url_bar.focus': 'Focus URL', 'workspace_settings.show': 'Open Workspace Settings', @@ -178,7 +181,7 @@ function handleKeyDown(e: KeyboardEvent) { if ( (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) && currentKeysWithModifiers.size === 1 && - currentKeysWithModifiers.has('Backspace') + (currentKeysWithModifiers.has('Backspace') || currentKeysWithModifiers.has('Delete')) ) { // Don't support Backspace-only modifiers within input fields. This is fairly brittle, so maybe there's a // better way to do stuff like this in the future. @@ -244,6 +247,10 @@ export function useFormattedHotkey(action: HotkeyAction | null): string[] | null labelParts.push('⇥'); } else if (p === 'Backspace') { labelParts.push('⌫'); + } else if (p === 'Minus') { + labelParts.push('-'); + } else if (p === 'Equal') { + labelParts.push('='); } else { labelParts.push(capitalize(p)); } diff --git a/src-web/hooks/useSidebarWidth.ts b/src-web/hooks/useSidebarWidth.ts index 8d843085..7808d277 100644 --- a/src-web/hooks/useSidebarWidth.ts +++ b/src-web/hooks/useSidebarWidth.ts @@ -1,5 +1,5 @@ import { useAtomValue } from 'jotai'; -import { useCallback, useMemo } from 'react'; +import { useCallback } from 'react'; import { useLocalStorage } from 'react-use'; import { activeWorkspaceIdAtom } from './useActiveWorkspace'; @@ -10,5 +10,5 @@ export function useSidebarWidth() { 250, ); const resetWidth = useCallback(() => setWidth(250), [setWidth]); - return useMemo(() => ({ width, setWidth, resetWidth }), [width, setWidth, resetWidth]); + return [width ?? null, setWidth, resetWidth] as const; }