mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-27 03:41:11 +01:00
Ability to sync environments to folder (#207)
This commit is contained in:
@@ -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"
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user