adding duplication and syncing of workspaces, a settings dialog and variable listing and editing
This commit is contained in:
338
src/lib/components/duplicate-workspace-dialog.svelte
Normal file
338
src/lib/components/duplicate-workspace-dialog.svelte
Normal 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>
|
||||||
262
src/lib/components/settings-dialog.svelte
Normal file
262
src/lib/components/settings-dialog.svelte
Normal 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>
|
||||||
418
src/lib/components/variables-panel.svelte
Normal file
418
src/lib/components/variables-panel.svelte
Normal 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>
|
||||||
33
src/lib/services/settings.ts
Normal file
33
src/lib/services/settings.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import type { AppSettings } from "$lib/types/workspace";
|
||||||
|
|
||||||
|
let settings: AppSettings = {
|
||||||
|
theme: "system",
|
||||||
|
defaultTimeout: 30000,
|
||||||
|
followRedirects: true,
|
||||||
|
validateSSL: true,
|
||||||
|
maxHistoryItems: 100,
|
||||||
|
autoSaveRequests: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function get_settings(): Promise<AppSettings> {
|
||||||
|
return { ...settings };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function update_settings(
|
||||||
|
updates: Partial<AppSettings>
|
||||||
|
): Promise<AppSettings> {
|
||||||
|
settings = { ...settings, ...updates };
|
||||||
|
return { ...settings };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reset_settings(): Promise<AppSettings> {
|
||||||
|
settings = {
|
||||||
|
theme: "system",
|
||||||
|
defaultTimeout: 30000,
|
||||||
|
followRedirects: true,
|
||||||
|
validateSSL: true,
|
||||||
|
maxHistoryItems: 100,
|
||||||
|
autoSaveRequests: true,
|
||||||
|
};
|
||||||
|
return { ...settings };
|
||||||
|
}
|
||||||
278
src/lib/services/sync.ts
Normal file
278
src/lib/services/sync.ts
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import type { Workspace, WorkspaceSyncGroup } from "$lib/types/workspace";
|
||||||
|
import type { Collection } from "$lib/types/collection";
|
||||||
|
import type { Request } from "$lib/types/request";
|
||||||
|
import type { Variable } from "$lib/types/variable";
|
||||||
|
import {
|
||||||
|
get_collections_by_workspace,
|
||||||
|
get_standalone_requests_by_workspace,
|
||||||
|
create_collection,
|
||||||
|
create_request,
|
||||||
|
} from "./collections";
|
||||||
|
import {
|
||||||
|
get_workspace_variables,
|
||||||
|
create_variable,
|
||||||
|
update_variable,
|
||||||
|
get_variables_by_scope,
|
||||||
|
} from "./variables";
|
||||||
|
|
||||||
|
const syncGroups: Map<string, WorkspaceSyncGroup> = new Map();
|
||||||
|
|
||||||
|
export async function get_sync_groups(): Promise<WorkspaceSyncGroup[]> {
|
||||||
|
return [...syncGroups.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get_sync_group(
|
||||||
|
id: string
|
||||||
|
): Promise<WorkspaceSyncGroup | null> {
|
||||||
|
return syncGroups.get(id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get_sync_group_for_workspace(
|
||||||
|
workspaceId: string
|
||||||
|
): Promise<WorkspaceSyncGroup | null> {
|
||||||
|
for (const group of syncGroups.values()) {
|
||||||
|
if (group.workspaceIds.includes(workspaceId)) {
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function create_sync_group(
|
||||||
|
name: string,
|
||||||
|
workspaceIds: string[],
|
||||||
|
syncedVariableNames: string[] = [],
|
||||||
|
syncSecrets: boolean = false
|
||||||
|
): Promise<WorkspaceSyncGroup> {
|
||||||
|
const group: WorkspaceSyncGroup = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name,
|
||||||
|
workspaceIds,
|
||||||
|
syncedVariableNames,
|
||||||
|
syncSecrets,
|
||||||
|
};
|
||||||
|
syncGroups.set(group.id, group);
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function update_sync_group(
|
||||||
|
id: string,
|
||||||
|
updates: Partial<Omit<WorkspaceSyncGroup, "id">>
|
||||||
|
): Promise<boolean> {
|
||||||
|
const group = syncGroups.get(id);
|
||||||
|
if (!group) return false;
|
||||||
|
Object.assign(group, updates);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function delete_sync_group(id: string): Promise<boolean> {
|
||||||
|
return syncGroups.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function add_workspace_to_sync_group(
|
||||||
|
groupId: string,
|
||||||
|
workspaceId: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const group = syncGroups.get(groupId);
|
||||||
|
if (!group) return false;
|
||||||
|
if (!group.workspaceIds.includes(workspaceId)) {
|
||||||
|
group.workspaceIds.push(workspaceId);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function remove_workspace_from_sync_group(
|
||||||
|
groupId: string,
|
||||||
|
workspaceId: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const group = syncGroups.get(groupId);
|
||||||
|
if (!group) return false;
|
||||||
|
group.workspaceIds = group.workspaceIds.filter((id) => id !== workspaceId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DuplicateWorkspaceOptions = {
|
||||||
|
sourceWorkspaceId: string;
|
||||||
|
newName: string;
|
||||||
|
newDescription: string;
|
||||||
|
environment?: string;
|
||||||
|
copyVariables: boolean;
|
||||||
|
copySecrets: boolean;
|
||||||
|
createSyncGroup: boolean;
|
||||||
|
syncGroupName?: string;
|
||||||
|
variablesToSync?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DuplicateWorkspaceResult = {
|
||||||
|
workspace: Workspace;
|
||||||
|
syncGroup?: WorkspaceSyncGroup;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function duplicate_workspace(
|
||||||
|
options: DuplicateWorkspaceOptions,
|
||||||
|
createWorkspaceFn: (workspace: Omit<Workspace, "Id">) => Promise<Workspace>
|
||||||
|
): Promise<DuplicateWorkspaceResult> {
|
||||||
|
const {
|
||||||
|
sourceWorkspaceId,
|
||||||
|
newName,
|
||||||
|
newDescription,
|
||||||
|
environment,
|
||||||
|
copyVariables,
|
||||||
|
copySecrets,
|
||||||
|
createSyncGroup: shouldCreateSyncGroup,
|
||||||
|
syncGroupName,
|
||||||
|
variablesToSync = [],
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// Create the new workspace
|
||||||
|
const newWorkspace = await createWorkspaceFn({
|
||||||
|
Name: newName,
|
||||||
|
Description: newDescription,
|
||||||
|
environment,
|
||||||
|
syncGroupId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Copy collections and requests
|
||||||
|
const collections = await get_collections_by_workspace(sourceWorkspaceId);
|
||||||
|
const collectionIdMap = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const collection of collections) {
|
||||||
|
const newCollection = await create_collection({
|
||||||
|
name: collection.name,
|
||||||
|
description: collection.description,
|
||||||
|
workspaceId: newWorkspace.Id,
|
||||||
|
});
|
||||||
|
collectionIdMap.set(collection.id, newCollection.id);
|
||||||
|
|
||||||
|
// Copy requests in collection
|
||||||
|
for (const request of collection.requests) {
|
||||||
|
await create_request({
|
||||||
|
name: request.name,
|
||||||
|
method: request.method,
|
||||||
|
url: request.url,
|
||||||
|
headers: [...request.headers],
|
||||||
|
params: [...request.params],
|
||||||
|
bodyType: request.bodyType,
|
||||||
|
body: request.body,
|
||||||
|
formData: [...request.formData],
|
||||||
|
collectionId: newCollection.id,
|
||||||
|
workspaceId: newWorkspace.Id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy standalone requests
|
||||||
|
const standaloneRequests = await get_standalone_requests_by_workspace(
|
||||||
|
sourceWorkspaceId
|
||||||
|
);
|
||||||
|
for (const request of standaloneRequests) {
|
||||||
|
await create_request({
|
||||||
|
name: request.name,
|
||||||
|
method: request.method,
|
||||||
|
url: request.url,
|
||||||
|
headers: [...request.headers],
|
||||||
|
params: [...request.params],
|
||||||
|
bodyType: request.bodyType,
|
||||||
|
body: request.body,
|
||||||
|
formData: [...request.formData],
|
||||||
|
collectionId: null,
|
||||||
|
workspaceId: newWorkspace.Id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy variables
|
||||||
|
if (copyVariables) {
|
||||||
|
const sourceVariables = await get_workspace_variables(sourceWorkspaceId);
|
||||||
|
for (const variable of sourceVariables) {
|
||||||
|
// Skip secrets if not copying them
|
||||||
|
if (variable.isSecret && !copySecrets) {
|
||||||
|
// Create variable with empty value for secrets
|
||||||
|
await create_variable({
|
||||||
|
name: variable.name,
|
||||||
|
value: "",
|
||||||
|
scope: "workspace",
|
||||||
|
scopeId: newWorkspace.Id,
|
||||||
|
isSecret: true,
|
||||||
|
description: variable.description,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await create_variable({
|
||||||
|
name: variable.name,
|
||||||
|
value: variable.value,
|
||||||
|
scope: "workspace",
|
||||||
|
scopeId: newWorkspace.Id,
|
||||||
|
isSecret: variable.isSecret,
|
||||||
|
description: variable.description,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create sync group if requested
|
||||||
|
let syncGroup: WorkspaceSyncGroup | undefined;
|
||||||
|
if (shouldCreateSyncGroup) {
|
||||||
|
syncGroup = await create_sync_group(
|
||||||
|
syncGroupName || `${newName} Sync Group`,
|
||||||
|
[sourceWorkspaceId, newWorkspace.Id],
|
||||||
|
variablesToSync,
|
||||||
|
copySecrets
|
||||||
|
);
|
||||||
|
newWorkspace.syncGroupId = syncGroup.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { workspace: newWorkspace, syncGroup };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sync_variables_in_group(
|
||||||
|
groupId: string,
|
||||||
|
sourceWorkspaceId: string
|
||||||
|
): Promise<{ synced: number; skipped: number }> {
|
||||||
|
const group = syncGroups.get(groupId);
|
||||||
|
if (!group) return { synced: 0, skipped: 0 };
|
||||||
|
|
||||||
|
const sourceVariables = await get_workspace_variables(sourceWorkspaceId);
|
||||||
|
let synced = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
for (const targetWorkspaceId of group.workspaceIds) {
|
||||||
|
if (targetWorkspaceId === sourceWorkspaceId) continue;
|
||||||
|
|
||||||
|
const targetVariables = await get_workspace_variables(targetWorkspaceId);
|
||||||
|
const targetVarMap = new Map(targetVariables.map((v) => [v.name, v]));
|
||||||
|
|
||||||
|
for (const sourceVar of sourceVariables) {
|
||||||
|
// Only sync variables that are in the sync list
|
||||||
|
if (!group.syncedVariableNames.includes(sourceVar.name)) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip secrets if not syncing them
|
||||||
|
if (sourceVar.isSecret && !group.syncSecrets) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetVar = targetVarMap.get(sourceVar.name);
|
||||||
|
if (targetVar) {
|
||||||
|
// Update existing variable
|
||||||
|
await update_variable(targetVar.id, { value: sourceVar.value });
|
||||||
|
synced++;
|
||||||
|
} else {
|
||||||
|
// Create new variable in target workspace
|
||||||
|
await create_variable({
|
||||||
|
name: sourceVar.name,
|
||||||
|
value:
|
||||||
|
sourceVar.isSecret && !group.syncSecrets ? "" : sourceVar.value,
|
||||||
|
scope: "workspace",
|
||||||
|
scopeId: targetWorkspaceId,
|
||||||
|
isSecret: sourceVar.isSecret,
|
||||||
|
description: sourceVar.description,
|
||||||
|
});
|
||||||
|
synced++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { synced, skipped };
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ const ws: Map<string, Workspace> = new Map<string, Workspace>([
|
|||||||
Id: "1",
|
Id: "1",
|
||||||
Name: "Test 1",
|
Name: "Test 1",
|
||||||
Description: "This is a test description",
|
Description: "This is a test description",
|
||||||
|
environment: "Development",
|
||||||
|
syncGroupId: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -15,6 +17,8 @@ const ws: Map<string, Workspace> = new Map<string, Workspace>([
|
|||||||
Id: "2",
|
Id: "2",
|
||||||
Name: "Test 2",
|
Name: "Test 2",
|
||||||
Description: "This is a longer test description",
|
Description: "This is a longer test description",
|
||||||
|
environment: "Production",
|
||||||
|
syncGroupId: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -23,6 +27,7 @@ const ws: Map<string, Workspace> = new Map<string, Workspace>([
|
|||||||
Id: "3",
|
Id: "3",
|
||||||
Name: "Test 3",
|
Name: "Test 3",
|
||||||
Description: "This is a slightly longer test description",
|
Description: "This is a slightly longer test description",
|
||||||
|
syncGroupId: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -31,6 +36,7 @@ const ws: Map<string, Workspace> = new Map<string, Workspace>([
|
|||||||
Id: "4",
|
Id: "4",
|
||||||
Name: "Test 4",
|
Name: "Test 4",
|
||||||
Description: "This is an even slightly longer test description",
|
Description: "This is an even slightly longer test description",
|
||||||
|
syncGroupId: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -40,6 +46,7 @@ const ws: Map<string, Workspace> = new Map<string, Workspace>([
|
|||||||
Name: "Test 5",
|
Name: "Test 5",
|
||||||
Description:
|
Description:
|
||||||
"This is a veryyyyyyyyyyyyyyyyyyyyyyyyyyy longggggggggggggggggggggggggggg test descriptionnnnnnnnnnnnnnnnnnnnnnnnnnnnnn",
|
"This is a veryyyyyyyyyyyyyyyyyyyyyyyyyyy longggggggggggggggggggggggggggg test descriptionnnnnnnnnnnnnnnnnnnnnnnnnnnnnn",
|
||||||
|
syncGroupId: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
@@ -67,8 +74,23 @@ export async function update_workspace(workspace: Workspace): Promise<boolean> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function create_workspace(workspace: Workspace): Promise<boolean> {
|
export async function create_workspace(
|
||||||
workspace.Id = crypto.randomUUID();
|
workspace: Omit<Workspace, "Id">
|
||||||
ws.set(workspace.Id, workspace);
|
): Promise<Workspace> {
|
||||||
return true;
|
const newWorkspace: Workspace = {
|
||||||
|
...workspace,
|
||||||
|
Id: crypto.randomUUID(),
|
||||||
|
};
|
||||||
|
ws.set(newWorkspace.Id, newWorkspace);
|
||||||
|
return newWorkspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function delete_workspace(id: string): Promise<boolean> {
|
||||||
|
return ws.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get_workspaces_by_sync_group(
|
||||||
|
syncGroupId: string
|
||||||
|
): Promise<Workspace[]> {
|
||||||
|
return [...ws.values()].filter((w) => w.syncGroupId === syncGroupId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,4 +2,23 @@ export type Workspace = {
|
|||||||
Id: string;
|
Id: string;
|
||||||
Name: string;
|
Name: string;
|
||||||
Description: string;
|
Description: string;
|
||||||
|
syncGroupId?: string | null;
|
||||||
|
environment?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceSyncGroup = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
workspaceIds: string[];
|
||||||
|
syncedVariableNames: string[];
|
||||||
|
syncSecrets: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AppSettings = {
|
||||||
|
theme: "light" | "dark" | "system";
|
||||||
|
defaultTimeout: number;
|
||||||
|
followRedirects: boolean;
|
||||||
|
validateSSL: boolean;
|
||||||
|
maxHistoryItems: number;
|
||||||
|
autoSaveRequests: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,30 +1,49 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Empty from "$lib/components/ui/empty/index.js";
|
import * as Empty from "$lib/components/ui/empty/index.js";
|
||||||
|
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
|
||||||
import { Button } from "$lib/components/ui/button/index.js";
|
import { Button } from "$lib/components/ui/button/index.js";
|
||||||
|
import { Badge } from "$lib/components/ui/badge/index.js";
|
||||||
import FolderCodeIcon from "@lucide/svelte/icons/folder-code";
|
import FolderCodeIcon from "@lucide/svelte/icons/folder-code";
|
||||||
|
import SettingsIcon from "@lucide/svelte/icons/settings";
|
||||||
|
import VariableIcon from "@lucide/svelte/icons/variable";
|
||||||
|
import MoreVerticalIcon from "@lucide/svelte/icons/more-vertical";
|
||||||
|
import CopyIcon from "@lucide/svelte/icons/copy";
|
||||||
|
import PencilIcon from "@lucide/svelte/icons/pencil";
|
||||||
|
import TrashIcon from "@lucide/svelte/icons/trash-2";
|
||||||
import {
|
import {
|
||||||
create_workspace,
|
create_workspace,
|
||||||
get_workspaces,
|
get_workspaces,
|
||||||
update_workspace,
|
update_workspace,
|
||||||
|
delete_workspace,
|
||||||
} from "$lib/services/workspaces";
|
} from "$lib/services/workspaces";
|
||||||
|
import {
|
||||||
|
duplicate_workspace,
|
||||||
|
type DuplicateWorkspaceOptions,
|
||||||
|
} from "$lib/services/sync";
|
||||||
import type { Workspace } from "$lib/types/workspace";
|
import type { Workspace } from "$lib/types/workspace";
|
||||||
import * as Card from "$lib/components/ui/card/index";
|
import * as Card from "$lib/components/ui/card/index";
|
||||||
import * as Dialog from "$lib/components/ui/dialog/index.js";
|
import * as Dialog from "$lib/components/ui/dialog/index.js";
|
||||||
import { Input } from "$lib/components/ui/input/index.js";
|
import { Input } from "$lib/components/ui/input/index.js";
|
||||||
import { Label } from "$lib/components/ui/label/index.js";
|
import { Label } from "$lib/components/ui/label/index.js";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import SettingsDialog from "$lib/components/settings-dialog.svelte";
|
||||||
|
import VariablesPanel from "$lib/components/variables-panel.svelte";
|
||||||
|
import DuplicateWorkspaceDialog from "$lib/components/duplicate-workspace-dialog.svelte";
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
get_workspaces().then((ws) => {
|
loadWorkspaces();
|
||||||
if (ws.length == 0) {
|
|
||||||
showPrompt = true;
|
|
||||||
} else {
|
|
||||||
console.log(ws);
|
|
||||||
workspaces = ws;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function loadWorkspaces() {
|
||||||
|
const ws = await get_workspaces();
|
||||||
|
if (ws.length == 0) {
|
||||||
|
showPrompt = true;
|
||||||
|
} else {
|
||||||
|
workspaces = ws;
|
||||||
|
showPrompt = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let showPrompt = $state(false);
|
let showPrompt = $state(false);
|
||||||
let workspaces = $state<Workspace[]>([]);
|
let workspaces = $state<Workspace[]>([]);
|
||||||
let dialogOpen = $state(false);
|
let dialogOpen = $state(false);
|
||||||
@@ -33,6 +52,13 @@
|
|||||||
let workspaceName = $state("");
|
let workspaceName = $state("");
|
||||||
let workspaceDescription = $state("");
|
let workspaceDescription = $state("");
|
||||||
|
|
||||||
|
let settingsOpen = $state(false);
|
||||||
|
let variablesOpen = $state(false);
|
||||||
|
let duplicateDialogOpen = $state(false);
|
||||||
|
let workspaceToDuplicate = $state<Workspace | null>(null);
|
||||||
|
let deleteDialogOpen = $state(false);
|
||||||
|
let workspaceToDelete = $state<Workspace | null>(null);
|
||||||
|
|
||||||
function openCreateDialog() {
|
function openCreateDialog() {
|
||||||
dialogMode = "create";
|
dialogMode = "create";
|
||||||
selectedWorkspace = null;
|
selectedWorkspace = null;
|
||||||
@@ -49,37 +75,54 @@
|
|||||||
dialogOpen = true;
|
dialogOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openDuplicateDialog(workspace: Workspace) {
|
||||||
|
workspaceToDuplicate = workspace;
|
||||||
|
duplicateDialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDeleteDialog(workspace: Workspace) {
|
||||||
|
workspaceToDelete = workspace;
|
||||||
|
deleteDialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
function handleWorkspaceClick(id: string) {
|
function handleWorkspaceClick(id: string) {
|
||||||
goto(`/workspaces/${id}`)
|
goto(`/workspaces/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDialogSubmit() {
|
async function handleDialogSubmit() {
|
||||||
let success = false;
|
|
||||||
if (dialogMode === "create") {
|
if (dialogMode === "create") {
|
||||||
const w: Workspace = {
|
await create_workspace({
|
||||||
// Id will be assigned in create_workspace
|
|
||||||
Id: "",
|
|
||||||
Name: workspaceName,
|
Name: workspaceName,
|
||||||
Description: workspaceDescription,
|
Description: workspaceDescription,
|
||||||
};
|
});
|
||||||
success = await create_workspace(w);
|
|
||||||
} else if (selectedWorkspace != null) {
|
} else if (selectedWorkspace != null) {
|
||||||
const w: Workspace = {
|
const w: Workspace = {
|
||||||
Id: selectedWorkspace.Id,
|
Id: selectedWorkspace.Id,
|
||||||
Name: workspaceName,
|
Name: workspaceName,
|
||||||
Description: workspaceDescription,
|
Description: workspaceDescription,
|
||||||
};
|
};
|
||||||
success = await update_workspace(w);
|
await update_workspace(w);
|
||||||
}
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
const updated = await get_workspaces();
|
|
||||||
workspaces = updated;
|
|
||||||
showPrompt = updated.length === 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await loadWorkspaces();
|
||||||
dialogOpen = false;
|
dialogOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDuplicate(options: DuplicateWorkspaceOptions) {
|
||||||
|
await duplicate_workspace(options, async (ws) => {
|
||||||
|
return await create_workspace(ws);
|
||||||
|
});
|
||||||
|
await loadWorkspaces();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteConfirm() {
|
||||||
|
if (workspaceToDelete) {
|
||||||
|
await delete_workspace(workspaceToDelete.Id);
|
||||||
|
await loadWorkspaces();
|
||||||
|
}
|
||||||
|
deleteDialogOpen = false;
|
||||||
|
workspaceToDelete = null;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog.Root bind:open={dialogOpen}>
|
<Dialog.Root bind:open={dialogOpen}>
|
||||||
@@ -107,7 +150,25 @@
|
|||||||
<div class="min-h-[calc(100vh-4rem)] p-6 space-y-4">
|
<div class="min-h-[calc(100vh-4rem)] p-6 space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h1 class="text-2xl font-semibold">Workspaces</h1>
|
<h1 class="text-2xl font-semibold">Workspaces</h1>
|
||||||
<Button onclick={openCreateDialog}>Create Workspace</Button>
|
<div class="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onclick={() => (variablesOpen = true)}
|
||||||
|
aria-label="Global Variables"
|
||||||
|
>
|
||||||
|
<VariableIcon class="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onclick={() => (settingsOpen = true)}
|
||||||
|
aria-label="Settings"
|
||||||
|
>
|
||||||
|
<SettingsIcon class="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button onclick={openCreateDialog}>Create Workspace</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="grid gap-6
|
class="grid gap-6
|
||||||
@@ -119,22 +180,69 @@
|
|||||||
>
|
>
|
||||||
{#each workspaces as workspace (workspace.Id)}
|
{#each workspaces as workspace (workspace.Id)}
|
||||||
<Card.Root
|
<Card.Root
|
||||||
class="min-h-40 w-full max-w-xs mx-auto flex flex-col justify-between cursor-pointer hover:shadow-md transition-shadow"
|
class="min-h-40 w-full max-w-xs mx-auto flex flex-col justify-between hover:shadow-md transition-shadow group"
|
||||||
>
|
>
|
||||||
<Card.Header>
|
<Card.Header class="relative">
|
||||||
<Card.Title class="truncate">{workspace.Name}</Card.Title>
|
<div
|
||||||
<Card.Description class="text-xs text-muted-foreground">
|
class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8"
|
||||||
|
{...props}
|
||||||
|
aria-label="Workspace options"
|
||||||
|
>
|
||||||
|
<MoreVerticalIcon class="size-4" />
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content align="end">
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onclick={() => openEditDialog(workspace)}
|
||||||
|
>
|
||||||
|
<PencilIcon class="size-4 mr-2" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onclick={() => openDuplicateDialog(workspace)}
|
||||||
|
>
|
||||||
|
<CopyIcon class="size-4 mr-2" />
|
||||||
|
Duplicate
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Separator />
|
||||||
|
<DropdownMenu.Item
|
||||||
|
class="text-destructive"
|
||||||
|
onclick={() => openDeleteDialog(workspace)}
|
||||||
|
>
|
||||||
|
<TrashIcon class="size-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</div>
|
||||||
|
<Card.Title class="truncate pr-8">{workspace.Name}</Card.Title>
|
||||||
|
<Card.Description
|
||||||
|
class="text-xs text-muted-foreground line-clamp-2"
|
||||||
|
>
|
||||||
{workspace.Description}
|
{workspace.Description}
|
||||||
</Card.Description>
|
</Card.Description>
|
||||||
|
{#if workspace.environment}
|
||||||
|
<Badge variant="secondary" class="w-fit mt-2"
|
||||||
|
>{workspace.environment}</Badge
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Footer class="flex items-center justify-center gap-2">
|
<Card.Footer class="flex items-center justify-center gap-2">
|
||||||
<Button size="sm" class="justify-center" onclick={() => handleWorkspaceClick(workspace.Id)}>Open</Button>
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
class="justify-center"
|
class="justify-center flex-1"
|
||||||
onclick={() => openEditDialog(workspace)}
|
onclick={() => handleWorkspaceClick(workspace.Id)}
|
||||||
>
|
>
|
||||||
Edit
|
Open
|
||||||
</Button>
|
</Button>
|
||||||
</Card.Footer>
|
</Card.Footer>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
@@ -181,3 +289,40 @@
|
|||||||
</form>
|
</form>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</Dialog.Root>
|
</Dialog.Root>
|
||||||
|
|
||||||
|
<Dialog.Root bind:open={deleteDialogOpen}>
|
||||||
|
<Dialog.Content class="sm:max-w-[425px]">
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>Delete Workspace</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
Are you sure you want to delete "{workspaceToDelete?.Name}"? This will
|
||||||
|
permanently delete all collections, requests, and variables in this
|
||||||
|
workspace.
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button variant="outline" onclick={() => (deleteDialogOpen = false)}
|
||||||
|
>Cancel</Button
|
||||||
|
>
|
||||||
|
<Button variant="destructive" onclick={handleDeleteConfirm}>Delete</Button
|
||||||
|
>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
|
||||||
|
<SettingsDialog
|
||||||
|
bind:open={settingsOpen}
|
||||||
|
onOpenChange={(v) => (settingsOpen = v)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<VariablesPanel
|
||||||
|
bind:open={variablesOpen}
|
||||||
|
onOpenChange={(v) => (variablesOpen = v)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DuplicateWorkspaceDialog
|
||||||
|
bind:open={duplicateDialogOpen}
|
||||||
|
onOpenChange={(v) => (duplicateDialogOpen = v)}
|
||||||
|
sourceWorkspace={workspaceToDuplicate}
|
||||||
|
onDuplicate={handleDuplicate}
|
||||||
|
/>
|
||||||
|
|||||||
@@ -10,7 +10,11 @@
|
|||||||
import RequestPanel from "$lib/components/request-panel.svelte";
|
import RequestPanel from "$lib/components/request-panel.svelte";
|
||||||
import * as Empty from "$lib/components/ui/empty/index.js";
|
import * as Empty from "$lib/components/ui/empty/index.js";
|
||||||
import SendIcon from "@lucide/svelte/icons/send";
|
import SendIcon from "@lucide/svelte/icons/send";
|
||||||
|
import VariableIcon from "@lucide/svelte/icons/variable";
|
||||||
|
import SettingsIcon from "@lucide/svelte/icons/settings";
|
||||||
import { get_workspace } from "$lib/services/workspaces";
|
import { get_workspace } from "$lib/services/workspaces";
|
||||||
|
import VariablesPanel from "$lib/components/variables-panel.svelte";
|
||||||
|
import SettingsDialog from "$lib/components/settings-dialog.svelte";
|
||||||
import {
|
import {
|
||||||
get_collections_by_workspace,
|
get_collections_by_workspace,
|
||||||
get_standalone_requests_by_workspace,
|
get_standalone_requests_by_workspace,
|
||||||
@@ -39,6 +43,9 @@
|
|||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let resolvedVariables = $state<ResolvedVariable[]>([]);
|
let resolvedVariables = $state<ResolvedVariable[]>([]);
|
||||||
|
|
||||||
|
let variablesOpen = $state(false);
|
||||||
|
let settingsOpen = $state(false);
|
||||||
|
|
||||||
let collectionDialogOpen = $state(false);
|
let collectionDialogOpen = $state(false);
|
||||||
let collectionDialogMode = $state<"create" | "edit">("create");
|
let collectionDialogMode = $state<"create" | "edit">("create");
|
||||||
let editingCollection = $state<Collection | null>(null);
|
let editingCollection = $state<Collection | null>(null);
|
||||||
@@ -280,6 +287,24 @@
|
|||||||
</h1>
|
</h1>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onclick={() => (variablesOpen = true)}
|
||||||
|
aria-label="Variables"
|
||||||
|
>
|
||||||
|
<VariableIcon class="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onclick={() => (settingsOpen = true)}
|
||||||
|
aria-label="Settings"
|
||||||
|
>
|
||||||
|
<SettingsIcon class="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="flex-1 overflow-hidden flex flex-col">
|
<div class="flex-1 overflow-hidden flex flex-col">
|
||||||
@@ -412,3 +437,15 @@
|
|||||||
</Dialog.Footer>
|
</Dialog.Footer>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</Dialog.Root>
|
</Dialog.Root>
|
||||||
|
|
||||||
|
<VariablesPanel
|
||||||
|
bind:open={variablesOpen}
|
||||||
|
onOpenChange={(v) => (variablesOpen = v)}
|
||||||
|
workspaceId={params.id}
|
||||||
|
collectionId={selectedRequest?.collectionId ?? undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SettingsDialog
|
||||||
|
bind:open={settingsOpen}
|
||||||
|
onOpenChange={(v) => (settingsOpen = v)}
|
||||||
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user