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>( async fn cmd_create_grpc_request<R: Runtime>(
workspace_id: &str, workspace_id: &str,
name: &str, name: &str,
sort_priority: f32, sort_priority: f64,
folder_id: Option<&str>, folder_id: Option<&str>,
app_handle: AppHandle<R>, app_handle: AppHandle<R>,
window: WebviewWindow<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. // 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, }; 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 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, }; 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 parent_id: Option<String>,
pub variables: Vec<EnvironmentVariable>, pub variables: Vec<EnvironmentVariable>,
pub color: Option<String>, pub color: Option<String>,
pub sort_priority: f64,
} }
impl UpsertModelInfo for Environment { impl UpsertModelInfo for Environment {
@@ -591,6 +592,7 @@ impl UpsertModelInfo for Environment {
(Color, self.color.into()), (Color, self.color.into()),
(Name, self.name.trim().into()), (Name, self.name.trim().into()),
(Public, self.public.into()), (Public, self.public.into()),
(SortPriority, self.sort_priority.into()),
(Variables, serde_json::to_string(&self.variables)?.into()), (Variables, serde_json::to_string(&self.variables)?.into()),
]) ])
} }
@@ -604,6 +606,7 @@ impl UpsertModelInfo for Environment {
EnvironmentIden::Name, EnvironmentIden::Name,
EnvironmentIden::Public, EnvironmentIden::Public,
EnvironmentIden::Variables, EnvironmentIden::Variables,
EnvironmentIden::SortPriority,
] ]
} }
@@ -626,6 +629,7 @@ impl UpsertModelInfo for Environment {
name: row.get("name")?, name: row.get("name")?,
public: row.get("public")?, public: row.get("public")?,
variables: serde_json::from_str(variables.as_str()).unwrap_or_default(), 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 // 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 // for compatibility because sync/export don't have a schema field
@@ -683,7 +687,7 @@ pub struct Folder {
pub description: String, pub description: String,
pub headers: Vec<HttpRequestHeader>, pub headers: Vec<HttpRequestHeader>,
pub name: String, pub name: String,
pub sort_priority: f32, pub sort_priority: f64,
} }
impl UpsertModelInfo for Folder { impl UpsertModelInfo for Folder {
@@ -1053,7 +1057,7 @@ pub struct WebsocketRequest {
pub headers: Vec<HttpRequestHeader>, pub headers: Vec<HttpRequestHeader>,
pub message: String, pub message: String,
pub name: String, pub name: String,
pub sort_priority: f32, pub sort_priority: f64,
pub url: String, pub url: String,
pub url_parameters: Vec<HttpUrlParameter>, pub url_parameters: Vec<HttpUrlParameter>,
} }
@@ -1488,7 +1492,7 @@ pub struct GrpcRequest {
pub method: Option<String>, pub method: Option<String>,
pub name: String, pub name: String,
pub service: Option<String>, pub service: Option<String>,
pub sort_priority: f32, pub sort_priority: f64,
pub url: String, 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. // 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, }; 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. // 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, }; export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };

View File

@@ -4,22 +4,25 @@ import type { CSSProperties } from 'react';
interface Props { interface Props {
color: string | null; color: string | null;
onClick?: () => void; onClick?: () => void;
className?: string;
} }
export function ColorIndicator({ color, onClick }: Props) { export function ColorIndicator({ color, onClick, className }: Props) {
const style: CSSProperties = { backgroundColor: color ?? undefined }; const style: CSSProperties = { backgroundColor: color ?? undefined };
const className = const finalClassName = classNames(
'inline-block w-[0.75em] h-[0.75em] rounded-full mr-1.5 border border-transparent'; className,
'inline-block w-[0.75em] h-[0.75em] rounded-full mr-1.5 border border-transparent flex-shrink-0',
);
if (onClick) { if (onClick) {
return ( return (
<button <button
onClick={onClick} onClick={onClick}
style={style} style={style}
className={classNames(className, 'hover:border-text')} className={classNames(finalClassName, 'hover:border-text')}
/> />
); );
} else { } 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({ export function EnvironmentColorIndicator({
environment, environment,
clickToEdit, clickToEdit,
className,
}: { }: {
environment: Environment | null; environment: Environment | null;
clickToEdit?: boolean; clickToEdit?: boolean;
className?: string;
}) { }) {
if (environment?.color == null) return null; if (environment?.color == null) return null;
return ( return (
<ColorIndicator <ColorIndicator
className={className}
color={environment?.color ?? null} color={environment?.color ?? null}
onClick={clickToEdit ? () => showColorPicker(environment) : undefined} 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 { duplicateModel, patchModel } from '@yaakapp-internal/models';
import classNames from 'classnames'; import { atom, useAtomValue } from 'jotai';
import type { ReactNode } from 'react'; import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useState } from 'react';
import { createSubEnvironmentAndActivate } from '../commands/createEnvironment'; 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 { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm';
import { jotaiStore } from '../lib/jotai';
import { isBaseEnvironment } from '../lib/model_util'; import { isBaseEnvironment } from '../lib/model_util';
import { showPrompt } from '../lib/prompt';
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';
import { Button } from './core/Button'; import type { ContextMenuProps, DropdownItem } from './core/Dropdown';
import type { DropdownItem } from './core/Dropdown';
import { ContextMenu } from './core/Dropdown';
import { Icon } from './core/Icon'; import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton'; import { IconButton } from './core/IconButton';
import { IconTooltip } from './core/IconTooltip'; import { IconTooltip } from './core/IconTooltip';
import { InlineCode } from './core/InlineCode'; import { InlineCode } from './core/InlineCode';
import { Separator } from './core/Separator';
import { SplitLayout } from './core/SplitLayout'; 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 { EnvironmentColorIndicator } from './EnvironmentColorIndicator';
import { EnvironmentEditor } from './EnvironmentEditor'; import { EnvironmentEditor } from './EnvironmentEditor';
import { EnvironmentSharableTooltip } from './EnvironmentSharableTooltip'; import { EnvironmentSharableTooltip } from './EnvironmentSharableTooltip';
@@ -28,9 +31,10 @@ interface Props {
initialEnvironment: Environment | null; initialEnvironment: Environment | null;
} }
type TreeModel = Environment | Workspace;
export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) { export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
const { baseEnvironment, otherBaseEnvironments, subEnvironments, allEnvironments } = const { allEnvironments, baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown();
useEnvironmentsBreakdown();
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string | null>( const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string | null>(
initialEnvironment?.id ?? null, initialEnvironment?.id ?? null,
); );
@@ -40,23 +44,71 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
? allEnvironments.find((e) => e.id === selectedEnvironmentId) ? allEnvironments.find((e) => e.id === selectedEnvironmentId)
: baseEnvironment; : baseEnvironment;
const handleCreateEnvironment = async () => { return (
if (baseEnvironment == null) return; <SplitLayout
const id = await createSubEnvironmentAndActivate.mutateAsync(baseEnvironment); name="env_editor"
if (id != null) setSelectedEnvironmentId(id); 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 sharableTooltip = (
const name = await showPrompt({ <IconTooltip
id: 'duplicate-environment', icon="eye"
title: 'Duplicate Environment', iconSize="sm"
label: 'Name', content="This environment will be included in Directory Sync and data exports"
defaultValue: environment.name, />
}); );
if (name) {
const newId = await duplicateModel({ ...environment, name, public: false }); function EnvironmentEditDialogSidebar({
setSelectedEnvironmentId(newId); 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( const handleDeleteEnvironment = useCallback(
@@ -66,217 +118,283 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
setSelectedEnvironmentId(baseEnvironment?.id ?? null); setSelectedEnvironmentId(baseEnvironment?.id ?? null);
} }
}, },
[baseEnvironment?.id, selectedEnvironmentId], [baseEnvironment?.id, selectedEnvironmentId, setSelectedEnvironmentId],
); );
if (baseEnvironment == null) { const actions = useMemo(() => {
return null; const enable = () => treeRef.current?.hasFocus() ?? false;
}
return ( const actions = {
<SplitLayout 'sidebar.selected.rename': {
name="env_editor" enable,
defaultRatio={0.75} allowDefault: true,
layout="horizontal" priority: 100,
className="gap-0" cb: async function (items: TreeModel[]) {
firstSlot={() => ( const item = items[0];
<aside className="w-full min-w-0 pt-2"> if (items.length === 1 && item != null) {
<div className="min-w-0 h-full overflow-y-auto pt-1"> treeRef.current?.renameItem(item.id);
{[baseEnvironment, ...otherBaseEnvironments].map((e) => ( }
<EnvironmentDialogSidebarButton },
key={e.id} },
active={selectedEnvironment?.id == e.id} 'sidebar.selected.delete': {
onClick={() => setSelectedEnvironmentId(e.id)} priority: 100,
environment={e} enable,
duplicateEnvironment={handleDuplicateEnvironment} cb: (items: TreeModel[]) => deleteModelWithConfirm(items),
// Allow deleting the base environment if there are multiples },
deleteEnvironment={ 'sidebar.selected.duplicate': {
otherBaseEnvironments.length > 0 ? handleDeleteEnvironment : null priority: 100,
} enable,
rightSlot={e.public && sharableTooltip} cb: async function (items: TreeModel[]) {
outerRightSlot={ if (items.length === 1) {
<IconButton const item = items[0]!;
size="sm" const newId = await duplicateModel(item);
iconSize="md" setSelectedEnvironmentId(newId);
hotkeyAction="model.create" } else {
hotkeyPriority={999} await Promise.all(items.map(duplicateModel));
title="Add sub environment" }
icon="plus_circle" },
iconClassName="text-text-subtlest group-hover:text-text-subtle" },
className="group mr-0.5" } as const;
onClick={handleCreateEnvironment} return actions;
/> }, [setSelectedEnvironmentId]);
}
> const hotkeys = useMemo<TreeProps<TreeModel>['hotkeys']>(() => ({ actions }), [actions]);
{resolvedModelName(e)}
</EnvironmentDialogSidebarButton> const getContextMenu = useCallback(
))} (items: TreeModel[]): ContextMenuProps['items'] => {
{subEnvironments.length > 0 && ( const environment = items[0];
<div className="px-2"> if (environment == null || environment.model !== 'environment') return [];
<Separator className="my-3" /> const singleEnvironment = items.length === 1;
</div>
)} const menuItems: DropdownItem[] = [
{subEnvironments.map((e) => ( {
<EnvironmentDialogSidebarButton label: 'Rename',
key={e.id} leftSlot: <Icon icon="pencil" />,
active={selectedEnvironment?.id === e.id} hidden: isBaseEnvironment(environment) || !singleEnvironment,
environment={e} hotKeyAction: 'sidebar.selected.rename',
onClick={() => setSelectedEnvironmentId(e.id)} hotKeyLabelOnly: true,
rightSlot={e.public && sharableTooltip} onSelect: async () => {
duplicateEnvironment={handleDuplicateEnvironment} // Not sure why this is needed, but without it the
deleteEnvironment={handleDeleteEnvironment} // edit input blurs immediately after opening.
> requestAnimationFrame(() => {
{e.name} actions['sidebar.selected.rename'].cb(items);
</EnvironmentDialogSidebarButton> });
))} },
</div> },
</aside> {
)} label: 'Duplicate',
secondSlot={() => leftSlot: <Icon icon="copy" />,
selectedEnvironment == null ? ( hidden: isBaseEnvironment(environment),
<div className="p-3 mt-10"> hotKeyAction: 'sidebar.selected.duplicate',
<Banner color="danger"> hotKeyLabelOnly: true,
Failed to find selected environment <InlineCode>{selectedEnvironmentId}</InlineCode> onSelect: () => actions['sidebar.selected.duplicate'].cb(items),
</Banner> },
</div> {
) : ( label: environment.color ? 'Change Color' : 'Assign Color',
<EnvironmentEditor leftSlot: <Icon icon="palette" />,
className="pl-4 pt-3 border-l border-border-subtle" hidden: isBaseEnvironment(environment) || !singleEnvironment,
environment={selectedEnvironment} 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({ const handleDragEnd = useCallback(async function handleDragEnd({
children, items,
className, children,
active, insertAt,
onClick, }: {
deleteEnvironment, items: TreeModel[];
rightSlot, children: TreeModel[];
outerRightSlot, insertAt: number;
duplicateEnvironment, }) {
environment, const prev = children[insertAt - 1] as Exclude<TreeModel, Workspace>;
}: { const next = children[insertAt] as Exclude<TreeModel, Workspace>;
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 handleContextMenu = useCallback((e: React.MouseEvent<HTMLElement>) => { const beforePriority = prev?.sortPriority ?? 0;
e.preventDefault(); const afterPriority = next?.sortPriority ?? 0;
e.stopPropagation(); const shouldUpdateAll = afterPriority - beforePriority < 1;
setShowContextMenu({ x: e.clientX, y: e.clientY });
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 ( return (
<> <>
<div {item.model === 'environment' && baseEnvironments.length <= 1 && isBaseEnvironment(item) && (
className={classNames( <IconButton
className, size="sm"
'w-full grid grid-cols-[minmax(0,1fr)_auto] items-center gap-0.5',
'px-2', // Padding to show the focus border
)}
>
<Button
color="custom" color="custom"
size="xs" iconSize="sm"
className={classNames( icon="plus_circle"
'w-full', className="opacity-50 hover:opacity-100"
active ? 'text bg-surface-active' : 'text-text-subtle hover:text', title="Add Sub-Environment"
)} onClick={createSubEnvironment}
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[]),
]}
/>
</> </>
); );
} }
const sharableTooltip = ( function ItemInner({ item }: { item: TreeModel }) {
<IconTooltip return (
icon="eye" <div className="grid grid-cols-[auto_minmax(0,1fr)] w-full items-center">
content="This environment will be included in Directory Sync and data exports" {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 ( 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"> <div className="flex flex-col gap-4">
<Heading className="w-full flex items-center gap-0.5"> <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>} {!hideName && <div className="mr-2">{environment?.name}</div>}
{isEncryptionEnabled ? ( {isEncryptionEnabled ? (
!allVariableAreEncrypted ? ( !allVariableAreEncrypted ? (

View File

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

View File

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

View File

@@ -90,7 +90,7 @@ export function ResizeHandle({
className, className,
'group z-10 flex select-none transition-colors hover:bg-surface-active rounded-full', 'group z-10 flex select-none transition-colors hover:bg-surface-active rounded-full',
// 'bg-info', // For debugging // '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 === 'center' && 'justify-center',
justify === 'end' && 'justify-end', justify === 'end' && 'justify-end',
justify === 'start' && 'justify-start', justify === 'start' && 'justify-start',

View File

@@ -164,6 +164,7 @@ function Sidebar({ className }: { className?: string }) {
n.selectItem(activeId); n.selectItem(activeId);
}, []); }, []);
// Ensure active id is always selected when it changes
useEffect(() => { useEffect(() => {
return jotaiStore.sub(activeIdAtom, () => { return jotaiStore.sub(activeIdAtom, () => {
const activeId = jotaiStore.get(activeIdAtom); const activeId = jotaiStore.get(activeIdAtom);
@@ -252,7 +253,7 @@ function Sidebar({ className }: { className?: string }) {
}, },
}, },
'sidebar.selected.duplicate': { 'sidebar.selected.duplicate': {
priority: 999, priority: 10,
enable, enable,
cb: async function (items: SidebarModel[]) { cb: async function (items: SidebarModel[]) {
if (items.length === 1) { if (items.length === 1) {
@@ -285,24 +286,7 @@ 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 [ return getCreateDropdownItems({ workspaceId, activeRequest: null, folderId: null });
...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,
},
];
} }
const workspaces = jotaiStore.get(workspacesAtom); const workspaces = jotaiStore.get(workspacesAtom);
@@ -429,7 +413,7 @@ function Sidebar({ className }: { className?: string }) {
} }
useEffect(() => { useEffect(() => {
const view = filterRef.current; // your EditorView 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) });
@@ -445,16 +429,15 @@ 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-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 && ( {(tree.children?.length ?? 0) > 0 && (
<> <>
<Input <Input
hideLabel hideLabel
ref={filterRef} ref={filterRef}
size="xs" size="sm"
label="filter" label="filter"
language={null} // Explicitly disable language={null} // Explicitly disable
containerClassName="!rounded-full px-1"
placeholder="Search" placeholder="Search"
onChange={handleFilterChange} onChange={handleFilterChange}
defaultValue={filterText.text} defaultValue={filterText.text}
@@ -515,7 +498,7 @@ function Sidebar({ className }: { className?: string }) {
hotkeys={hotkeys} hotkeys={hotkeys}
getItemKey={getItemKey} getItemKey={getItemKey}
ItemInner={SidebarInnerItem} ItemInner={SidebarInnerItem}
ItemLeftSlot={SidebarLeftSlot} ItemLeftSlotInner={SidebarLeftSlot}
getContextMenu={getContextMenu} getContextMenu={getContextMenu}
onActivate={handleActivate} onActivate={handleActivate}
getEditOptions={getEditOptions} getEditOptions={getEditOptions}
@@ -683,7 +666,7 @@ const SidebarLeftSlot = memo(function SidebarLeftSlot({
return ( return (
<HttpMethodTag <HttpMethodTag
short short
className={classNames('text-xs', !isSelected && OPACITY_SUBTLE)} className={classNames('text-xs pl-1.5', !isSelected && OPACITY_SUBTLE)}
request={item} request={item}
/> />
); );

View File

@@ -78,7 +78,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
// Solids // Solids
variant === 'solid' && 'border-transparent', variant === 'solid' && 'border-transparent',
variant === 'solid' && color === 'custom' && 'outline-border-focus', variant === 'solid' && color === 'custom' && 'focus-visible:outline-2 outline-border-focus',
variant === 'solid' && variant === 'solid' &&
color !== 'custom' && color !== 'custom' &&
'enabled:hocus:text-text enabled:hocus:bg-surface-highlight outline-border-subtle', '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) => { (e: KeyboardEvent) => {
if (e.key !== 'Enter') return; if (e.key !== 'Enter') return;
console.log('HELLO?');
const form = wrapperRef.current?.closest('form'); const form = wrapperRef.current?.closest('form');
if (!isValid || form == null) return; if (!isValid || form == null) return;
@@ -375,7 +374,7 @@ function EncryptionInput({
security: ReturnType<typeof analyzeTemplate> | null; security: ReturnType<typeof analyzeTemplate> | null;
obscured: boolean; obscured: boolean;
error: string | null; error: string | null;
}>({ fieldType: 'encrypted', value: null, security: null, obscured: true, error: null }, [ }>({ fieldType: 'text', value: null, security: null, obscured: true, error: null }, [
ogForceUpdateKey, ogForceUpdateKey,
]); ]);

View File

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

View File

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

View File

@@ -51,9 +51,10 @@ 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?: (items: T[]) => Promise<ContextMenuProps['items']>; getContextMenu?: (items: T[]) => ContextMenuProps['items'] | Promise<ContextMenuProps['items']>;
ItemInner: ComponentType<{ treeId: string; item: T }>; 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; className?: string;
onActivate?: (item: T) => void; onActivate?: (item: T) => void;
onDragEnd?: (opt: { items: T[]; parent: T; children: T[]; insertAt: number }) => void; onDragEnd?: (opt: { items: T[]; parent: T; children: T[]; insertAt: number }) => void;
@@ -86,7 +87,8 @@ function TreeInner<T extends { id: string }>(
onActivate, onActivate,
onDragEnd, onDragEnd,
ItemInner, ItemInner,
ItemLeftSlot, ItemLeftSlotInner,
ItemRightSlot,
root, root,
treeId, treeId,
}: TreeProps<T>, }: 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(() => { const handleCloseContextMenu = useCallback(() => {
setShowContextMenu(null); setShowContextMenu(null);
}, []); }, []);
@@ -152,6 +168,7 @@ function TreeInner<T extends { id: string }>(
// Ensure there's always a tabbable item after render // Ensure there's always a tabbable item after render
useEffect(() => { useEffect(() => {
requestAnimationFrame(ensureTabbableItem); requestAnimationFrame(ensureTabbableItem);
ensureTabbableItem();
}); });
const setSelected = useCallback( const setSelected = useCallback(
@@ -199,12 +216,12 @@ function TreeInner<T extends { id: string }>(
} else { } else {
// 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
jotaiStore.set(selectedIdsFamily(treeId), [item.id]); setSelected([item.id], false);
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id })); jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id }));
return getContextMenu([item]); return getContextMenu([item]);
} }
}; };
}, [getContextMenu, selectableItems, 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 }) => {
@@ -411,6 +428,24 @@ function TreeInner<T extends { id: string }>(
return; 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 // Root is anything past the end of the list, so set it to the end
const hoveringRoot = over.id === root.item.id; const hoveringRoot = over.id === root.item.id;
if (hoveringRoot) { if (hoveringRoot) {
@@ -423,12 +458,7 @@ function TreeInner<T extends { id: string }>(
return; return;
} }
const selectableItem = selectableItems.find((i) => i.node.item.id === over.id) ?? null; const node = overSelectableItem.node;
if (selectableItem == null) {
return;
}
const node = selectableItem.node;
const side = computeSideForDragMove(node.item.id, e); const side = computeSideForDragMove(node.item.id, e);
const item = node.item; 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 dragIndex = 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 = 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 // 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') {
@@ -567,7 +597,8 @@ function TreeInner<T extends { id: string }>(
onClick: handleClick, onClick: handleClick,
getEditOptions, getEditOptions,
ItemInner, ItemInner,
ItemLeftSlot, ItemLeftSlotInner,
ItemRightSlot,
}; };
const handleContextMenu = useCallback( const handleContextMenu = useCallback(

View File

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

View File

@@ -24,12 +24,12 @@ export interface TreeItemClickEvent {
export type TreeItemProps<T extends { id: string }> = Pick< export type TreeItemProps<T extends { id: string }> = Pick<
TreeProps<T>, TreeProps<T>,
'ItemInner' | 'ItemLeftSlot' | 'treeId' | 'getEditOptions' | 'getItemKey' 'ItemInner' | 'ItemLeftSlotInner' | 'ItemRightSlot' | 'treeId' | 'getEditOptions' | 'getItemKey'
> & { > & {
node: TreeNode<T>; node: TreeNode<T>;
className?: string; className?: string;
onClick?: (item: T, e: TreeItemClickEvent) => void; onClick?: (item: T, e: TreeItemClickEvent) => void;
getContextMenu?: (item: T) => Promise<ContextMenuProps['items']>; getContextMenu?: (item: T) => ContextMenuProps['items'] | Promise<ContextMenuProps['items']>;
depth: number; depth: number;
addRef?: (item: T, n: TreeItemHandle | null) => void; addRef?: (item: T, n: TreeItemHandle | null) => void;
}; };
@@ -47,7 +47,8 @@ function TreeItem_<T extends { id: string }>({
treeId, treeId,
node, node,
ItemInner, ItemInner,
ItemLeftSlot, ItemLeftSlotInner,
ItemRightSlot,
getContextMenu, getContextMenu,
onClick, onClick,
getEditOptions, getEditOptions,
@@ -135,7 +136,7 @@ function TreeItem_<T extends { id: string }>({
}, [node.item.id, treeId]); }, [node.item.id, treeId]);
const handleSubmitNameEdit = useCallback( const handleSubmitNameEdit = useCallback(
async function submitNameEdit(el: HTMLInputElement) { async (el: HTMLInputElement) => {
getEditOptions?.(node.item).onChange(node.item, el.value); getEditOptions?.(node.item).onChange(node.item, el.value);
onClick?.(node.item, { shiftKey: false, ctrlKey: false, metaKey: false }); onClick?.(node.item, { shiftKey: false, ctrlKey: false, metaKey: false });
// Slight delay for the model to propagate to the local store // Slight delay for the model to propagate to the local store
@@ -243,7 +244,12 @@ function TreeItem_<T extends { id: string }>({
setShowContextMenu(null); 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 { setNodeRef: setDroppableRef } = useDroppable({ id: node.item.id });
const handlePointerDown = useCallback( const handlePointerDown = useCallback(
@@ -290,7 +296,7 @@ function TreeItem_<T extends { id: string }>({
<div <div
className={classNames( className={classNames(
'text-text-subtle', '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 && ( {showContextMenu && (
@@ -301,7 +307,11 @@ function TreeItem_<T extends { id: string }>({
/> />
)} )}
{node.children != null ? ( {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
icon={node.children.length === 0 ? 'dot' : 'chevron_right'} icon={node.children.length === 0 ? 'dot' : 'chevron_right'}
className={classNames( className={classNames(
@@ -322,12 +332,12 @@ function TreeItem_<T extends { id: string }>({
onClick={handleClick} onClick={handleClick}
onDoubleClick={handleDoubleClick} onDoubleClick={handleDoubleClick}
disabled={editing} 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} {...attributes}
{...listeners} {...listeners}
tabIndex={isLastSelected ? 0 : -1} tabIndex={isLastSelected ? 0 : -1}
> >
{ItemLeftSlot != null && <ItemLeftSlot treeId={treeId} item={node.item} />} {ItemLeftSlotInner != null && <ItemLeftSlotInner treeId={treeId} item={node.item} />}
{getEditOptions != null && editing ? ( {getEditOptions != null && editing ? (
(() => { (() => {
const { defaultValue, placeholder } = getEditOptions(node.item); const { defaultValue, placeholder } = getEditOptions(node.item);
@@ -347,6 +357,11 @@ function TreeItem_<T extends { id: string }>({
<ItemInner treeId={treeId} item={node.item} /> <ItemInner treeId={treeId} item={node.item} />
)} )}
</button> </button>
{ItemRightSlot != null ? (
<ItemRightSlot treeId={treeId} item={node.item} />
) : (
<span aria-hidden />
)}
</div> </div>
</li> </li>
); );

View File

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

View File

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

View File

@@ -1,19 +1,25 @@
import { environmentsAtom } from '@yaakapp-internal/models'; import { environmentsAtom } from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai'; import { atom, useAtomValue } from 'jotai';
import { useMemo } from 'react';
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() { export function useEnvironmentsBreakdown() {
const allEnvironments = useAtomValue(environmentsAtom); return useAtomValue(environmentsBreakdownAtom);
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]);
} }