mirror of
https://github.com/appwrite/console.git
synced 2026-06-06 19:27:48 +00:00
svelte 5
This commit is contained in:
@@ -3,33 +3,41 @@
|
||||
import type { createTreeView } from '@melt-ui/svelte';
|
||||
import { IconChevronRight } from '@appwrite.io/pink-icons-svelte';
|
||||
import { Icon, Layout, Selector, Spinner, Typography } from '@appwrite.io/pink-svelte';
|
||||
import DirectoryItemSelf from './DirectoryItem.svelte';
|
||||
|
||||
export let directories: Array<{
|
||||
title: string;
|
||||
fileCount?: number;
|
||||
fullPath: string;
|
||||
thumbnailUrl?: string;
|
||||
thumbnailIcon?: typeof Icon;
|
||||
thumbnailHtml?: string;
|
||||
children?: typeof directories;
|
||||
hasChildren?: boolean;
|
||||
showThumbnail?: boolean;
|
||||
loading?: boolean;
|
||||
}>;
|
||||
export let level = 0;
|
||||
export let containerWidth: number | undefined;
|
||||
export let selectedPath: string | undefined;
|
||||
export let onSelect:
|
||||
| ((detail: { title: string; fullPath: string; hasChildren: boolean }) => void)
|
||||
| undefined;
|
||||
let {
|
||||
directories,
|
||||
level = 0,
|
||||
containerWidth,
|
||||
selectedPath,
|
||||
onSelect
|
||||
}: {
|
||||
directories: Array<{
|
||||
title: string;
|
||||
fileCount?: number;
|
||||
fullPath: string;
|
||||
thumbnailUrl?: string;
|
||||
thumbnailIcon?: typeof Icon;
|
||||
thumbnailHtml?: string;
|
||||
children?: typeof directories;
|
||||
hasChildren?: boolean;
|
||||
showThumbnail?: boolean;
|
||||
loading?: boolean;
|
||||
}>;
|
||||
level?: number;
|
||||
containerWidth?: number;
|
||||
selectedPath?: string;
|
||||
onSelect?: (detail: { title: string; fullPath: string; hasChildren: boolean }) => void;
|
||||
} = $props();
|
||||
|
||||
const Radio = Selector.Radio;
|
||||
|
||||
let radioInputs: Array<HTMLInputElement | undefined> = [];
|
||||
let value: string | undefined;
|
||||
let thumbnailStates: Array<{ loading: boolean; error: boolean }> = [];
|
||||
let radioInputs = $state<Array<HTMLInputElement | undefined>>([]);
|
||||
let value = $state<string | undefined>(undefined);
|
||||
let thumbnailStates = $state<Array<{ loading: boolean; error: boolean }>>([]);
|
||||
|
||||
$: if (directories) {
|
||||
$effect(() => {
|
||||
if (!directories) return;
|
||||
if (thumbnailStates.length < directories.length) {
|
||||
thumbnailStates = [
|
||||
...thumbnailStates,
|
||||
@@ -41,7 +49,7 @@
|
||||
} else if (thumbnailStates.length > directories.length) {
|
||||
thumbnailStates = thumbnailStates.slice(0, directories.length);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function handleThumbnailLoad(index: number) {
|
||||
if (!thumbnailStates[index]) return;
|
||||
@@ -62,40 +70,41 @@
|
||||
|
||||
const paddingLeftStyle = `padding-left: ${32 * level + 8}px`;
|
||||
|
||||
$: if (selectedPath && directories?.length) {
|
||||
const idx = directories.findIndex((d) => d.fullPath === selectedPath);
|
||||
if (idx !== -1 && radioInputs[idx]) {
|
||||
radioInputs[idx].checked = true;
|
||||
$effect(() => {
|
||||
if (selectedPath && directories?.length) {
|
||||
const idx = directories.findIndex((d) => d.fullPath === selectedPath);
|
||||
if (idx !== -1 && radioInputs[idx]) {
|
||||
radioInputs[idx].checked = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#each directories as { title, fileCount, fullPath, thumbnailUrl, thumbnailIcon, thumbnailHtml, children, hasChildren: explicitHasChildren, showThumbnail = true, loading = false }, i}
|
||||
{@const hasChildren = explicitHasChildren ?? !!children?.length}
|
||||
{@const __MELTUI_BUILDER_1__ = $group({ id: fullPath })}
|
||||
{@const __MELTUI_BUILDER_0__ = $item({
|
||||
id: fullPath,
|
||||
hasChildren
|
||||
})}
|
||||
id: fullPath,
|
||||
hasChildren
|
||||
})}
|
||||
|
||||
<div class="directory-item-container">
|
||||
<button
|
||||
class="folder"
|
||||
type="button"
|
||||
style={paddingLeftStyle}
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
if (radioInputs[i]) radioInputs[i].checked = true;
|
||||
onSelect?.({ title, fullPath, hasChildren });
|
||||
}}
|
||||
{...__MELTUI_BUILDER_0__} use:__MELTUI_BUILDER_0__.action
|
||||
>
|
||||
{...__MELTUI_BUILDER_0__}
|
||||
use:__MELTUI_BUILDER_0__.action>
|
||||
<Layout.Stack direction="row" justifyContent="space-between">
|
||||
<Layout.Stack
|
||||
direction="row"
|
||||
justifyContent="flex-start"
|
||||
gap="xxs"
|
||||
alignItems="center"
|
||||
>
|
||||
alignItems="center">
|
||||
<div>
|
||||
<Layout.Stack direction="row" gap="xxs" alignItems="center">
|
||||
<Radio
|
||||
@@ -103,18 +112,15 @@
|
||||
name="directory"
|
||||
size="s"
|
||||
bind:value
|
||||
bind:radioInput={radioInputs[i]}
|
||||
/>
|
||||
bind:radioInput={radioInputs[i]} />
|
||||
<div
|
||||
class:folder-open={$isExpanded(fullPath)}
|
||||
class:disabled={!hasChildren}
|
||||
class="chevron-container"
|
||||
>
|
||||
class="chevron-container">
|
||||
<Icon
|
||||
icon={IconChevronRight}
|
||||
size="s"
|
||||
color="--fgcolor-neutral-tertiary"
|
||||
/>
|
||||
color="--fgcolor-neutral-tertiary" />
|
||||
</div>
|
||||
</Layout.Stack>
|
||||
</div>
|
||||
@@ -122,13 +128,11 @@
|
||||
class="title"
|
||||
style={containerWidth
|
||||
? `max-width: ${containerWidth - 100 - level * 40}px`
|
||||
: ''}>{title}</span
|
||||
>
|
||||
: ''}>{title}</span>
|
||||
{#if fileCount !== undefined}
|
||||
<div class="fileCount">
|
||||
<Typography.Text variant="m-400" color="--fgcolor-neutral-tertiary"
|
||||
>({fileCount} files)</Typography.Text
|
||||
>
|
||||
>({fileCount} files)</Typography.Text>
|
||||
</div>
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
@@ -138,16 +142,15 @@
|
||||
{/if}
|
||||
|
||||
{#if thumbnailStates[i]?.error}
|
||||
<div class="thumbnail-fallback" />
|
||||
<div class="thumbnail-fallback"></div>
|
||||
{:else if thumbnailUrl}
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt="Directory thumbnail"
|
||||
class="thumbnail"
|
||||
class:hidden={thumbnailStates[i]?.loading}
|
||||
on:load={() => handleThumbnailLoad(i)}
|
||||
on:error={() => handleThumbnailError(i)}
|
||||
/>
|
||||
onload={() => handleThumbnailLoad(i)}
|
||||
onerror={() => handleThumbnailError(i)} />
|
||||
{:else if thumbnailIcon}
|
||||
<div class="thumbnail">
|
||||
<Icon icon={thumbnailIcon} size="l" />
|
||||
@@ -164,13 +167,12 @@
|
||||
|
||||
{#if children}
|
||||
<div {...__MELTUI_BUILDER_1__} use:__MELTUI_BUILDER_1__.action>
|
||||
<svelte:self
|
||||
<DirectoryItemSelf
|
||||
directories={children}
|
||||
level={level + 1}
|
||||
{containerWidth}
|
||||
{selectedPath}
|
||||
{onSelect}
|
||||
/>
|
||||
{onSelect} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -3,17 +3,30 @@
|
||||
import { onMount, setContext } from 'svelte';
|
||||
import { writable, type Writable } from 'svelte/store';
|
||||
import DirectoryItem from '$lib/components/git/DirectoryItem.svelte';
|
||||
import type { DirectoryEntry } from '$lib/components/git/types';
|
||||
import { Spinner } from '@appwrite.io/pink-svelte';
|
||||
|
||||
export let expanded: Writable<string[]> = writable(['lib-0', 'tree-0']);
|
||||
export let selected: string | undefined;
|
||||
export let openTo: string | undefined;
|
||||
export let directories: Array<Record<string, unknown>>;
|
||||
export let isLoading = true;
|
||||
export let onSelect:
|
||||
| ((detail: { fullPath: string; hasChildren: boolean; title: string }) => void | Promise<void>)
|
||||
| undefined;
|
||||
export let onChange: ((detail: { fullPath: string }) => void | Promise<void>) | undefined;
|
||||
let {
|
||||
expanded = $bindable(writable(['lib-0', 'tree-0'])),
|
||||
selected = $bindable(undefined),
|
||||
openTo,
|
||||
directories,
|
||||
isLoading = true,
|
||||
onSelect,
|
||||
onChange
|
||||
}: {
|
||||
expanded?: Writable<string[]>;
|
||||
selected?: string;
|
||||
openTo?: string;
|
||||
directories: DirectoryEntry[];
|
||||
isLoading?: boolean;
|
||||
onSelect?: (detail: {
|
||||
fullPath: string;
|
||||
hasChildren: boolean;
|
||||
title: string;
|
||||
}) => void | Promise<void>;
|
||||
onChange?: (detail: { fullPath: string }) => void | Promise<void>;
|
||||
} = $props();
|
||||
|
||||
const ctx = createTreeView({ expanded });
|
||||
setContext('tree', ctx);
|
||||
@@ -22,11 +35,13 @@
|
||||
elements: { tree }
|
||||
} = ctx;
|
||||
|
||||
let rootContainer: HTMLDivElement | undefined;
|
||||
let containerWidth: number | undefined;
|
||||
let internalSelected: string | undefined;
|
||||
let rootContainer = $state<HTMLDivElement | undefined>(undefined);
|
||||
let containerWidth = $state<number | undefined>(undefined);
|
||||
let internalSelected = $state<string | undefined>(undefined);
|
||||
|
||||
$: internalSelected = selected;
|
||||
$effect(() => {
|
||||
internalSelected = selected;
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
updateWidth();
|
||||
@@ -63,7 +78,9 @@
|
||||
if (onSelect) onSelect(detail);
|
||||
}
|
||||
|
||||
$: containerWidth = rootContainer ? rootContainer.getBoundingClientRect().width : undefined;
|
||||
$effect(() => {
|
||||
containerWidth = rootContainer ? rootContainer.getBoundingClientRect().width : undefined;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window on:resize={updateWidth} />
|
||||
@@ -78,8 +95,7 @@
|
||||
{directories}
|
||||
{containerWidth}
|
||||
selectedPath={internalSelected}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
onSelect={handleSelect} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -44,7 +44,19 @@
|
||||
}
|
||||
]);
|
||||
let currentPath = $state('/');
|
||||
let expandedStore = writable<string[]>([]);
|
||||
let expandedPaths = $state<string[]>([]);
|
||||
const expandedStore = writable<string[]>([]);
|
||||
|
||||
$effect(() => {
|
||||
expandedStore.set(expandedPaths);
|
||||
});
|
||||
$effect(() => {
|
||||
const unsub = expandedStore.subscribe((v) => {
|
||||
expandedPaths = v;
|
||||
});
|
||||
return unsub;
|
||||
});
|
||||
|
||||
let initialized = $state(false);
|
||||
let initialPath = $state('/');
|
||||
const inFlightPaths = new Set<string>();
|
||||
@@ -52,6 +64,7 @@
|
||||
string,
|
||||
{ fileCount: number; directories: Array<{ name: string }> }
|
||||
>();
|
||||
const iconCache = new Map<string, string | null>();
|
||||
|
||||
let hasChanges = $derived(currentPath !== initialPath);
|
||||
|
||||
@@ -84,6 +97,9 @@
|
||||
|
||||
async function detectRuntimeOrFramework(path: string): Promise<string | null> {
|
||||
try {
|
||||
if (iconCache.has(path)) {
|
||||
return iconCache.get(path) ?? null;
|
||||
}
|
||||
const detection = await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.vcs.createRepositoryDetection({
|
||||
@@ -98,12 +114,38 @@
|
||||
product === 'sites'
|
||||
? detection.framework
|
||||
: (detection as unknown as Models.DetectionRuntime).runtime;
|
||||
return resolveIconUrl(iconName);
|
||||
const resolved = resolveIconUrl(iconName);
|
||||
iconCache.set(path, resolved);
|
||||
return resolved;
|
||||
} catch (err) {
|
||||
iconCache.set(path, null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function detectIconsForChildren(parentPath: string) {
|
||||
const targetDir = getDirByPath(parentPath);
|
||||
if (!targetDir?.children?.length) return;
|
||||
|
||||
const children = targetDir.children;
|
||||
const concurrency = 3;
|
||||
let index = 0;
|
||||
|
||||
async function worker() {
|
||||
while (index < children.length) {
|
||||
const current = index;
|
||||
index += 1;
|
||||
const child = children[current];
|
||||
const icon = await detectRuntimeOrFramework(child.fullPath);
|
||||
if (icon && icon !== child.thumbnailUrl) {
|
||||
child.thumbnailUrl = icon;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, children.length) }, worker));
|
||||
}
|
||||
|
||||
async function fetchContents(path: string) {
|
||||
const cached = contentsCache.get(path);
|
||||
if (cached) return cached;
|
||||
@@ -208,8 +250,9 @@
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
expandedStore.update((exp) => [...new Set([...exp, '/'])]);
|
||||
expandedPaths = [...new Set([...expandedPaths, '/'])];
|
||||
prefetchPath(rootDir || '/');
|
||||
detectIconsForChildren('/');
|
||||
} catch (error) {
|
||||
console.error('Failed to load root directory:', error);
|
||||
isLoading = false;
|
||||
@@ -247,7 +290,7 @@
|
||||
if (contentDirectories.length === 0) {
|
||||
targetDir.hasChildren = false;
|
||||
targetDir.children = [];
|
||||
expandedStore.update((exp) => [...new Set([...exp, path])]);
|
||||
expandedPaths = [...new Set([...expandedPaths, path])];
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -260,8 +303,9 @@
|
||||
}
|
||||
|
||||
ensureChildren(path, contentDirectories);
|
||||
detectIconsForChildren(path);
|
||||
|
||||
expandedStore.update((exp) => [...new Set([...exp, path])]);
|
||||
expandedPaths = [...new Set([...expandedPaths, path])];
|
||||
} catch (error) {
|
||||
console.error('Failed to load directory:', error);
|
||||
} finally {
|
||||
@@ -303,7 +347,7 @@
|
||||
currentDir = nextDir;
|
||||
}
|
||||
|
||||
expandedStore.update((exp) => [...new Set([...exp, ...pathsToExpand])]);
|
||||
expandedPaths = [...new Set([...expandedPaths, ...pathsToExpand])];
|
||||
|
||||
// ensure each segment loads in order so deeper children appear
|
||||
for (const pathToLoad of pathsToExpand) {
|
||||
@@ -350,7 +394,7 @@
|
||||
<DirectoryPicker
|
||||
{directories}
|
||||
{isLoading}
|
||||
bind:expanded={expandedStore}
|
||||
expanded={expandedStore}
|
||||
bind:selected={currentPath}
|
||||
openTo={initialPath}
|
||||
onSelect={handleSelect}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Icon } from '@appwrite.io/pink-svelte';
|
||||
|
||||
export type DirectoryEntry = {
|
||||
title: string;
|
||||
fullPath: string;
|
||||
fileCount?: number;
|
||||
thumbnailUrl?: string;
|
||||
thumbnailIcon?: typeof Icon;
|
||||
thumbnailHtml?: string;
|
||||
children?: DirectoryEntry[];
|
||||
hasChildren?: boolean;
|
||||
showThumbnail?: boolean;
|
||||
loading?: boolean;
|
||||
};
|
||||
Reference in New Issue
Block a user