Skip to content

Commit cf9b4e7

Browse files
feat: new environment switcher dialog (getarcaneapp#1126)
Co-authored-by: Kyle Mendell <ksm@ofkm.us>
1 parent 62ca153 commit cf9b4e7

5 files changed

Lines changed: 444 additions & 213 deletions

File tree

backend/internal/huma/handlers/environments.go

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/getarcaneapp/arcane/backend/internal/services"
1313
"github.com/getarcaneapp/arcane/backend/internal/utils"
1414
"github.com/getarcaneapp/arcane/backend/internal/utils/mapper"
15+
"github.com/getarcaneapp/arcane/backend/internal/utils/pagination"
1516
"go.getarcane.app/types/base"
1617
"go.getarcane.app/types/environment"
1718
)
@@ -39,10 +40,11 @@ type EnvironmentPaginatedResponse struct {
3940
}
4041

4142
type ListEnvironmentsInput struct {
42-
Page int `query:"pagination[page]" default:"1" doc:"Page number"`
43-
Limit int `query:"pagination[limit]" default:"20" doc:"Items per page"`
44-
SortCol string `query:"sort[column]" doc:"Column to sort by"`
45-
SortDir string `query:"sort[direction]" default:"asc" doc:"Sort direction"`
43+
Search string `query:"search" doc:"Search query for filtering by name or API URL"`
44+
Sort string `query:"sort" doc:"Column to sort by"`
45+
Order string `query:"order" default:"asc" doc:"Sort direction (asc or desc)"`
46+
Start int `query:"start" default:"0" doc:"Start index for pagination"`
47+
Limit int `query:"limit" default:"20" doc:"Items per page"`
4648
}
4749

