Use TRee component for Environment dialog (#288)

This commit is contained in:
Gregory Schier
2025-10-31 09:16:29 -07:00
committed by GitHub
parent c9698c0f23
commit e3e67c8df7
25 changed files with 502 additions and 342 deletions

View File

@@ -1178,7 +1178,7 @@ async fn cmd_install_plugin<R: Runtime>(
async fn cmd_create_grpc_request<R: Runtime>(
workspace_id: &str,
name: &str,
sort_priority: f32,
sort_priority: f64,
folder_id: Option<&str>,
app_handle: AppHandle<R>,
window: WebviewWindow<R>,

View File

@@ -1,6 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array<EnvironmentVariable>, color: string | null, };
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };

View File

@@ -14,7 +14,7 @@ export type EditorKeymap = "default" | "vim" | "vscode" | "emacs";
export type EncryptedKey = { encryptedKey: string, };
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array<EnvironmentVariable>, color: string | null, };
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };

View File

@@ -0,0 +1,2 @@
ALTER TABLE environments
ADD COLUMN sort_priority REAL DEFAULT 0 NOT NULL;

View File

@@ -554,6 +554,7 @@ pub struct Environment {
pub parent_id: Option<String>,
pub variables: Vec<EnvironmentVariable>,
pub color: Option<String>,
pub sort_priority: f64,
}
impl UpsertModelInfo for Environment {
@@ -591,6 +592,7 @@ impl UpsertModelInfo for Environment {
(Color, self.color.into()),
(Name, self.name.trim().into()),
(Public, self.public.into()),
(SortPriority, self.sort_priority.into()),
(Variables, serde_json::to_string(&self.variables)?.into()),
])
}
@@ -604,6 +606,7 @@ impl UpsertModelInfo for Environment {
EnvironmentIden::Name,
EnvironmentIden::Public,
EnvironmentIden::Variables,
EnvironmentIden::SortPriority,
]
}
@@ -626,6 +629,7 @@ impl UpsertModelInfo for Environment {
name: row.get("name")?,
public: row.get("public")?,
variables: serde_json::from_str(variables.as_str()).unwrap_or_default(),
sort_priority: row.get("sort_priority")?,
// Deprecated field, but we need to keep it around for a couple of versions
// for compatibility because sync/export don't have a schema field
@@ -683,7 +687,7 @@ pub struct Folder {
pub description: String,
pub headers: Vec<HttpRequestHeader>,
pub name: String,
pub sort_priority: f32,
pub sort_priority: f64,
}
impl UpsertModelInfo for Folder {
@@ -1053,7 +1057,7 @@ pub struct WebsocketRequest {
pub headers: Vec<HttpRequestHeader>,
pub message: String,
pub name: String,
pub sort_priority: f32,
pub sort_priority: f64,
pub url: String,
pub url_parameters: Vec<HttpUrlParameter>,
}
@@ -1488,7 +1492,7 @@ pub struct GrpcRequest {
pub method: Option<String>,
pub name: String,
pub service: Option<String>,
pub sort_priority: f32,
pub sort_priority: f64,
pub url: String,
}

View File

@@ -1,6 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array<EnvironmentVariable>, color: string | null, };
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };

View File

@@ -1,6 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array<EnvironmentVariable>, color: string | null, };
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };

View File

