Sidebar filtering and improvements (#285)

This commit is contained in:
Gregory Schier
2025-10-27 14:10:28 -07:00
committed by GitHub
parent b2766509e3
commit 99a6c38632
15 changed files with 476 additions and 246 deletions

View File

@@ -1,5 +1,6 @@
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'; import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
import { resolvedModelName } from '@yaakapp/app/lib/resolvedModelName';
import { AnyModel, ModelPayload } from '../bindings/gen_models'; import { AnyModel, ModelPayload } from '../bindings/gen_models';
import { modelStoreDataAtom } from './atoms'; import { modelStoreDataAtom } from './atoms';
import { ExtractModel, JotaiStore, ModelStoreData } from './types'; import { ExtractModel, JotaiStore, ModelStoreData } from './types';
@@ -69,15 +70,12 @@ export async function changeModelStoreWorkspace(workspaceId: string | null) {
_activeWorkspaceId = workspaceId; _activeWorkspaceId = workspaceId;
} }
export function getAnyModel(id: string): AnyModel | null { export function listModels<M extends AnyModel['model'], T extends ExtractModel<AnyModel, M>>(
modelType: M | ReadonlyArray<M>,
): T[] {
let data = mustStore().get(modelStoreDataAtom); let data = mustStore().get(modelStoreDataAtom);
for (const modelData of Object.values(data)) { const types: ReadonlyArray<M> = Array.isArray(modelType) ? modelType : [modelType];
let model = modelData[id]; return types.flatMap((t) => Object.values(data[t]) as T[]);
if (model != null) {
return model;
}
}
return null;
} }
export function getModel<M extends AnyModel['model'], T extends ExtractModel<AnyModel, M>>( export function getModel<M extends AnyModel['model'], T extends ExtractModel<AnyModel, M>>(
@@ -137,23 +135,43 @@ export async function deleteModel<M extends AnyModel['model'], T extends Extract
await invoke<string>('plugin:yaak-models|delete', { model }); await invoke<string>('plugin:yaak-models|delete', { model });
} }
export function duplicateModelById<
M extends AnyModel['model'],
T extends ExtractModel<AnyModel, M>,
>(modelType: M | ReadonlyArray<M>, id: string) {
let model = getModel<M, T>(modelType, id);
return duplicateModel(model);
}
export function duplicateModel<M extends AnyModel['model'], T extends ExtractModel<AnyModel, M>>( export function duplicateModel<M extends AnyModel['model'], T extends ExtractModel<AnyModel, M>>(
model: T | null, model: T | null,
) { ) {
if (model == 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<string>('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( (?<n>\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<string>('plugin:yaak-models|duplicate', { model: { ...model, name } });
} }
export async function createGlobalModel<T extends Exclude<AnyModel, { workspaceId: string }>>( export async function createGlobalModel<T extends Exclude<AnyModel, { workspaceId: string }>>(

View File

@@ -34,7 +34,7 @@ export const createFolder = createFastMutation<
confirmText: 'Create', confirmText: 'Create',
placeholder: 'Name', placeholder: 'Name',
}); });
if (name == null) throw new Error('No name provided to create folder'); if (name == null) return;
patch.name = name; patch.name = name;
} }

View File

