This commit is contained in:
Harsh Mahajan
2026-02-19 19:11:09 +05:30
parent 7f31579759
commit aecdfd5b46
4 changed files with 152 additions and 76 deletions
+55 -53
View File
@@ -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>
+32 -16
View File
@@ -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>
+51 -7
View File
@@ -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}
+14
View File
@@ -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;
};