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