adding duplication and syncing of workspaces, a settings dialog and variable listing and editing

This commit is contained in:
xyroscar
2025-11-26 11:25:57 -08:00
parent e2a7761388
commit ce75694ffb
9 changed files with 1587 additions and 35 deletions

View File

@@ -0,0 +1,338 @@
<script lang="ts">
import * as Dialog from "$lib/components/ui/dialog/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import { Textarea } from "$lib/components/ui/textarea/index.js";
import { Badge } from "$lib/components/ui/badge/index.js";
import { Separator } from "$lib/components/ui/separator/index.js";
import CopyIcon from "@lucide/svelte/icons/copy";
import LinkIcon from "@lucide/svelte/icons/link";
import type { Workspace } from "$lib/types/workspace";
import type { Variable } from "$lib/types/variable";
import { get_workspace_variables } from "$lib/services/variables";
import {
duplicate_workspace,
type DuplicateWorkspaceOptions,
} from "$lib/services/sync";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
sourceWorkspace: Workspace | null;
onDuplicate: (options: DuplicateWorkspaceOptions) => Promise<void>;
};
let {
open = $bindable(),
onOpenChange,
sourceWorkspace,
onDuplicate,
}: Props = $props();
let newName = $state("");
let newDescription = $state("");
let environment = $state("");
let copyVariables = $state(true);
let copySecrets = $state(false);
let createSyncGroup = $state(false);
let syncGroupName = $state("");
let variablesToSync = $state<string[]>([]);
let workspaceVariables = $state<Variable[]>([]);
let loading = $state(false);
$effect(() => {
if (open && sourceWorkspace) {
newName = `${sourceWorkspace.Name} (Copy)`;
newDescription = sourceWorkspace.Description;
environment = "";
syncGroupName = `${sourceWorkspace.Name} Environments`;
loadVariables();
}
});
async function loadVariables() {
if (sourceWorkspace) {
workspaceVariables = await get_workspace_variables(sourceWorkspace.Id);
}
}
function toggleVariableSync(varName: string) {
if (variablesToSync.includes(varName)) {
variablesToSync = variablesToSync.filter((v) => v !== varName);
} else {
variablesToSync = [...variablesToSync, varName];
}
}
function selectAllVariables() {
variablesToSync = workspaceVariables
.filter((v) => !v.isSecret || copySecrets)
.map((v) => v.name);
}
function deselectAllVariables() {
variablesToSync = [];
}
async function handleSubmit() {
if (!sourceWorkspace) return;
loading = true;
try {
await onDuplicate({
sourceWorkspaceId: sourceWorkspace.Id,
newName,
newDescription,
environment: environment || undefined,
copyVariables,
copySecrets,
createSyncGroup,
syncGroupName: createSyncGroup ? syncGroupName : undefined,
variablesToSync: createSyncGroup ? variablesToSync : undefined,
});
onOpenChange(false);
} finally {
loading = false;
}
}
</script>
<Dialog.Root bind:open {onOpenChange}>
<Dialog.Content
class="sm:max-w-[550px] max-h-[85vh] overflow-hidden flex flex-col"
>
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<CopyIcon class="size-5" />
Duplicate Workspace
</Dialog.Title>
<Dialog.Description>
Create a copy of "{sourceWorkspace?.Name}" with all its collections and
requests.
</Dialog.Description>
</Dialog.Header>
<form
class="flex-1 overflow-auto space-y-4 py-4"
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
>
<div class="space-y-4">
<div class="grid grid-cols-4 items-center gap-4">
<Label for="new-name" class="text-end">Name</Label>
<Input
id="new-name"
class="col-span-3"
placeholder="New workspace name"
bind:value={newName}
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="new-desc" class="text-end">Description</Label>
<Textarea
id="new-desc"
class="col-span-3"
placeholder="Workspace description"
bind:value={newDescription}
rows={2}
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="environment" class="text-end">Environment</Label>
<Input
id="environment"
class="col-span-3"
placeholder="e.g., Development, Staging, Production"
bind:value={environment}
/>
</div>
</div>
<Separator />
<div class="space-y-4">
<h4 class="font-medium">Variables</h4>
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<Label>Copy Variables</Label>
<p class="text-xs text-muted-foreground">
Copy workspace variables to the new workspace.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={copyVariables}
aria-label="Toggle copy variables"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 {copyVariables
? 'bg-primary'
: 'bg-input'}"
onclick={() => (copyVariables = !copyVariables)}
>
<span
class="pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform {copyVariables
? 'translate-x-5'
: 'translate-x-0'}"
></span>
</button>
</div>
{#if copyVariables}
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<Label>Copy Secret Values</Label>
<p class="text-xs text-muted-foreground">
Copy secret values (otherwise they'll be empty).
</p>
</div>
<button
type="button"
role="switch"
aria-checked={copySecrets}
aria-label="Toggle copy secrets"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 {copySecrets
? 'bg-primary'
: 'bg-input'}"
onclick={() => (copySecrets = !copySecrets)}
>
<span
class="pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform {copySecrets
? 'translate-x-5'
: 'translate-x-0'}"
></span>
</button>
</div>
{/if}
</div>
<Separator />
<div class="space-y-4">
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<div class="flex items-center gap-2">
<LinkIcon class="size-4" />
<Label>Create Sync Group</Label>
</div>
<p class="text-xs text-muted-foreground">
Link workspaces to sync selected variables between environments.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={createSyncGroup}
aria-label="Toggle create sync group"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 {createSyncGroup
? 'bg-primary'
: 'bg-input'}"
onclick={() => (createSyncGroup = !createSyncGroup)}
>
<span
class="pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform {createSyncGroup
? 'translate-x-5'
: 'translate-x-0'}"
></span>
</button>
</div>
{#if createSyncGroup}
<div class="grid grid-cols-4 items-center gap-4">
<Label for="sync-name" class="text-end">Group Name</Label>
<Input
id="sync-name"
class="col-span-3"
placeholder="Sync group name"
bind:value={syncGroupName}
/>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<Label>Variables to Sync</Label>
<div class="flex gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onclick={selectAllVariables}
>
Select All
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onclick={deselectAllVariables}
>
Deselect All
</Button>
</div>
</div>
<p class="text-xs text-muted-foreground mb-2">
Selected variables will be kept in sync across linked workspaces.
</p>
{#if workspaceVariables.length === 0}
<div class="text-center py-4 text-muted-foreground text-sm">
No workspace variables to sync.
</div>
{:else}
<div
class="grid grid-cols-2 gap-2 max-h-[150px] overflow-auto p-2 border rounded-md"
>
{#each workspaceVariables as variable (variable.id)}
{#if !variable.isSecret || copySecrets}
<button
type="button"
class="flex items-center gap-2 p-2 rounded text-left text-sm hover:bg-accent transition-colors {variablesToSync.includes(
variable.name
)
? 'bg-accent'
: ''}"
onclick={() => toggleVariableSync(variable.name)}
>
<input
type="checkbox"
checked={variablesToSync.includes(variable.name)}
class="rounded"
onchange={() => {}}
/>
<span class="font-mono text-xs truncate"
>{variable.name}</span
>
{#if variable.isSecret}
<Badge variant="secondary" class="text-xs">secret</Badge
>
{/if}
</button>
{/if}
{/each}
</div>
{/if}
</div>
{/if}
</div>
<Dialog.Footer class="pt-4">
<Button
type="button"
variant="outline"
onclick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button type="submit" disabled={loading || !newName.trim()}>
{loading ? "Creating..." : "Duplicate Workspace"}
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,262 @@
<script lang="ts">
import * as Dialog from "$lib/components/ui/dialog/index.js";
import * as Tabs from "$lib/components/ui/tabs/index.js";
import * as Select from "$lib/components/ui/select/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import { Separator } from "$lib/components/ui/separator/index.js";
import SettingsIcon from "@lucide/svelte/icons/settings";
import type { AppSettings } from "$lib/types/workspace";
import {
get_settings,
update_settings,
reset_settings,
} from "$lib/services/settings";
import { onMount } from "svelte";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
};
let { open = $bindable(), onOpenChange }: Props = $props();
let settings = $state<AppSettings>({
theme: "system",
defaultTimeout: 30000,
followRedirects: true,
validateSSL: true,
maxHistoryItems: 100,
autoSaveRequests: true,
});
let loading = $state(false);
onMount(async () => {
settings = await get_settings();
});
$effect(() => {
if (open) {
loadSettings();
}
});
async function loadSettings() {
settings = await get_settings();
}
async function handleSave() {
loading = true;
await update_settings(settings);
loading = false;
onOpenChange(false);
}
async function handleReset() {
settings = await reset_settings();
}
function handleThemeChange(value: string) {
settings.theme = value as "light" | "dark" | "system";
}
</script>
<Dialog.Root bind:open {onOpenChange}>
<Dialog.Content
class="sm:max-w-[600px] max-h-[80vh] overflow-hidden flex flex-col"
>
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<SettingsIcon class="size-5" />
Settings
</Dialog.Title>
<Dialog.Description>
Configure application settings and preferences.
</Dialog.Description>
</Dialog.Header>
<Tabs.Root value="general" class="flex-1 overflow-hidden">
<Tabs.List class="grid w-full grid-cols-3">
<Tabs.Trigger value="general">General</Tabs.Trigger>
<Tabs.Trigger value="requests">Requests</Tabs.Trigger>
<Tabs.Trigger value="advanced">Advanced</Tabs.Trigger>
</Tabs.List>
<div class="mt-4 overflow-auto max-h-[400px]">
<Tabs.Content value="general" class="space-y-4">
<div class="space-y-2">
<Label for="theme">Theme</Label>
<Select.Root
type="single"
value={settings.theme}
onValueChange={handleThemeChange}
>
<Select.Trigger id="theme" class="w-full">
{settings.theme === "light"
? "Light"
: settings.theme === "dark"
? "Dark"
: "System"}
</Select.Trigger>
<Select.Content>
<Select.Item value="system">System</Select.Item>
<Select.Item value="light">Light</Select.Item>
<Select.Item value="dark">Dark</Select.Item>
</Select.Content>
</Select.Root>
<p class="text-xs text-muted-foreground">
Choose the application color theme.
</p>
</div>
<Separator />
<div class="space-y-2">
<Label for="maxHistory">History Items</Label>
<Input
id="maxHistory"
type="number"
min="10"
max="1000"
bind:value={settings.maxHistoryItems}
/>
<p class="text-xs text-muted-foreground">
Maximum number of request history items to keep.
</p>
</div>
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<Label>Auto-save Requests</Label>
<p class="text-xs text-muted-foreground">
Automatically save request changes.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={settings.autoSaveRequests}
aria-label="Toggle auto-save requests"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 {settings.autoSaveRequests
? 'bg-primary'
: 'bg-input'}"
onclick={() =>
(settings.autoSaveRequests = !settings.autoSaveRequests)}
>
<span
class="pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform {settings.autoSaveRequests
? 'translate-x-5'
: 'translate-x-0'}"
></span>
</button>
</div>
</Tabs.Content>
<Tabs.Content value="requests" class="space-y-4">
<div class="space-y-2">
<Label for="timeout">Default Timeout (ms)</Label>
<Input
id="timeout"
type="number"
min="1000"
max="300000"
step="1000"
bind:value={settings.defaultTimeout}
/>
<p class="text-xs text-muted-foreground">
Default timeout for HTTP requests in milliseconds.
</p>
</div>
<Separator />
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<Label>Follow Redirects</Label>
<p class="text-xs text-muted-foreground">
Automatically follow HTTP redirects.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={settings.followRedirects}
aria-label="Toggle follow redirects"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 {settings.followRedirects
? 'bg-primary'
: 'bg-input'}"
onclick={() =>
(settings.followRedirects = !settings.followRedirects)}
>
<span
class="pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform {settings.followRedirects
? 'translate-x-5'
: 'translate-x-0'}"
></span>
</button>
</div>
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<Label>Validate SSL Certificates</Label>
<p class="text-xs text-muted-foreground">
Verify SSL/TLS certificates for HTTPS requests.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={settings.validateSSL}
aria-label="Toggle SSL validation"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 {settings.validateSSL
? 'bg-primary'
: 'bg-input'}"
onclick={() => (settings.validateSSL = !settings.validateSSL)}
>
<span
class="pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform {settings.validateSSL
? 'translate-x-5'
: 'translate-x-0'}"
></span>
</button>
</div>
</Tabs.Content>
<Tabs.Content value="advanced" class="space-y-4">
<div class="rounded-lg border p-4 bg-muted/30">
<h4 class="font-medium mb-2">Reset Settings</h4>
<p class="text-sm text-muted-foreground mb-4">
Reset all settings to their default values. This action cannot be
undone.
</p>
<Button variant="destructive" size="sm" onclick={handleReset}>
Reset to Defaults
</Button>
</div>
<div class="rounded-lg border p-4 bg-muted/30">
<h4 class="font-medium mb-2">Data Management</h4>
<p class="text-sm text-muted-foreground mb-4">
Export or import your workspaces, collections, and settings.
</p>
<div class="flex gap-2">
<Button variant="outline" size="sm" disabled>Export Data</Button>
<Button variant="outline" size="sm" disabled>Import Data</Button>
</div>
</div>
</Tabs.Content>
</div>
</Tabs.Root>
<Dialog.Footer class="mt-4">
<Button variant="outline" onclick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onclick={handleSave} disabled={loading}>
{loading ? "Saving..." : "Save Changes"}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,418 @@
<script lang="ts">
import * as Dialog from "$lib/components/ui/dialog/index.js";
import * as Tabs from "$lib/components/ui/tabs/index.js";
import * as Select from "$lib/components/ui/select/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import { Badge } from "$lib/components/ui/badge/index.js";
import { Textarea } from "$lib/components/ui/textarea/index.js";
import PlusIcon from "@lucide/svelte/icons/plus";
import TrashIcon from "@lucide/svelte/icons/trash-2";
import PencilIcon from "@lucide/svelte/icons/pencil";
import LockIcon from "@lucide/svelte/icons/lock";
import EyeIcon from "@lucide/svelte/icons/eye";
import EyeOffIcon from "@lucide/svelte/icons/eye-off";
import VariableIcon from "@lucide/svelte/icons/variable";
import type { Variable, VariableScope } from "$lib/types/variable";
import {
get_global_variables,
get_workspace_variables,
get_collection_variables,
create_variable,
update_variable,
delete_variable,
} from "$lib/services/variables";
import { onMount } from "svelte";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
workspaceId?: string;
collectionId?: string;
};
let {
open = $bindable(),
onOpenChange,
workspaceId,
collectionId,
}: Props = $props();
let globalVariables = $state<Variable[]>([]);
let workspaceVariables = $state<Variable[]>([]);
let collectionVariables = $state<Variable[]>([]);
let editDialogOpen = $state(false);
let editingVariable = $state<Variable | null>(null);
let isCreating = $state(false);
let formName = $state("");
let formValue = $state("");
let formDescription = $state("");
let formIsSecret = $state(false);
let formScope = $state<VariableScope>("global");
let showSecretValues = $state<Set<string>>(new Set());
$effect(() => {
if (open) {
loadVariables();
}
});
async function loadVariables() {
globalVariables = await get_global_variables();
if (workspaceId) {
workspaceVariables = await get_workspace_variables(workspaceId);
}
if (collectionId) {
collectionVariables = await get_collection_variables(collectionId);
}
}
function openCreateDialog(scope: VariableScope) {
isCreating = true;
editingVariable = null;
formName = "";
formValue = "";
formDescription = "";
formIsSecret = false;
formScope = scope;
editDialogOpen = true;
}
function openEditDialog(variable: Variable) {
isCreating = false;
editingVariable = variable;
formName = variable.name;
formValue = variable.value;
formDescription = variable.description || "";
formIsSecret = variable.isSecret;
formScope = variable.scope;
editDialogOpen = true;
}
async function handleSave() {
const scopeId =
formScope === "global"
? null
: formScope === "workspace"
? (workspaceId ?? null)
: formScope === "collection"
? (collectionId ?? null)
: null;
if (isCreating) {
await create_variable({
name: formName,
value: formValue,
scope: formScope,
scopeId,
isSecret: formIsSecret,
description: formDescription || undefined,
});
} else if (editingVariable) {
await update_variable(editingVariable.id, {
name: formName,
value: formValue,
isSecret: formIsSecret,
description: formDescription || undefined,
});
}
await loadVariables();
editDialogOpen = false;
}
async function handleDelete(variable: Variable) {
await delete_variable(variable.id);
await loadVariables();
}
function toggleSecretVisibility(id: string) {
if (showSecretValues.has(id)) {
showSecretValues.delete(id);
} else {
showSecretValues.add(id);
}
showSecretValues = new Set(showSecretValues);
}
function getScopeColor(scope: VariableScope): string {
const colors: Record<VariableScope, string> = {
global: "bg-purple-500/10 text-purple-500",
workspace: "bg-blue-500/10 text-blue-500",
collection: "bg-green-500/10 text-green-500",
request: "bg-orange-500/10 text-orange-500",
};
return colors[scope];
}
function getMaskedValue(value: string): string {
return "•".repeat(Math.min(value.length, 20));
}
</script>
<Dialog.Root bind:open {onOpenChange}>
<Dialog.Content
class="sm:max-w-[700px] max-h-[80vh] overflow-hidden flex flex-col"
>
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<VariableIcon class="size-5" />
Variables & Secrets
</Dialog.Title>
<Dialog.Description>
Manage variables and secrets at different scopes. Variables can be used
in requests with {"{{VAR_NAME}}"} syntax.
</Dialog.Description>
</Dialog.Header>
<Tabs.Root value="global" class="flex-1 overflow-hidden">
<Tabs.List class="grid w-full grid-cols-3">
<Tabs.Trigger value="global">Global</Tabs.Trigger>
<Tabs.Trigger value="workspace" disabled={!workspaceId}
>Workspace</Tabs.Trigger
>
<Tabs.Trigger value="collection" disabled={!collectionId}
>Collection</Tabs.Trigger
>
</Tabs.List>
<div class="mt-4 overflow-auto max-h-[400px]">
<Tabs.Content value="global" class="space-y-2">
<div class="flex justify-between items-center mb-4">
<p class="text-sm text-muted-foreground">
Global variables are available in all workspaces.
</p>
<Button size="sm" onclick={() => openCreateDialog("global")}>
<PlusIcon class="size-4 mr-1" />
Add Variable
</Button>
</div>
{#if globalVariables.length === 0}
<div class="text-center py-8 text-muted-foreground">
No global variables defined.
</div>
{:else}
{#each globalVariables as variable (variable.id)}
{@render variableRow(variable)}
{/each}
{/if}
</Tabs.Content>
<Tabs.Content value="workspace" class="space-y-2">
<div class="flex justify-between items-center mb-4">
<p class="text-sm text-muted-foreground">
Workspace variables are available in this workspace only.
</p>
<Button
size="sm"
onclick={() => openCreateDialog("workspace")}
disabled={!workspaceId}
>
<PlusIcon class="size-4 mr-1" />
Add Variable
</Button>
</div>
{#if workspaceVariables.length === 0}
<div class="text-center py-8 text-muted-foreground">
No workspace variables defined.
</div>
{:else}
{#each workspaceVariables as variable (variable.id)}
{@render variableRow(variable)}
{/each}
{/if}
</Tabs.Content>
<Tabs.Content value="collection" class="space-y-2">
<div class="flex justify-between items-center mb-4">
<p class="text-sm text-muted-foreground">
Collection variables are available in this collection only.
</p>
<Button
size="sm"
onclick={() => openCreateDialog("collection")}
disabled={!collectionId}
>
<PlusIcon class="size-4 mr-1" />
Add Variable
</Button>
</div>
{#if collectionVariables.length === 0}
<div class="text-center py-8 text-muted-foreground">
No collection variables defined.
</div>
{:else}
{#each collectionVariables as variable (variable.id)}
{@render variableRow(variable)}
{/each}
{/if}
</Tabs.Content>
</div>
</Tabs.Root>
<Dialog.Footer class="mt-4">
<Button variant="outline" onclick={() => onOpenChange(false)}>
Close
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
{#snippet variableRow(variable: Variable)}
<div
class="flex items-center gap-3 p-3 rounded-lg border bg-card hover:bg-accent/50 transition-colors"
>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-mono font-medium text-sm">{variable.name}</span>
{#if variable.isSecret}
<LockIcon class="size-3 text-muted-foreground" />
{/if}
<Badge class={getScopeColor(variable.scope)} variant="secondary">
{variable.scope}
</Badge>
</div>
<div class="flex items-center gap-2 mt-1">
<code
class="text-xs text-muted-foreground font-mono truncate max-w-[300px]"
>
{#if variable.isSecret && !showSecretValues.has(variable.id)}
{getMaskedValue(variable.value)}
{:else}
{variable.value}
{/if}
</code>
{#if variable.isSecret}
<button
type="button"
class="text-muted-foreground hover:text-foreground"
onclick={() => toggleSecretVisibility(variable.id)}
aria-label={showSecretValues.has(variable.id)
? "Hide value"
: "Show value"}
>
{#if showSecretValues.has(variable.id)}
<EyeOffIcon class="size-3" />
{:else}
<EyeIcon class="size-3" />
{/if}
</button>
{/if}
</div>
{#if variable.description}
<p class="text-xs text-muted-foreground mt-1">{variable.description}</p>
{/if}
</div>
<div class="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onclick={() => openEditDialog(variable)}
aria-label="Edit variable"
>
<PencilIcon class="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onclick={() => handleDelete(variable)}
aria-label="Delete variable"
>
<TrashIcon class="size-4 text-destructive" />
</Button>
</div>
</div>
{/snippet}
<Dialog.Root bind:open={editDialogOpen}>
<Dialog.Content class="sm:max-w-[425px]">
<Dialog.Header>
<Dialog.Title>
{isCreating ? "Create Variable" : "Edit Variable"}
</Dialog.Title>
<Dialog.Description>
{isCreating
? "Add a new variable or secret."
: "Update variable details."}
</Dialog.Description>
</Dialog.Header>
<form
class="grid gap-4 py-4"
onsubmit={(e) => {
e.preventDefault();
handleSave();
}}
>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="var-name" class="text-end">Name</Label>
<Input
id="var-name"
class="col-span-3 font-mono"
placeholder="VARIABLE_NAME"
bind:value={formName}
pattern="[A-Za-z_][A-Za-z0-9_]*"
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="var-value" class="text-end">Value</Label>
<Input
id="var-value"
class="col-span-3 font-mono"
type={formIsSecret ? "password" : "text"}
placeholder="Variable value"
bind:value={formValue}
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="var-desc" class="text-end">Description</Label>
<Textarea
id="var-desc"
class="col-span-3"
placeholder="Optional description"
bind:value={formDescription}
rows={2}
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-end">Secret</Label>
<div class="col-span-3 flex items-center gap-2">
<button
type="button"
role="switch"
aria-checked={formIsSecret}
aria-label="Mark as secret"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 {formIsSecret
? 'bg-primary'
: 'bg-input'}"
onclick={() => (formIsSecret = !formIsSecret)}
>
<span
class="pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform {formIsSecret
? 'translate-x-5'
: 'translate-x-0'}"
></span>
</button>
<span class="text-sm text-muted-foreground">
{formIsSecret ? "Value will be hidden" : "Value will be visible"}
</span>
</div>
</div>
<Dialog.Footer>
<Button
type="button"
variant="outline"
onclick={() => (editDialogOpen = false)}
>
Cancel
</Button>
<Button type="submit" disabled={!formName.trim()}>
{isCreating ? "Create" : "Save"}
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>