From 9a8b1eb2984481f18625179de469769bcdd755eb Mon Sep 17 00:00:00 2001 From: jR4dh3y Date: Fri, 12 Jun 2026 21:15:46 +0530 Subject: [PATCH 1/3] Improve settings progress and wallpaper preview --- .../wallpaper/WallpaperPickerModal.svelte | 116 +++++++++++--- .../wallpaper/WallpaperSettings.svelte | 1 + .../lib/components/ui/ProgressButton.svelte | 108 ++++++++++++++ frontend/src/lib/components/ui/Select.svelte | 34 +++-- frontend/src/lib/components/ui/index.ts | 1 + frontend/src/lib/utils/wallpaper.ts | 12 +- frontend/src/routes/settings/+page.svelte | 141 +++++++++++++++--- 7 files changed, 363 insertions(+), 50 deletions(-) create mode 100644 frontend/src/lib/components/ui/ProgressButton.svelte diff --git a/frontend/src/lib/components/settings/wallpaper/WallpaperPickerModal.svelte b/frontend/src/lib/components/settings/wallpaper/WallpaperPickerModal.svelte index 6ca2fed..920f825 100644 --- a/frontend/src/lib/components/settings/wallpaper/WallpaperPickerModal.svelte +++ b/frontend/src/lib/components/settings/wallpaper/WallpaperPickerModal.svelte @@ -9,7 +9,7 @@ Image as ImageIcon, Upload } from 'lucide-svelte'; - import { Button, Modal, Select } from '$lib/components/ui'; + import { Button, Modal, ProgressBar, Select } from '$lib/components/ui'; import { listDirectory, listRoots, type FileInfo, type MountPoint } from '$lib/api/files'; import { resolveBackgroundImage, toServerBackgroundImage } from '$lib/stores/settings'; import { formatFileSize } from '$lib/utils/format'; @@ -34,17 +34,19 @@ interface Props { open?: boolean; currentMode?: BackgroundImageMode; + frostedGlass?: boolean; showHiddenFiles: boolean; onapply?: (selection: WallpaperSelection) => void; onclose?: () => void; } - const LOCAL_WALLPAPER_MAX_BYTES = 10 * 1024 * 1024; + const LOCAL_WALLPAPER_MAX_BYTES = 20 * 1024 * 1024; const PREVIEW_ROWS = [0, 1, 2, 3, 4, 5]; let { open = true, currentMode = DEFAULT_BACKGROUND_IMAGE_MODE, + frostedGlass = false, showHiddenFiles, onapply, onclose @@ -57,6 +59,9 @@ let serverWallpaperLoading = $state(false); let serverWallpaperError = $state(null); let localWallpaperError = $state(null); + let localWallpaperUploading = $state(false); + let localWallpaperProgress = $state(0); + let localWallpaperProgressLabel = $state(''); let previewBackgroundImage = $state(null); let previewName = $state(''); let previewOrigin = $state(null); @@ -144,6 +149,8 @@ } function openLocalWallpaperPicker() { + if (localWallpaperUploading) return; + localWallpaperError = null; localWallpaperInput?.click(); } @@ -153,26 +160,38 @@ const file = input.files?.[0]; input.value = ''; - if (!file) return; + if (!file || localWallpaperUploading) return; localWallpaperError = null; + localWallpaperProgress = 0; + localWallpaperProgressLabel = ''; if (!file.type.startsWith('image/') && !isLocalImageFile(file.name)) { localWallpaperError = 'Choose an image file.'; return; } if (file.size > LOCAL_WALLPAPER_MAX_BYTES) { - localWallpaperError = `Choose an image smaller than ${formatFileSize(LOCAL_WALLPAPER_MAX_BYTES)}.`; + localWallpaperError = `Choose an image smaller than ${formatFileSize(LOCAL_WALLPAPER_MAX_BYTES)} so it can be saved locally.`; return; } try { - previewBackgroundImage = await readFileAsDataUrl(file); + localWallpaperUploading = true; + localWallpaperProgressLabel = 'Preparing wallpaper upload...'; + previewBackgroundImage = await readFileAsDataUrl(file, (progress) => { + localWallpaperProgress = progress; + localWallpaperProgressLabel = + progress >= 100 ? 'Preparing preview...' : 'Uploading wallpaper...'; + }); previewName = file.name; previewOrigin = 'local'; previewMode = normalizeBackgroundImageMode(currentMode); } catch { localWallpaperError = 'Unable to read this image.'; + localWallpaperProgress = 0; + localWallpaperProgressLabel = ''; + } finally { + localWallpaperUploading = false; } } @@ -186,6 +205,8 @@ previewBackgroundImage = null; previewName = ''; previewOrigin = null; + localWallpaperProgress = 0; + localWallpaperProgressLabel = ''; if (origin === 'server') { wallpaperSource = 'server'; @@ -236,23 +257,24 @@ style:background-repeat={previewBackgroundStyle.repeat} style:background-position={previewBackgroundStyle.position} > -
+
-
-
-
-
+
+
+
+
-
-
-
+
+
+
@@ -261,11 +283,11 @@ class="grid grid-cols-[1fr_90px_110px] items-center gap-4 border-b border-white/10 px-3 py-3 last:border-b-0" >
-
-
+
+
-
-
+
+
{/each}
@@ -316,17 +338,35 @@
+ {#if localWallpaperUploading} +
+
+ {localWallpaperProgressLabel} + {localWallpaperProgress}% +
+ +
+ {/if} + {#if localWallpaperError}
{/if} + + diff --git a/frontend/src/lib/components/settings/wallpaper/WallpaperSettings.svelte b/frontend/src/lib/components/settings/wallpaper/WallpaperSettings.svelte index 409854c..08bd930 100644 --- a/frontend/src/lib/components/settings/wallpaper/WallpaperSettings.svelte +++ b/frontend/src/lib/components/settings/wallpaper/WallpaperSettings.svelte @@ -125,6 +125,7 @@ (wallpaperDialogOpen = false)} diff --git a/frontend/src/lib/components/ui/ProgressButton.svelte b/frontend/src/lib/components/ui/ProgressButton.svelte new file mode 100644 index 0000000..c749f11 --- /dev/null +++ b/frontend/src/lib/components/ui/ProgressButton.svelte @@ -0,0 +1,108 @@ + + + diff --git a/frontend/src/lib/components/ui/Select.svelte b/frontend/src/lib/components/ui/Select.svelte index 2d87c6b..646ca70 100644 --- a/frontend/src/lib/components/ui/Select.svelte +++ b/frontend/src/lib/components/ui/Select.svelte @@ -1,4 +1,6 @@ - +
+ + +
diff --git a/frontend/src/lib/components/ui/index.ts b/frontend/src/lib/components/ui/index.ts index b240c3e..b20308f 100644 --- a/frontend/src/lib/components/ui/index.ts +++ b/frontend/src/lib/components/ui/index.ts @@ -4,6 +4,7 @@ */ export { default as Button } from './Button.svelte'; +export { default as ProgressButton } from './ProgressButton.svelte'; export { default as Input } from './Input.svelte'; export { default as Select } from './Select.svelte'; export { default as Toggle } from './Toggle.svelte'; diff --git a/frontend/src/lib/utils/wallpaper.ts b/frontend/src/lib/utils/wallpaper.ts index 617f6ac..baa9ca8 100644 --- a/frontend/src/lib/utils/wallpaper.ts +++ b/frontend/src/lib/utils/wallpaper.ts @@ -52,11 +52,21 @@ export function isWallpaperImageFile(item: FileInfo): boolean { ); } -export function readFileAsDataUrl(file: File): Promise { +export function readFileAsDataUrl( + file: File, + onProgress?: (progress: number) => void +): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); + reader.onloadstart = () => onProgress?.(0); + reader.onprogress = (event) => { + if (event.lengthComputable && event.total > 0) { + onProgress?.(Math.round((event.loaded / event.total) * 100)); + } + }; reader.onload = () => { if (typeof reader.result === 'string') { + onProgress?.(100); resolve(reader.result); } else { reject(new Error('Unsupported file result')); diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte index a30fbd4..533291b 100644 --- a/frontend/src/routes/settings/+page.svelte +++ b/frontend/src/routes/settings/+page.svelte @@ -2,6 +2,7 @@ /** * Settings page - workspace-style preferences screen matching the file browser shell. */ + import { onDestroy, tick } from 'svelte'; import { goto } from '$app/navigation'; import { resolve } from '$app/paths'; import { authStore } from '$lib/stores/auth'; @@ -16,7 +17,7 @@ } from '$lib/stores/settings'; import SearchBar from '$lib/components/SearchBar.svelte'; import WallpaperSettings from '$lib/components/settings/wallpaper/WallpaperSettings.svelte'; - import { Button, Select, Toggle } from '$lib/components/ui'; + import { Button, ProgressButton, Select, Toggle } from '$lib/components/ui'; import { normalizeBackgroundImageMode } from '$lib/utils/wallpaper'; import { ChevronLeft, @@ -35,10 +36,16 @@ type SettingsSectionId = 'display' | 'personalization' | 'behavior' | 'defaults' | 'account'; type SettingsCategory = 'all' | SettingsSectionId; + type ApplyProgressVariant = 'default' | 'success' | 'danger'; let settings = $state({ ...$settingsStore }); let activeCategory = $state('all'); let searchQuery = $state(''); + let isApplyingSettings = $state(false); + let applyProgress = $state(0); + let applyProgressStatus = $state(''); + let applyProgressVariant = $state('default'); + let applyProgressResetTimer: ReturnType | null = null; const hasChanges = $derived(JSON.stringify(settings) !== JSON.stringify($settingsStore)); const normalizedSearch = $derived(searchQuery.trim().toLowerCase()); @@ -47,7 +54,15 @@ normalizeAccentColor(settings.accentColor) ?? DEFAULT_ACCENT_COLOR ); const backgroundImageIsValid = $derived(isValidBackgroundImage(settings.backgroundImage)); - const canSave = $derived(hasChanges && accentColorIsValid && backgroundImageIsValid); + const canSave = $derived( + hasChanges && accentColorIsValid && backgroundImageIsValid && !isApplyingSettings + ); + const showApplyProgress = $derived(isApplyingSettings || applyProgress > 0); + const saveButtonText = $derived.by(() => { + if (applyProgressVariant === 'danger' && applyProgress > 0) return 'Failed'; + if (applyProgressVariant === 'success' && applyProgress >= 100) return 'Saved'; + return isApplyingSettings ? 'Saving' : 'Save'; + }); const navItems: Array<{ id: SettingsCategory; @@ -114,27 +129,114 @@ const panelHeaderClass = 'flex items-start justify-between gap-4 border-b border-border-secondary bg-surface-primary/55 px-4 py-3'; const settingRowClass = - 'flex items-center justify-between gap-4 border-b border-border-secondary px-4 py-3 last:border-b-0'; + 'flex min-h-12 items-center justify-between gap-4 border-b border-border-secondary px-4 py-2 last:border-b-0'; + + onDestroy(() => { + clearApplyProgressResetTimer(); + }); + + function clearApplyProgressResetTimer() { + if (!applyProgressResetTimer) return; + + clearTimeout(applyProgressResetTimer); + applyProgressResetTimer = null; + } - function handleSave() { - if (!accentColorIsValid || !backgroundImageIsValid) return; + function scheduleApplyProgressReset(delay: number) { + clearApplyProgressResetTimer(); + applyProgressResetTimer = setTimeout(() => { + applyProgress = 0; + applyProgressStatus = ''; + applyProgressVariant = 'default'; + applyProgressResetTimer = null; + }, delay); + } - const backgroundImage = normalizeBackgroundImage(settings.backgroundImage); + function waitForPaint(): Promise { + return new Promise((resolvePaint) => { + if (typeof requestAnimationFrame === 'undefined') { + resolvePaint(); + return; + } - settingsStore.set({ - ...settings, - accentColor: normalizeAccentColor(settings.accentColor), - backgroundImage, - backgroundImageMode: normalizeBackgroundImageMode(settings.backgroundImageMode), - frostedGlass: backgroundImage ? settings.frostedGlass : false + requestAnimationFrame(() => resolvePaint()); }); } + async function setApplyProgress(value: number, status: string) { + applyProgress = value; + applyProgressStatus = status; + await tick(); + await waitForPaint(); + } + + async function handleSave() { + if (!accentColorIsValid || !backgroundImageIsValid || isApplyingSettings) return; + + clearApplyProgressResetTimer(); + isApplyingSettings = true; + applyProgressVariant = 'default'; + + try { + await setApplyProgress(15, 'Validating settings...'); + + const backgroundImage = normalizeBackgroundImage(settings.backgroundImage); + const nextSettings = { + ...settings, + accentColor: normalizeAccentColor(settings.accentColor), + backgroundImage, + backgroundImageMode: normalizeBackgroundImageMode(settings.backgroundImageMode), + frostedGlass: backgroundImage ? settings.frostedGlass : false + }; + + await setApplyProgress( + 55, + backgroundImage ? 'Applying wallpaper and preferences...' : 'Applying preferences...' + ); + + settingsStore.set(nextSettings); + settings = { ...nextSettings }; + + await setApplyProgress(85, 'Refreshing workspace...'); + applyProgressVariant = 'success'; + await setApplyProgress(100, 'Settings applied'); + scheduleApplyProgressReset(900); + } catch (error) { + applyProgressVariant = 'danger'; + applyProgress = 100; + applyProgressStatus = getApplyErrorMessage(error); + scheduleApplyProgressReset(4000); + } finally { + isApplyingSettings = false; + } + } + + function getApplyErrorMessage(error: unknown): string { + if (isStorageQuotaError(error)) { + return 'This wallpaper is too large to save locally. Choose a smaller image or pick one from the server.'; + } + + return error instanceof Error ? error.message : 'Unable to apply settings.'; + } + + function isStorageQuotaError(error: unknown): boolean { + const errorText = error instanceof Error ? `${error.name} ${error.message}` : String(error); + return /quota|NS_ERROR_DOM_QUOTA_REACHED|exceeded/i.test(errorText); + } + function handleCancel() { + clearApplyProgressResetTimer(); + applyProgress = 0; + applyProgressStatus = ''; + applyProgressVariant = 'default'; settings = { ...$settingsStore }; } function handleReset() { + clearApplyProgressResetTimer(); + applyProgress = 0; + applyProgressStatus = ''; + applyProgressVariant = 'default'; settingsStore.reset(); settings = { ...$settingsStore }; } @@ -300,21 +402,26 @@ size="sm" onclick={handleCancel} title="Discard changes" - disabled={!hasChanges} + disabled={!hasChanges || isApplyingSettings} > - + +
From 1848f61e27939ec7d30f01cd163ba4f680e3f8b5 Mon Sep 17 00:00:00 2001 From: jR4dh3y Date: Fri, 12 Jun 2026 23:42:03 +0530 Subject: [PATCH 2/3] Add editable path suggestions --- frontend/src/lib/components/Toolbar.svelte | 538 +++++++++++++++++++- frontend/src/lib/components/ui/Toast.svelte | 17 +- frontend/src/lib/utils/fileTypes.ts | 67 ++- frontend/src/routes/browse/+page.svelte | 3 +- 4 files changed, 593 insertions(+), 32 deletions(-) diff --git a/frontend/src/lib/components/Toolbar.svelte b/frontend/src/lib/components/Toolbar.svelte index f5a026b..45077ba 100644 --- a/frontend/src/lib/components/Toolbar.svelte +++ b/frontend/src/lib/components/Toolbar.svelte @@ -9,10 +9,24 @@ Home, RefreshCw, Settings, - FolderUp + FolderUp, + Folder, + Loader2 } from 'lucide-svelte'; + import { tick } from 'svelte'; + import { listDirectory, listRoots } from '$lib/api/files'; import SearchBar from '$lib/components/SearchBar.svelte'; + interface PathSuggestion { + path: string; + } + + interface PathDisplayPart { + text: string; + selected: boolean; + caret?: boolean; + } + interface Props { pathSegments?: string[]; canGoBack?: boolean; @@ -31,6 +45,7 @@ searchLoading?: boolean; onSearchInput?: (query: string) => void; onSearchClear?: () => void; + includeHiddenSuggestions?: boolean; } let { @@ -50,9 +65,47 @@ searchValue = '', searchLoading = false, onSearchInput, - onSearchClear + onSearchClear, + includeHiddenSuggestions = false }: Props = $props(); + const suggestionPageSize = 1000; + + let isEditingPath = $state(false); + let pathDraft = $state(''); + let suggestions = $state([]); + let activeSuggestionIndex = $state(0); + let suggestionSelectedByKeyboard = $state(false); + let isLoadingSuggestions = $state(false); + let pathSelectionStart = $state(0); + let pathSelectionEnd = $state(0); + let pathInputEl = $state(); + let suggestionListEl = $state(); + let suggestionTimer: ReturnType | undefined; + let blurTimer: ReturnType | undefined; + let suggestionRequestId = 0; + + const currentPath = $derived(pathSegments.join('/')); + const activeSuggestion = $derived(suggestions[activeSuggestionIndex] ?? null); + const completionSuffix = $derived.by(() => { + if (!isEditingPath || !activeSuggestion) return ''; + + const normalizedDraft = normalizeInputPath(pathDraft).toLocaleLowerCase(); + const suggestionPath = activeSuggestion.path; + + if (!suggestionPath.toLocaleLowerCase().startsWith(normalizedDraft)) return ''; + return suggestionPath.slice(normalizedDraft.length); + }); + const hasPathSelection = $derived(pathSelectionStart !== pathSelectionEnd); + const pathSelectionMin = $derived(Math.min(pathSelectionStart, pathSelectionEnd)); + const pathSelectionMax = $derived(Math.max(pathSelectionStart, pathSelectionEnd)); + const draftDisplayParts = $derived.by(() => + buildPathDisplayParts(pathDraft, pathSelectionMin, pathSelectionMax, pathSelectionStart) + ); + const completionDisplayText = $derived( + !hasPathSelection && pathDraft.length > 0 ? formatPathText(completionSuffix) : '' + ); + function buildPath(index: number): string { return pathSegments.slice(0, index + 1).join('/'); } @@ -65,12 +118,366 @@ onNavigate?.(''); } + function normalizeInputPath(value: string): string { + return value + .replaceAll('\\', '/') + .replace(/^\/+/, '') + .replace(/\/{2,}/g, '/'); + } + + function normalizeNavigablePath(value: string): string { + return normalizeInputPath(value).replace(/\/+$/, '').trim(); + } + + function formatPathText(value: string): string { + return value + .replaceAll('\\', '/') + .replace(/\/{2,}/g, '/') + .replaceAll('/', ' / '); + } + + function pushDisplayPart(parts: PathDisplayPart[], part: PathDisplayPart): void { + const previous = parts.at(-1); + if (!part.caret && previous && !previous.caret && previous.selected === part.selected) { + previous.text += part.text; + return; + } + + parts.push(part); + } + + function buildPathDisplayParts( + value: string, + selectionMin: number, + selectionMax: number, + caretOffset: number + ): PathDisplayPart[] { + const normalized = normalizeInputPath(value); + const parts: PathDisplayPart[] = []; + + for (let offset = 0; offset <= normalized.length; offset += 1) { + if (selectionMin === selectionMax && offset === caretOffset) { + pushDisplayPart(parts, { text: '', selected: false, caret: true }); + } + + if (offset === normalized.length) break; + + pushDisplayPart(parts, { + text: normalized[offset] === '/' ? ' / ' : normalized[offset], + selected: offset >= selectionMin && offset < selectionMax + }); + } + + return parts; + } + + function syncPathSelection(): void { + if (!pathInputEl) return; + + pathSelectionStart = pathInputEl.selectionStart ?? pathDraft.length; + pathSelectionEnd = pathInputEl.selectionEnd ?? pathSelectionStart; + } + + function scrollActiveSuggestionIntoView(): void { + const activeOption = suggestionListEl?.querySelector( + `[data-suggestion-index="${activeSuggestionIndex}"]` + ); + + activeOption?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + } + + function queueActiveSuggestionScroll(): void { + void tick().then(scrollActiveSuggestionIntoView); + } + + function resetSuggestionScroll(): void { + void tick().then(() => { + if (suggestionListEl) { + suggestionListEl.scrollTop = 0; + } + }); + } + + function getSuggestionParts(value: string): { parentPath: string; prefix: string } { + const normalized = normalizeInputPath(value); + const withoutTrailingSlash = normalized.replace(/\/+$/, ''); + + if (normalized.endsWith('/')) { + return { parentPath: withoutTrailingSlash, prefix: '' }; + } + + const lastSlashIndex = withoutTrailingSlash.lastIndexOf('/'); + if (lastSlashIndex === -1) { + return { parentPath: '', prefix: withoutTrailingSlash }; + } + + return { + parentPath: withoutTrailingSlash.slice(0, lastSlashIndex), + prefix: withoutTrailingSlash.slice(lastSlashIndex + 1) + }; + } + + function buildSuggestionPath(parentPath: string, name: string): string { + return parentPath ? `${parentPath}/${name}` : name; + } + + function filterSuggestions( + names: Array<{ name: string }>, + parentPath: string, + prefix: string + ): PathSuggestion[] { + const normalizedPrefix = prefix.toLocaleLowerCase(); + + return names + .filter((item) => item.name.toLocaleLowerCase().startsWith(normalizedPrefix)) + .map((item) => ({ + path: buildSuggestionPath(parentPath, item.name) + })); + } + + async function fetchRootSuggestions(prefix: string): Promise { + const response = await listRoots(); + return filterSuggestions(response.roots, '', prefix); + } + + async function fetchChildSuggestions( + parentPath: string, + prefix: string + ): Promise { + let page = 1; + let loadedCount = 0; + let totalCount = Number.POSITIVE_INFINITY; + const directories: Array<{ name: string }> = []; + + while (loadedCount < totalCount) { + const response = await listDirectory(parentPath, { + page, + pageSize: suggestionPageSize, + sortBy: 'name', + sortDir: 'asc', + includeHidden: includeHiddenSuggestions || prefix.startsWith('.'), + filter: prefix || undefined + }); + + directories.push(...response.items.filter((item) => item.isDir)); + loadedCount += response.items.length; + totalCount = response.totalCount; + + if (response.items.length === 0) break; + page += 1; + } + + return filterSuggestions(directories, parentPath, prefix); + } + + async function loadSuggestions(value: string): Promise { + const requestId = ++suggestionRequestId; + const { parentPath, prefix } = getSuggestionParts(value); + + isLoadingSuggestions = true; + + try { + const nextSuggestions = parentPath + ? await fetchChildSuggestions(parentPath, prefix) + : await fetchRootSuggestions(prefix); + + if (requestId !== suggestionRequestId) return; + + suggestions = nextSuggestions; + activeSuggestionIndex = 0; + suggestionSelectedByKeyboard = false; + resetSuggestionScroll(); + } catch { + if (requestId !== suggestionRequestId) return; + suggestions = []; + activeSuggestionIndex = 0; + suggestionSelectedByKeyboard = false; + resetSuggestionScroll(); + } finally { + if (requestId === suggestionRequestId) { + isLoadingSuggestions = false; + } + } + } + + function queueSuggestions(): void { + if (suggestionTimer) { + clearTimeout(suggestionTimer); + } + + if (!isEditingPath) return; + + const value = pathDraft; + suggestionTimer = setTimeout(() => { + void loadSuggestions(value); + }, 120); + } + + async function beginPathEdit(): Promise { + if (isEditingPath) return; + + isEditingPath = true; + pathDraft = currentPath; + pathSelectionStart = 0; + pathSelectionEnd = currentPath.length; + suggestions = []; + activeSuggestionIndex = 0; + suggestionSelectedByKeyboard = false; + queueSuggestions(); + + await tick(); + + pathInputEl?.focus(); + pathInputEl?.select(); + syncPathSelection(); + } + + function cancelPathEdit(): void { + if (suggestionTimer) { + clearTimeout(suggestionTimer); + } + + isEditingPath = false; + pathDraft = ''; + pathSelectionStart = 0; + pathSelectionEnd = 0; + suggestions = []; + activeSuggestionIndex = 0; + suggestionSelectedByKeyboard = false; + isLoadingSuggestions = false; + suggestionRequestId++; + } + + function commitPath(value: string = pathDraft): void { + const nextPath = normalizeNavigablePath(value); + cancelPathEdit(); + + if (nextPath !== currentPath) { + onNavigate?.(nextPath); + } + } + + function completePath(value: string): void { + pathDraft = `${value}/`; + activeSuggestionIndex = 0; + suggestionSelectedByKeyboard = false; + queueSuggestions(); + + void tick().then(() => { + const cursorPosition = pathDraft.length; + pathInputEl?.focus(); + pathInputEl?.setSelectionRange(cursorPosition, cursorPosition); + syncPathSelection(); + }); + } + + function handlePathBarClick(event: MouseEvent): void { + const target = event.target as HTMLElement; + if (target.closest('button') || isEditingPath) return; + + void beginPathEdit(); + } + + function handlePathBarKeydown(event: KeyboardEvent): void { + const target = event.target as HTMLElement; + if (target.closest('button, input') || isEditingPath) return; + if (event.key !== 'Enter' && event.key !== 'F2') return; + + event.preventDefault(); + void beginPathEdit(); + } + + function handlePathInput(event: Event): void { + pathDraft = normalizeInputPath((event.target as HTMLInputElement).value); + activeSuggestionIndex = 0; + suggestionSelectedByKeyboard = false; + queueSuggestions(); + + void tick().then(syncPathSelection); + } + + function handlePathInputFocus(): void { + if (blurTimer) { + clearTimeout(blurTimer); + } + + syncPathSelection(); + } + + function handlePathInputBlur(): void { + blurTimer = setTimeout(() => { + if (isEditingPath) { + cancelPathEdit(); + } + }, 120); + } + + function handlePathSubmit(event: SubmitEvent): void { + event.preventDefault(); + + const shouldUseSuggestion = + Boolean(activeSuggestion) && + (suggestionSelectedByKeyboard || (pathDraft.length > 0 && !pathDraft.endsWith('/'))); + + commitPath(shouldUseSuggestion ? activeSuggestion?.path : pathDraft); + } + + function isCaretAtEnd(): boolean { + return ( + pathInputEl?.selectionStart === pathDraft.length && + pathInputEl?.selectionEnd === pathDraft.length + ); + } + + function handlePathKeydown(event: KeyboardEvent): void { + switch (event.key) { + case 'Escape': + event.preventDefault(); + cancelPathEdit(); + break; + + case 'ArrowDown': + if (suggestions.length === 0) return; + event.preventDefault(); + activeSuggestionIndex = (activeSuggestionIndex + 1) % suggestions.length; + suggestionSelectedByKeyboard = true; + queueActiveSuggestionScroll(); + break; + + case 'ArrowUp': + if (suggestions.length === 0) return; + event.preventDefault(); + activeSuggestionIndex = + (activeSuggestionIndex - 1 + suggestions.length) % suggestions.length; + suggestionSelectedByKeyboard = true; + queueActiveSuggestionScroll(); + break; + + case 'Tab': + if (!activeSuggestion) return; + event.preventDefault(); + completePath(activeSuggestion.path); + break; + + case 'ArrowRight': + if (!activeSuggestion || !completionSuffix || !isCaretAtEnd()) return; + event.preventDefault(); + completePath(activeSuggestion.path); + break; + } + } + + function handleSuggestionPointerDown(event: PointerEvent, suggestion: PathSuggestion): void { + event.preventDefault(); + commitPath(suggestion.path); + } + const navBtnClass = 'w-7 h-7 flex items-center justify-center bg-transparent border-none rounded text-text-secondary cursor-pointer transition-all duration-100 hover:enabled:bg-surface-elevated hover:enabled:text-text-primary disabled:text-text-disabled disabled:cursor-not-allowed';
@@ -93,36 +500,131 @@
-
- {#if pathSegments.length === 0} +
+ {#if isEditingPath} +
+ + + + +
+ {:else if pathSegments.length === 0} This Server {:else} - {#each pathSegments as segment, index (index)} - {#if index > 0} - / +
+ {#each pathSegments as segment, index (index)} + {#if index > 0} + / + {/if} + {#if index === pathSegments.length - 1} + {segment} + {:else} + + {/if} + {/each} +
+ {/if} + + {#if isEditingPath && (suggestions.length > 0 || isLoadingSuggestions)} +
+ {#if isLoadingSuggestions && suggestions.length === 0} +
+ + Finding folders... +
{/if} - {#if index === pathSegments.length - 1} - {segment} - {:else} + + {#each suggestions as suggestion, index (suggestion.path)} - {/if} - {/each} + {/each} +
{/if}
diff --git a/frontend/src/lib/components/ui/Toast.svelte b/frontend/src/lib/components/ui/Toast.svelte index fb6bc29..e2d5da4 100644 --- a/frontend/src/lib/components/ui/Toast.svelte +++ b/frontend/src/lib/components/ui/Toast.svelte @@ -3,7 +3,7 @@ * Toast notification container component * Displays toast notifications from the toastStore */ - import { toastStore, type Toast } from '$lib/stores/toast.svelte'; + import { toastStore } from '$lib/stores/toast.svelte'; import { CheckCircle, XCircle, Info, AlertTriangle, X } from 'lucide-svelte'; import { fly, fade } from 'svelte/transition'; @@ -34,28 +34,31 @@ {#if toastStore.toasts.length > 0} -
+
{#each toastStore.toasts as toast (toast.id)} + {@const Icon = iconMap[toast.type]}