Environment colors (#225)

This commit is contained in:
Gregory Schier
2025-06-07 18:21:54 -07:00
committed by GitHub
parent 27901231dc
commit d0fde99b1c
19 changed files with 182 additions and 25 deletions

11
package-lock.json generated
View File

@@ -12946,6 +12946,16 @@
"node": ">=0.10.0"
}
},
"node_modules/react-colorful": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz",
"integrity": "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/react-dnd": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz",
@@ -17626,6 +17636,7 @@
"papaparse": "^5.4.1",
"parse-color": "^1.0.0",
"react": "^18.3.1",
"react-colorful": "^5.6.1",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.3.1",

View File

@@ -0,0 +1 @@
ALTER TABLE environments ADD COLUMN color TEXT;

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, base: boolean, variables: Array<EnvironmentVariable>, };
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, base: boolean, variables: Array<EnvironmentVariable>, color: string | null, };
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, base: boolean, variables: Array<EnvironmentVariable>, };
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, base: boolean, variables: Array<EnvironmentVariable>, color: string | null, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };

View File

@@ -1,9 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Environment } from "./gen_models.js";
import type { Folder } from "./gen_models.js";
import type { GrpcRequest } from "./gen_models.js";
import type { HttpRequest } from "./gen_models.js";
import type { WebsocketRequest } from "./gen_models.js";
import type { Workspace } from "./gen_models.js";
import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models.js";
export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };

View File

@@ -520,6 +520,7 @@ pub struct Environment {
pub public: bool,
pub base: bool,
pub variables: Vec<EnvironmentVariable>,
pub color: Option<String>,
}
impl UpsertModelInfo for Environment {
@@ -553,6 +554,7 @@ impl UpsertModelInfo for Environment {
(UpdatedAt, upsert_date(source, self.updated_at)),
(WorkspaceId, self.workspace_id.into()),
(Base, self.base.into()),
(Color, self.color.into()),
(Name, self.name.trim().into()),
(Public, self.public.into()),
(Variables, serde_json::to_string(&self.variables)?.into()),
@@ -563,6 +565,7 @@ impl UpsertModelInfo for Environment {
vec![
EnvironmentIden::UpdatedAt,
EnvironmentIden::Base,
EnvironmentIden::Color,
EnvironmentIden::Name,
EnvironmentIden::Public,
EnvironmentIden::Variables,
@@ -581,6 +584,7 @@ impl UpsertModelInfo for Environment {
created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?,
base: row.get("base")?,
color: row.get("color")?,
name: row.get("name")?,
public: row.get("public")?,
variables: serde_json::from_str(variables.as_str()).unwrap_or_default(),

View File

@@ -1,12 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Environment } from "./gen_models.js";
import type { Folder } from "./gen_models.js";
import type { GrpcRequest } from "./gen_models.js";
import type { HttpRequest } from "./gen_models.js";
import type { HttpResponse } from "./gen_models.js";
import type { Environment, Folder, GrpcRequest, HttpRequest, HttpResponse, WebsocketRequest, Workspace } from "./gen_models.js";
import type { JsonValue } from "./serde_json/JsonValue.js";
import type { WebsocketRequest } from "./gen_models.js";
import type { Workspace } from "./gen_models.js";
export type BootRequest = { dir: string, watch: boolean, };

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, base: boolean, variables: Array<EnvironmentVariable>, };
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, base: boolean, variables: Array<EnvironmentVariable>, color: string | null, };
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, base: boolean, variables: Array<EnvironmentVariable>, };
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, base: boolean, variables: Array<EnvironmentVariable>, color: string | null, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };

View File

@@ -1,6 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { SyncModel } from "./gen_models.js";
import type { SyncState } from "./gen_models.js";
import type { SyncModel, SyncState } from "./gen_models.js";
export type FsCandidate = { "type": "FsCandidate", model: SyncModel, relPath: string, checksum: string, };

View File

