A bunch of fixes

This commit is contained in:
Gregory Schier
2025-11-04 08:44:08 -08:00
parent 81ceb981e8
commit 0cb633e479
11 changed files with 301 additions and 401 deletions

View File

@@ -10,7 +10,7 @@ import {
} from '../hooks/useEnvironmentsBreakdown';
import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm';
import { jotaiStore } from '../lib/jotai';
import { isBaseEnvironment } from '../lib/model_util';
import { isBaseEnvironment, isSubEnvironment } from '../lib/model_util';
import { resolvedModelName } from '../lib/resolvedModelName';
import { showColorPicker } from '../lib/showColorPicker';
import { Banner } from './core/Banner';
@@ -170,7 +170,18 @@ function EnvironmentEditDialogSidebar({
const getContextMenu = useCallback(
(items: TreeModel[]): ContextMenuProps['items'] => {
const environment = items[0];
if (environment == null || environment.model !== 'environment') return [];
const addEnvironmentItem: DropdownItem = {
label: 'Create Sub Environment',
leftSlot: <Icon icon="plus" />,
onSelect: async () => {
await createSubEnvironment();
},
};
if (environment == null || environment.model !== 'environment') {
return [addEnvironmentItem];
}
const singleEnvironment = items.length === 1;
const menuItems: DropdownItem[] = [
@@ -206,6 +217,7 @@ function EnvironmentEditDialogSidebar({
label: `Make ${environment.public ? 'Private' : 'Sharable'}`,
leftSlot: <Icon icon={environment.public ? 'eye_closed' : 'eye'} />,
rightSlot: <EnvironmentSharableTooltip />,
hidden: items.length > 1,
onSelect: async () => {
await patchModel(environment, { public: !environment.public });
},
@@ -215,22 +227,18 @@ function EnvironmentEditDialogSidebar({
label: 'Delete',
hotKeyAction: 'sidebar.selected.delete',
hotKeyLabelOnly: true,
hidden: !(isBaseEnvironment(environment) && baseEnvironments.length > 1),
hidden:
(isBaseEnvironment(environment) && baseEnvironments.length <= 1) ||
!isSubEnvironment(environment),
leftSlot: <Icon icon="trash" />,
onSelect: () => handleDeleteEnvironment(environment),
},
];
// Add sub environment to base environment
if (isBaseEnvironment(environment) && singleEnvironment) {
menuItems.push({ type: 'separator' });
menuItems.push({
label: 'Create Sub Environment',
leftSlot: <Icon icon="plus" />,
hidden: !isBaseEnvironment(environment),
onSelect: async () => {
await createSubEnvironment();
},
});
menuItems.push(addEnvironmentItem);
}
return menuItems;

View File

@@ -2,7 +2,7 @@ import type { Environment } from '@yaakapp-internal/models';
import { patchModel } from '@yaakapp-internal/models';
import type { GenericCompletionOption } from '@yaakapp-internal/plugins';
import classNames from 'classnames';
import React, { useCallback, useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown';
import { useIsEncryptionEnabled } from '../hooks/useIsEncryptionEnabled';
import { useKeyValue } from '../hooks/useKeyValue';

View File

@@ -1,7 +1,6 @@
import { revealItemInDir } from '@tauri-apps/plugin-opener';
import { patchModel, settingsAtom } from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai';
import React from 'react';
import { activeWorkspaceAtom } from '../../hooks/useActiveWorkspace';
import { appInfo } from '../../lib/appInfo';
import { useCheckForUpdates } from '../../hooks/useCheckForUpdates';

View File

@@ -21,8 +21,7 @@ import {
import classNames from 'classnames';
import { atom, useAtomValue } from 'jotai';
import { selectAtom } from 'jotai/utils';
import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { useKey } from 'react-use';
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { moveToWorkspace } from '../commands/moveToWorkspace';
import { openFolderSettings } from '../commands/openFolderSettings';
import { activeCookieJarAtom } from '../hooks/useActiveCookieJar';
@@ -150,33 +149,31 @@ function Sidebar({ className }: { className?: string }) {
await Promise.all(
items.map((m, i) =>
// Spread item sortPriority out over before/after range
patchModel(m, { sortPriority: beforePriority + (i + 1) * increment, folderId }),
patchModel(m, {
sortPriority: beforePriority + (i + 1) * increment,
folderId,
}),
),
);
}
);
op>
}
} catch (e) {
console.error(e);
}
}, []);
const handleTreeRefInit = useCallback((n: TreeHandle) => {
treeRef.current = n;
if (n == null) return;
const activeId = jotaiStore.get(activeIdAtom);
if (activeId == null) return;
const selectedIds = jotaiStore.get(selectedIdsFamily(treeId));
if (selectedIds.length > 0) return;
n.selectItem(activeId);
}, [treeId]);
// Ensure active id is always selected when it changes
useEffect(() => {
return jotaiStore.sub(activeIdAtom, () => {
const handleTreeRefInit = useCallback(
(n: TreeHandle) => {
treeRef.current = n;
if (n == null) return;
const activeId = jotaiStore.get(activeIdAtom);
if (activeId == null) return;
treeRef.current?.selectItem(activeId);
});
}, []);
const selectedIds = jotaiStore.get(selectedIdsFamily(treeId));
if (selectedIds.length > 0) return;
n.selectItem(activeId);
},
[treeId],
);
const clearFilterText = useCallback(() => {
jotaiStore.set(sidebarFilterAtom, { text: '', key: `${Math.random()}` });
@@ -204,14 +201,6 @@ function Sidebar({ className }: { className?: string }) {
[],
);
// Focus the first sidebar item on arrow down from filter
useKey('ArrowDown', (e) => {
if (e.key === 'ArrowDown' && filterRef.current?.isFocused()) {
e.preventDefault();
treeRef.current?.focus();
}
});
const actions = useMemo(() => {
const enable = () => treeRef.current?.hasFocus() ?? false;
@@ -291,7 +280,11 @@ function Sidebar({ className }: { className?: string }) {
// No children means we're in the root
if (child == null) {
return getCreateDropdownItems({ workspaceId, activeRequest: null, folderId: null });
return getCreateDropdownItems({
workspaceId,
activeRequest: null,
folderId: null,
});
}
const workspaces = jotaiStore.get(workspacesAtom);
@@ -355,12 +348,19 @@ function Sidebar({ className }: { className?: string }) {
items.length === 1 && child.model === 'folder'
? [
{ type: 'separator' },
...getCreateDropdownItems({ workspaceId, activeRequest: null, folderId: child.id }),
...getCreateDropdownItems({
workspaceId,
activeRequest: null,
folderId: child.id,
}),
]
: [];
const menuItems: ContextMenuProps['items'] = [
...initialItems,
{ type: 'separator', hidden: initialItems.filter((v) => !v.hidden).length === 0 },
{
type: 'separator',
hidden: initialItems.filter((v) => !v.hidden).length === 0,
},
{
label: 'Rename',
leftSlot: <Icon icon="pencil" />,
@@ -421,7 +421,9 @@ function Sidebar({ className }: { className?: string }) {
const view = filterRef.current;
if (!view) return;
const ext = filter({ fields: allFields ?? [] });
view.dispatch({ effects: filterLanguageCompartmentRef.current.reconfigure(ext) });
view.dispatch({
effects: filterLanguageCompartmentRef.current.reconfigure(ext),
});
}, [allFields]);
if (tree == null || hidden) {
@@ -434,7 +436,7 @@ function Sidebar({ className }: { className?: string }) {
aria-hidden={hidden ?? undefined}
className={classNames(className, 'h-full grid grid-rows-[auto_minmax(0,1fr)_auto]')}
>
<div className="px-3 pt-3 grid grid-cols-[1fr_auto] items-center -mr-2.5">
<div className="w-full px-3 pt-3 grid grid-cols-[minmax(0,1fr)_auto] items-center -mr-2.5">
{(tree.children?.length ?? 0) > 0 && (
<>
<Input
@@ -551,7 +553,10 @@ const allPotentialChildrenAtom = atom<SidebarModel[]>((get) => {
const memoAllPotentialChildrenAtom = deepEqualAtom(allPotentialChildrenAtom);
const sidebarFilterAtom = atom<{ text: string; key: string }>({ text: '', key: '' });
const sidebarFilterAtom = atom<{ text: string; key: string }>({
text: '',
key: '',
});
const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get) => {
const allModels = get(memoAllPotentialChildrenAtom);
@@ -580,21 +585,24 @@ const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get)
const build = (node: TreeNode<SidebarModel>, depth: number): boolean => {
const childItems = childrenMap[node.item.id] ?? [];
let matchesSelf = true;
const fields = getItemFields(node.item);
const fields = getItemFields(node);
const model = node.item.model;
const isLeafNode = !(model === 'folder' || model === 'workspace');
for (const [field, value] of Object.entries(fields)) {
if (!value) continue;
allFields[field] = allFields[field] ?? new Set();
allFields[field].add(value);
}
if (queryAst != null) {
matchesSelf = evaluate(queryAst, { text: getItemText(node.item), fields });
matchesSelf = isLeafNode && evaluate(queryAst, { text: getItemText(node.item), fields });
}
let matchesChild = false;
// Recurse to children
const m = node.item.model;
node.children = m === 'folder' || m === 'workspace' ? [] : undefined;
node.children = !isLeafNode ? [] : undefined;
if (node.children != null) {
childItems.sort((a, b) => {
@@ -623,7 +631,7 @@ const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get)
const root: TreeNode<SidebarModel> = {
item: activeWorkspace,
parent: null,
parent: null,
children: [],
depth: 0,
};
@@ -633,7 +641,10 @@ const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get)
const fields: FieldDef[] = [];
for (const [name, values] of Object.entries(allFields)) {
fields.push({ name, values: Array.from(values).filter((v) => v.length < 20) });
fields.push({
name,
values: Array.from(values).filter((v) => v.length < 20),
});
}
return [root, fields] as const;
});
@@ -716,7 +727,9 @@ const SidebarInnerItem = memo(function SidebarInnerItem({
);
});
function getItemFields(item: SidebarModel): Record<string, string> {
function getItemFields(node: TreeNode<SidebarModel>): Record<string, string> {
const item = node.item;
if (item.model === 'workspace') return {};
const fields: Record<string, string> = {};
@@ -736,9 +749,20 @@ function getItemFields(item: SidebarModel): Record<string, string> {
if (item.model === 'grpc_request') fields.type = 'grpc';
else if (item.model === 'websocket_request') fields.type = 'ws';
if (node.parent?.item.model === 'folder') {
fields.folder = node.parent.item.name;
}
return fields;
}
function getItemText(item: SidebarModel): string {
return resolvedModelName(item);
const segments = [];
if (item.model === 'http_request') {
segments.push(item.method);
}
segments.push(resolvedModelName(item));
return segments.join(' ');
}

View File

@@ -1,49 +1,36 @@
import {
enableEncryption,
revealWorkspaceKey,
setWorkspaceKey,
} from "@yaakapp-internal/crypto";
import type { WorkspaceMeta } from "@yaakapp-internal/models";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import { useEffect, useState } from "react";
import {
activeWorkspaceAtom,
activeWorkspaceMetaAtom,
} from "../hooks/useActiveWorkspace";
import { createFastMutation } from "../hooks/useFastMutation";
import { useStateWithDeps } from "../hooks/useStateWithDeps";
import { CopyIconButton } from "./CopyIconButton";
import { Banner } from "./core/Banner";
import type { ButtonProps } from "./core/Button";
import { Button } from "./core/Button";
import { IconButton } from "./core/IconButton";
import { IconTooltip } from "./core/IconTooltip";
import { Label } from "./core/Label";
import { PlainInput } from "./core/PlainInput";
import { HStack, VStack } from "./core/Stacks";
import { EncryptionHelp } from "./EncryptionHelp";
import { enableEncryption, revealWorkspaceKey, setWorkspaceKey } from '@yaakapp-internal/crypto';
import type { WorkspaceMeta } from '@yaakapp-internal/models';
import classNames from 'classnames';
import { useAtomValue } from 'jotai';
import { useEffect, useState } from 'react';
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from '../hooks/useActiveWorkspace';
import { createFastMutation } from '../hooks/useFastMutation';
import { useStateWithDeps } from '../hooks/useStateWithDeps';
import { CopyIconButton } from './CopyIconButton';
import { Banner } from './core/Banner';
import type { ButtonProps } from './core/Button';
import { Button } from './core/Button';
import { IconButton } from './core/IconButton';
import { IconTooltip } from './core/IconTooltip';
import { Label } from './core/Label';
import { PlainInput } from './core/PlainInput';
import { HStack, VStack } from './core/Stacks';
import { EncryptionHelp } from './EncryptionHelp';
interface Props {
size?: ButtonProps["size"];
size?: ButtonProps['size'];
expanded?: boolean;
onDone?: () => void;
onEnabledEncryption?: () => void;
}
export function WorkspaceEncryptionSetting(
{ size, expanded, onDone, onEnabledEncryption }: Props,
) {
const [justEnabledEncryption, setJustEnabledEncryption] = useState<boolean>(
false,
);
export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEncryption }: Props) {
const [justEnabledEncryption, setJustEnabledEncryption] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const workspace = useAtomValue(activeWorkspaceAtom);
const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
const [key, setKey] = useState<
{ key: string | null; error: string | null } | null
>(null);
const [key, setKey] = useState<{ key: string | null; error: string | null } | null>(null);
useEffect(() => {
if (workspaceMeta == null) {
@@ -122,7 +109,7 @@ export function WorkspaceEncryptionSetting(
return (
<div className="mb-auto flex flex-col-reverse">
<Button
color={expanded ? "info" : "secondary"}
color={expanded ? 'info' : 'secondary'}
size={size}
onClick={async () => {
setError(null);
@@ -130,30 +117,32 @@ export function WorkspaceEncryptionSetting(
await enableEncryption(workspaceMeta.workspaceId);
setJustEnabledEncryption(true);
} catch (err) {
setError("Failed to enable encryption: " + err);
setError('Failed to enable encryption: ' + err);
}
}}
>
Enable Encryption
</Button>
{error && <Banner color="danger" className="mb-2">{error}</Banner>}
{expanded
? (
<Banner color="info" className="mb-6">
<EncryptionHelp />
</Banner>
)
: (
<Label htmlFor={null} help={<EncryptionHelp />}>
Workspace encryption
</Label>
)}
{error && (
<Banner color="danger" className="mb-2">
{error}
</Banner>
)}
{expanded ? (
<Banner color="info" className="mb-6">
<EncryptionHelp />
</Banner>
) : (
<Label htmlFor={null} help={<EncryptionHelp />}>
Workspace encryption
</Label>
)}
</div>
);
}
const setWorkspaceKeyMut = createFastMutation({
mutationKey: ["set-workspace-key"],
mutationKey: ['set-workspace-key'],
mutationFn: setWorkspaceKey,
});
@@ -166,13 +155,15 @@ function EnterWorkspaceKey({
onEnabled?: () => void;
error?: string | null;
}) {
const [key, setKey] = useState<string>("");
const [key, setKey] = useState<string>('');
return (
<VStack space={4} className="w-full">
{error ? <Banner color="danger">{error}</Banner> : (
{error ? (
<Banner color="danger">{error}</Banner>
) : (
<Banner color="info">
This workspace contains encrypted values but no key is configured.
Please enter the workspace key to access the encrypted data.
This workspace contains encrypted values but no key is configured. Please enter the
workspace key to access the encrypted data.
</Banner>
)}
<HStack
@@ -219,35 +210,24 @@ function KeyRevealer({
return (
<div
className={classNames(
"w-full border border-border rounded-md pl-3 py-2 p-1",
"grid gap-1 grid-cols-[minmax(0,1fr)_auto] items-center",
'w-full border border-border rounded-md pl-3 py-2 p-1',
'grid gap-1 grid-cols-[minmax(0,1fr)_auto] items-center',
)}
>
<VStack space={0.5}>
{!disableLabel && (
<span className="text-sm text-primary flex items-center gap-1">
Workspace encryption key{" "}
<IconTooltip
iconSize="sm"
size="lg"
content={helpAfterEncryption}
/>
Workspace encryption key{' '}
<IconTooltip iconSize="sm" size="lg" content={helpAfterEncryption} />
</span>
)}
{encryptionKey && (
<HighlightedKey
keyText={encryptionKey}
show={show}
/>
)}
{encryptionKey && <HighlightedKey keyText={encryptionKey} show={show} />}
</VStack>
<HStack>
{encryptionKey && (
<CopyIconButton text={encryptionKey} title="Copy workspace key" />
)}
{encryptionKey && <CopyIconButton text={encryptionKey} title="Copy workspace key" />}
<IconButton
title={show ? "Hide" : "Reveal" + "workspace key"}
icon={show ? "eye_closed" : "eye"}
title={show ? 'Hide' : 'Reveal' + 'workspace key'}
icon={show ? 'eye_closed' : 'eye'}
onClick={() => setShow((v) => !v)}
/>
</HStack>
@@ -258,32 +238,31 @@ function KeyRevealer({
function HighlightedKey({ keyText, show }: { keyText: string; show: boolean }) {
return (
<span className="text-xs font-mono [&_*]:cursor-auto [&_*]:select-text">
{show
? (
keyText.split("").map((c, i) => {
return (
<span
key={i}
className={classNames(
c.match(/[0-9]/) && "text-info",
c == "-" && "text-text-subtle",
)}
>
{c}
</span>
);
})
)
: <div className="text-text-subtle"></div>}
{show ? (
keyText.split('').map((c, i) => {
return (
<span
key={i}
className={classNames(
c.match(/[0-9]/) && 'text-info',
c == '-' && 'text-text-subtle',
)}
>
{c}
</span>
);
})
) : (
<div className="text-text-subtle"></div>
)}
</span>
);
}
const helpAfterEncryption = (
<p>
The following key is used for encryption operations within this workspace.
It is stored securely using your OS keychain, but it is recommended to back
it up. If you share this workspace with others, you&apos;ll need to send
them this key to access any encrypted values.
The following key is used for encryption operations within this workspace. It is stored securely
using your OS keychain, but it is recommended to back it up. If you share this workspace with
others, you&apos;ll need to send them this key to access any encrypted values.
</p>
);

View File

@@ -1,8 +1,4 @@
import type {
DragEndEvent,
DragMoveEvent,
DragStartEvent,
} from "@dnd-kit/core";
import type { DragEndEvent, DragMoveEvent, DragStartEvent } from '@dnd-kit/core';
import {
DndContext,
MeasuringStrategy,
@@ -11,16 +7,10 @@ import {
useDroppable,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { type } from "@tauri-apps/plugin-os";
import classNames from "classnames";
import type {
ComponentType,
MouseEvent,
ReactElement,
Ref,
RefAttributes,
} from "react";
} from '@dnd-kit/core';
import { type } from '@tauri-apps/plugin-os';
import classNames from 'classnames';
import type { ComponentType, MouseEvent, ReactElement, Ref, RefAttributes } from 'react';
import {
forwardRef,
memo,
@@ -30,14 +20,14 @@ import {
useMemo,
useRef,
useState,
} from "react";
import { useKey, useKeyPressEvent } from "react-use";
import type { HotkeyAction, HotKeyOptions } from "../../../hooks/useHotKey";
import { useHotKey } from "../../../hooks/useHotKey";
import { computeSideForDragMove } from "../../../lib/dnd";
import { jotaiStore } from "../../../lib/jotai";
import type { ContextMenuProps, DropdownItem } from "../Dropdown";
import { ContextMenu } from "../Dropdown";
} from 'react';
import { useKey, useKeyPressEvent } from 'react-use';
import type { HotkeyAction, HotKeyOptions } from '../../../hooks/useHotKey';
import { useHotKey } from '../../../hooks/useHotKey';
import { computeSideForDragMove } from '../../../lib/dnd';
import { jotaiStore } from '../../../lib/jotai';
import type { ContextMenuProps, DropdownItem } from '../Dropdown';
import { ContextMenu } from '../Dropdown';
import {
collapsedFamily,
draggingIdsFamily,
@@ -45,23 +35,14 @@ import {
hoveredParentFamily,
isCollapsedFamily,
selectedIdsFamily,
} from "./atoms";
import type { SelectableTreeNode, TreeNode } from "./common";
import {
closestVisibleNode,
equalSubtree,
getSelectedItems,
hasAncestor,
} from "./common";
import { TreeDragOverlay } from "./TreeDragOverlay";
import type {
TreeItemClickEvent,
TreeItemHandle,
TreeItemProps,
} from "./TreeItem";
import type { TreeItemListProps } from "./TreeItemList";
import { TreeItemList } from "./TreeItemList";
import { useSelectableItems } from "./useSelectableItems";
} from './atoms';
import type { SelectableTreeNode, TreeNode } from './common';
import { closestVisibleNode, equalSubtree, getSelectedItems, hasAncestor } from './common';
import { TreeDragOverlay } from './TreeDragOverlay';
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 } };
@@ -70,21 +51,15 @@ export interface TreeProps<T extends { id: string }> {
root: TreeNode<T>;
treeId: string;
getItemKey: (item: T) => string;
getContextMenu?: (
items: T[],
) => ContextMenuProps["items"] | Promise<ContextMenuProps["items"]>;
getContextMenu?: (items: T[]) => ContextMenuProps['items'] | Promise<ContextMenuProps['items']>;
ItemInner: ComponentType<{ treeId: string; item: T }>;
ItemLeftSlotInner?: ComponentType<{ treeId: string; item: T }>;
ItemRightSlot?: ComponentType<{ treeId: string; item: T }>;
className?: string;
onActivate?: (item: T) => void;
onDragEnd?: (
opt: { items: T[]; parent: T; children: T[]; insertAt: number },
) => void;
onDragEnd?: (opt: { items: T[]; parent: T; children: T[]; insertAt: number }) => void;
hotkeys?: {
actions: Partial<
Record<HotkeyAction, { cb: (items: T[]) => void } & HotKeyOptions>
>;
actions: Partial<Record<HotkeyAction, { cb: (items: T[]) => void } & HotKeyOptions>>;
};
getEditOptions?: (item: T) => {
defaultValue: string;
@@ -121,24 +96,19 @@ function TreeInner<T extends { id: string }>(
) {
const treeRef = useRef<HTMLDivElement>(null);
const selectableItems = useSelectableItems(root);
const [showContextMenu, setShowContextMenu] = useState<
{
items: DropdownItem[];
x: number;
y: number;
} | null
>(null);
const [showContextMenu, setShowContextMenu] = useState<{
items: DropdownItem[];
x: number;
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];
} else {
treeItemRefs.current[item.id] = r;
}
},
[],
);
const handleAddTreeItemRef = useCallback((item: T, r: TreeItemHandle | null) => {
if (r == null) {
delete treeItemRefs.current[item.id];
} else {
treeItemRefs.current[item.id] = r;
}
}, []);
// Select the first item on first render
useEffect(() => {
@@ -176,8 +146,8 @@ function TreeInner<T extends { id: string }>(
const ensureTabbableItem = useCallback(() => {
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
const lastSelectedItem = selectableItems.find((i) =>
i.node.item.id === lastSelectedId && !i.node.hidden
const lastSelectedItem = selectableItems.find(
(i) => i.node.item.id === lastSelectedId && !i.node.hidden,
);
// If no item found, default to selecting the first item (prefer leaf node);
@@ -224,8 +194,7 @@ function TreeInner<T extends { id: string }>(
() => ({
treeId,
focus: tryFocus,
hasFocus: () =>
treeRef.current?.contains(document.activeElement) ?? false,
hasFocus: () => treeRef.current?.contains(document.activeElement) ?? false,
renameItem: (id) => treeItemRefs.current[id]?.rename(),
selectItem: (id) => {
if (jotaiStore.get(selectedIdsFamily(treeId)).includes(id)) {
@@ -240,9 +209,7 @@ function TreeInner<T extends { id: string }>(
const items = getSelectedItems(treeId, selectableItems);
const menuItems = await getContextMenu(items);
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
const rect = lastSelectedId
? treeItemRefs.current[lastSelectedId]?.rect()
: null;
const rect = lastSelectedId ? treeItemRefs.current[lastSelectedId]?.rect() : null;
if (rect == null) return;
setShowContextMenu({ items: menuItems, x: rect.x, y: rect.y });
},
@@ -264,66 +231,43 @@ function TreeInner<T extends { id: string }>(
// If right-clicked an item that was NOT in the multiple-selection, just use that one
// Also update the selection with it
setSelected([item.id], false);
jotaiStore.set(
focusIdsFamily(treeId),
(prev) => ({ ...prev, lastId: item.id }),
);
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id }));
return getContextMenu([item]);
}
};
}, [getContextMenu, selectableItems, setSelected, treeId]);
const handleSelect = useCallback<NonNullable<TreeItemProps<T>["onClick"]>>(
const handleSelect = useCallback<NonNullable<TreeItemProps<T>['onClick']>>(
(item, { shiftKey, metaKey, ctrlKey }) => {
const anchorSelectedId = jotaiStore.get(focusIdsFamily(treeId)).anchorId;
const selectedIdsAtom = selectedIdsFamily(treeId);
const selectedIds = jotaiStore.get(selectedIdsAtom);
// Mark the item as the last one selected
jotaiStore.set(
focusIdsFamily(treeId),
(prev) => ({ ...prev, lastId: item.id }),
);
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id }));
if (shiftKey) {
const anchorIndex = selectableItems.findIndex((i) =>
i.node.item.id === anchorSelectedId
);
const currIndex = selectableItems.findIndex((v) =>
v.node.item.id === item.id
);
const validSelectableItems = getValidSelectableItems(treeId, selectableItems);
const anchorIndex = validSelectableItems.findIndex((i) => i.node.item.id === anchorSelectedId);
const currIndex = validSelectableItems.findIndex((v) => v.node.item.id === item.id);
// Nothing was selected yet, so just select this item
if (
selectedIds.length === 0 || anchorIndex === -1 || currIndex === -1
) {
if (selectedIds.length === 0 || anchorIndex === -1 || currIndex === -1) {
setSelected([item.id], true);
jotaiStore.set(
focusIdsFamily(treeId),
(prev) => ({ ...prev, anchorId: item.id }),
);
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, anchorId: item.id }));
return;
}
const validSelectableItems = getValidSelectableItems(
treeId,
selectableItems,
);
if (currIndex > anchorIndex) {
// Selecting down
const itemsToSelect = validSelectableItems.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 = validSelectableItems.slice(
currIndex,
anchorIndex + 1,
);
const itemsToSelect = validSelectableItems.slice(currIndex, anchorIndex + 1);
setSelected(
itemsToSelect.map((v) => v.node.item.id),
true,
@@ -331,7 +275,7 @@ function TreeInner<T extends { id: string }>(
} else {
setSelected([item.id], true);
}
} else if (type() === "macos" ? metaKey : ctrlKey) {
} else if (type() === 'macos' ? metaKey : ctrlKey) {
const withoutCurr = selectedIds.filter((id) => id !== item.id);
if (withoutCurr.length === selectedIds.length) {
// It wasn't in there, so add it
@@ -343,16 +287,13 @@ function TreeInner<T extends { id: string }>(
} else {
// Select single
setSelected([item.id], true);
jotaiStore.set(
focusIdsFamily(treeId),
(prev) => ({ ...prev, anchorId: item.id }),
);
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, anchorId: item.id }));
}
},
[selectableItems, setSelected, treeId],
);
const handleClick = useCallback<NonNullable<TreeItemProps<T>["onClick"]>>(
const handleClick = useCallback<NonNullable<TreeItemProps<T>['onClick']>>(
(item, e) => {
if (e.shiftKey || e.ctrlKey || e.metaKey) {
handleSelect(item, e);
@@ -367,13 +308,8 @@ function TreeInner<T extends { id: string }>(
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 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);
@@ -385,13 +321,8 @@ function TreeInner<T extends { id: string }>(
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 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);
@@ -404,8 +335,7 @@ function TreeInner<T extends { id: string }>(
(e: TreeItemClickEvent) => {
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
const lastSelectedItem =
selectableItems.find((i) => i.node.item.id === lastSelectedId)?.node ??
null;
selectableItems.find((i) => i.node.item.id === lastSelectedId)?.node ?? null;
if (lastSelectedItem?.parent != null) {
handleSelect(lastSelectedItem.parent.item, e);
}
@@ -414,7 +344,7 @@ function TreeInner<T extends { id: string }>(
);
useKey(
(e) => e.key === "ArrowUp" || e.key.toLowerCase() === "k",
(e) => e.key === 'ArrowUp' || e.key.toLowerCase() === 'k',
(e) => {
if (!isTreeFocused()) return;
e.preventDefault();
@@ -425,7 +355,7 @@ function TreeInner<T extends { id: string }>(
);
useKey(
(e) => e.key === "ArrowDown" || e.key.toLowerCase() === "j",
(e) => e.key === 'ArrowDown' || e.key.toLowerCase() === 'j',
(e) => {
if (!isTreeFocused()) return;
e.preventDefault();
@@ -437,26 +367,21 @@ function TreeInner<T extends { id: string }>(
// If the selected item is a collapsed folder, expand it. Otherwise, select next item
useKey(
(e) => e.key === "ArrowRight" || e.key === "l",
(e) => e.key === 'ArrowRight' || e.key === 'l',
(e) => {
if (!isTreeFocused()) 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
);
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,
);
jotaiStore.set(isCollapsedFamily({ treeId, itemId: lastSelectedId }), false);
} else {
selectNextItem(e);
}
@@ -468,26 +393,21 @@ function TreeInner<T extends { id: string }>(
// If the selected item is in a folder, select its parent.
// If the selected item is an expanded folder, collapse it.
useKey(
(e) => e.key === "ArrowLeft" || e.key === "h",
(e) => e.key === 'ArrowLeft' || e.key === 'h',
(e) => {
if (!isTreeFocused()) 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
);
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,
);
jotaiStore.set(isCollapsedFamily({ treeId, itemId: lastSelectedId }), true);
} else {
selectParentItem(e);
}
@@ -496,7 +416,7 @@ function TreeInner<T extends { id: string }>(
[selectableItems, handleSelect],
);
useKeyPressEvent("Escape", async () => {
useKeyPressEvent('Escape', async () => {
if (!treeRef.current?.contains(document.activeElement)) return;
clearDragState();
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
@@ -535,22 +455,19 @@ function TreeInner<T extends { id: string }>(
return;
}
const overSelectableItem =
selectableItems.find((i) => i.node.item.id === over.id) ?? null;
const overSelectableItem = selectableItems.find((i) => i.node.item.id === over.id) ?? null;
if (overSelectableItem == null) {
return;
}
const draggingItems = jotaiStore.get(draggingIdsFamily(treeId));
for (const id of draggingItems) {
const item = selectableItems.find((i) => i.node.item.id === id)?.node ??
null;
const item = selectableItems.find((i) => i.node.item.id === id)?.node ?? null;
if (item == null) {
return;
}
const isSameParent =
item.parent?.item.id === overSelectableItem.node.parent?.item.id;
const isSameParent = item.parent?.item.id === overSelectableItem.node.parent?.item.id;
if (item.localDrag && !isSameParent) {
return;
}
@@ -561,15 +478,13 @@ function TreeInner<T extends { id: string }>(
const item = node.item;
let hoveredParent = node.parent;
const dragIndex =
selectableItems.findIndex((n) => n.node.item.id === item.id) ?? -1;
const dragIndex = selectableItems.findIndex((n) => n.node.item.id === item.id) ?? -1;
const hovered = selectableItems[dragIndex]?.node ?? null;
const hoveredIndex = dragIndex + (side === "above" ? 0 : 1);
let hoveredChildIndex = overSelectableItem.index +
(side === "above" ? 0 : 1);
const hoveredIndex = dragIndex + (side === 'above' ? 0 : 1);
let hoveredChildIndex = overSelectableItem.index + (side === 'above' ? 0 : 1);
// Move into the folder if it's open and we're moving below it
if (hovered?.children != null && side === "below") {
if (hovered?.children != null && side === 'below') {
hoveredParent = hovered;
hoveredChildIndex = 0;
}
@@ -601,9 +516,7 @@ function TreeInner<T extends { id: string }>(
const handleDragStart = useCallback(
function handleDragStart(e: DragStartEvent) {
const selectedItems = getSelectedItems(treeId, selectableItems);
const isDraggingSelectedItem = selectedItems.find((i) =>
i.id === e.active.id
);
const isDraggingSelectedItem = selectedItems.find((i) => i.id === e.active.id);
// If we started dragging an already-selected item, we'll use that
if (isDraggingSelectedItem) {
@@ -613,9 +526,7 @@ function TreeInner<T extends { id: string }>(
);
} else {
// If we started dragging a non-selected item, only drag that item
const activeItem = selectableItems.find((i) =>
i.node.item.id === e.active.id
)?.node.item;
const activeItem = selectableItems.find((i) => i.node.item.id === e.active.id)?.node.item;
if (activeItem != null) {
jotaiStore.set(draggingIdsFamily(treeId), [activeItem.id]);
// Also update selection to just be this one
@@ -656,30 +567,25 @@ function TreeInner<T extends { id: string }>(
return;
}
const hoveredParentS = hoveredParentId === root.item.id
? { node: root, depth: 0, index: 0 }
: (selectableItems.find((i) => i.node.item.id === hoveredParentId) ??
null);
const hoveredParentS =
hoveredParentId === root.item.id
? { node: root, depth: 0, index: 0 }
: (selectableItems.find((i) => i.node.item.id === hoveredParentId) ?? null);
const hoveredParent = hoveredParentS?.node ?? null;
if (
hoveredParent == null || hoveredIndex == null || !draggingItems?.length
) {
if (hoveredParent == null || hoveredIndex == null || !draggingItems?.length) {
return;
}
// Resolve the actual tree nodes for each dragged item (keeps order of draggingItems)
const draggedNodes: TreeNode<T>[] = draggingItems
.map((id) => {
return selectableItems.find((i) => i.node.item.id === id)?.node ??
null;
return selectableItems.find((i) => i.node.item.id === id)?.node ?? null;
})
.filter((n) => n != null)
// Filter out invalid drags (dragging into descendant)
.filter(
(n) =>
hoveredParent.item.id !== n.item.id &&
!hasAncestor(hoveredParent, n.item.id),
(n) => hoveredParent.item.id !== n.item.id && !hasAncestor(hoveredParent, n.item.id),
);
// Work on a local copy of target children
@@ -708,7 +614,7 @@ function TreeInner<T extends { id: string }>(
const treeItemListProps: Omit<
TreeItemListProps<T>,
"nodes" | "treeId" | "activeIdAtom" | "hoveredParent" | "hoveredIndex"
'nodes' | 'treeId' | 'activeIdAtom' | 'hoveredParent' | 'hoveredIndex'
> = {
getItemKey,
getContextMenu: handleGetContextMenu,
@@ -731,17 +637,11 @@ function TreeInner<T extends { id: string }>(
[getContextMenu],
);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
);
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
return (
<>
<TreeHotKeys
treeId={treeId}
hotkeys={hotkeys}
selectableItems={selectableItems}
/>
<TreeHotKeys treeId={treeId} hotkeys={hotkeys} selectableItems={selectableItems} />
{showContextMenu && (
<ContextMenu
items={showContextMenu.items}
@@ -764,23 +664,23 @@ function TreeInner<T extends { id: string }>(
ref={treeRef}
className={classNames(
className,
"outline-none h-full",
"overflow-y-auto overflow-x-hidden",
"grid grid-rows-[auto_1fr]",
'outline-none h-full',
'overflow-y-auto overflow-x-hidden',
'grid grid-rows-[auto_1fr]',
)}
>
<div
className={classNames(
"[&_.tree-item.selected_.tree-item-inner]:text-text",
"[&:focus-within]:[&_.tree-item.selected]:bg-surface-active",
"[&:not(:focus-within)]:[&_.tree-item.selected]:bg-surface-highlight",
'[&_.tree-item.selected_.tree-item-inner]:text-text',
'[&:focus-within]:[&_.tree-item.selected]:bg-surface-active',
'[&:not(:focus-within)]:[&_.tree-item.selected]:bg-surface-highlight',
// Round the items, but only if the ends of the selection.
// Also account for the drop marker being in between items
"[&_.tree-item]:rounded-md",
"[&_.tree-item.selected+.tree-item.selected]:rounded-t-none",
"[&_.tree-item.selected+.drop-marker+.tree-item.selected]:rounded-t-none",
"[&_.tree-item.selected:has(+.tree-item.selected)]:rounded-b-none",
"[&_.tree-item.selected:has(+.drop-marker+.tree-item.selected)]:rounded-b-none",
'[&_.tree-item]:rounded-md',
'[&_.tree-item.selected+.tree-item.selected]:rounded-t-none',
'[&_.tree-item.selected+.drop-marker+.tree-item.selected]:rounded-t-none',
'[&_.tree-item.selected:has(+.tree-item.selected)]:rounded-b-none',
'[&_.tree-item.selected:has(+.drop-marker+.tree-item.selected)]:rounded-b-none',
)}
>
<TreeItemList
@@ -791,10 +691,7 @@ function TreeInner<T extends { id: string }>(
/>
</div>
{/* Assign root ID so we can reuse our same move/end logic */}
<DropRegionAfterList
id={root.item.id}
onContextMenu={handleContextMenu}
/>
<DropRegionAfterList id={root.item.id} onContextMenu={handleContextMenu} />
</div>
<TreeDragOverlay
treeId={treeId}
@@ -816,10 +713,7 @@ export const Tree = memo(
Tree_,
({ root: prevNode, ...prevProps }, { root: nextNode, ...nextProps }) => {
for (const key of Object.keys(prevProps)) {
if (
prevProps[key as keyof typeof prevProps] !==
nextProps[key as keyof typeof nextProps]
) {
if (prevProps[key as keyof typeof prevProps] !== nextProps[key as keyof typeof nextProps]) {
return false;
}
}
@@ -864,7 +758,7 @@ function TreeHotKey<T extends { id: string }>({
...options,
enable: () => {
if (enable == null) return true;
if (typeof enable === "function") return enable();
if (typeof enable === 'function') return enable();
else return enable;
},
},
@@ -878,7 +772,7 @@ function TreeHotKeys<T extends { id: string }>({
selectableItems,
}: {
treeId: string;
hotkeys: TreeProps<T>["hotkeys"];
hotkeys: TreeProps<T>['hotkeys'];
selectableItems: SelectableTreeNode<T>[];
}) {
if (hotkeys == null) return null;