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 { 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<M extends AnyModel['model'], T extends ExtractModel<AnyModel, M>>(
modelType: M | ReadonlyArray<M>,
): 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<M> = Array.isArray(modelType) ? modelType : [modelType];
return types.flatMap((t) => Object.values(data[t]) as T[]);
}
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 });
}
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>>(
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<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 }>>(

View File

@@ -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;
}

View File

@@ -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',

View File

@@ -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<HTMLDivElement>) => 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<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 (
<div
aria-hidden
style={style}
onPointerDown={onResizeStart}
onDoubleClick={onReset}
onPointerDown={handlePointerDown}
className={classNames(
className,
'group z-10 flex select-none transition-colors hover:bg-surface-active rounded-full',
@@ -45,7 +103,8 @@ export function ResizeHandle({
{isResizing && (
<div
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-col-resize',
)}

View File

@@ -1,3 +1,4 @@
import { debounce } from '@yaakapp-internal/lib';
import type {
Folder,
GrpcRequest,
@@ -16,8 +17,10 @@ import {
workspacesAtom,
} from '@yaakapp-internal/models';
import classNames from 'classnames';
import { fuzzyMatch } from 'fuzzbunny';
import { atom, useAtomValue } from 'jotai';
import { selectAtom } from 'jotai/utils';
import type { KeyboardEvent } from 'react';
import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { moveToWorkspace } from '../commands/moveToWorkspace';
import { openFolderSettings } from '../commands/openFolderSettings';
@@ -36,7 +39,6 @@ import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { deepEqualAtom } from '../lib/atoms';
import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm';
import { jotaiStore } from '../lib/jotai';
import { renameModelWithPrompt } from '../lib/renameModelWithPrompt';
import { resolvedModelName } from '../lib/resolvedModelName';
import { isSidebarFocused } from '../lib/scopes';
import { navigateToRequestOrFolderOrWorkspace } from '../lib/setWorkspaceSearchParams';
@@ -45,7 +47,10 @@ import type { ContextMenuProps, DropdownItem } from './core/Dropdown';
import { HttpMethodTag } from './core/HttpMethodTag';
import { HttpStatusTag } from './core/HttpStatusTag';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { InlineCode } from './core/InlineCode';
import { LoadingIcon } from './core/LoadingIcon';
import { PlainInput } from './core/PlainInput';
import { isSelectedFamily } from './core/tree/atoms';
import type { TreeNode } from './core/tree/common';
import type { TreeHandle, TreeProps } from './core/tree/Tree';
@@ -57,18 +62,35 @@ type SidebarModel = Workspace | Folder | HttpRequest | GrpcRequest | WebsocketRe
const OPACITY_SUBTLE = 'opacity-80';
function NewSidebar({ className }: { className?: string }) {
function Sidebar({ className }: { className?: string }) {
const [hidden, setHidden] = useSidebarHidden();
const tree = useAtomValue(sidebarTreeAtom);
const activeWorkspaceId = useAtomValue(activeWorkspaceAtom)?.id;
const treeId = 'tree.' + (activeWorkspaceId ?? 'unknown');
const filter = useAtomValue(sidebarFilterAtom);
const tree = useAtomValue(sidebarTreeAtom);
const wrapperRef = useRef<HTMLElement>(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(() => {
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<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) {
return null;
}
@@ -150,28 +197,63 @@ function NewSidebar({ className }: { className?: string }) {
<aside
ref={wrapperRef}
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
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}
/>
<div className="px-2 py-1.5 pb-0">
{(tree.children?.length ?? 0) > 0 && (
<PlainInput
hideLabel
ref={filterRef}
size="xs"
label="filter"
containerClassName="!rounded-full px-1"
placeholder="Search"
onChange={handleFilterChange}
defaultValue={filter.text}
forceUpdateKey={filter.key}
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 />
</aside>
);
}
export default NewSidebar;
export default Sidebar;
const activeIdAtom = atom<string | null>((get) => {
return get(activeRequestIdAtom) || get(activeFolderIdAtom);
@@ -206,9 +288,12 @@ const allPotentialChildrenAtom = atom<SidebarModel[]>((get) => {
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 activeWorkspace = get(activeWorkspaceAtom);
const filter = get(sidebarFilterAtom);
const childrenMap: Record<string, Exclude<SidebarModel, Workspace>[]> = {};
for (const item of allModels) {
@@ -221,38 +306,57 @@ const sidebarTreeAtom = atom((get) => {
}
}
const treeParentMap: Record<string, TreeNode<SidebarModel>> = {};
if (activeWorkspace == null) {
return null;
}
// Put requests and folders into a tree structure
const next = (node: TreeNode<SidebarModel>, depth: number): TreeNode<SidebarModel> => {
// returns true if this node OR any child matches the filter
const build = (node: TreeNode<SidebarModel>, 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<SidebarModel> = {
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);
},
},
{

View File

@@ -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<boolean>(false);
const moveState = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>(
null,
);
const startWidth = useRef<number | null>(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<HTMLDivElement>) => {
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<CSSProperties>(
() => ({
@@ -164,7 +149,7 @@ export function Workspace() {
<SidebarActions />
</HeaderSize>
<ErrorBoundary name="Sidebar (Floating)">
<NewSidebar />
<Sidebar />
</ErrorBoundary>
</m.div>
</Overlay>
@@ -172,15 +157,17 @@ export function Workspace() {
<>
<div style={side} className={classNames('x-theme-sidebar', 'overflow-hidden bg-surface')}>
<ErrorBoundary name="Sidebar">
<NewSidebar className="border-r border-border-subtle" />
<Sidebar className="border-r border-border-subtle" />
</ErrorBoundary>
</div>
<ResizeHandle
className="-translate-x-[50%]"
style={drag}
className="-translate-x-[1px]"
justify="end"
side="right"
isResizing={isResizing}
onResizeStart={handleResizeStart}
onResizeEnd={handleResizeEnd}
onResizeMove={handleResizeMove}
onReset={resetWidth}
/>
</>
@@ -276,9 +263,6 @@ function useGlobalWorkspaceHooks() {
useSyncWorkspaceRequestTitle();
const toggleCommandPalette = useToggleCommandPalette();
useHotKey('command_palette.toggle', toggleCommandPalette);
useHotKey('model.duplicate', () =>
duplicateRequestOrFolderAndNavigate(jotaiStore.get(activeRequestAtom)),
);

View File

@@ -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
}
/>
<IconButton
icon={type() == 'macos' ? 'command' : 'square_terminal'}
icon="search"
title="Search or execute a command"
size="sm"
hotkeyAction="command_palette.toggle"
iconColor="secondary"
onClick={togglePalette}
/>

View File

@@ -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<boolean>(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<HTMLDivElement>) => {
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({
<>
<ResizeHandle
style={areaD}
isResizing={isResizing}
className={classNames(vertical ? '-translate-y-1' : '-translate-x-1')}
onResizeStart={handleResizeStart}
onResizeMove={handleResizeMove}
onReset={handleReset}
side={vertical ? 'top' : 'left'}
justify="center"

View File

@@ -1,6 +1,7 @@
import type { DragEndEvent, DragMoveEvent, DragStartEvent } from '@dnd-kit/core';
import {
DndContext,
MeasuringStrategy,
PointerSensor,
pointerWithin,
useDroppable,
@@ -24,17 +25,28 @@ import type { HotkeyAction, HotKeyOptions } from '../../../hooks/useHotKey';
import { useHotKey } from '../../../hooks/useHotKey';
import { computeSideForDragMove } from '../../../lib/dnd';
import { jotaiStore } from '../../../lib/jotai';
import { isSidebarFocused } from '../../../lib/scopes';
import type { ContextMenuProps, DropdownItem } 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 { equalSubtree, getSelectedItems, hasAncestor } from './common';
import { TreeDragOverlay } from './TreeDragOverlay';
import type { TreeItemHandle, TreeItemProps } from './TreeItem';
import type { TreeItemClickEvent, TreeItemHandle, TreeItemProps } from './TreeItem';
import type { TreeItemListProps } from './TreeItemList';
import { TreeItemList } from './TreeItemList';
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 }> {
root: TreeNode<T>;
treeId: string;
@@ -93,7 +105,6 @@ function TreeInner<T extends { id: string }>(
y: number;
} | null>(null);
const treeItemRefs = useRef<Record<string, TreeItemHandle>>({});
const handleAddTreeItemRef = useCallback((item: T, r: TreeItemHandle | null) => {
if (r == null) {
delete treeItemRefs.current[item.id];
@@ -170,16 +181,17 @@ function TreeInner<T extends { id: string }>(
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<T extends { id: string }>(
[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<T extends { id: string }>(
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<T extends { id: string }>(
onDragEnd={handleDragEnd}
onDragCancel={clearDragState}
onDragAbort={clearDragState}
measuring={measuring}
onDragMove={handleDragMove}
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
key={i}
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',
)}
/>

View File

@@ -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<T extends { id: string }> = Pick<
> & {
node: TreeNode<T>;
className?: string;
onClick?: (item: T, e: OnClickEvent) => void;
onClick?: (item: T, e: TreeItemClickEvent) => void;
getContextMenu?: (item: T) => Promise<ContextMenuProps['items']>;
depth: number;
addRef?: (item: T, n: TreeItemHandle | null) => void;
@@ -157,8 +158,10 @@ function TreeItem_<T extends { id: string }>({
}
break;
case 'Escape':
e.preventDefault();
setEditing(false);
if (editing) {
e.preventDefault();
setEditing(false);
}
break;
}
},
@@ -253,6 +256,8 @@ function TreeItem_<T extends { id: string }>({
[setDraggableRef, setDroppableRef],
);
if (node.hidden || isAncestorCollapsed) return null;
return (
<li
ref={listItemRef}
@@ -266,7 +271,6 @@ function TreeItem_<T extends { id: string }>({
'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_;

View File

@@ -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<T extends { id: string }> = Pick<
addTreeItemRef?: (item: T, n: TreeItemHandle | null) => void;
};
function TreeItemList_<T extends { id: string }>({
export function TreeItemList<T extends { id: string }>({
className,
getContextMenu,
getEditOptions,
@@ -55,33 +55,3 @@ function TreeItemList_<T extends { id: string }>({
</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 }> {
children?: TreeNode<T>[];
item: T;
hidden?: boolean;
parent: TreeNode<T> | null;
depth: number;
}
@@ -27,19 +28,23 @@ export function getSelectedItems<T extends { id: string }>(
export function equalSubtree<T extends { id: string }>(
a: TreeNode<T>,
b: TreeNode<T>,
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<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) {
if (node.parent == null) return false;
if (node.parent.item.id === ancestorId) return true;

View File

@@ -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<HotkeyAction, string[]> = {
'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<HotkeyAction, string> = {
'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));
}

View File

@@ -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;
}