Ability to sync environments to folder (#207)

This commit is contained in:
Gregory Schier
2025-05-08 14:10:07 -07:00
committed by GitHub
parent 77cdea2f9f
commit 94d4227bc1
54 changed files with 710 additions and 425 deletions

View File

@@ -1,5 +1,5 @@
import type { Environment } from '@yaakapp-internal/models';
import { patchModel } from '@yaakapp-internal/models';
import { duplicateModel, patchModel } from '@yaakapp-internal/models';
import type { GenericCompletionOption } from '@yaakapp-internal/plugins';
import classNames from 'classnames';
import type { ReactNode } from 'react';
@@ -12,6 +12,7 @@ import { useRandomKey } from '../hooks/useRandomKey';
import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm';
import { analyzeTemplate, convertTemplateToSecure } from '../lib/encryption';
import { showPrompt } from '../lib/prompt';
import { resolvedModelName } from '../lib/resolvedModelName';
import {
setupOrConfigureEncryption,
withEncryptionEnabled,
@@ -19,18 +20,21 @@ import {
import { BadgeButton } from './core/BadgeButton';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { DismissibleBanner } from './core/DismissibleBanner';
import type { DropdownItem } from './core/Dropdown';
import { ContextMenu } from './core/Dropdown';
import type { GenericCompletionConfig } from './core/Editor/genericCompletion';
import { Heading } from './core/Heading';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { IconTooltip } from './core/IconTooltip';
import { InlineCode } from './core/InlineCode';
import type { PairWithId } from './core/PairEditor';
import { ensurePairId } from './core/PairEditor';
import { PairOrBulkEditor } from './core/PairOrBulkEditor';
import { Separator } from './core/Separator';
import { SplitLayout } from './core/SplitLayout';
import { HStack, VStack } from './core/Stacks';
import { VStack } from './core/Stacks';
interface Props {
initialEnvironment: Environment | null;
@@ -38,7 +42,8 @@ interface Props {
export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
const createEnvironment = useCreateEnvironment();
const { baseEnvironment, subEnvironments, allEnvironments } = useEnvironmentsBreakdown();
const { baseEnvironment, otherBaseEnvironments, subEnvironments, allEnvironments } =
useEnvironmentsBreakdown();
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string | null>(
initialEnvironment?.id ?? null,
);
@@ -51,9 +56,36 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
const handleCreateEnvironment = async () => {
if (baseEnvironment == null) return;
const id = await createEnvironment.mutateAsync(baseEnvironment);
setSelectedEnvironmentId(id);
if (id != null) setSelectedEnvironmentId(id);
};
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 handleDeleteEnvironment = useCallback(
async (environment: Environment) => {
await deleteModelWithConfirm(environment);
if (selectedEnvironmentId === environment.id) {
setSelectedEnvironmentId(baseEnvironment?.id ?? null);
}
},
[baseEnvironment?.id, selectedEnvironmentId],
);
if (baseEnvironment == null) {
return null;
}
return (
<SplitLayout
name="env_editor"
@@ -63,24 +95,33 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
firstSlot={() => (
<aside className="w-full min-w-0 pt-2">
<div className="min-w-0 h-full overflow-y-auto pt-1">
<SidebarButton
active={selectedEnvironment?.id == baseEnvironment?.id}
onClick={() => setSelectedEnvironmentId(null)}
environment={null}
rightSlot={
<IconButton
size="sm"
iconSize="md"
title="Add sub environment"
icon="plus_circle"
iconClassName="text-text-subtlest group-hover:text-text-subtle"
className="group"
onClick={handleCreateEnvironment}
/>
}
>
{baseEnvironment?.name}
</SidebarButton>
{[baseEnvironment, ...otherBaseEnvironments].map((e) => (
<SidebarButton
key={e.id}
active={selectedEnvironment?.id == e.id}
onClick={() => setSelectedEnvironmentId(e.id)}
environment={e}
duplicateEnvironment={handleDuplicateEnvironment}
// Allow deleting base environment if there are multiples
deleteEnvironment={
otherBaseEnvironments.length > 0 ? handleDeleteEnvironment : null
}
rightSlot={e.public && sharableTooltip}
outerRightSlot={
<IconButton
size="sm"
iconSize="md"
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)}
</SidebarButton>
))}
{subEnvironments.length > 0 && (
<div className="px-2">
<Separator className="my-3"></Separator>
@@ -92,11 +133,9 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
active={selectedEnvironment?.id === e.id}
environment={e}
onClick={() => setSelectedEnvironmentId(e.id)}
onDelete={() => {
if (e.id === selectedEnvironmentId) {
setSelectedEnvironmentId(null);
}
}}
rightSlot={e.public && sharableTooltip}
duplicateEnvironment={handleDuplicateEnvironment}
deleteEnvironment={handleDeleteEnvironment}
>
{e.name}
</SidebarButton>
@@ -143,11 +182,10 @@ const EnvironmentEditor = function ({
);
const [forceUpdateKey, regenerateForceUpdateKey] = useRandomKey();
// Gather a list of env names from other environments, to help the user get them aligned
// Gather a list of env names from other environments to help the user get them aligned
const nameAutocomplete = useMemo<GenericCompletionConfig>(() => {
const options: GenericCompletionOption[] = [];
const isBaseEnv = activeEnvironment.environmentId == null;
if (isBaseEnv) {
if (activeEnvironment.base) {
return { options };
}
@@ -166,10 +204,10 @@ const EnvironmentEditor = function ({
});
}
return { options };
}, [activeEnvironment.environmentId, activeEnvironment.id, allEnvironments]);
}, [activeEnvironment.base, activeEnvironment.id, allEnvironments]);
const validateName = useCallback((name: string) => {
// Empty just means the variable doesn't have a name yet, and is unusable
// Empty just means the variable doesn't have a name yet and is unusable
if (name === '') return true;
return name.match(/^[a-z_][a-z0-9_-]*$/i) != null;
}, []);
@@ -177,7 +215,7 @@ const EnvironmentEditor = function ({
const valueType = !isEncryptionEnabled && valueVisibility.value ? 'text' : 'password';
const promptToEncrypt = useMemo(() => {
if (!isEncryptionEnabled) {
return false;
return true;
} else {
return !activeEnvironment.variables.every(
(v) => v.value === '' || analyzeTemplate(v.value) !== 'insecure',
@@ -199,28 +237,33 @@ const EnvironmentEditor = function ({
return (
<VStack space={4} className={classNames(className, 'pl-4')}>
<HStack space={2} className="justify-between">
<Heading className="w-full flex items-center gap-1">
<div>{activeEnvironment?.name}</div>
{promptToEncrypt ? (
<Heading className="w-full flex items-center gap-0.5">
<div className="mr-2">{activeEnvironment?.name}</div>
{isEncryptionEnabled ? (
promptToEncrypt ? (
<BadgeButton color="notice" onClick={() => encryptEnvironment(activeEnvironment)}>
Encrypt All Variables
</BadgeButton>
) : isEncryptionEnabled ? (
) : (
<BadgeButton color="secondary" onClick={setupOrConfigureEncryption}>
Encryption Settings
</BadgeButton>
) : (
<IconButton
size="sm"
icon={valueVisibility.value ? 'eye' : 'eye_closed'}
title={valueVisibility.value ? 'Hide Values' : 'Reveal Values'}
onClick={() => valueVisibility.set((v) => !v)}
/>
)}
</Heading>
</HStack>
<div className="h-full pr-2 pb-2">
)
) : (
<>
<BadgeButton color="secondary" onClick={() => valueVisibility.set((v) => !v)}>
{valueVisibility.value ? 'Conceal Values' : 'Reveal Values'}
</BadgeButton>
</>
)}
</Heading>
{activeEnvironment.public && promptToEncrypt && (
<DismissibleBanner id={activeEnvironment.id} color="notice" className="mr-3">
This environment is sharable. Ensure variable values are encrypted to avoid accidental
leaking of secrets during directory sync or data export.
</DismissibleBanner>
)}
<div className="h-full pr-2 pb-2 grid grid-rows-[minmax(0,1fr)] overflow-auto">
<PairOrBulkEditor
allowMultilineValues
preferenceName="environment"
@@ -230,6 +273,7 @@ const EnvironmentEditor = function ({
valueType={valueType}
valueAutocompleteVariables
valueAutocompleteFunctions
forcedEnvironmentId={activeEnvironment.id}
forceUpdateKey={`${activeEnvironment.id}::${forceUpdateKey}`}
pairs={activeEnvironment.variables}
onChange={handleChange}
@@ -245,17 +289,21 @@ function SidebarButton({
className,
active,
onClick,
onDelete,
deleteEnvironment,
rightSlot,
outerRightSlot,
duplicateEnvironment,
environment,
}: {
className?: string;
children: ReactNode;
active: boolean;
onClick: () => void;
onDelete?: () => void;
rightSlot?: ReactNode;
environment: Environment | null;
outerRightSlot?: ReactNode;
environment: Environment;
deleteEnvironment: ((environment: Environment) => void) | null;
duplicateEnvironment: ((environment: Environment) => void) | null;
}) {
const [showContextMenu, setShowContextMenu] = useState<{
x: number;
@@ -287,49 +335,88 @@ function SidebarButton({
justify="start"
onClick={onClick}
onContextMenu={handleContextMenu}
rightSlot={rightSlot}
>
{children}
</Button>
{rightSlot}
{outerRightSlot}
</div>
{environment != null && (
<ContextMenu
triggerPosition={showContextMenu}
onClose={() => setShowContextMenu(null)}
items={[
{
label: 'Rename',
leftSlot: <Icon icon="pencil" size="sm" />,
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 });
},
<ContextMenu
triggerPosition={showContextMenu}
onClose={() => setShowContextMenu(null)}
items={[
{
label: 'Rename',
leftSlot: <Icon icon="pencil" />,
hidden: environment.base,
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 });
},
{
color: 'danger',
label: 'Delete',
leftSlot: <Icon icon="trash" size="sm" />,
onSelect: async () => {
await deleteModelWithConfirm(environment);
onDelete?.();
},
},
...((duplicateEnvironment
? [
{
label: 'Duplicate',
leftSlot: <Icon icon="copy" />,
onSelect: () => {
duplicateEnvironment?.(environment);
},
},
]
: []) as DropdownItem[]),
{
label: `Make ${environment.public ? 'Private' : 'Sharable'}`,
leftSlot: <Icon icon={environment.public ? 'eye_closed' : 'eye'} />,
rightSlot: (
<IconTooltip
content={
<>
Sharable environments will be included in Directory Sync or data export. It is
recommended to encrypt all variable values within sharable environments to
prevent accidentally leaking secrets.
</>
}
/>
),
onSelect: async () => {
await patchModel(environment, { public: !environment.public });
},
]}
/>
)}
},
...((deleteEnvironment
? [
{
color: 'danger',
label: 'Delete',
leftSlot: <Icon icon="trash" />,
onSelect: () => {
deleteEnvironment(environment);
},
},
]
: []) as DropdownItem[]),
]}
/>
</>
);
}
const sharableTooltip = (
<IconTooltip
icon="eye"
content="This environment will be included in Directory Sync and data exports"
/>
);