@@ -85,11 +85,6 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
action: 'settings.show', action: 'settings.show',
onSelect: () => openSettings.mutate(null), onSelect: () => openSettings.mutate(null),
}, },
{
key: 'folder.create',
label: 'Create Folder',
onSelect: () => createFolder.mutate({}),
},
{ {
key: 'app.create', key: 'app.create',
label: 'Create Workspace', label: 'Create Workspace',

View File

@@ -1,12 +1,22 @@
import classNames from 'classnames'; import classNames from 'classnames';
import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react'; 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; style?: CSSProperties;
className?: string; className?: string;
isResizing: boolean; onResizeStart?: () => void;
onResizeStart: (e: ReactMouseEvent<HTMLDivElement>) => void; onResizeEnd?: () => void;
onResizeMove?: (e: ResizeHandleEvent) => void;
onReset?: () => void; onReset?: () => void;
side: 'left' | 'right' | 'top'; side: 'left' | 'right' | 'top';
justify: 'center' | 'end' | 'start'; justify: 'center' | 'end' | 'start';
@@ -17,17 +27,65 @@ export function ResizeHandle({
justify, justify,
className, className,
onResizeStart, onResizeStart,
onResizeEnd,
onResizeMove,
onReset, onReset,
isResizing,
side, side,
}: ResizeBarProps) { }: Props) {
const vertical = side === 'top'; const vertical = side === 'top';
const [isResizing, setIsResizing] = useState<boolean>(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<HTMLDivElement>) => {
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 ( return (
<div <div
aria-hidden aria-hidden
style={style} style={style}
onPointerDown={onResizeStart}
onDoubleClick={onReset} onDoubleClick={onReset}
onPointerDown={handlePointerDown}
className={classNames( className={classNames(
className, className,
'group z-10 flex select-none transition-colors hover:bg-surface-active rounded-full', 'group z-10 flex select-none transition-colors hover:bg-surface-active rounded-full',
@@ -45,7 +103,8 @@ export function ResizeHandle({
{isResizing && ( {isResizing && (
<div <div
className={classNames( className={classNames(
'fixed -left-20 -right-20 -top-20 -bottom-20', // 'bg-[rgba(255,0,0,0.1)]', // For debugging
'fixed -left-[100vw] -right-[100vw] -top-[100vh] -bottom-[100vh]',
vertical && 'cursor-row-resize', vertical && 'cursor-row-resize',
!vertical && 'cursor-col-resize', !vertical && 'cursor-col-resize',
)} )}

View File

@@ -1,3 +1,4 @@
import { debounce } from '@yaakapp-internal/lib';
import type { import type {
Folder, Folder,
GrpcRequest, GrpcRequest,
@@ -16,8 +17,10 @@ import {
workspacesAtom, workspacesAtom,
} from '@yaakapp-internal/models'; } from '@yaakapp-internal/models';
import classNames from 'classnames'; import classNames from 'classnames';
import { fuzzyMatch } from 'fuzzbunny';
import { atom, useAtomValue } from 'jotai'; import { atom, useAtomValue } from 'jotai';
import { selectAtom } from 'jotai/utils'; import { selectAtom } from 'jotai/utils';
import type { KeyboardEvent } from 'react';
import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react'; import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { moveToWorkspace } from '../commands/moveToWorkspace'; import { moveToWorkspace } from '../commands/moveToWorkspace';
import { openFolderSettings } from '../commands/openFolderSettings'; import { openFolderSettings } from '../commands/openFolderSettings';
@@ -36,7 +39,6 @@ import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { deepEqualAtom } from '../lib/atoms'; import { deepEqualAtom } from '../lib/atoms';
import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm'; import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm';
import { jotaiStore } from '../lib/jotai'; import { jotaiStore } from '../lib/jotai';
import { renameModelWithPrompt } from '../lib/renameModelWithPrompt';
import { resolvedModelName } from '../lib/resolvedModelName'; import { resolvedModelName } from '../lib/resolvedModelName';
import { isSidebarFocused } from '../lib/scopes'; import { isSidebarFocused } from '../lib/scopes';
import { navigateToRequestOrFolderOrWorkspace } from '../lib/setWorkspaceSearchParams'; import { navigateToRequestOrFolderOrWorkspace } from '../lib/setWorkspaceSearchParams';
@@ -45,7 +47,10 @@ import type { ContextMenuProps, DropdownItem } from './core/Dropdown';
import { HttpMethodTag } from './core/HttpMethodTag'; import { HttpMethodTag } from './core/HttpMethodTag';
import { HttpStatusTag } from './core/HttpStatusTag'; import { HttpStatusTag } from './core/HttpStatusTag';
import { Icon } from './core/Icon'; import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { InlineCode } from './core/InlineCode';
import { LoadingIcon } from './core/LoadingIcon'; import { LoadingIcon } from './core/LoadingIcon';
import { PlainInput } from './core/PlainInput';
import { isSelectedFamily } from './core/tree/atoms'; import { isSelectedFamily } from './core/tree/atoms';
import type { TreeNode } from './core/tree/common'; import type { TreeNode } from './core/tree/common';
import type { TreeHandle, TreeProps } from './core/tree/Tree'; import type { TreeHandle, TreeProps } from './core/tree/Tree';
@@ -57,18 +62,35 @@ type SidebarModel = Workspace | Folder | HttpRequest | GrpcRequest | WebsocketRe
const OPACITY_SUBTLE = 'opacity-80'; const OPACITY_SUBTLE = 'opacity-80';
function NewSidebar({ className }: { className?: string }) { function Sidebar({ className }: { className?: string }) {
const [hidden, setHidden] = useSidebarHidden(); const [hidden, setHidden] = useSidebarHidden();
const tree = useAtomValue(sidebarTreeAtom);
const activeWorkspaceId = useAtomValue(activeWorkspaceAtom)?.id; const activeWorkspaceId = useAtomValue(activeWorkspaceAtom)?.id;
const treeId = 'tree.' + (activeWorkspaceId ?? 'unknown'); const treeId = 'tree.' + (activeWorkspaceId ?? 'unknown');
const filter = useAtomValue(sidebarFilterAtom);
const tree = useAtomValue(sidebarTreeAtom);
const wrapperRef = useRef<HTMLElement>(null); const wrapperRef = useRef<HTMLElement>(null);
const treeRef = useRef<TreeHandle>(null); const treeRef = useRef<TreeHandle>(null);
const filterRef = useRef<HTMLInputElement>(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(() => { const focusActiveItem = useCallback(() => {
treeRef.current?.focus(); treeRef.current?.focus();
}, []); }, []);
useHotKey(
'sidebar.filter',
() => {
filterRef.current?.focus();
},
{
enable: isSidebarFocused,
},
);
useHotKey('sidebar.focus', async function focusHotkey() { useHotKey('sidebar.focus', async function focusHotkey() {
// Hide the sidebar if it's already focused // Hide the sidebar if it's already focused
if (!hidden && isSidebarFocused()) { 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<HTMLInputElement>) => {
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) { if (tree == null || hidden) {
return null; return null;
} }
@@ -150,28 +197,63 @@ function NewSidebar({ className }: { className?: string }) {
<aside <aside
ref={wrapperRef} ref={wrapperRef}
aria-hidden={hidden ?? undefined} aria-hidden={hidden ?? undefined}
className={classNames(className, 'h-full grid grid-rows-[minmax(0,1fr)_auto]')} className={classNames(className, 'h-full grid grid-rows-[auto_minmax(0,1fr)_auto]')}
> >
<Tree <div className="px-2 py-1.5 pb-0">
ref={handleTreeRefInit} {(tree.children?.length ?? 0) > 0 && (
root={tree} <PlainInput
treeId={treeId} hideLabel
hotkeys={hotkeys} ref={filterRef}
getItemKey={getItemKey} size="xs"
ItemInner={SidebarInnerItem} label="filter"
ItemLeftSlot={SidebarLeftSlot} containerClassName="!rounded-full px-1"
getContextMenu={getContextMenu} placeholder="Search"
onActivate={handleActivate} onChange={handleFilterChange}
getEditOptions={getEditOptions} defaultValue={filter.text}
className="pl-2 pr-3 pt-2 pb-2" forceUpdateKey={filter.key}
onDragEnd={handleDragEnd} onKeyDownCapture={handleFilterKeyDown}
/> rightSlot={
filter.text && (
<IconButton
color="custom"
className="!h-auto min-h-full opacity-50 hover:opacity-100 -mr-1.5"
icon="x"
title="Clear filter"
onClick={() => {
clearFilterText();
}}
/>
)
}
/>
)}
</div>
{allHidden ? (
<div className="italic text-text-subtle p-3 mt-2 text-sm text-center">
No results for <InlineCode>{filter.text}</InlineCode>
</div>
) : (
<Tree
ref={handleTreeRefInit}
root={tree}
treeId={treeId}
hotkeys={hotkeys}
getItemKey={getItemKey}
ItemInner={SidebarInnerItem}
ItemLeftSlot={SidebarLeftSlot}
getContextMenu={getContextMenu}
onActivate={handleActivate}
getEditOptions={getEditOptions}
className="pl-2 pr-3 pt-2 pb-2"
onDragEnd={handleDragEnd}
/>
)}
<GitDropdown /> <GitDropdown />
</aside> </aside>
); );
} }
export default NewSidebar; export default Sidebar;
const activeIdAtom = atom<string | null>((get) => { const activeIdAtom = atom<string | null>((get) => {
return get(activeRequestIdAtom) || get(activeFolderIdAtom); return get(activeRequestIdAtom) || get(activeFolderIdAtom);
@@ -206,9 +288,12 @@ const allPotentialChildrenAtom = atom<SidebarModel[]>((get) => {
const memoAllPotentialChildrenAtom = deepEqualAtom(allPotentialChildrenAtom); const memoAllPotentialChildrenAtom = deepEqualAtom(allPotentialChildrenAtom);
const sidebarTreeAtom = atom((get) => { const sidebarFilterAtom = atom<{ text: string; key: string }>({ text: '', key: '' });
const sidebarTreeAtom = atom<TreeNode<SidebarModel> | null>((get) => {
const allModels = get(memoAllPotentialChildrenAtom); const allModels = get(memoAllPotentialChildrenAtom);
const activeWorkspace = get(activeWorkspaceAtom); const activeWorkspace = get(activeWorkspaceAtom);
const filter = get(sidebarFilterAtom);
const childrenMap: Record<string, Exclude<SidebarModel, Workspace>[]> = {}; const childrenMap: Record<string, Exclude<SidebarModel, Workspace>[]> = {};
for (const item of allModels) { for (const item of allModels) {
@@ -221,38 +306,57 @@ const sidebarTreeAtom = atom((get) => {
} }
} }
const treeParentMap: Record<string, TreeNode<SidebarModel>> = {};
if (activeWorkspace == null) { if (activeWorkspace == null) {
return null; return null;
} }
// Put requests and folders into a tree structure // returns true if this node OR any child matches the filter
const next = (node: TreeNode<SidebarModel>, depth: number): TreeNode<SidebarModel> => { const build = (node: TreeNode<SidebarModel>, depth: number): boolean => {
const childItems = childrenMap[node.item.id] ?? []; const childItems = childrenMap[node.item.id] ?? [];
const matchesSelf = !filter || fuzzyMatch(resolvedModelName(node.item), filter.text) != null;
let matchesChild = false;
// Recurse to children // Recurse to children
childItems.sort((a, b) => a.sortPriority - b.sortPriority); const m = node.item.model;
if (node.item.model === 'folder' || node.item.model === 'workspace') { node.children = m === 'folder' || m === 'workspace' ? [] : undefined;
node.children = node.children ?? [];
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) { for (const item of childItems) {
treeParentMap[item.id] = node; const childNode = { item, parent: node, depth };
node.children.push(next({ item, parent: node, depth }, depth + 1)); 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( const root: TreeNode<SidebarModel> = {
{ item: activeWorkspace,
item: activeWorkspace, parent: null,
children: [], children: [],
parent: null, depth: 0,
depth: 0, };
},
1, // Build tree and mark visibility in one pass
); build(root, 1);
return root;
}); });
const actions = { const actions = {
@@ -379,12 +483,8 @@ async function getContextMenu(tree: TreeHandle, items: SidebarModel[]): Promise<
hidden: items.length > 1, hidden: items.length > 1,
hotKeyAction: 'sidebar.selected.rename', hotKeyAction: 'sidebar.selected.rename',
hotKeyLabelOnly: true, hotKeyLabelOnly: true,
onSelect: async () => { onSelect: () => {
const request = getModel( tree.renameItem(child.id);
['folder', 'http_request', 'grpc_request', 'websocket_request'],
child.id,
);
await renameModelWithPrompt(request);
}, },
}, },
{ {

View File

@@ -2,7 +2,7 @@ import { workspacesAtom } from '@yaakapp-internal/models';
import classNames from 'classnames'; import classNames from 'classnames';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import * as m from 'motion/react-m'; 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 { useCallback, useMemo, useRef, useState } from 'react';
import { import {
useEnsureActiveCookieJar, useEnsureActiveCookieJar,
@@ -27,7 +27,6 @@ import { useShouldFloatSidebar } from '../hooks/useShouldFloatSidebar';
import { useSidebarHidden } from '../hooks/useSidebarHidden'; import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { useSidebarWidth } from '../hooks/useSidebarWidth'; import { useSidebarWidth } from '../hooks/useSidebarWidth';
import { useSyncWorkspaceRequestTitle } from '../hooks/useSyncWorkspaceRequestTitle'; import { useSyncWorkspaceRequestTitle } from '../hooks/useSyncWorkspaceRequestTitle';
import { useToggleCommandPalette } from '../hooks/useToggleCommandPalette';
import { duplicateRequestOrFolderAndNavigate } from '../lib/duplicateRequestOrFolderAndNavigate'; import { duplicateRequestOrFolderAndNavigate } from '../lib/duplicateRequestOrFolderAndNavigate';
import { importData } from '../lib/importData'; import { importData } from '../lib/importData';
import { jotaiStore } from '../lib/jotai'; import { jotaiStore } from '../lib/jotai';
@@ -42,9 +41,10 @@ import { FolderLayout } from './FolderLayout';
import { GrpcConnectionLayout } from './GrpcConnectionLayout'; import { GrpcConnectionLayout } from './GrpcConnectionLayout';
import { HeaderSize } from './HeaderSize'; import { HeaderSize } from './HeaderSize';
import { HttpRequestLayout } from './HttpRequestLayout'; import { HttpRequestLayout } from './HttpRequestLayout';
import NewSidebar from './NewSidebar';
import { Overlay } from './Overlay'; import { Overlay } from './Overlay';
import type { ResizeHandleEvent } from './ResizeHandle';
import { ResizeHandle } from './ResizeHandle'; import { ResizeHandle } from './ResizeHandle';
import Sidebar from './Sidebar';
import { SidebarActions } from './SidebarActions'; import { SidebarActions } from './SidebarActions';
import { WebsocketRequestLayout } from './WebsocketRequestLayout'; import { WebsocketRequestLayout } from './WebsocketRequestLayout';
import { WorkspaceHeader } from './WorkspaceHeader'; import { WorkspaceHeader } from './WorkspaceHeader';
@@ -59,55 +59,40 @@ export function Workspace() {
useGlobalWorkspaceHooks(); useGlobalWorkspaceHooks();
const workspaces = useAtomValue(workspacesAtom); const workspaces = useAtomValue(workspacesAtom);
const { setWidth, width, resetWidth } = useSidebarWidth(); const [width, setWidth, resetWidth] = useSidebarWidth();
const [sidebarHidden, setSidebarHidden] = useSidebarHidden(); const [sidebarHidden, setSidebarHidden] = useSidebarHidden();
const [floatingSidebarHidden, setFloatingSidebarHidden] = useFloatingSidebarHidden(); const [floatingSidebarHidden, setFloatingSidebarHidden] = useFloatingSidebarHidden();
const activeEnvironment = useAtomValue(activeEnvironmentAtom); const activeEnvironment = useAtomValue(activeEnvironmentAtom);
const floating = useShouldFloatSidebar(); const floating = useShouldFloatSidebar();
const [isResizing, setIsResizing] = useState<boolean>(false); const [isResizing, setIsResizing] = useState<boolean>(false);
const moveState = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>( const startWidth = useRef<number | null>(null);
null,
);
const unsub = () => { const handleResizeMove = useCallback(
if (moveState.current !== null) { async ({ x, xStart }: ResizeHandleEvent) => {
document.documentElement.removeEventListener('mousemove', moveState.current.move); if (width == null || startWidth.current == null) return;
document.documentElement.removeEventListener('mouseup', moveState.current.up);
}
};
const handleResizeStart = useCallback( const newWidth = startWidth.current + (x - xStart);
(e: ReactMouseEvent<HTMLDivElement>) => { if (newWidth < 50) {
if (width === undefined) return; await setSidebarHidden(true);
resetWidth();
unsub(); } else {
const mouseStartX = e.clientX; await setSidebarHidden(false);
const startWidth = width; setWidth(newWidth);
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);
}, },
[width, setSidebarHidden, resetWidth, setWidth], [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 sideWidth = sidebarHidden ? 0 : width;
const styles = useMemo<CSSProperties>( const styles = useMemo<CSSProperties>(
() => ({ () => ({
@@ -164,7 +149,7 @@ export function Workspace() {
<SidebarActions /> <SidebarActions />
</HeaderSize> </HeaderSize>
<ErrorBoundary name="Sidebar (Floating)"> <ErrorBoundary name="Sidebar (Floating)">
<NewSidebar /> <Sidebar />
</ErrorBoundary> </ErrorBoundary>
</m.div> </m.div>
</Overlay> </Overlay>
@@ -172,15 +157,17 @@ export function Workspace() {
<> <>
<div style={side} className={classNames('x-theme-sidebar', 'overflow-hidden bg-surface')}> <div style={side} className={classNames('x-theme-sidebar', 'overflow-hidden bg-surface')}>
<ErrorBoundary name="Sidebar"> <ErrorBoundary name="Sidebar">
<NewSidebar className="border-r border-border-subtle" /> <Sidebar className="border-r border-border-subtle" />
</ErrorBoundary> </ErrorBoundary>
</div> </div>
<ResizeHandle <ResizeHandle
className="-translate-x-[50%]" style={drag}
className="-translate-x-[1px]"
justify="end" justify="end"
side="right" side="right"
isResizing={isResizing}
onResizeStart={handleResizeStart} onResizeStart={handleResizeStart}
onResizeEnd={handleResizeEnd}
onResizeMove={handleResizeMove}
onReset={resetWidth} onReset={resetWidth}
/> />
</> </>
@@ -276,9 +263,6 @@ function useGlobalWorkspaceHooks() {
useSyncWorkspaceRequestTitle(); useSyncWorkspaceRequestTitle();
const toggleCommandPalette = useToggleCommandPalette();
useHotKey('command_palette.toggle', toggleCommandPalette);
useHotKey('model.duplicate', () => useHotKey('model.duplicate', () =>
duplicateRequestOrFolderAndNavigate(jotaiStore.get(activeRequestAtom)), duplicateRequestOrFolderAndNavigate(jotaiStore.get(activeRequestAtom)),
); );

View File

@@ -1,4 +1,3 @@
import { type } from '@tauri-apps/plugin-os';
import classNames from 'classnames'; import classNames from 'classnames';
import { useAtom, useAtomValue } from 'jotai'; import { useAtom, useAtomValue } from 'jotai';
import React, { memo } from 'react'; import React, { memo } from 'react';
@@ -75,9 +74,10 @@ export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Prop
} }
/> />
<IconButton <IconButton
icon={type() == 'macos' ? 'command' : 'square_terminal'} icon="search"
title="Search or execute a command" title="Search or execute a command"
size="sm" size="sm"
hotkeyAction="command_palette.toggle"
iconColor="secondary" iconColor="secondary"
onClick={togglePalette} onClick={togglePalette}
/> />

View File

@@ -1,11 +1,12 @@
import classNames from 'classnames'; import classNames from 'classnames';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactNode } from 'react'; import type { CSSProperties, ReactNode } from 'react';
import React, { useCallback, useMemo, useRef, useState } from 'react'; import React, { useCallback, useMemo, useRef } from 'react';
import { useLocalStorage } from 'react-use'; import { useLocalStorage } from 'react-use';
import { activeWorkspaceAtom } from '../../hooks/useActiveWorkspace'; import { activeWorkspaceAtom } from '../../hooks/useActiveWorkspace';
import { useContainerSize } from '../../hooks/useContainerQuery'; import { useContainerSize } from '../../hooks/useContainerQuery';
import { clamp } from '../../lib/clamp'; import { clamp } from '../../lib/clamp';
import type { ResizeHandleEvent } from '../ResizeHandle';
import { ResizeHandle } from '../ResizeHandle'; import { ResizeHandle } from '../ResizeHandle';
export type SplitLayoutLayout = 'responsive' | 'horizontal' | 'vertical'; export type SplitLayoutLayout = 'responsive' | 'horizontal' | 'vertical';
@@ -55,10 +56,6 @@ export function SplitLayout({
); );
const width = widthRaw ?? defaultRatio; const width = widthRaw ?? defaultRatio;
let height = heightRaw ?? defaultRatio; let height = heightRaw ?? defaultRatio;
const [isResizing, setIsResizing] = useState<boolean>(false);
const moveState = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>(
null,
);
if (!secondSlot) { if (!secondSlot) {
height = 0; height = 0;
@@ -86,60 +83,37 @@ export function SplitLayout({
}; };
}, [style, vertical, height, minHeightPx, width]); }, [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(() => { const handleReset = useCallback(() => {
if (vertical) setHeight(defaultRatio); if (vertical) setHeight(defaultRatio);
else setWidth(defaultRatio); else setWidth(defaultRatio);
}, [vertical, setHeight, defaultRatio, setWidth]); }, [vertical, setHeight, defaultRatio, setWidth]);
const handleResizeStart = useCallback( const handleResizeMove = useCallback(
(e: ReactMouseEvent<HTMLDivElement>) => { (e: ResizeHandleEvent) => {
if (containerRef.current === null) return; 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 mouseStartX = e.xStart;
const mouseStartY = e.clientY; const mouseStartY = e.yStart;
const startWidth = containerRect.width * width; const startWidth = containerWidth * width;
const startHeight = containerRect.height * height; const startHeight = containerHeight * height;
moveState.current = { if (vertical) {
move: (e: MouseEvent) => { const maxHeightPx = containerHeight - minHeightPx;
setIsResizing(true); // Set this here so we don't block double-clicks const newHeightPx = clamp(startHeight - (e.y - mouseStartY), minHeightPx, maxHeightPx);
e.preventDefault(); // Prevent text selection and things setHeight(newHeightPx / containerHeight);
if (vertical) { } else {
const maxHeightPx = containerRect.height - minHeightPx; const maxWidthPx = containerWidth - minWidthPx;
const newHeightPx = clamp( const newWidthPx = clamp(startWidth - (e.x - mouseStartX), minWidthPx, maxWidthPx);
startHeight - (e.clientY - mouseStartY), setWidth(newWidthPx / containerWidth);
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);
}, },
[width, height, vertical, minHeightPx, setHeight, minWidthPx, setWidth], [width, height, vertical, minHeightPx, setHeight, minWidthPx, setWidth],
); );
@@ -155,9 +129,8 @@ export function SplitLayout({
<> <>
<ResizeHandle <ResizeHandle
style={areaD} style={areaD}
isResizing={isResizing}
className={classNames(vertical ? '-translate-y-1' : '-translate-x-1')} className={classNames(vertical ? '-translate-y-1' : '-translate-x-1')}
onResizeStart={handleResizeStart} onResizeMove={handleResizeMove}
onReset={handleReset} onReset={handleReset}
side={vertical ? 'top' : 'left'} side={vertical ? 'top' : 'left'}
justify="center" justify="center"

View File

@@ -1,6 +1,7 @@
import type { DragEndEvent, DragMoveEvent, DragStartEvent } from '@dnd-kit/core'; import type { DragEndEvent, DragMoveEvent, DragStartEvent } from '@dnd-kit/core';
import { import {
DndContext, DndContext,
MeasuringStrategy,
PointerSensor, PointerSensor,
pointerWithin, pointerWithin,
useDroppable, useDroppable,
@@ -24,17 +25,28 @@ import type { HotkeyAction, HotKeyOptions } from '../../../hooks/useHotKey';
import { useHotKey } from '../../../hooks/useHotKey'; import { useHotKey } from '../../../hooks/useHotKey';
import { computeSideForDragMove } from '../../../lib/dnd'; import { computeSideForDragMove } from '../../../lib/dnd';
import { jotaiStore } from '../../../lib/jotai'; import { jotaiStore } from '../../../lib/jotai';
import { isSidebarFocused } from '../../../lib/scopes';
import type { ContextMenuProps, DropdownItem } from '../Dropdown'; import type { ContextMenuProps, DropdownItem } from '../Dropdown';
import { ContextMenu } from '../Dropdown'; import { ContextMenu } from '../Dropdown';
import { draggingIdsFamily, focusIdsFamily, hoveredParentFamily, selectedIdsFamily } from './atoms'; import {
collapsedFamily,
draggingIdsFamily,
focusIdsFamily,
hoveredParentFamily,
isCollapsedFamily,
selectedIdsFamily,
} from './atoms';
import type { SelectableTreeNode, TreeNode } from './common'; import type { SelectableTreeNode, TreeNode } from './common';
import { equalSubtree, getSelectedItems, hasAncestor } from './common'; import { equalSubtree, getSelectedItems, hasAncestor } from './common';
import { TreeDragOverlay } from './TreeDragOverlay'; import { TreeDragOverlay } from './TreeDragOverlay';
import type { TreeItemHandle, TreeItemProps } from './TreeItem'; import type { TreeItemClickEvent, TreeItemHandle, TreeItemProps } from './TreeItem';
import type { TreeItemListProps } from './TreeItemList'; import type { TreeItemListProps } from './TreeItemList';
import { TreeItemList } from './TreeItemList'; import { TreeItemList } from './TreeItemList';
import { useSelectableItems } from './useSelectableItems'; import { useSelectableItems } from './useSelectableItems';
/** So we re-calculate after expanding a folder during drag */
const measuring = { droppable: { strategy: MeasuringStrategy.Always } };
export interface TreeProps<T extends { id: string }> { export interface TreeProps<T extends { id: string }> {
root: TreeNode<T>; root: TreeNode<T>;
treeId: string; treeId: string;
@@ -93,7 +105,6 @@ function TreeInner<T extends { id: string }>(
y: number; y: number;
} | null>(null); } | null>(null);
const treeItemRefs = useRef<Record<string, TreeItemHandle>>({}); const treeItemRefs = useRef<Record<string, TreeItemHandle>>({});
const handleAddTreeItemRef = useCallback((item: T, r: TreeItemHandle | null) => { const handleAddTreeItemRef = useCallback((item: T, r: TreeItemHandle | null) => {
if (r == null) { if (r == null) {
delete treeItemRefs.current[item.id]; delete treeItemRefs.current[item.id];
@@ -170,16 +181,17 @@ function TreeInner<T extends { id: string }>(
return; return;
} }
const validSelectableItems = getValidSelectableItems(treeId, selectableItems);
if (currIndex > anchorIndex) { if (currIndex > anchorIndex) {
// Selecting down // Selecting down
const itemsToSelect = selectableItems.slice(anchorIndex, currIndex + 1); const itemsToSelect = validSelectableItems.slice(anchorIndex, currIndex + 1);
setSelected( setSelected(
itemsToSelect.map((v) => v.node.item.id), itemsToSelect.map((v) => v.node.item.id),
true, true,
); );
} else if (currIndex < anchorIndex) { } else if (currIndex < anchorIndex) {
// Selecting up // Selecting up
const itemsToSelect = selectableItems.slice(currIndex, anchorIndex + 1); const itemsToSelect = validSelectableItems.slice(currIndex, anchorIndex + 1);
setSelected( setSelected(
itemsToSelect.map((v) => v.node.item.id), itemsToSelect.map((v) => v.node.item.id),
true, true,
@@ -217,15 +229,50 @@ function TreeInner<T extends { id: string }>(
[handleSelect, onActivate], [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( useKey(
'ArrowUp', 'ArrowUp',
(e) => { (e) => {
if (!treeRef.current?.contains(document.activeElement)) return; if (!isSidebarFocused()) return;
e.preventDefault(); e.preventDefault();
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId; selectPrevItem(e);
const index = selectableItems.findIndex((i) => i.node.item.id === lastSelectedId);
const item = selectableItems[index - 1];
if (item != null) handleSelect(item.node.item, e);
}, },
undefined, undefined,
[selectableItems, handleSelect], [selectableItems, handleSelect],
@@ -234,12 +281,60 @@ function TreeInner<T extends { id: string }>(
useKey( useKey(
'ArrowDown', 'ArrowDown',
(e) => { (e) => {
if (!treeRef.current?.contains(document.activeElement)) return; if (!isSidebarFocused()) return;
e.preventDefault(); 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 lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
const index = selectableItems.findIndex((i) => i.node.item.id === lastSelectedId); const lastSelectedItem = selectableItems.find((i) => i.node.item.id === lastSelectedId);
const item = selectableItems[index + 1];
if (item != null) handleSelect(item.node.item, e); 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, undefined,
[selectableItems, handleSelect], [selectableItems, handleSelect],
@@ -467,6 +562,7 @@ function TreeInner<T extends { id: string }>(
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
onDragCancel={clearDragState} onDragCancel={clearDragState}
onDragAbort={clearDragState} onDragAbort={clearDragState}
measuring={measuring}
onDragMove={handleDragMove} onDragMove={handleDragMove}
autoScroll autoScroll
> >
@@ -608,3 +704,19 @@ function TreeHotKeys<T extends { id: string }>({
</> </>
); );
} }
function getValidSelectableItems<T extends { id: string }>(
treeId: string,
selectableItems: SelectableTreeNode<T>[],
) {
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;
});
}

View File

@@ -21,7 +21,7 @@ export const TreeIndentGuide = memo(function TreeIndentGuide({
<div <div
key={i} key={i}
className={classNames( className={classNames(
'w-[1rem] border-r border-r-text-subtlest', 'w-[calc(1rem+0.5px)] border-r border-r-text-subtlest',
!(parentDepth === i + 1 && isHovered) && 'opacity-30', !(parentDepth === i + 1 && isHovered) && 'opacity-30',
)} )}
/> />

View File

@@ -12,10 +12,11 @@ import { ContextMenu } from '../Dropdown';
import { Icon } from '../Icon'; import { Icon } from '../Icon';
import { collapsedFamily, isCollapsedFamily, isLastFocusedFamily, isSelectedFamily } from './atoms'; import { collapsedFamily, isCollapsedFamily, isLastFocusedFamily, isSelectedFamily } from './atoms';
import type { TreeNode } from './common'; import type { TreeNode } from './common';
import { getNodeKey } from './common';
import type { TreeProps } from './Tree'; import type { TreeProps } from './Tree';
import { TreeIndentGuide } from './TreeIndentGuide'; import { TreeIndentGuide } from './TreeIndentGuide';
interface OnClickEvent { export interface TreeItemClickEvent {
shiftKey: boolean; shiftKey: boolean;
ctrlKey: boolean; ctrlKey: boolean;
metaKey: boolean; metaKey: boolean;
@@ -27,7 +28,7 @@ export type TreeItemProps<T extends { id: string }> = Pick<
> & { > & {
node: TreeNode<T>; node: TreeNode<T>;
className?: string; className?: string;
onClick?: (item: T, e: OnClickEvent) => void; onClick?: (item: T, e: TreeItemClickEvent) => void;
getContextMenu?: (item: T) => Promise<ContextMenuProps['items']>; getContextMenu?: (item: T) => Promise<ContextMenuProps['items']>;
depth: number; depth: number;
addRef?: (item: T, n: TreeItemHandle | null) => void; addRef?: (item: T, n: TreeItemHandle | null) => void;
@@ -157,8 +158,10 @@ function TreeItem_<T extends { id: string }>({
} }
break; break;
case 'Escape': case 'Escape':
e.preventDefault(); if (editing) {
setEditing(false); e.preventDefault();
setEditing(false);
}
break; break;
} }
}, },
@@ -253,6 +256,8 @@ function TreeItem_<T extends { id: string }>({
[setDraggableRef, setDroppableRef], [setDraggableRef, setDroppableRef],
); );
if (node.hidden || isAncestorCollapsed) return null;
return ( return (
<li <li
ref={listItemRef} ref={listItemRef}
@@ -266,7 +271,6 @@ function TreeItem_<T extends { id: string }>({
'tree-item', 'tree-item',
'h-sm', 'h-sm',
'grid grid-cols-[auto_minmax(0,1fr)]', 'grid grid-cols-[auto_minmax(0,1fr)]',
isAncestorCollapsed && 'hidden',
editing && 'ring-1 focus-within:ring-focus', editing && 'ring-1 focus-within:ring-focus',
dropHover != null && 'relative z-10 ring-2 ring-primary', dropHover != null && 'relative z-10 ring-2 ring-primary',
dropHover === 'animate' && 'animate-blinkRing', dropHover === 'animate' && 'animate-blinkRing',
@@ -350,6 +354,9 @@ export const TreeItem = memo(
if (nonEqualKeys.length > 0) { if (nonEqualKeys.length > 0) {
return false; return false;
} }
return nextProps.getItemKey(prevNode.item) === nextProps.getItemKey(nextNode.item);
return (
getNodeKey(prevNode, prevProps.getItemKey) === getNodeKey(nextNode, nextProps.getItemKey)
);
}, },
) as typeof TreeItem_; ) as typeof TreeItem_;

View File

@@ -1,5 +1,5 @@
import type { CSSProperties } from 'react'; import type { CSSProperties} from 'react';
import { Fragment, memo } from 'react'; import { Fragment } from 'react';
import type { SelectableTreeNode } from './common'; import type { SelectableTreeNode } from './common';
import type { TreeProps } from './Tree'; import type { TreeProps } from './Tree';
import { TreeDropMarker } from './TreeDropMarker'; import { TreeDropMarker } from './TreeDropMarker';
@@ -18,7 +18,7 @@ export type TreeItemListProps<T extends { id: string }> = Pick<
addTreeItemRef?: (item: T, n: TreeItemHandle | null) => void; addTreeItemRef?: (item: T, n: TreeItemHandle | null) => void;
}; };
function TreeItemList_<T extends { id: string }>({ export function TreeItemList<T extends { id: string }>({
className, className,
getContextMenu, getContextMenu,
getEditOptions, getEditOptions,
@@ -55,33 +55,3 @@ function TreeItemList_<T extends { id: string }>({
</ul> </ul>
); );
} }
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_;

View File

@@ -4,6 +4,7 @@ import { selectedIdsFamily } from './atoms';
export interface TreeNode<T extends { id: string }> { export interface TreeNode<T extends { id: string }> {
children?: TreeNode<T>[]; children?: TreeNode<T>[];
item: T; item: T;
hidden?: boolean;
parent: TreeNode<T> | null; parent: TreeNode<T> | null;
depth: number; depth: number;
} }
@@ -27,19 +28,23 @@ export function getSelectedItems<T extends { id: string }>(
export function equalSubtree<T extends { id: string }>( export function equalSubtree<T extends { id: string }>(
a: TreeNode<T>, a: TreeNode<T>,
b: TreeNode<T>, b: TreeNode<T>,
getKey: (t: T) => string, getItemKey: (t: T) => string,
): boolean { ): boolean {
if (getKey(a.item) !== getKey(b.item)) return false; if (getNodeKey(a, getItemKey) !== getNodeKey(b, getItemKey)) return false;
const ak = a.children ?? []; const ak = a.children ?? [];
const bk = b.children ?? []; const bk = b.children ?? [];
if (ak.length !== bk.length) return false; if (ak.length !== bk.length) return false;
for (let i = 0; i < ak.length; i++) { 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; return true;
} }
export function getNodeKey<T extends { id: string }>(a: TreeNode<T>, getItemKey: (i: T) => string) {
return getItemKey(a.item) + a.hidden;
}
export function hasAncestor<T extends { id: string }>(node: TreeNode<T>, ancestorId: string) { export function hasAncestor<T extends { id: string }>(node: TreeNode<T>, ancestorId: string) {
if (node.parent == null) return false; if (node.parent == null) return false;
if (node.parent.item.id === ancestorId) return true; if (node.parent.item.id === ancestorId) return true;

View File

@@ -23,6 +23,7 @@ export type HotkeyAction =
| 'switcher.prev' | 'switcher.prev'
| 'switcher.toggle' | 'switcher.toggle'
| 'settings.show' | 'settings.show'
| 'sidebar.filter'
| 'sidebar.selected.delete' | 'sidebar.selected.delete'
| 'sidebar.selected.duplicate' | 'sidebar.selected.duplicate'
| 'sidebar.selected.rename' | 'sidebar.selected.rename'
@@ -45,6 +46,7 @@ const hotkeys: Record<HotkeyAction, string[]> = {
'switcher.prev': ['Control+Tab'], 'switcher.prev': ['Control+Tab'],
'switcher.toggle': ['CmdCtrl+p'], 'switcher.toggle': ['CmdCtrl+p'],
'settings.show': ['CmdCtrl+,'], 'settings.show': ['CmdCtrl+,'],
'sidebar.filter': ['CmdCtrl+f'],
'sidebar.selected.delete': ['Delete', 'CmdCtrl+Backspace'], 'sidebar.selected.delete': ['Delete', 'CmdCtrl+Backspace'],
'sidebar.selected.duplicate': ['CmdCtrl+d'], 'sidebar.selected.duplicate': ['CmdCtrl+d'],
'sidebar.selected.rename': ['Enter'], 'sidebar.selected.rename': ['Enter'],
@@ -62,15 +64,16 @@ const hotkeyLabels: Record<HotkeyAction, string> = {
'hotkeys.showHelp': 'Show Keyboard Shortcuts', 'hotkeys.showHelp': 'Show Keyboard Shortcuts',
'model.create': 'New Request', 'model.create': 'New Request',
'model.duplicate': 'Duplicate Request', 'model.duplicate': 'Duplicate Request',
'request.rename': 'Rename', 'request.rename': 'Rename Active Request',
'request.send': 'Send', 'request.send': 'Send Active Request',
'switcher.next': 'Go To Previous Request', 'switcher.next': 'Go To Previous Request',
'switcher.prev': 'Go To Next Request', 'switcher.prev': 'Go To Next Request',
'switcher.toggle': 'Toggle Request Switcher', 'switcher.toggle': 'Toggle Request Switcher',
'settings.show': 'Open Settings', 'settings.show': 'Open Settings',
'sidebar.selected.delete': 'Delete', 'sidebar.filter': 'Filter Sidebar',
'sidebar.selected.duplicate': 'Duplicate', 'sidebar.selected.delete': 'Delete Selected Sidebar Item',
'sidebar.selected.rename': 'Rename', 'sidebar.selected.duplicate': 'Duplicate Selected Sidebar Item',
'sidebar.selected.rename': 'Rename Selected Sidebar Item',
'sidebar.focus': 'Focus or Toggle Sidebar', 'sidebar.focus': 'Focus or Toggle Sidebar',
'url_bar.focus': 'Focus URL', 'url_bar.focus': 'Focus URL',
'workspace_settings.show': 'Open Workspace Settings', 'workspace_settings.show': 'Open Workspace Settings',
@@ -178,7 +181,7 @@ function handleKeyDown(e: KeyboardEvent) {
if ( if (
(e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) && (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) &&
currentKeysWithModifiers.size === 1 && 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 // 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. // better way to do stuff like this in the future.
@@ -244,6 +247,10 @@ export function useFormattedHotkey(action: HotkeyAction | null): string[] | null
labelParts.push('⇥'); labelParts.push('⇥');
} else if (p === 'Backspace') { } else if (p === 'Backspace') {
labelParts.push('⌫'); labelParts.push('⌫');
} else if (p === 'Minus') {
labelParts.push('-');
} else if (p === 'Equal') {
labelParts.push('=');
} else { } else {
labelParts.push(capitalize(p)); labelParts.push(capitalize(p));
} }

View File

@@ -1,5 +1,5 @@
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { useCallback, useMemo } from 'react'; import { useCallback } from 'react';
import { useLocalStorage } from 'react-use'; import { useLocalStorage } from 'react-use';
import { activeWorkspaceIdAtom } from './useActiveWorkspace'; import { activeWorkspaceIdAtom } from './useActiveWorkspace';
@@ -10,5 +10,5 @@ export function useSidebarWidth() {
250, 250,
); );
const resetWidth = useCallback(() => setWidth(250), [setWidth]); const resetWidth = useCallback(() => setWidth(250), [setWidth]);
return useMemo(() => ({ width, setWidth, resetWidth }), [width, setWidth, resetWidth]); return [width ?? null, setWidth, resetWidth] as const;
} }