mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-01-11 20:00:29 +01:00
Sidebar filtering and improvements (#285)
This commit is contained in:
@@ -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 }>>(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -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)),
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user