@@ -4,22 +4,25 @@ import type { CSSProperties } from 'react';
interface Props {
color: string | null;
onClick?: () => void;
className?: string;
}
export function ColorIndicator({ color, onClick }: Props) {
export function ColorIndicator({ color, onClick, className }: Props) {
const style: CSSProperties = { backgroundColor: color ?? undefined };
const className =
'inline-block w-[0.75em] h-[0.75em] rounded-full mr-1.5 border border-transparent';
const finalClassName = classNames(
className,
'inline-block w-[0.75em] h-[0.75em] rounded-full mr-1.5 border border-transparent flex-shrink-0',
);
if (onClick) {
return (
<button
onClick={onClick}
style={style}
className={classNames(className, 'hover:border-text')}
className={classNames(finalClassName, 'hover:border-text')}
/>
);
} else {
return <span style={style} className={className} />;
return <span style={style} className={finalClassName} />;
}
}

View File

@@ -5,14 +5,17 @@ import { ColorIndicator } from './ColorIndicator';
export function EnvironmentColorIndicator({
environment,
clickToEdit,
className,
}: {
environment: Environment | null;
clickToEdit?: boolean;
className?: string;
}) {
if (environment?.color == null) return null;
return (
<ColorIndicator
className={className}
color={environment?.color ?? null}
onClick={clickToEdit ? () => showColorPicker(environment) : undefined}
/>

View File

@@ -1,25 +1,28 @@
import type { Environment } from '@yaakapp-internal/models';
import type { Environment, Workspace } from '@yaakapp-internal/models';
import { duplicateModel, patchModel } from '@yaakapp-internal/models';
import classNames from 'classnames';
import type { ReactNode } from 'react';
import React, { useCallback, useState } from 'react';
import { atom, useAtomValue } from 'jotai';
import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createSubEnvironmentAndActivate } from '../commands/createEnvironment';
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown';
import { activeWorkspaceAtom, activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
import {
environmentsBreakdownAtom,
useEnvironmentsBreakdown,
} from '../hooks/useEnvironmentsBreakdown';
import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm';
import { jotaiStore } from '../lib/jotai';
import { isBaseEnvironment } from '../lib/model_util';
import { showPrompt } from '../lib/prompt';
import { resolvedModelName } from '../lib/resolvedModelName';
import { showColorPicker } from '../lib/showColorPicker';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import type { DropdownItem } from './core/Dropdown';
import { ContextMenu } from './core/Dropdown';
import type { ContextMenuProps, DropdownItem } from './core/Dropdown';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { IconTooltip } from './core/IconTooltip';
import { InlineCode } from './core/InlineCode';
import { Separator } from './core/Separator';
import { SplitLayout } from './core/SplitLayout';
import type { TreeNode } from './core/tree/common';
import type { TreeHandle, TreeProps } from './core/tree/Tree';
import { Tree } from './core/tree/Tree';
import { EnvironmentColorIndicator } from './EnvironmentColorIndicator';
import { EnvironmentEditor } from './EnvironmentEditor';
import { EnvironmentSharableTooltip } from './EnvironmentSharableTooltip';
@@ -28,9 +31,10 @@ interface Props {
initialEnvironment: Environment | null;
}
type TreeModel = Environment | Workspace;
export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
const { baseEnvironment, otherBaseEnvironments, subEnvironments, allEnvironments } =
useEnvironmentsBreakdown();
const { allEnvironments, baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown();
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string | null>(
initialEnvironment?.id ?? null,
);
@@ -40,23 +44,71 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
? allEnvironments.find((e) => e.id === selectedEnvironmentId)
: baseEnvironment;
const handleCreateEnvironment = async () => {
if (baseEnvironment == null) return;
const id = await createSubEnvironmentAndActivate.mutateAsync(baseEnvironment);
if (id != null) setSelectedEnvironmentId(id);
};
return (
<SplitLayout
name="env_editor"
defaultRatio={0.75}
layout="horizontal"
className="gap-0"
resizeHandleClassName="-translate-x-[1px]"
firstSlot={() => (
<EnvironmentEditDialogSidebar
selectedEnvironmentId={selectedEnvironment?.id ?? null}
setSelectedEnvironmentId={setSelectedEnvironmentId}
/>
)}
secondSlot={() => (
<div className="grid grid-rows-[auto_minmax(0,1fr)]">
{baseEnvironments.length > 1 ? (
<div className="p-3">
<Banner color="notice">
There are multiple base environments for this workspace. Please delete the
environments you no longer need.
</Banner>
</div>
) : (
<span />
)}
{selectedEnvironment == null ? (
<div className="p-3 mt-10">
<Banner color="danger">
Failed to find selected environment <InlineCode>{selectedEnvironmentId}</InlineCode>
</Banner>
</div>
) : (
<EnvironmentEditor className="pl-4 pt-3" environment={selectedEnvironment} />
)}
</div>
)}
/>
);
};
const handleDuplicateEnvironment = useCallback(async (environment: Environment) => {
const name = await showPrompt({
id: 'duplicate-environment',
title: 'Duplicate Environment',
label: 'Name',
defaultValue: environment.name,
});
if (name) {
const newId = await duplicateModel({ ...environment, name, public: false });
setSelectedEnvironmentId(newId);
}
const sharableTooltip = (
<IconTooltip
icon="eye"
iconSize="sm"
content="This environment will be included in Directory Sync and data exports"
/>
);
function EnvironmentEditDialogSidebar({
selectedEnvironmentId,
setSelectedEnvironmentId,
}: {
selectedEnvironmentId: string | null;
setSelectedEnvironmentId: (id: string | null) => void;
}) {
const activeWorkspaceId = useAtomValue(activeWorkspaceIdAtom) ?? '';
const treeId = `environment.${activeWorkspaceId}.sidebar`;
const treeRef = useRef<TreeHandle>(null);
const { baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown();
useLayoutEffect(() => {
if (selectedEnvironmentId == null) return;
treeRef.current?.selectItem(selectedEnvironmentId);
treeRef.current?.focus();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleDeleteEnvironment = useCallback(
@@ -66,217 +118,283 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
setSelectedEnvironmentId(baseEnvironment?.id ?? null);
}
},
[baseEnvironment?.id, selectedEnvironmentId],
[baseEnvironment?.id, selectedEnvironmentId, setSelectedEnvironmentId],
);
if (baseEnvironment == null) {
return null;
}
const actions = useMemo(() => {
const enable = () => treeRef.current?.hasFocus() ?? false;
return (
<SplitLayout
name="env_editor"
defaultRatio={0.75}
layout="horizontal"
className="gap-0"
firstSlot={() => (
<aside className="w-full min-w-0 pt-2">
<div className="min-w-0 h-full overflow-y-auto pt-1">
{[baseEnvironment, ...otherBaseEnvironments].map((e) => (
<EnvironmentDialogSidebarButton
key={e.id}
active={selectedEnvironment?.id == e.id}
onClick={() => setSelectedEnvironmentId(e.id)}
environment={e}
duplicateEnvironment={handleDuplicateEnvironment}
// Allow deleting the base environment if there are multiples
deleteEnvironment={
otherBaseEnvironments.length > 0 ? handleDeleteEnvironment : null
}
rightSlot={e.public && sharableTooltip}
outerRightSlot={
<IconButton
size="sm"
iconSize="md"
hotkeyAction="model.create"
hotkeyPriority={999}
title="Add sub environment"
icon="plus_circle"
iconClassName="text-text-subtlest group-hover:text-text-subtle"
className="group mr-0.5"
onClick={handleCreateEnvironment}
/>
}
>
{resolvedModelName(e)}
</EnvironmentDialogSidebarButton>
))}
{subEnvironments.length > 0 && (
<div className="px-2">
<Separator className="my-3" />
</div>
)}
{subEnvironments.map((e) => (
<EnvironmentDialogSidebarButton
key={e.id}
active={selectedEnvironment?.id === e.id}
environment={e}
onClick={() => setSelectedEnvironmentId(e.id)}
rightSlot={e.public && sharableTooltip}
duplicateEnvironment={handleDuplicateEnvironment}
deleteEnvironment={handleDeleteEnvironment}
>
{e.name}
</EnvironmentDialogSidebarButton>
))}
</div>
</aside>
)}
secondSlot={() =>
selectedEnvironment == null ? (
<div className="p-3 mt-10">
<Banner color="danger">
Failed to find selected environment <InlineCode>{selectedEnvironmentId}</InlineCode>
</Banner>
</div>
) : (
<EnvironmentEditor
className="pl-4 pt-3 border-l border-border-subtle"
environment={selectedEnvironment}
/>
)
const actions = {
'sidebar.selected.rename': {
enable,
allowDefault: true,
priority: 100,
cb: async function (items: TreeModel[]) {
const item = items[0];
if (items.length === 1 && item != null) {
treeRef.current?.renameItem(item.id);
}
},
},
'sidebar.selected.delete': {
priority: 100,
enable,
cb: (items: TreeModel[]) => deleteModelWithConfirm(items),
},
'sidebar.selected.duplicate': {
priority: 100,
enable,
cb: async function (items: TreeModel[]) {
if (items.length === 1) {
const item = items[0]!;
const newId = await duplicateModel(item);
setSelectedEnvironmentId(newId);
} else {
await Promise.all(items.map(duplicateModel));
}
},
},
} as const;
return actions;
}, [setSelectedEnvironmentId]);
const hotkeys = useMemo<TreeProps<TreeModel>['hotkeys']>(() => ({ actions }), [actions]);
const getContextMenu = useCallback(
(items: TreeModel[]): ContextMenuProps['items'] => {
const environment = items[0];
if (environment == null || environment.model !== 'environment') return [];
const singleEnvironment = items.length === 1;
const menuItems: DropdownItem[] = [
{
label: 'Rename',
leftSlot: <Icon icon="pencil" />,
hidden: isBaseEnvironment(environment) || !singleEnvironment,
hotKeyAction: 'sidebar.selected.rename',
hotKeyLabelOnly: true,
onSelect: async () => {
// Not sure why this is needed, but without it the
// edit input blurs immediately after opening.
requestAnimationFrame(() => {
actions['sidebar.selected.rename'].cb(items);
});
},
},
{
label: 'Duplicate',
leftSlot: <Icon icon="copy" />,
hidden: isBaseEnvironment(environment),
hotKeyAction: 'sidebar.selected.duplicate',
hotKeyLabelOnly: true,
onSelect: () => actions['sidebar.selected.duplicate'].cb(items),
},
{
label: environment.color ? 'Change Color' : 'Assign Color',
leftSlot: <Icon icon="palette" />,
hidden: isBaseEnvironment(environment) || !singleEnvironment,
onSelect: async () => showColorPicker(environment),
},
{
label: `Make ${environment.public ? 'Private' : 'Sharable'}`,
leftSlot: <Icon icon={environment.public ? 'eye_closed' : 'eye'} />,
rightSlot: <EnvironmentSharableTooltip />,
onSelect: async () => {
await patchModel(environment, { public: !environment.public });
},
},
{
color: 'danger',
label: 'Delete',
hotKeyAction: 'sidebar.selected.delete',
hotKeyLabelOnly: true,
hidden: !(isBaseEnvironment(environment) && baseEnvironments.length > 1),
leftSlot: <Icon icon="trash" />,
onSelect: () => handleDeleteEnvironment(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();
},
});
}
/>
return menuItems;
},
[actions, baseEnvironments.length, handleDeleteEnvironment],
);
};
function EnvironmentDialogSidebarButton({
children,
className,
active,
onClick,
deleteEnvironment,
rightSlot,
outerRightSlot,
duplicateEnvironment,
environment,
}: {
className?: string;
children: ReactNode;
active: boolean;
onClick: () => void;
rightSlot?: ReactNode;
outerRightSlot?: ReactNode;
environment: Environment;
deleteEnvironment: ((environment: Environment) => void) | null;
duplicateEnvironment: ((environment: Environment) => void) | null;
}) {
const [showContextMenu, setShowContextMenu] = useState<{
x: number;
y: number;
} | null>(null);
const handleDragEnd = useCallback(async function handleDragEnd({
items,
children,
insertAt,
}: {
items: TreeModel[];
children: TreeModel[];
insertAt: number;
}) {
const prev = children[insertAt - 1] as Exclude<TreeModel, Workspace>;
const next = children[insertAt] as Exclude<TreeModel, Workspace>;
const handleContextMenu = useCallback((e: React.MouseEvent<HTMLElement>) => {
e.preventDefault();
e.stopPropagation();
setShowContextMenu({ x: e.clientX, y: e.clientY });
const beforePriority = prev?.sortPriority ?? 0;
const afterPriority = next?.sortPriority ?? 0;
const shouldUpdateAll = afterPriority - beforePriority < 1;
try {
if (shouldUpdateAll) {
// Add items to children at insertAt
children.splice(insertAt, 0, ...items);
await Promise.all(children.map((m, i) => patchModel(m, { sortPriority: i * 1000 })));
} else {
const range = afterPriority - beforePriority;
const increment = range / (items.length + 2);
await Promise.all(
items.map((m, i) => {
const sortPriority = beforePriority + (i + 1) * increment;
// Spread item sortPriority out over before/after range
return patchModel(m, { sortPriority });
}),
);
}
} catch (e) {
console.error(e);
}
}, []);
const handleActivate = useCallback(
(item: TreeModel) => {
setSelectedEnvironmentId(item.id);
},
[setSelectedEnvironmentId],
);
const tree = useAtomValue(treeAtom);
return (
<aside className="x-theme-sidebar h-full w-full min-w-0 grid overflow-y-auto border-r border-border-subtle ">
{tree != null && (
<div className="pt-2">
<Tree
ref={treeRef}
treeId={treeId}
className="px-2 pb-10"
hotkeys={hotkeys}
root={tree}
getContextMenu={getContextMenu}
onDragEnd={handleDragEnd}
getItemKey={(i) => `${i.id}::${i.name}`}
ItemLeftSlotInner={ItemLeftSlotInner}
ItemRightSlot={ItemRightSlot}
ItemInner={ItemInner}
onActivate={handleActivate}
getEditOptions={getEditOptions}
/>
</div>
)}
</aside>
);
}
const treeAtom = atom<TreeNode<TreeModel> | null>((get) => {
const activeWorkspace = get(activeWorkspaceAtom);
const { baseEnvironment, baseEnvironments, subEnvironments } = get(environmentsBreakdownAtom);
if (activeWorkspace == null || baseEnvironment == null) return null;
const root: TreeNode<TreeModel> = {
item: activeWorkspace,
parent: null,
children: [],
depth: 0,
};
for (const item of baseEnvironments) {
root.children?.push({
item,
parent: root,
depth: 0,
draggable: false,
});
}
const parent = root.children?.[0];
if (baseEnvironments.length <= 1 && parent != null) {
const sortedEnvironments = [...subEnvironments].sort((a, b) => {
if (a.sortPriority === b.sortPriority) return a.updatedAt > b.updatedAt ? 1 : -1;
else return a.sortPriority - b.sortPriority;
});
parent.children = sortedEnvironments.map((item) => ({
item,
parent,
depth: 1,
localDrag: true,
}));
}
return root;
});
function ItemLeftSlotInner({ item }: { item: TreeModel }) {
const { baseEnvironments } = useEnvironmentsBreakdown();
return baseEnvironments.length > 1 ? (
<Icon icon="alert_triangle" color="notice" />
) : (
item.model === 'environment' && item.color && <EnvironmentColorIndicator environment={item} />
);
}
function ItemRightSlot({ item }: { item: TreeModel }) {
const { baseEnvironments } = useEnvironmentsBreakdown();
return (
<>
<div
className={classNames(
className,
'w-full grid grid-cols-[minmax(0,1fr)_auto] items-center gap-0.5',
'px-2', // Padding to show the focus border
)}
>
<Button
{item.model === 'environment' && baseEnvironments.length <= 1 && isBaseEnvironment(item) && (
<IconButton
size="sm"
color="custom"
size="xs"
className={classNames(
'w-full',
active ? 'text bg-surface-active' : 'text-text-subtle hover:text',
)}
justify="start"
onClick={onClick}
onContextMenu={handleContextMenu}
rightSlot={rightSlot}
>
<EnvironmentColorIndicator environment={environment} />
{children}
</Button>
{outerRightSlot}
</div>
<ContextMenu
triggerPosition={showContextMenu}
onClose={() => setShowContextMenu(null)}
items={[
{
label: 'Rename',
leftSlot: <Icon icon="pencil" />,
hidden: isBaseEnvironment(environment),
onSelect: async () => {
const name = await showPrompt({
id: 'rename-environment',
title: 'Rename Environment',
description: (
<>
Enter a new name for <InlineCode>{environment.name}</InlineCode>
</>
),
label: 'Name',
confirmText: 'Save',
placeholder: 'New Name',
defaultValue: environment.name,
});
if (name == null) return;
await patchModel(environment, { name });
},
},
{
label: 'Duplicate',
leftSlot: <Icon icon="copy" />,
hidden: isBaseEnvironment(environment),
onSelect: () => {
duplicateEnvironment?.(environment);
},
},
{
label: environment.color ? 'Change Color' : 'Assign Color',
leftSlot: <Icon icon="palette" />,
hidden: isBaseEnvironment(environment),
onSelect: async () => showColorPicker(environment),
},
{
label: `Make ${environment.public ? 'Private' : 'Sharable'}`,
leftSlot: <Icon icon={environment.public ? 'eye_closed' : 'eye'} />,
rightSlot: <EnvironmentSharableTooltip />,
onSelect: async () => {
await patchModel(environment, { public: !environment.public });
},
},
...((deleteEnvironment
? [
{
color: 'danger',
label: 'Delete',
leftSlot: <Icon icon="trash" />,
onSelect: () => {
deleteEnvironment(environment);
},
},
]
: []) as DropdownItem[]),
]}
/>
iconSize="sm"
icon="plus_circle"
className="opacity-50 hover:opacity-100"
title="Add Sub-Environment"
onClick={createSubEnvironment}
/>
)}
</>
);
}
const sharableTooltip = (
<IconTooltip
icon="eye"
content="This environment will be included in Directory Sync and data exports"
/>
);
function ItemInner({ item }: { item: TreeModel }) {
return (
<div className="grid grid-cols-[auto_minmax(0,1fr)] w-full items-center">
{item.model === 'environment' && item.public ? (
<div className="mr-2 flex items-center">{sharableTooltip}</div>
) : (
<span aria-hidden />
)}
<div className="truncate min-w-0 text-left">{resolvedModelName(item)}</div>
</div>
);
}
async function createSubEnvironment() {
const { baseEnvironment } = jotaiStore.get(environmentsBreakdownAtom);
if (baseEnvironment == null) return;
const id = await createSubEnvironmentAndActivate.mutateAsync(baseEnvironment);
return id;
}
function getEditOptions(item: TreeModel) {
const options: ReturnType<NonNullable<TreeProps<TreeModel>['getEditOptions']>> = {
defaultValue: item.name,
placeholder: 'Name',
async onChange(item, name) {
await patchModel(item, { name });
},
};
return options;
}

View File

@@ -98,10 +98,10 @@ export function EnvironmentEditor({
};
return (
<div className={classNames(className, 'h-full grid grid-rows-[auto_minmax(0,1fr)] gap-2')}>
<div className={classNames(className, 'h-full grid grid-rows-[auto_minmax(0,1fr)] gap-2 pr-3 pb-3')}>
<div className="flex flex-col gap-4">
<Heading className="w-full flex items-center gap-0.5">
<EnvironmentColorIndicator clickToEdit environment={environment ?? null} />
<EnvironmentColorIndicator className="mr-2" clickToEdit environment={environment ?? null} />
{!hideName && <div className="mr-2">{environment?.name}</div>}
{isEncryptionEnabled ? (
!allVariableAreEncrypted ? (

View File

@@ -31,11 +31,11 @@ interface Props {
workspace: Workspace;
}
interface TreeNode {
interface CommitTreeNode {
model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Environment | Workspace;
status: GitStatusEntry;
children: TreeNode[];
ancestors: TreeNode[];
children: CommitTreeNode[];
ancestors: CommitTreeNode[];
}
export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
@@ -80,14 +80,14 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
const hasAddedAnything = allEntries.find((e) => e.staged) != null;
const hasAnythingToAdd = allEntries.find((e) => e.status !== 'current') != null;
const tree: TreeNode | null = useMemo(() => {
const next = (model: TreeNode['model'], ancestors: TreeNode[]): TreeNode | null => {
const tree: CommitTreeNode | null = useMemo(() => {
const next = (model: CommitTreeNode['model'], ancestors: CommitTreeNode[]): CommitTreeNode | null => {
const statusEntry = internalEntries?.find((s) => s.relaPath.includes(model.id));
if (statusEntry == null) {
return null;
}
const node: TreeNode = {
const node: CommitTreeNode = {
model,
status: statusEntry,
children: [],
@@ -128,7 +128,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
return <EmptyStateText>No changes since last commit</EmptyStateText>;
}
const checkNode = (treeNode: TreeNode) => {
const checkNode = (treeNode: CommitTreeNode) => {
const checked = nodeCheckedStatus(treeNode);
const newChecked = checked === 'indeterminate' ? true : !checked;
setCheckedAndChildren(treeNode, newChecked, unstage.mutate, add.mutate);
@@ -211,9 +211,9 @@ function TreeNodeChildren({
depth,
onCheck,
}: {
node: TreeNode | null;
node: CommitTreeNode | null;
depth: number;
onCheck: (node: TreeNode, checked: boolean) => void;
onCheck: (node: CommitTreeNode, checked: boolean) => void;
}) {
if (node === null) return null;
if (!isNodeRelevant(node)) return null;
@@ -318,12 +318,12 @@ function ExternalTreeNode({
);
}
function nodeCheckedStatus(root: TreeNode): CheckboxProps['checked'] {
function nodeCheckedStatus(root: CommitTreeNode): CheckboxProps['checked'] {
let numVisited = 0;
let numChecked = 0;
let numCurrent = 0;
const visitChildren = (n: TreeNode) => {
const visitChildren = (n: CommitTreeNode) => {
numVisited += 1;
if (n.status.status === 'current') {
numCurrent += 1;
@@ -347,7 +347,7 @@ function nodeCheckedStatus(root: TreeNode): CheckboxProps['checked'] {
}
function setCheckedAndChildren(
node: TreeNode,
node: CommitTreeNode,
checked: boolean,
unstage: (args: { relaPaths: string[] }) => void,
add: (args: { relaPaths: string[] }) => void,
@@ -355,7 +355,7 @@ function setCheckedAndChildren(
const toAdd: string[] = [];
const toUnstage: string[] = [];
const next = (node: TreeNode) => {
const next = (node: CommitTreeNode) => {
for (const child of node.children) {
next(child);
}
@@ -375,7 +375,7 @@ function setCheckedAndChildren(
if (toUnstage.length > 0) unstage({ relaPaths: toUnstage });
}
function isNodeRelevant(node: TreeNode): boolean {
function isNodeRelevant(node: CommitTreeNode): boolean {
if (node.status.status !== 'current') {
return true;
}

View File

@@ -54,7 +54,6 @@ export function Overlay({
focusTrapOptions={{
allowOutsideClick: true, // So we can still click toasts and things
delayInitialFocus: true,
fallbackFocus: () => containerRef.current!, // always have a target
initialFocus: () =>
// Doing this explicitly seems to work better than the default behavior for some reason
containerRef.current?.querySelector<HTMLElement>(
@@ -67,12 +66,11 @@ export function Overlay({
'[tabindex]:not([tabindex="-1"])',
'[contenteditable]:not([contenteditable="false"])',
].join(', '),
) ?? undefined,
) ?? false,
}}
>
<m.div
ref={containerRef}
tabIndex={-1}
className={classNames('fixed inset-0', zIndexes[zIndex])}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}

View File

@@ -90,7 +90,7 @@ export function ResizeHandle({
className,
'group z-10 flex select-none transition-colors hover:bg-surface-active rounded-full',
// 'bg-info', // For debugging
vertical ? 'w-full h-2 cursor-row-resize' : 'h-full w-2 cursor-col-resize',
vertical ? 'w-full h-1.5 cursor-row-resize' : 'h-full w-1.5 cursor-col-resize',
justify === 'center' && 'justify-center',
justify === 'end' && 'justify-end',
justify === 'start' && 'justify-start',

View File

@@ -164,6 +164,7 @@ function Sidebar({ className }: { className?: string }) {
n.selectItem(activeId);
}, []);
// Ensure active id is always selected when it changes
useEffect(() => {
return jotaiStore.sub(activeIdAtom, () => {
const activeId = jotaiStore.get(activeIdAtom);
@@ -252,7 +253,7 @@ function Sidebar({ className }: { className?: string }) {
},
},
'sidebar.selected.duplicate': {
priority: 999,
priority: 10,
enable,
cb: async function (items: SidebarModel[]) {
if (items.length === 1) {
@@ -285,24 +286,7 @@ function Sidebar({ className }: { className?: string }) {
// No children means we're in the root
if (child == null) {
return [
...getCreateDropdownItems({ workspaceId, activeRequest: null, folderId: null }),
{ type: 'separator' },
{
label: 'Expand All Folders',
leftSlot: <Icon icon="chevrons_up_down" />,
onSelect: actions['sidebar.expand_all'].cb,
hotKeyAction: 'sidebar.expand_all',
hotKeyLabelOnly: true,
},
{
label: 'Collapse All Folders',
leftSlot: <Icon icon="chevrons_down_up" />,
onSelect: actions['sidebar.collapse_all'].cb,
hotKeyAction: 'sidebar.collapse_all',
hotKeyLabelOnly: true,
},
];
return getCreateDropdownItems({ workspaceId, activeRequest: null, folderId: null });
}
const workspaces = jotaiStore.get(workspacesAtom);
@@ -429,7 +413,7 @@ function Sidebar({ className }: { className?: string }) {
}
useEffect(() => {
const view = filterRef.current; // your EditorView
const view = filterRef.current;
if (!view) return;
const ext = filter({ fields: allFields ?? [] });
view.dispatch({ effects: filterLanguageCompartmentRef.current.reconfigure(ext) });
@@ -445,16 +429,15 @@ 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-2 py-1.5 pb-0 grid grid-cols-[1fr_auto] items-center -mr-1.5">
<div className="px-3 pt-3 grid grid-cols-[1fr_auto] items-center -mr-2.5">
{(tree.children?.length ?? 0) > 0 && (
<>
<Input
hideLabel
ref={filterRef}
size="xs"
size="sm"
label="filter"
language={null} // Explicitly disable
containerClassName="!rounded-full px-1"
placeholder="Search"
onChange={handleFilterChange}
defaultValue={filterText.text}
@@ -515,7 +498,7 @@ function Sidebar({ className }: { className?: string }) {
hotkeys={hotkeys}
getItemKey={getItemKey}
ItemInner={SidebarInnerItem}
ItemLeftSlot={SidebarLeftSlot}
ItemLeftSlotInner={SidebarLeftSlot}
getContextMenu={getContextMenu}
onActivate={handleActivate}
getEditOptions={getEditOptions}
@@ -683,7 +666,7 @@ const SidebarLeftSlot = memo(function SidebarLeftSlot({
return (
<HttpMethodTag
short
className={classNames('text-xs', !isSelected && OPACITY_SUBTLE)}
className={classNames('text-xs pl-1.5', !isSelected && OPACITY_SUBTLE)}
request={item}
/>
);

View File

@@ -78,7 +78,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
// Solids
variant === 'solid' && 'border-transparent',
variant === 'solid' && color === 'custom' && 'outline-border-focus',
variant === 'solid' && color === 'custom' && 'focus-visible:outline-2 outline-border-focus',
variant === 'solid' &&
color !== 'custom' &&
'enabled:hocus:text-text enabled:hocus:bg-surface-highlight outline-border-subtle',

View File

@@ -229,7 +229,6 @@ const BaseInput = forwardRef<InputHandle, InputProps>(function InputBase(
(e: KeyboardEvent) => {
if (e.key !== 'Enter') return;
console.log('HELLO?');
const form = wrapperRef.current?.closest('form');
if (!isValid || form == null) return;
@@ -375,7 +374,7 @@ function EncryptionInput({
security: ReturnType<typeof analyzeTemplate> | null;
obscured: boolean;
error: string | null;
}>({ fieldType: 'encrypted', value: null, security: null, obscured: true, error: null }, [
}>({ fieldType: 'text', value: null, security: null, obscured: true, error: null }, [
ogForceUpdateKey,
]);

View File

@@ -26,6 +26,7 @@ interface Props {
minHeightPx?: number;
minWidthPx?: number;
layout?: SplitLayoutLayout;
resizeHandleClassName?: string;
}
const baseProperties = { minWidth: 0 };
@@ -42,6 +43,7 @@ export function SplitLayout({
className,
name,
layout = 'responsive',
resizeHandleClassName,
defaultRatio = 0.5,
minHeightPx = 10,
minWidthPx = 10,
@@ -129,7 +131,10 @@ export function SplitLayout({
<>
<ResizeHandle
style={areaD}
className={classNames(vertical ? '-translate-y-1' : '-translate-x-1')}
className={classNames(
resizeHandleClassName,
vertical ? '-translate-y-1' : '-translate-x-1',
)}
onResizeMove={handleResizeMove}
onReset={handleReset}
side={vertical ? 'top' : 'left'}

View File

@@ -57,7 +57,6 @@ export function Tabs({
if (parent !== ref.current) {
// Tab is part of a nested tab container, so ignore it
} else if (v === value) {
tab.setAttribute('tabindex', '-1');
tab.setAttribute('data-state', 'active');
tab.setAttribute('aria-hidden', 'false');
tab.style.display = 'block';

View File

@@ -51,9 +51,10 @@ export interface TreeProps<T extends { id: string }> {
root: TreeNode<T>;
treeId: string;
getItemKey: (item: T) => string;
getContextMenu?: (items: T[]) => Promise<ContextMenuProps['items']>;
getContextMenu?: (items: T[]) => ContextMenuProps['items'] | Promise<ContextMenuProps['items']>;
ItemInner: ComponentType<{ treeId: string; item: T }>;
ItemLeftSlot?: 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;
@@ -86,7 +87,8 @@ function TreeInner<T extends { id: string }>(
onActivate,
onDragEnd,
ItemInner,
ItemLeftSlot,
ItemLeftSlotInner,
ItemRightSlot,
root,
treeId,
}: TreeProps<T>,
@@ -108,6 +110,20 @@ function TreeInner<T extends { id: string }>(
}
}, []);
// Select the first item on first render
useEffect(() => {
const ids = jotaiStore.get(selectedIdsFamily(treeId));
const fallback = selectableItems[0];
if (ids.length === 0 && fallback != null) {
jotaiStore.set(selectedIdsFamily(treeId), [fallback.node.item.id]);
jotaiStore.set(focusIdsFamily(treeId), {
anchorId: fallback.node.item.id,
lastId: fallback.node.item.id,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [treeId]);
const handleCloseContextMenu = useCallback(() => {
setShowContextMenu(null);
}, []);
@@ -152,6 +168,7 @@ function TreeInner<T extends { id: string }>(
// Ensure there's always a tabbable item after render
useEffect(() => {
requestAnimationFrame(ensureTabbableItem);
ensureTabbableItem();
});
const setSelected = useCallback(
@@ -199,12 +216,12 @@ function TreeInner<T extends { id: string }>(
} else {
// If right-clicked an item that was NOT in the multiple-selection, just use that one
// Also update the selection with it
jotaiStore.set(selectedIdsFamily(treeId), [item.id]);
setSelected([item.id], false);
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id }));
return getContextMenu([item]);
}
};
}, [getContextMenu, selectableItems, treeId]);
}, [getContextMenu, selectableItems, setSelected, treeId]);
const handleSelect = useCallback<NonNullable<TreeItemProps<T>['onClick']>>(
(item, { shiftKey, metaKey, ctrlKey }) => {
@@ -411,6 +428,24 @@ function TreeInner<T extends { id: string }>(
return;
}
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;
if (item == null) {
return;
}
const isSameParent = item.parent?.item.id === overSelectableItem.node.parent?.item.id;
if (item.localDrag && !isSameParent) {
return;
}
}
// Root is anything past the end of the list, so set it to the end
const hoveringRoot = over.id === root.item.id;
if (hoveringRoot) {
@@ -423,12 +458,7 @@ function TreeInner<T extends { id: string }>(
return;
}
const selectableItem = selectableItems.find((i) => i.node.item.id === over.id) ?? null;
if (selectableItem == null) {
return;
}
const node = selectableItem.node;
const node = overSelectableItem.node;
const side = computeSideForDragMove(node.item.id, e);
const item = node.item;
@@ -436,7 +466,7 @@ function TreeInner<T extends { id: string }>(
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 = selectableItem.index + (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') {
@@ -567,7 +597,8 @@ function TreeInner<T extends { id: string }>(
onClick: handleClick,
getEditOptions,
ItemInner,
ItemLeftSlot,
ItemLeftSlotInner,
ItemRightSlot,
};
const handleContextMenu = useCallback(

View File

@@ -10,11 +10,11 @@ export function TreeDragOverlay<T extends { id: string }>({
selectableItems,
getItemKey,
ItemInner,
ItemLeftSlot,
ItemLeftSlotInner,
}: {
treeId: string;
selectableItems: SelectableTreeNode<T>[];
} & Pick<TreeProps<T>, 'getItemKey' | 'ItemInner' | 'ItemLeftSlot'>) {
} & Pick<TreeProps<T>, 'getItemKey' | 'ItemInner' | 'ItemLeftSlotInner'>) {
const draggingItems = useAtomValue(draggingIdsFamily(treeId));
return (
<DragOverlay dropAnimation={null}>
@@ -23,7 +23,7 @@ export function TreeDragOverlay<T extends { id: string }>({
nodes={selectableItems.filter((i) => draggingItems.includes(i.node.item.id))}
getItemKey={getItemKey}
ItemInner={ItemInner}
ItemLeftSlot={ItemLeftSlot}
ItemLeftSlotInner={ItemLeftSlotInner}
forceDepth={0}
/>
</DragOverlay>

View File

@@ -24,12 +24,12 @@ export interface TreeItemClickEvent {
export type TreeItemProps<T extends { id: string }> = Pick<
TreeProps<T>,
'ItemInner' | 'ItemLeftSlot' | 'treeId' | 'getEditOptions' | 'getItemKey'
'ItemInner' | 'ItemLeftSlotInner' | 'ItemRightSlot' | 'treeId' | 'getEditOptions' | 'getItemKey'
> & {
node: TreeNode<T>;
className?: string;
onClick?: (item: T, e: TreeItemClickEvent) => void;
getContextMenu?: (item: T) => Promise<ContextMenuProps['items']>;
getContextMenu?: (item: T) => ContextMenuProps['items'] | Promise<ContextMenuProps['items']>;
depth: number;
addRef?: (item: T, n: TreeItemHandle | null) => void;
};
@@ -47,7 +47,8 @@ function TreeItem_<T extends { id: string }>({
treeId,
node,
ItemInner,
ItemLeftSlot,
ItemLeftSlotInner,
ItemRightSlot,
getContextMenu,
onClick,
getEditOptions,
@@ -135,7 +136,7 @@ function TreeItem_<T extends { id: string }>({
}, [node.item.id, treeId]);
const handleSubmitNameEdit = useCallback(
async function submitNameEdit(el: HTMLInputElement) {
async (el: HTMLInputElement) => {
getEditOptions?.(node.item).onChange(node.item, el.value);
onClick?.(node.item, { shiftKey: false, ctrlKey: false, metaKey: false });
// Slight delay for the model to propagate to the local store
@@ -243,7 +244,12 @@ function TreeItem_<T extends { id: string }>({
setShowContextMenu(null);
}, []);
const { attributes, listeners, setNodeRef: setDraggableRef } = useDraggable({ id: node.item.id });
const {
attributes,
listeners,
setNodeRef: setDraggableRef,
} = useDraggable({ id: node.item.id, disabled: node.draggable === false });
const { setNodeRef: setDroppableRef } = useDroppable({ id: node.item.id });
const handlePointerDown = useCallback(
@@ -290,7 +296,7 @@ function TreeItem_<T extends { id: string }>({
<div
className={classNames(
'text-text-subtle',
'grid grid-cols-[auto_minmax(0,1fr)] items-center rounded-md',
'grid grid-cols-[auto_minmax(0,1fr)_auto] gap-x-2 items-center rounded-md',
)}
>
{showContextMenu && (
@@ -301,7 +307,11 @@ function TreeItem_<T extends { id: string }>({
/>
)}
{node.children != null ? (
<button tabIndex={-1} className="h-full pl-[0.5rem]" onClick={toggleCollapsed}>
<button
tabIndex={-1}
className="h-full pl-[0.5rem] outline-none"
onClick={toggleCollapsed}
>
<Icon
icon={node.children.length === 0 ? 'dot' : 'chevron_right'}
className={classNames(
@@ -322,12 +332,12 @@ function TreeItem_<T extends { id: string }>({
onClick={handleClick}
onDoubleClick={handleDoubleClick}
disabled={editing}
className="cursor-default tree-item-inner px-2 focus:outline-none flex items-center gap-2 h-full whitespace-nowrap"
className="cursor-default tree-item-inner pr-1 focus:outline-none flex items-center gap-2 h-full whitespace-nowrap"
{...attributes}
{...listeners}
tabIndex={isLastSelected ? 0 : -1}
>
{ItemLeftSlot != null && <ItemLeftSlot treeId={treeId} item={node.item} />}
{ItemLeftSlotInner != null && <ItemLeftSlotInner treeId={treeId} item={node.item} />}
{getEditOptions != null && editing ? (
(() => {
const { defaultValue, placeholder } = getEditOptions(node.item);
@@ -347,6 +357,11 @@ function TreeItem_<T extends { id: string }>({
<ItemInner treeId={treeId} item={node.item} />
)}
</button>
{ItemRightSlot != null ? (
<ItemRightSlot treeId={treeId} item={node.item} />
) : (
<span aria-hidden />
)}
</div>
</li>
);

View File

@@ -1,4 +1,4 @@
import type { CSSProperties} from 'react';
import type { CSSProperties } from 'react';
import { Fragment } from 'react';
import type { SelectableTreeNode } from './common';
import type { TreeProps } from './Tree';
@@ -8,7 +8,7 @@ import { TreeItem } from './TreeItem';
export type TreeItemListProps<T extends { id: string }> = Pick<
TreeProps<T>,
'ItemInner' | 'ItemLeftSlot' | 'treeId' | 'getItemKey' | 'getEditOptions'
'ItemInner' | 'ItemLeftSlotInner' | 'ItemRightSlot' | 'treeId' | 'getItemKey' | 'getEditOptions'
> &
Pick<TreeItemProps<T>, 'onClick' | 'getContextMenu'> & {
nodes: SelectableTreeNode<T>[];
@@ -20,17 +20,13 @@ export type TreeItemListProps<T extends { id: string }> = Pick<
export function TreeItemList<T extends { id: string }>({
className,
getContextMenu,
getEditOptions,
getItemKey,
nodes,
onClick,
ItemInner,
ItemLeftSlot,
style,
treeId,
forceDepth,
addTreeItemRef,
...props
}: TreeItemListProps<T>) {
return (
<ul role="tree" style={style} className={className}>
@@ -38,16 +34,12 @@ export function TreeItemList<T extends { id: string }>({
{nodes.map((child, i) => (
<Fragment key={getItemKey(child.node.item)}>
<TreeItem
addRef={addTreeItemRef}
treeId={treeId}
addRef={addTreeItemRef}
node={child.node}
ItemInner={ItemInner}
ItemLeftSlot={ItemLeftSlot}
onClick={onClick}
getEditOptions={getEditOptions}
getContextMenu={getContextMenu}
getItemKey={getItemKey}
depth={forceDepth == null ? child.depth : forceDepth}
{...props}
/>
<TreeDropMarker node={child.node} treeId={treeId} index={i + 1} />
</Fragment>

View File

@@ -7,6 +7,8 @@ export interface TreeNode<T extends { id: string }> {
hidden?: boolean;
parent: TreeNode<T> | null;
depth: number;
draggable?: boolean;
localDrag?: boolean;
}
export interface SelectableTreeNode<T extends { id: string }> {

View File

@@ -1,19 +1,25 @@
import { environmentsAtom } from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai';
import { useMemo } from 'react';
import { atom, useAtomValue } from 'jotai';
export const environmentsBreakdownAtom = atom((get) => {
const allEnvironments = get(environmentsAtom);
const baseEnvironments = allEnvironments.filter((e) => e.parentModel === 'workspace') ?? [];
const subEnvironments = allEnvironments.filter((e) => e.parentModel === 'environment') ?? [];
const folderEnvironments =
allEnvironments.filter((e) => e.parentModel === 'folder' && e.parentId != null) ?? [];
const baseEnvironment = baseEnvironments[0] ?? null;
const otherBaseEnvironments = baseEnvironments.filter((e) => e.id !== baseEnvironment?.id) ?? [];
return {
allEnvironments,
baseEnvironment,
subEnvironments,
folderEnvironments,
otherBaseEnvironments,
baseEnvironments,
};
});
export function useEnvironmentsBreakdown() {
const allEnvironments = useAtomValue(environmentsAtom);
return useMemo(() => {
const baseEnvironments = allEnvironments.filter((e) => e.parentModel == 'workspace') ?? [];
const subEnvironments =
allEnvironments.filter((e) => e.parentModel === 'environment') ?? [];
const folderEnvironments =
allEnvironments.filter((e) => e.parentModel === 'folder' && e.parentId != null) ?? [];
const baseEnvironment = baseEnvironments[0] ?? null;
const otherBaseEnvironments =
baseEnvironments.filter((e) => e.id !== baseEnvironment?.id) ?? [];
return { allEnvironments, baseEnvironment, subEnvironments, folderEnvironments, otherBaseEnvironments };
}, [allEnvironments]);
return useAtomValue(environmentsBreakdownAtom);
}