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

@@ -86,7 +86,7 @@ impl YaakNotifier {
#[cfg(feature = "license")] #[cfg(feature = "license")]
let license_check = { let license_check = {
use yaak_license::{check_license, LicenseCheckStatus}; use yaak_license::{LicenseCheckStatus, check_license};
match check_license(window).await { match check_license(window).await {
Ok(LicenseCheckStatus::PersonalUse { .. }) => "personal".to_string(), Ok(LicenseCheckStatus::PersonalUse { .. }) => "personal".to_string(),
Ok(LicenseCheckStatus::CommercialUse) => "commercial".to_string(), Ok(LicenseCheckStatus::CommercialUse) => "commercial".to_string(),

View File

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

View File

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

View File

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

View File

@@ -21,8 +21,7 @@ import {
import classNames from 'classnames'; import classNames from 'classnames';
import { atom, useAtomValue } from 'jotai'; import { atom, useAtomValue } from 'jotai';
import { selectAtom } from 'jotai/utils'; import { selectAtom } from 'jotai/utils';
import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react'; import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { useKey } from 'react-use';
import { moveToWorkspace } from '../commands/moveToWorkspace'; import { moveToWorkspace } from '../commands/moveToWorkspace';
import { openFolderSettings } from '../commands/openFolderSettings'; import { openFolderSettings } from '../commands/openFolderSettings';
import { activeCookieJarAtom } from '../hooks/useActiveCookieJar'; import { activeCookieJarAtom } from '../hooks/useActiveCookieJar';
@@ -150,33 +149,31 @@ function Sidebar({ className }: { className?: string }) {
await Promise.all( await Promise.all(
items.map((m, i) => items.map((m, i) =>
// Spread item sortPriority out over before/after range // 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) { } catch (e) {
console.error(e); console.error(e);
} }
}, []); }, []);
const handleTreeRefInit = useCallback((n: TreeHandle) => { const handleTreeRefInit = useCallback(
treeRef.current = n; (n: TreeHandle) => {
if (n == null) return; treeRef.current = n;
const activeId = jotaiStore.get(activeIdAtom); if (n == null) return;
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 activeId = jotaiStore.get(activeIdAtom); const activeId = jotaiStore.get(activeIdAtom);
if (activeId == null) return; 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(() => { const clearFilterText = useCallback(() => {
jotaiStore.set(sidebarFilterAtom, { text: '', key: `${Math.random()}` }); 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 actions = useMemo(() => {
const enable = () => treeRef.current?.hasFocus() ?? false; const enable = () => treeRef.current?.hasFocus() ?? false;
@@ -291,7 +280,11 @@ function Sidebar({ className }: { className?: string }) {
// No children means we're in the root // No children means we're in the root
if (child == null) { if (child == null) {
return getCreateDropdownItems({ workspaceId, activeRequest: null, folderId: null }); return getCreateDropdownItems({
workspaceId,
activeRequest: null,
folderId: null,
});
} }
const workspaces = jotaiStore.get(workspacesAtom); const workspaces = jotaiStore.get(workspacesAtom);
@@ -355,12 +348,19 @@ function Sidebar({ className }: { className?: string }) {
items.length === 1 && child.model === 'folder' items.length === 1 && child.model === 'folder'
? [ ? [
{ type: 'separator' }, { type: 'separator' },
...getCreateDropdownItems({ workspaceId, activeRequest: null, folderId: child.id }), ...getCreateDropdownItems({
workspaceId,
activeRequest: null,
folderId: child.id,
}),
] ]
: []; : [];
const menuItems: ContextMenuProps['items'] = [ const menuItems: ContextMenuProps['items'] = [
...initialItems, ...initialItems,
{ type: 'separator', hidden: initialItems.filter((v) => !v.hidden).length === 0 }, {
type: 'separator',
hidden: initialItems.filter((v) => !v.hidden).length === 0,
},
{ {
label: 'Rename', label: 'Rename',
leftSlot: <Icon icon="pencil" />, leftSlot: <Icon icon="pencil" />,
@@ -421,7 +421,9 @@ function Sidebar({ className }: { className?: string }) {
const view = filterRef.current; const view = filterRef.current;
if (!view) return; if (!view) return;
const ext = filter({ fields: allFields ?? [] }); const ext = filter({ fields: allFields ?? [] });
view.dispatch({ effects: filterLanguageCompartmentRef.current.reconfigure(ext) }); view.dispatch({
effects: filterLanguageCompartmentRef.current.reconfigure(ext),
});
}, [allFields]); }, [allFields]);
if (tree == null || hidden) { if (tree == null || hidden) {
@@ -434,7 +436,7 @@ function Sidebar({ className }: { className?: string }) {
aria-hidden={hidden ?? undefined} aria-hidden={hidden ?? undefined}
className={classNames(className, 'h-full grid grid-rows-[auto_minmax(0,1fr)_auto]')} 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 && ( {(tree.children?.length ?? 0) > 0 && (
<> <>
<Input <Input
@@ -551,7 +553,10 @@ const allPotentialChildrenAtom = atom<SidebarModel[]>((get) => {
const memoAllPotentialChildrenAtom = deepEqualAtom(allPotentialChildrenAtom); 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 sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get) => {
const allModels = get(memoAllPotentialChildrenAtom); 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 build = (node: TreeNode<SidebarModel>, depth: number): boolean => {
const childItems = childrenMap[node.item.id] ?? []; const childItems = childrenMap[node.item.id] ?? [];
let matchesSelf = true; 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)) { for (const [field, value] of Object.entries(fields)) {
if (!value) continue; if (!value) continue;
allFields[field] = allFields[field] ?? new Set(); allFields[field] = allFields[field] ?? new Set();
allFields[field].add(value); allFields[field].add(value);
} }
if (queryAst != null) { if (queryAst != null) {
matchesSelf = evaluate(queryAst, { text: getItemText(node.item), fields }); matchesSelf = isLeafNode && evaluate(queryAst, { text: getItemText(node.item), fields });
} }
let matchesChild = false; let matchesChild = false;
// Recurse to children // Recurse to children
const m = node.item.model; node.children = !isLeafNode ? [] : undefined;
node.children = m === 'folder' || m === 'workspace' ? [] : undefined;
if (node.children != null) { if (node.children != null) {
childItems.sort((a, b) => { childItems.sort((a, b) => {
@@ -623,7 +631,7 @@ const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get)
const root: TreeNode<SidebarModel> = { const root: TreeNode<SidebarModel> = {
item: activeWorkspace, item: activeWorkspace,
parent: null, parent: null,
children: [], children: [],
depth: 0, depth: 0,
}; };
@@ -633,7 +641,10 @@ const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get)
const fields: FieldDef[] = []; const fields: FieldDef[] = [];
for (const [name, values] of Object.entries(allFields)) { 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; 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 {}; if (item.model === 'workspace') return {};
const fields: Record<string, string> = {}; const fields: Record<string, string> = {};
@@ -736,9 +749,20 @@ function getItemFields(item: SidebarModel): Record<string, string> {
if (item.model === 'grpc_request') fields.type = 'grpc'; if (item.model === 'grpc_request') fields.type = 'grpc';
else if (item.model === 'websocket_request') fields.type = 'ws'; else if (item.model === 'websocket_request') fields.type = 'ws';
if (node.parent?.item.model === 'folder') {
fields.folder = node.parent.item.name;
}
return fields; return fields;
} }
function getItemText(item: SidebarModel): string { 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 { import { enableEncryption, revealWorkspaceKey, setWorkspaceKey } from '@yaakapp-internal/crypto';
enableEncryption, import type { WorkspaceMeta } from '@yaakapp-internal/models';
revealWorkspaceKey, import classNames from 'classnames';
setWorkspaceKey, import { useAtomValue } from 'jotai';
} from "@yaakapp-internal/crypto"; import { useEffect, useState } from 'react';
import type { WorkspaceMeta } from "@yaakapp-internal/models"; import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from '../hooks/useActiveWorkspace';
import classNames from "classnames"; import { createFastMutation } from '../hooks/useFastMutation';
import { useAtomValue } from "jotai"; import { useStateWithDeps } from '../hooks/useStateWithDeps';
import { useEffect, useState } from "react"; import { CopyIconButton } from './CopyIconButton';
import { import { Banner } from './core/Banner';
activeWorkspaceAtom, import type { ButtonProps } from './core/Button';
activeWorkspaceMetaAtom, import { Button } from './core/Button';
} from "../hooks/useActiveWorkspace"; import { IconButton } from './core/IconButton';
import { createFastMutation } from "../hooks/useFastMutation"; import { IconTooltip } from './core/IconTooltip';
import { useStateWithDeps } from "../hooks/useStateWithDeps"; import { Label } from './core/Label';
import { CopyIconButton } from "./CopyIconButton"; import { PlainInput } from './core/PlainInput';
import { Banner } from "./core/Banner"; import { HStack, VStack } from './core/Stacks';
import type { ButtonProps } from "./core/Button"; import { EncryptionHelp } from './EncryptionHelp';
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 { interface Props {
size?: ButtonProps["size"]; size?: ButtonProps['size'];
expanded?: boolean; expanded?: boolean;
onDone?: () => void; onDone?: () => void;
onEnabledEncryption?: () => void; onEnabledEncryption?: () => void;
} }
export function WorkspaceEncryptionSetting( export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEncryption }: Props) {
{ size, expanded, onDone, onEnabledEncryption }: Props, const [justEnabledEncryption, setJustEnabledEncryption] = useState<boolean>(false);
) {
const [justEnabledEncryption, setJustEnabledEncryption] = useState<boolean>(
false,
);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const workspace = useAtomValue(activeWorkspaceAtom); const workspace = useAtomValue(activeWorkspaceAtom);
const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom); const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
const [key, setKey] = useState< const [key, setKey] = useState<{ key: string | null; error: string | null } | null>(null);
{ key: string | null; error: string | null } | null
>(null);
useEffect(() => { useEffect(() => {
if (workspaceMeta == null) { if (workspaceMeta == null) {
@@ -122,7 +109,7 @@ export function WorkspaceEncryptionSetting(
return ( return (
<div className="mb-auto flex flex-col-reverse"> <div className="mb-auto flex flex-col-reverse">
<Button <Button
color={expanded ? "info" : "secondary"} color={expanded ? 'info' : 'secondary'}
size={size} size={size}
onClick={async () => { onClick={async () => {
setError(null); setError(null);
@@ -130,30 +117,32 @@ export function WorkspaceEncryptionSetting(
await enableEncryption(workspaceMeta.workspaceId); await enableEncryption(workspaceMeta.workspaceId);
setJustEnabledEncryption(true); setJustEnabledEncryption(true);
} catch (err) { } catch (err) {
setError("Failed to enable encryption: " + err); setError('Failed to enable encryption: ' + err);
} }
}} }}
> >
Enable Encryption Enable Encryption
</Button> </Button>
{error && <Banner color="danger" className="mb-2">{error}</Banner>} {error && (
{expanded <Banner color="danger" className="mb-2">
? ( {error}
<Banner color="info" className="mb-6"> </Banner>
<EncryptionHelp /> )}
</Banner> {expanded ? (
) <Banner color="info" className="mb-6">
: ( <EncryptionHelp />
<Label htmlFor={null} help={<EncryptionHelp />}> </Banner>
Workspace encryption ) : (
</Label> <Label htmlFor={null} help={<EncryptionHelp />}>
)} Workspace encryption
</Label>
)}
</div> </div>
); );
} }
const setWorkspaceKeyMut = createFastMutation({ const setWorkspaceKeyMut = createFastMutation({
mutationKey: ["set-workspace-key"], mutationKey: ['set-workspace-key'],
mutationFn: setWorkspaceKey, mutationFn: setWorkspaceKey,
}); });
@@ -166,13 +155,15 @@ function EnterWorkspaceKey({
onEnabled?: () => void; onEnabled?: () => void;
error?: string | null; error?: string | null;
}) { }) {
const [key, setKey] = useState<string>(""); const [key, setKey] = useState<string>('');
return ( return (
<VStack space={4} className="w-full"> <VStack space={4} className="w-full">
{error ? <Banner color="danger">{error}</Banner> : ( {error ? (
<Banner color="danger">{error}</Banner>
) : (
<Banner color="info"> <Banner color="info">
This workspace contains encrypted values but no key is configured. This workspace contains encrypted values but no key is configured. Please enter the
Please enter the workspace key to access the encrypted data. workspace key to access the encrypted data.
</Banner> </Banner>
)} )}
<HStack <HStack
@@ -219,35 +210,24 @@ function KeyRevealer({
return ( return (
<div <div
className={classNames( className={classNames(
"w-full border border-border rounded-md pl-3 py-2 p-1", 'w-full border border-border rounded-md pl-3 py-2 p-1',
"grid gap-1 grid-cols-[minmax(0,1fr)_auto] items-center", 'grid gap-1 grid-cols-[minmax(0,1fr)_auto] items-center',
)} )}
> >
<VStack space={0.5}> <VStack space={0.5}>
{!disableLabel && ( {!disableLabel && (
<span className="text-sm text-primary flex items-center gap-1"> <span className="text-sm text-primary flex items-center gap-1">
Workspace encryption key{" "} Workspace encryption key{' '}
<IconTooltip <IconTooltip iconSize="sm" size="lg" content={helpAfterEncryption} />
iconSize="sm"
size="lg"
content={helpAfterEncryption}
/>
</span> </span>
)} )}
{encryptionKey && ( {encryptionKey && <HighlightedKey keyText={encryptionKey} show={show} />}
<HighlightedKey
keyText={encryptionKey}
show={show}
/>
)}
</VStack> </VStack>
<HStack> <HStack>
{encryptionKey && ( {encryptionKey && <CopyIconButton text={encryptionKey} title="Copy workspace key" />}
<CopyIconButton text={encryptionKey} title="Copy workspace key" />
)}
<IconButton <IconButton
title={show ? "Hide" : "Reveal" + "workspace key"} title={show ? 'Hide' : 'Reveal' + 'workspace key'}
icon={show ? "eye_closed" : "eye"} icon={show ? 'eye_closed' : 'eye'}
onClick={() => setShow((v) => !v)} onClick={() => setShow((v) => !v)}
/> />
</HStack> </HStack>
@@ -258,32 +238,31 @@ function KeyRevealer({
function HighlightedKey({ keyText, show }: { keyText: string; show: boolean }) { function HighlightedKey({ keyText, show }: { keyText: string; show: boolean }) {
return ( return (
<span className="text-xs font-mono [&_*]:cursor-auto [&_*]:select-text"> <span className="text-xs font-mono [&_*]:cursor-auto [&_*]:select-text">
{show {show ? (
? ( keyText.split('').map((c, i) => {
keyText.split("").map((c, i) => { return (
return ( <span
<span key={i}
key={i} className={classNames(
className={classNames( c.match(/[0-9]/) && 'text-info',
c.match(/[0-9]/) && "text-info", c == '-' && 'text-text-subtle',
c == "-" && "text-text-subtle", )}
)} >
> {c}
{c} </span>
</span> );
); })
}) ) : (
) <div className="text-text-subtle"></div>
: <div className="text-text-subtle"></div>} )}
</span> </span>
); );
} }
const helpAfterEncryption = ( const helpAfterEncryption = (
<p> <p>
The following key is used for encryption operations within this workspace. The following key is used for encryption operations within this workspace. It is stored securely
It is stored securely using your OS keychain, but it is recommended to back using your OS keychain, but it is recommended to back it up. If you share this workspace with
it up. If you share this workspace with others, you&apos;ll need to send others, you&apos;ll need to send them this key to access any encrypted values.
them this key to access any encrypted values.
</p> </p>
); );

View File

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

View File

@@ -4,7 +4,18 @@ import { atom, useAtomValue } from 'jotai';
export const environmentsBreakdownAtom = atom((get) => { export const environmentsBreakdownAtom = atom((get) => {
const allEnvironments = get(environmentsAtom); const allEnvironments = get(environmentsAtom);
const baseEnvironments = allEnvironments.filter((e) => e.parentModel === 'workspace') ?? []; const baseEnvironments = allEnvironments.filter((e) => e.parentModel === 'workspace') ?? [];
const subEnvironments = allEnvironments.filter((e) => e.parentModel === 'environment') ?? [];
const subEnvironments =
allEnvironments
.filter((e) => e.parentModel === 'environment')
?.sort((a, b) => {
if (a.sortPriority === b.sortPriority) {
return a.updatedAt > b.updatedAt ? 1 : -1;
} else {
return a.sortPriority - b.sortPriority;
}
}) ?? [];
const folderEnvironments = const folderEnvironments =
allEnvironments.filter((e) => e.parentModel === 'folder' && e.parentId != null) ?? []; allEnvironments.filter((e) => e.parentModel === 'folder' && e.parentId != null) ?? [];

View File

@@ -4,7 +4,6 @@ import { useEffect, useMemo } from 'react';
import { jotaiStore } from '../lib/jotai'; import { jotaiStore } from '../lib/jotai';
import { getKeyValue, setKeyValue } from '../lib/keyValueStore'; import { getKeyValue, setKeyValue } from '../lib/keyValueStore';
import { activeCookieJarAtom } from './useActiveCookieJar'; import { activeCookieJarAtom } from './useActiveCookieJar';
import { activeWorkspaceIdAtom } from './useActiveWorkspace';
import { useKeyValue } from './useKeyValue'; import { useKeyValue } from './useKeyValue';
const kvKey = (workspaceId: string) => 'recent_cookie_jars::' + workspaceId; const kvKey = (workspaceId: string) => 'recent_cookie_jars::' + workspaceId;
@@ -13,9 +12,8 @@ const fallback: string[] = [];
export function useRecentCookieJars() { export function useRecentCookieJars() {
const cookieJars = useAtomValue(cookieJarsAtom); const cookieJars = useAtomValue(cookieJarsAtom);
const activeWorkspaceId = useAtomValue(activeWorkspaceIdAtom);
const kv = useKeyValue<string[]>({ const kv = useKeyValue<string[]>({
key: kvKey(activeWorkspaceId ?? 'n/a'), key: kvKey(cookieJars[0]?.workspaceId ?? 'n/a'),
namespace, namespace,
fallback, fallback,
}); });
@@ -31,18 +29,16 @@ export function useRecentCookieJars() {
export function useSubscribeRecentCookieJars() { export function useSubscribeRecentCookieJars() {
useEffect(() => { useEffect(() => {
return jotaiStore.sub(activeCookieJarAtom, async () => { return jotaiStore.sub(activeCookieJarAtom, async () => {
const activeWorkspaceId = jotaiStore.get(activeWorkspaceIdAtom); const activeCookieJar = jotaiStore.get(activeCookieJarAtom);
const activeCookieJarId = jotaiStore.get(activeCookieJarAtom)?.id ?? null; if (activeCookieJar == null) return;
if (activeWorkspaceId == null) return;
if (activeCookieJarId == null) return;
const key = kvKey(activeWorkspaceId); const key = kvKey(activeCookieJar.workspaceId);
const recentIds = getKeyValue<string[]>({ namespace, key, fallback }); const recentIds = getKeyValue<string[]>({ namespace, key, fallback });
if (recentIds[0] === activeCookieJarId) return; // Short-circuit if (recentIds[0] === activeCookieJar.id) return; // Short-circuit
const withoutActiveId = recentIds.filter((id) => id !== activeCookieJarId); const withoutActiveId = recentIds.filter((id) => id !== activeCookieJar.id);
const value = [activeCookieJarId, ...withoutActiveId]; const value = [activeCookieJar.id, ...withoutActiveId];
await setKeyValue({ namespace, key, value }); await setKeyValue({ namespace, key, value });
}); });
}, []); }, []);

View File

@@ -1,9 +1,7 @@
import { useAtomValue } from 'jotai';
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo } from 'react';
import { jotaiStore } from '../lib/jotai'; import { jotaiStore } from '../lib/jotai';
import { getKeyValue, setKeyValue } from '../lib/keyValueStore'; import { getKeyValue, setKeyValue } from '../lib/keyValueStore';
import { activeEnvironmentIdAtom } from './useActiveEnvironment'; import { activeEnvironmentAtom } from './useActiveEnvironment';
import { activeWorkspaceAtom, activeWorkspaceIdAtom } from './useActiveWorkspace';
import { useEnvironmentsBreakdown } from './useEnvironmentsBreakdown'; import { useEnvironmentsBreakdown } from './useEnvironmentsBreakdown';
import { useKeyValue } from './useKeyValue'; import { useKeyValue } from './useKeyValue';
@@ -12,10 +10,9 @@ const namespace = 'global';
const fallback: string[] = []; const fallback: string[] = [];
export function useRecentEnvironments() { export function useRecentEnvironments() {
const { subEnvironments } = useEnvironmentsBreakdown(); const { subEnvironments, allEnvironments } = useEnvironmentsBreakdown();
const activeWorkspace = useAtomValue(activeWorkspaceAtom);
const kv = useKeyValue<string[]>({ const kv = useKeyValue<string[]>({
key: kvKey(activeWorkspace?.id ?? 'n/a'), key: kvKey(allEnvironments[0]?.workspaceId ?? 'n/a'),
namespace, namespace,
fallback, fallback,
}); });
@@ -30,19 +27,16 @@ export function useRecentEnvironments() {
export function useSubscribeRecentEnvironments() { export function useSubscribeRecentEnvironments() {
useEffect(() => { useEffect(() => {
return jotaiStore.sub(activeEnvironmentIdAtom, async () => { return jotaiStore.sub(activeEnvironmentAtom, async () => {
const activeWorkspaceId = jotaiStore.get(activeWorkspaceIdAtom); const activeEnvironment = jotaiStore.get(activeEnvironmentAtom);
const activeEnvironmentId = jotaiStore.get(activeEnvironmentIdAtom); if (activeEnvironment == null) return;
if (activeWorkspaceId == null) return;
if (activeEnvironmentId == null) return;
const key = kvKey(activeWorkspaceId);
const key = kvKey(activeEnvironment.workspaceId);
const recentIds = getKeyValue<string[]>({ namespace, key, fallback }); const recentIds = getKeyValue<string[]>({ namespace, key, fallback });
if (recentIds[0] === activeEnvironmentId) return; // Short-circuit if (recentIds[0] === activeEnvironment.id) return; // Short-circuit
const withoutActiveId = recentIds.filter((id) => id !== activeEnvironmentId); const withoutActiveId = recentIds.filter((id) => id !== activeEnvironment.id);
const value = [activeEnvironmentId, ...withoutActiveId]; const value = [activeEnvironment.id, ...withoutActiveId];
await setKeyValue({ namespace, key, value }); await setKeyValue({ namespace, key, value });
}); });
}, []); }, []);

View File

@@ -1,11 +1,9 @@
import { useAtomValue } from 'jotai';
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo } from 'react';
import { jotaiStore } from '../lib/jotai'; import { jotaiStore } from '../lib/jotai';
import { getKeyValue, setKeyValue } from '../lib/keyValueStore'; import { getKeyValue, setKeyValue } from '../lib/keyValueStore';
import { activeRequestIdAtom } from './useActiveRequestId';
import { activeWorkspaceIdAtom } from './useActiveWorkspace';
import { useKeyValue } from './useKeyValue'; import { useKeyValue } from './useKeyValue';
import { useAllRequests } from './useAllRequests'; import { useAllRequests } from './useAllRequests';
import { activeRequestAtom } from './useActiveRequest';
const kvKey = (workspaceId: string) => 'recent_requests::' + workspaceId; const kvKey = (workspaceId: string) => 'recent_requests::' + workspaceId;
const namespace = 'global'; const namespace = 'global';
@@ -13,10 +11,9 @@ const fallback: string[] = [];
export function useRecentRequests() { export function useRecentRequests() {
const requests = useAllRequests(); const requests = useAllRequests();
const activeWorkspaceId = useAtomValue(activeWorkspaceIdAtom);
const { set: setRecentRequests, value: recentRequests } = useKeyValue<string[]>({ const { set: setRecentRequests, value: recentRequests } = useKeyValue<string[]>({
key: kvKey(activeWorkspaceId ?? 'n/a'), key: kvKey(requests[0]?.workspaceId ?? 'n/a'),
namespace, namespace,
fallback, fallback,
}); });
@@ -31,19 +28,17 @@ export function useRecentRequests() {
export function useSubscribeRecentRequests() { export function useSubscribeRecentRequests() {
useEffect(() => { useEffect(() => {
return jotaiStore.sub(activeRequestIdAtom, async () => { return jotaiStore.sub(activeRequestAtom, async () => {
const activeWorkspaceId = jotaiStore.get(activeWorkspaceIdAtom); const activeRequest = jotaiStore.get(activeRequestAtom);
const activeRequestId = jotaiStore.get(activeRequestIdAtom); if (activeRequest == null) return;
if (activeWorkspaceId == null) return;
if (activeRequestId == null) return;
const key = kvKey(activeWorkspaceId); const key = kvKey(activeRequest.workspaceId);
const recentIds = getKeyValue<string[]>({ namespace, key, fallback }); const recentIds = getKeyValue<string[]>({ namespace, key, fallback });
if (recentIds[0] === activeRequestId) return; // Short-circuit if (recentIds[0] === activeRequest.id) return; // Short-circuit
const withoutActiveId = recentIds.filter((id) => id !== activeRequestId); const withoutActiveId = recentIds.filter((id) => id !== activeRequest.id);
const value = [activeRequestId, ...withoutActiveId]; const value = [activeRequest.id, ...withoutActiveId];
await setKeyValue({ namespace, key, value }); await setKeyValue({ namespace, key, value });
}); });
}, []); }, []);