@@ -4,11 +4,12 @@ import { useActiveEnvironment } from '../hooks/useActiveEnvironment';
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown';
import { toggleDialog } from '../lib/dialog';
import { setWorkspaceSearchParams } from '../lib/setWorkspaceSearchParams';
import type { ButtonProps } from './core/Button';
import { Button } from './core/Button';
import type { ButtonProps } from './core/Button';
import type { DropdownItem } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
import { EnvironmentColorCircle } from './EnvironmentColorCircle';
import { EnvironmentEditDialog } from './EnvironmentEditDialog';
type Props = {
@@ -38,6 +39,7 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
(e) => ({
key: e.id,
label: e.name,
rightSlot: <EnvironmentColorCircle environment={e} />,
leftSlot: e.id === activeEnvironment?.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: async () => {
if (e.id !== activeEnvironment?.id) {
@@ -80,6 +82,7 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
onClick={subEnvironments.length === 0 ? showEnvironmentDialog : undefined}
{...buttonProps}
>
<EnvironmentColorCircle environment={activeEnvironment ?? null} />
{activeEnvironment?.name ?? (hasBaseVars ? 'Environment' : 'No Environment')}
</Button>
</Dropdown>

View File

@@ -0,0 +1,29 @@
import type { Environment } from '@yaakapp-internal/models';
import classNames from 'classnames';
import { showColorPicker } from '../lib/showColorPicker';
export function EnvironmentColorCircle({
environment,
clickToEdit,
}: {
environment: Environment | null;
clickToEdit?: boolean;
}) {
if (environment?.color == null) return null;
const style = { backgroundColor: environment.color };
const className =
'inline-block w-[0.75em] h-[0.75em] rounded-full mr-1.5 border border-transparent';
if (clickToEdit) {
return (
<button
onClick={() => showColorPicker(environment)}
style={style}
className={classNames(className, 'hover:border-text')}
/>
);
} else {
return <span style={style} className={className} />;
}
}

View File

@@ -0,0 +1,26 @@
import { useState } from 'react';
import { Button } from './core/Button';
import { ColorPicker } from './core/ColorPicker';
export function EnvironmentColorPicker({
color: defaultColor,
onChange,
}: {
color: string | null;
onChange: (color: string | null) => void;
}) {
const [color, setColor] = useState<string | null>(defaultColor);
return (
<div className="flex flex-col items-stretch gap-3 pb-2 w-full">
<ColorPicker color={color} onChange={setColor} />
<div className="grid grid-cols-[1fr_1fr] gap-1.5">
<Button variant="border" color="secondary" onClick={() => onChange(null)}>
Clear
</Button>
<Button color="primary" onClick={() => onChange(color)}>
Save
</Button>
</div>
</div>
);
}

View File

@@ -17,6 +17,7 @@ import {
setupOrConfigureEncryption,
withEncryptionEnabled,
} from '../lib/setupOrConfigureEncryption';
import { showColorPicker } from '../lib/showColorPicker';
import { BadgeButton } from './core/BadgeButton';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
@@ -35,6 +36,7 @@ import { PairOrBulkEditor } from './core/PairOrBulkEditor';
import { Separator } from './core/Separator';
import { SplitLayout } from './core/SplitLayout';
import { VStack } from './core/Stacks';
import { EnvironmentColorCircle } from './EnvironmentColorCircle';
interface Props {
initialEnvironment: Environment | null;
@@ -123,7 +125,7 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
))}
{subEnvironments.length > 0 && (
<div className="px-2">
<Separator className="my-3"></Separator>
<Separator className="my-3" />
</div>
)}
{subEnvironments.map((e) => (
@@ -237,6 +239,7 @@ const EnvironmentEditor = function ({
return (
<VStack space={4} className={classNames(className, 'pl-4')}>
<Heading className="w-full flex items-center gap-0.5">
<EnvironmentColorCircle clickToEdit environment={selectedEnvironment ?? null} />
<div className="mr-2">{selectedEnvironment?.name}</div>
{isEncryptionEnabled ? (
promptToEncrypt ? (
@@ -344,6 +347,7 @@ function SidebarButton({
onContextMenu={handleContextMenu}
rightSlot={rightSlot}
>
<EnvironmentColorCircle environment={environment} />
{children}
</Button>
{outerRightSlot}
@@ -385,6 +389,12 @@ function SidebarButton({
},
]
: []) as DropdownItem[]),
{
label: 'Set Color',
leftSlot: <Icon icon="palette" />,
hidden: environment.base,
onSelect: async () => showColorPicker(environment),
},
{
label: `Make ${environment.public ? 'Private' : 'Sharable'}`,
leftSlot: <Icon icon={environment.public ? 'eye_closed' : 'eye'} />,

View File

@@ -8,7 +8,10 @@ import {
useEnsureActiveCookieJar,
useSubscribeActiveCookieJarId,
} from '../hooks/useActiveCookieJar';
import { useSubscribeActiveEnvironmentId } from '../hooks/useActiveEnvironment';
import {
activeEnvironmentAtom,
useSubscribeActiveEnvironmentId,
} from '../hooks/useActiveEnvironment';
import { activeRequestAtom } from '../hooks/useActiveRequest';
import { useSubscribeActiveRequestId } from '../hooks/useActiveRequestId';
import { activeWorkspaceAtom } from '../hooks/useActiveWorkspace';
@@ -32,6 +35,7 @@ import { HotKeyList } from './core/HotKeyList';
import { FeedbackLink } from './core/Link';
import { HStack } from './core/Stacks';
import { CreateDropdown } from './CreateDropdown';
import { ErrorBoundary } from './ErrorBoundary';
import { GrpcConnectionLayout } from './GrpcConnectionLayout';
import { HeaderSize } from './HeaderSize';
import { HttpRequestLayout } from './HttpRequestLayout';
@@ -41,7 +45,6 @@ import { Sidebar } from './sidebar/Sidebar';
import { SidebarActions } from './sidebar/SidebarActions';
import { WebsocketRequestLayout } from './WebsocketRequestLayout';
import { WorkspaceHeader } from './WorkspaceHeader';
import { ErrorBoundary } from './ErrorBoundary';
const side = { gridArea: 'side' };
const head = { gridArea: 'head' };
@@ -56,6 +59,7 @@ export function Workspace() {
const { setWidth, width, resetWidth } = useSidebarWidth();
const [sidebarHidden, setSidebarHidden] = useSidebarHidden();
const [floatingSidebarHidden, setFloatingSidebarHidden] = useFloatingSidebarHidden();
const activeEnvironment = useAtomValue(activeEnvironmentAtom);
const floating = useShouldFloatSidebar();
const [isResizing, setIsResizing] = useState<boolean>(false);
const moveState = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>(
@@ -117,6 +121,12 @@ export function Workspace() {
[sideWidth, floating],
);
const environmentBg = useMemo(() => {
if (activeEnvironment?.color == null) return undefined;
const background = `linear-gradient(to right, ${activeEnvironment.color} 15%, transparent 40%)`;
return { background };
}, [activeEnvironment?.color ?? 'n/a']);
// We're loading still
if (workspaces.length === 0) {
return null;
@@ -175,9 +185,19 @@ export function Workspace() {
<HeaderSize
data-tauri-drag-region
size="lg"
className="x-theme-appHeader bg-surface"
className="relative x-theme-appHeader bg-surface"
style={head}
>
<div className="absolute inset-0 pointer-events-none">
<div // Add subtle background
style={environmentBg}
className="absolute inset-0 opacity-5"
/>
<div // Add subtle border bottom
style={environmentBg}
className="absolute left-0 right-0 bottom-0 h-[0.5px] opacity-20"
/>
</div>
<WorkspaceHeader className="pointer-events-none" />
</HeaderSize>
<ErrorBoundary name="Workspace Body">

View File

@@ -0,0 +1,40 @@
import { useState } from 'react';
import { HexColorPicker } from 'react-colorful';
import { useRandomKey } from '../../hooks/useRandomKey';
import { PlainInput } from './PlainInput';
interface Props {
onChange: (value: string | null) => void;
color: string | null;
}
export function ColorPicker({ onChange, color: defaultColor }: Props) {
const [updateKey, regenerateKey] = useRandomKey();
const [color, setColor] = useState<string | null>(defaultColor);
return (
<form
className="flex flex-col gap-3 items-stretch w-full"
onSubmit={(e) => {
e.preventDefault();
onChange(color);
}}
>
<HexColorPicker
color={color ?? undefined}
className="!w-full"
onChange={(color) => {
setColor(color.toUpperCase());
regenerateKey();
}}
/>
<PlainInput
hideLabel
label="Plain Color"
forceUpdateKey={updateKey}
defaultValue={color ?? ''}
onChange={(c) => setColor(c.toUpperCase())}
validate={(color) => color.match(/#[0-9a-fA-F]{6}$/) !== null}
/>
</form>
);
}

View File

@@ -26,9 +26,9 @@ const icons = {
cake: lucide.CakeIcon,
chat: lucide.MessageSquare,
check: lucide.CheckIcon,
check_circle: lucide.CheckCircleIcon,
check_square_checked: lucide.SquareCheckIcon,
check_square_unchecked: lucide.SquareIcon,
check_circle: lucide.CheckCircleIcon,
chevron_down: lucide.ChevronDownIcon,
chevron_right: lucide.ChevronRightIcon,
circle_alert: lucide.CircleAlertIcon,
@@ -78,6 +78,7 @@ const icons = {
minus_circle: lucide.MinusCircleIcon,
moon: lucide.MoonIcon,
more_vertical: lucide.MoreVerticalIcon,
palette: lucide.PaletteIcon,
paste: lucide.ClipboardPasteIcon,
pencil: lucide.PencilIcon,
pin: lucide.PinIcon,

View File

@@ -0,0 +1,23 @@
import type { Environment } from '@yaakapp-internal/models';
import { patchModel } from '@yaakapp-internal/models';
import { showDialog } from './dialog';
import { EnvironmentColorPicker } from '../components/EnvironmentColorPicker';
export function showColorPicker(environment: Environment) {
showDialog({
title: 'Environment Color',
id: 'color-picker',
size: 'dynamic',
render: ({ hide }) => {
return (
<EnvironmentColorPicker
color={environment.color ?? '#54dc44'}
onChange={async (color) => {
await patchModel(environment, { color });
hide();
}}
/>
);
},
});
}

View File

@@ -54,6 +54,7 @@
"papaparse": "^5.4.1",
"parse-color": "^1.0.0",
"react": "^18.3.1",
"react-colorful": "^5.6.1",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.3.1",