4850
type ListEnvironmentsOutput struct {
@@ -307,7 +309,19 @@ func (h *EnvironmentHandler) ListEnvironments(ctx context.Context, input *ListEn
307309
return nil, huma.Error500InternalServerError("service not available")
308310
}
309311

310-
params := buildPaginationParams(input.Page, input.Limit, input.SortCol, input.SortDir)
312+
params := pagination.QueryParams{
313+
SearchQuery: pagination.SearchQuery{
314+
Search: input.Search,
315+
},
316+
SortParams: pagination.SortParams{
317+
Sort: input.Sort,
318+
Order: pagination.SortOrder(input.Order),
319+
},
320+
PaginationParams: pagination.PaginationParams{
321+
Start: input.Start,
322+
Limit: input.Limit,
323+
},
324+
}
311325

312326
envs, paginationResp, err := h.environmentService.ListEnvironmentsPaginated(ctx, params)
313327
if err != nil {
Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
1+
<script lang="ts">
2+
import { ResponsiveDialog } from '$lib/components/ui/responsive-dialog/index.js';
3+
import { Button } from '$lib/components/ui/button/index.js';
4+
import { Input } from '$lib/components/ui/input/index.js';
5+
import { Spinner } from '$lib/components/ui/spinner/index.js';
6+
import { environmentStore } from '$lib/stores/environment.store.svelte';
7+
import { environmentManagementService } from '$lib/services/env-mgmt-service';
8+
import type { Environment } from '$lib/types/environment.type';
9+
import { goto } from '$app/navigation';
10+
import { toast } from 'svelte-sonner';
11+
import { m } from '$lib/paraglide/messages';
12+
import { cn } from '$lib/utils';
13+
import settingsStore from '$lib/stores/config-store';
14+
import { debounced } from '$lib/utils/utils';
15+
import type { SearchPaginationSortRequest } from '$lib/types/pagination.type';
16+
import { tick } from 'svelte';
17+
import { EnvironmentsIcon, RemoteEnvironmentIcon, AddIcon, SearchIcon, CloseIcon } from '$lib/icons';
18+
19+
type Props = {
20+
open: boolean;
21+
isAdmin?: boolean;
22+
};
23+
24+
let { open = $bindable(false), isAdmin = false }: Props = $props();
25+
26+
let searchQuery = $state('');
27+
let environments = $state<Environment[]>([]);
28+
let isLoading = $state(false);
29+
let isLoadingMore = $state(false);
30+
let currentPage = $state(1);
31+
let totalPages = $state(1);
32+
let scrollContainer = $state<HTMLDivElement | null>(null);
33+
let lastScrollTop = $state(0);
34+
let loadError = $state<string | null>(null);
35+
let environmentsPromise = $state<Promise<void> | null>(null);
36+
let currentRequestId = 0;
37+
38+
const PAGE_SIZE = 10;
39+
40+
const DEFAULT_REQUEST_OPTIONS: SearchPaginationSortRequest = {
41+
pagination: { page: 1, limit: PAGE_SIZE },
42+
sort: { column: 'name', direction: 'asc' },
43+
search: undefined
44+
};
45+
46+
let requestOptions = $state<SearchPaginationSortRequest>(DEFAULT_REQUEST_OPTIONS);
47+
48+
async function resetScrollToTop() {
49+
await tick();
50+
if (scrollContainer) scrollContainer.scrollTop = 0;
51+
lastScrollTop = 0;
52+
}
53+
54+
function normalizeSearch(query: string): string | undefined {
55+
const trimmed = query.trim();
56+
return trimmed ? trimmed : undefined;
57+
}
58+
59+
async function fetchEnvironments(options: SearchPaginationSortRequest, append: boolean, throwOnError = false) {
60+
currentRequestId++;
61+
const requestId = currentRequestId;
62+
loadError = null;
63+
requestOptions = options;
64+
65+
if (append) {
66+
isLoadingMore = true;
67+
} else {
68+
isLoading = true;
69+
isLoadingMore = false;
70+
}
71+
72+
try {
73+
const result = await environmentManagementService.getEnvironments(options);
74+
if (requestId !== currentRequestId) return;
75+
76+
environments = append ? [...environments, ...result.data] : result.data;
77+
currentPage = result.pagination.currentPage;
78+
totalPages = result.pagination.totalPages;
79+
} catch (error) {
80+
if (requestId !== currentRequestId) return;
81+
console.error('Failed to load environments:', error);
82+
loadError = 'Failed to load environments';
83+
toast.error(loadError);
84+
if (throwOnError) throw error;
85+
} finally {
86+
if (requestId !== currentRequestId) return;
87+
isLoading = false;
88+
isLoadingMore = false;
89+
}
90+
}
91+
92+
async function loadInitial() {
93+
environments = [];
94+
currentPage = 1;
95+
totalPages = 1;
96+
await resetScrollToTop();
97+
const options: SearchPaginationSortRequest = {
98+
...requestOptions,
99+
search: normalizeSearch(searchQuery),
100+
pagination: { page: 1, limit: PAGE_SIZE },
101+
sort: { column: 'name', direction: 'asc' }
102+
};
103+
await fetchEnvironments(options, false, true);
104+
}
105+
106+
function resetDialogState() {
107+
// Invalidate any inflight request
108+
currentRequestId++;
109+
searchQuery = '';
110+
requestOptions = DEFAULT_REQUEST_OPTIONS;
111+
lastScrollTop = 0;
112+
environmentsPromise = null;
113+
loadError = null;
114+
// Keep environments around or clear? Clear so reopening always shows a clean slate
115+
environments = [];
116+
currentPage = 1;
117+
totalPages = 1;
118+
}
119+
120+
function startInitialLoad() {
121+
environmentsPromise = Promise.resolve().then(() => loadInitial());
122+
}
123+
124+
function closeDialog() {
125+
open = false;
126+
resetDialogState();
127+
}
128+
129+
function openSession(node: HTMLElement) {
130+
// Runs when the dialog content mounts (i.e., when `open` becomes true)
131+
startInitialLoad();
132+
return {
133+
destroy() {
134+
// Runs when the dialog content unmounts (i.e., when `open` becomes false)
135+
resetDialogState();
136+
}
137+
};
138+
}
139+
140+
const debouncedSearch = debounced((query: string) => {
141+
// Prevent stale debounced callbacks from re-applying an old query (e.g. after clearing the input)
142+
if (query !== searchQuery) return;
143+
const options: SearchPaginationSortRequest = {
144+
...requestOptions,
145+
search: normalizeSearch(query),
146+
pagination: { page: 1, limit: PAGE_SIZE },
147+
sort: { column: 'name', direction: 'asc' }
148+
};
149+
environmentsPromise = Promise.resolve().then(async () => {
150+
if (query !== searchQuery) return;
151+
await resetScrollToTop();
152+
await fetchEnvironments(options, false, true);
153+
});
154+
}, 300);
155+
156+
function clearSearch() {
157+
searchQuery = '';
158+
const options: SearchPaginationSortRequest = {
159+
...requestOptions,
160+
search: undefined,
161+
pagination: { page: 1, limit: PAGE_SIZE },
162+
sort: { column: 'name', direction: 'asc' }
163+
};
164+
environmentsPromise = Promise.resolve().then(async () => {
165+
await resetScrollToTop();
166+
await fetchEnvironments(options, false, true);
167+
});
168+
}
169+
170+
function handleScroll(e: Event) {
171+
const target = e.target as HTMLDivElement;
172+
const { scrollTop, scrollHeight, clientHeight } = target;
173+
174+
// If content isn't scrollable yet, don't auto-fetch more pages.
175+
if (scrollHeight <= clientHeight) return;
176+
177+
// Only react to downward scrolling; prevents programmatic scrollTop resets from triggering load-more loops.
178+
if (scrollTop <= lastScrollTop) return;
179+
lastScrollTop = scrollTop;
180+
181+
// Load more when user scrolls near the bottom (within 50px)
182+
if (scrollHeight - scrollTop - clientHeight < 50) {
183+
if (!isLoadingMore && currentPage < totalPages) {
184+
loadMoreEnvironments();
185+
}
186+
}
187+
}
188+
189+
async function loadMoreEnvironments() {
190+
if (isLoading || isLoadingMore) return;
191+
if (currentPage >= totalPages) return;
192+
isLoadingMore = true;
193+
try {
194+
const options: SearchPaginationSortRequest = {
195+
...requestOptions,
196+
search: normalizeSearch(searchQuery),
197+
pagination: { page: currentPage + 1, limit: PAGE_SIZE },
198+
sort: { column: 'name', direction: 'asc' }
199+
};
200+
await fetchEnvironments(options, true, false);
201+
} catch {
202+
// fetchEnvironments already handles toasts/errors (and stale-response protection)
203+
} finally {
204+
// fetchEnvironments controls isLoadingMore; this is only a safety net.
205+
isLoadingMore = false;
206+
}
207+
}
208+
209+
async function handleSelect(env: Environment) {
210+
if (!env || !env.enabled) return;
211+
try {
212+
await environmentStore.setEnvironment(env);
213+
closeDialog();
214+
toast.success(m.environments_switched_to({ name: env.name }));
215+
} catch (error) {
216+
console.error('Failed to set environment:', error);
217+
toast.error('Failed to Connect to Environment');
218+
}
219+
}
220+
221+
function getConnectionString(env: Environment): string {
222+
if (env.id === '0') {
223+
return $settingsStore.dockerHost || 'unix:///var/run/docker.sock';
224+
} else {
225+
return env.apiUrl;
226+
}
227+
}
228+
</script>
229+
230+
<ResponsiveDialog bind:open title={m.sidebar_select_environment()} contentClass="max-w-2xl">
231+
{#snippet children()}
232+
<div class="m-2 flex flex-col gap-4">
233+
{#if open}
234+
<div class="hidden" use:openSession aria-hidden="true"></div>
235+
{/if}
236+
<div class="relative">
237+
<SearchIcon class="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2" />
238+
<Input
239+
type="text"
240+
placeholder={m.common_search()}
241+
value={searchQuery}
242+
oninput={(e) => {
243+
searchQuery = (e.target as HTMLInputElement).value;
244+
debouncedSearch(searchQuery);
245+
}}
246+
class="h-9 pr-10 pl-10"
247+
/>
248+
{#if searchQuery}
249+
<button
250+
type="button"
251+
onclick={clearSearch}
252+
class="text-muted-foreground hover:text-foreground hover:bg-muted absolute top-1/2 right-3 -translate-y-1/2 rounded-sm p-0.5 transition-colors"
253+
title="Clear search"
254+
>
255+
<CloseIcon class="size-4" />
256+
</button>
257+
{/if}
258+
</div>
259+
260+
<div bind:this={scrollContainer} onscroll={handleScroll} class="max-h-[50vh] min-h-[200px] overflow-y-auto">
261+
{#await environmentsPromise}
262+
<div class="flex items-center justify-center py-10">
263+
<Spinner class="size-6" />
264+
</div>
265+
{:then}
266+
{#if environments.length === 0}
267+
<div class="text-muted-foreground py-10 text-center">
268+
<EnvironmentsIcon class="mx-auto mb-4 size-12 opacity-50" />
269+
<p>{m.sidebar_no_environments()}</p>
270+
</div>
271+
{:else}
272+
<div class="space-y-1">
273+
{#each environments as env (env.id)}
274+
{@const isActive = environmentStore.selected?.id === env.id}
275+
{@const isDisabled = !env.enabled}
276+
<button
277+
type="button"
278+
onclick={() => !isActive && !isDisabled && handleSelect(env)}
279+
disabled={isDisabled}
280+
class={cn(
281+
'flex w-full items-center gap-3 rounded-lg p-3 text-left transition-colors',
282+
isActive && 'bg-primary/10 border-primary border font-medium',
283+
!isActive && !isDisabled && 'hover:bg-muted/50',
284+
isDisabled && 'cursor-not-allowed opacity-50'
285+
)}
286+
>
287+
<div
288+
class={cn(
289+
'flex size-8 shrink-0 items-center justify-center rounded-md border',
290+
isActive ? 'bg-primary border-primary' : 'border-border'
291+
)}
292+
>
293+
{#if env.id === '0'}
294+
<EnvironmentsIcon class={cn('size-4', isActive && 'text-primary-foreground')} />
295+
{:else}
296+
<RemoteEnvironmentIcon class={cn('size-4', isActive && 'text-primary-foreground')} />
297+
{/if}
298+
</div>
299+
<div class="flex min-w-0 flex-1 flex-col">
300+
<span class="truncate">{env.name}</span>
301+
<span class={cn('truncate text-xs', isActive ? 'text-primary/70' : 'text-muted-foreground')}>
302+
{getConnectionString(env)}
303+
</span>
304+
</div>
305+
{#if isActive}
306+
<span class="text-primary text-xs font-medium">{m.environments_current_environment()}</span>
307+
{/if}
308+
</button>
309+
{/each}
310+
311+
{#if isLoadingMore}
312+
<div class="flex items-center justify-center py-4">
313+
<Spinner class="size-5" />
314+
</div>
315+
{/if}
316+
</div>
317+
{/if}
318+
{:catch}
319+
<div class="text-destructive py-10 text-center">
320+
<p>{m.error_generic()}</p>
321+
</div>
322+
{/await}
323+
</div>
324+
</div>
325+
{/snippet}
326+
327+
{#snippet footer()}
328+
<div class="flex w-full items-center justify-between gap-2">
329+
{#if isAdmin}
330+
<Button
331+
variant="outline"
332+
onclick={() => {
333+
closeDialog();
334+
goto('/environments');
335+
}}
336+
>
337+
<AddIcon class="size-4" />
338+
{m.sidebar_manage_environments()}
339+
</Button>
340+
{:else}
341+
<div></div>
342+
{/if}
343+
<Button variant="outline" onclick={closeDialog}>
344+
{m.common_close()}
345+
</Button>
346+
</div>
347+
{/snippet}
348+
</ResponsiveDialog>

0 commit comments

Comments
 (0)