|
| 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