diff --git a/src/lib/components/git/DirectoryItem.svelte b/src/lib/components/git/DirectoryItem.svelte
new file mode 100644
index 0000000000..baf7066013
--- /dev/null
+++ b/src/lib/components/git/DirectoryItem.svelte
@@ -0,0 +1,247 @@
+
+
+{#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
+ })}
+
+
+
+
+ {#if children}
+
+
+
+ {/if}
+
+{/each}
+
+
diff --git a/src/lib/components/git/DirectoryPicker.svelte b/src/lib/components/git/DirectoryPicker.svelte
new file mode 100644
index 0000000000..d071ab2ee8
--- /dev/null
+++ b/src/lib/components/git/DirectoryPicker.svelte
@@ -0,0 +1,132 @@
+
+
+
+
+
+ {#if isLoading}
+
+ Loading directory data...
+
+ {:else}
+
+ {/if}
+
+
+
diff --git a/src/lib/components/git/selectRootModal.svelte b/src/lib/components/git/selectRootModal.svelte
index fed59657ce..a121239cbf 100644
--- a/src/lib/components/git/selectRootModal.svelte
+++ b/src/lib/components/git/selectRootModal.svelte
@@ -6,140 +6,378 @@
import { sdk } from '$lib/stores/sdk';
import { installation, repository } from '$lib/stores/vcs';
import { VCSDetectionType, type Models } from '@appwrite.io/console';
- import { DirectoryPicker } from '@appwrite.io/pink-svelte';
- import { onMount } from 'svelte';
+ import DirectoryPicker from '$lib/components/git/DirectoryPicker.svelte';
import { writable } from 'svelte/store';
type Directory = {
title: string;
fullPath: string;
- fileCount: number;
- thumbnailUrl: string;
+ fileCount?: number;
+ thumbnailUrl?: string;
children?: Directory[];
+ hasChildren?: boolean;
loading?: boolean;
};
- export let show = false;
- export let rootDir: string;
- export let product: 'sites' | 'functions' = 'functions';
- export let branch: string;
+ let {
+ show = $bindable(false),
+ rootDir = $bindable(''),
+ product = 'functions' as 'sites' | 'functions',
+ branch
+ }: {
+ show?: boolean;
+ rootDir?: string;
+ product?: 'sites' | 'functions';
+ branch: string;
+ } = $props();
- let isLoading = true;
- let directories: Directory[] = [
+ let isLoading = $state(true);
+ let directories = $state([
{
- title: 'Root',
- fullPath: './',
- fileCount: 0,
- thumbnailUrl: 'root',
+ title: 'Repository (root)',
+ fullPath: '/',
+ fileCount: undefined,
+ thumbnailUrl: $iconPath('empty', 'grayscale'),
children: [],
+ hasChildren: true,
loading: false
}
- ];
- let currentPath: string = './';
- let currentDir: Directory;
- export let expanded = writable(['lib-0', 'tree-0']);
+ ]);
+ let currentPath = $state('/');
+ let expandedPaths = $state([]);
+ const expandedStore = writable([]);
- onMount(async () => {
+ $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();
+ const contentsCache = new Map<
+ string,
+ { fileCount: number; directories: Array<{ name: string }> }
+ >();
+ const iconCache = new Map();
+
+ let hasChanges = $derived(currentPath !== initialPath);
+
+ const iconAliases = new Map([
+ ['svelte-kit', 'svelte'],
+ ['sveltekit', 'svelte'],
+ ['svelte_kit', 'svelte'],
+ ['sveltejs', 'svelte'],
+ ['other', 'empty']
+ ]);
+
+ function normalizePath(path: string): string {
+ if (!path || path === './' || path === '/') return '/';
+ const trimmed = path.replace(/^\.\//, '').replace(/^\/+/, '').replace(/\/$/, '');
+ return `/${trimmed}`;
+ }
+
+ function toProviderPath(path: string): string {
+ const normalized = normalizePath(path);
+ if (normalized === '/') return './';
+ return `./${normalized.slice(1)}`;
+ }
+
+ function resolveIconUrl(rawIconName: string | null | undefined): string | null {
+ if (!rawIconName) return null;
+ const normalized = rawIconName.toLowerCase();
+ const iconName = iconAliases.get(normalized) ?? normalized;
+ return $iconPath(iconName, 'color');
+ }
+
+ async function detectRuntimeOrFramework(path: string): Promise {
try {
- const content = await sdk
+ if (iconCache.has(path)) {
+ return iconCache.get(path) ?? null;
+ }
+ const detection = await sdk
.forProject(page.params.region, page.params.project)
- .vcs.getRepositoryContents({
+ .vcs.createRepositoryDetection({
installationId: $installation.$id,
providerRepositoryId: $repository.id,
- providerRootDirectory: currentPath,
- providerReference: branch
+ type:
+ product === 'sites' ? VCSDetectionType.Framework : VCSDetectionType.Runtime,
+ providerRootDirectory: toProviderPath(path)
});
- directories[0].fileCount = content.contents?.length ?? 0;
- directories[0].children = content.contents
- .filter((e) => e.isDirectory)
- .map((dir) => ({
- title: dir.name,
- fullPath: currentPath + dir.name,
- fileCount: undefined,
- thumbnailUrl: dir.name,
- loading: false
- }));
- currentDir = directories[0];
- isLoading = false;
- } catch {
+
+ const iconName =
+ product === 'sites'
+ ? detection.framework
+ : (detection as unknown as Models.DetectionRuntime).runtime;
+ 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;
+
+ const content = await sdk
+ .forProject(page.params.region, page.params.project)
+ .vcs.getRepositoryContents({
+ installationId: $installation.$id,
+ providerRepositoryId: $repository.id,
+ providerRootDirectory: toProviderPath(path),
+ providerReference: branch
+ });
+
+ const contents = content.contents ?? [];
+ const fileCount = contents.length;
+ const directories = contents
+ .filter((e) => e.isDirectory)
+ .map((dir) => ({ name: dir.name }));
+
+ const result = { fileCount, directories };
+ contentsCache.set(path, result);
+ return result;
+ }
+
+ function ensureChildren(path: string, directories: Array<{ name: string }>) {
+ const targetDir = getDirByPath(path);
+ if (!targetDir) return;
+
+ if (directories.length === 0) {
+ targetDir.hasChildren = false;
+ targetDir.children = [];
return;
}
- });
- async function fetchContents(e: CustomEvent) {
- const path = e.detail.fullPath as string;
- currentPath = path;
+ const existingByTitle = new Map(
+ (targetDir.children ?? []).map((child) => [child.title, child])
+ );
+ targetDir.children = directories.map((dir) => {
+ const fullPath = path === '/' ? `/${dir.name}` : `${path}/${dir.name}`;
+ const existing = existingByTitle.get(dir.name);
+ if (existing) {
+ existing.fullPath = fullPath;
+ existing.hasChildren = true;
+ existing.loading = existing.loading ?? false;
+ existing.thumbnailUrl = existing.thumbnailUrl ?? $iconPath('empty', 'grayscale');
+ existing.children = existing.children ?? [];
+ return existing;
+ }
+ return {
+ title: dir.name,
+ fullPath,
+ fileCount: undefined,
+ thumbnailUrl: $iconPath('empty', 'grayscale'),
+ children: [],
+ hasChildren: true,
+ loading: false
+ };
+ });
+ }
- const pathSegments = path.split('/').filter((segment) => segment !== '.' && segment !== '');
- let traversedDir = directories[0]; // Start at root
+ async function prefetchPath(path: string) {
+ const normalized = normalizePath(path);
+ const segments = normalized.split('/').filter((s) => s !== '');
+ const pathsToLoad = ['/'];
+ let currentPath = '/';
- for (const segment of pathSegments) {
- const nextDir = traversedDir.children?.find((dir) => dir.title === segment);
- if (!nextDir) break;
- traversedDir = nextDir;
+ for (const segment of segments) {
+ currentPath = currentPath === '/' ? `/${segment}` : `${currentPath}/${segment}`;
+ pathsToLoad.push(currentPath);
}
- currentDir = traversedDir;
+ for (const pathToLoad of pathsToLoad) {
+ const { fileCount, directories } = await fetchContents(pathToLoad);
+ const targetDir = getDirByPath(pathToLoad);
+ if (targetDir) {
+ targetDir.fileCount = fileCount;
+ }
+ ensureChildren(pathToLoad, directories);
+ }
+ }
- if (!currentDir.fileCount) {
- currentDir.loading = true;
- directories = [...directories];
+ $effect(() => {
+ if (!isLoading) return;
+ (async () => {
try {
- const content = await sdk
- .forProject(page.params.region, page.params.project)
- .vcs.getRepositoryContents({
- installationId: $installation.$id,
- providerRepositoryId: $repository.id,
- providerRootDirectory: path,
- providerReference: branch
- });
-
- const fileCount = content.contents?.length ?? 0;
- const contentDirectories = content.contents.filter((e) => e.isDirectory);
-
- if (contentDirectories.length === 0) {
- return;
- }
+ const content = await fetchContents('/');
- currentDir.fileCount = fileCount;
- currentDir.children = contentDirectories.map((dir) => ({
- title: dir.name,
- fullPath: path + '/' + dir.name,
- fileCount: undefined,
- thumbnailUrl: undefined
- }));
- const runtime = await sdk
- .forProject(page.params.region, page.params.project)
- .vcs.createRepositoryDetection({
- installationId: $installation.$id,
- providerRepositoryId: $repository.id,
- type:
- product === 'sites'
- ? VCSDetectionType.Framework
- : VCSDetectionType.Runtime,
- providerRootDirectory: path
- });
- if (product === 'sites') {
- currentDir.children.forEach((dir) => {
- dir.thumbnailUrl = $iconPath(runtime.framework, 'color');
- });
- } else if (product === 'functions') {
- currentDir.children.forEach((dir) => {
- dir.thumbnailUrl = $iconPath(
- (runtime as unknown as Models.DetectionRuntime).runtime,
- 'color'
- );
- });
+ const repoTitle = $repository?.name
+ ? `${$repository.name} (root)`
+ : 'Repository (root)';
+
+ directories[0] = {
+ ...directories[0],
+ title: repoTitle,
+ fileCount: content.fileCount
+ };
+ ensureChildren('/', content.directories);
+
+ const detectedIcon = await detectRuntimeOrFramework('/');
+ if (detectedIcon) {
+ directories[0].thumbnailUrl = detectedIcon;
}
- directories = [...directories];
- $expanded = [...$expanded, path];
+
+ isLoading = false;
+ expandedPaths = [...new Set([...expandedPaths, '/'])];
+ prefetchPath(rootDir || '/');
+ detectIconsForChildren('/');
} catch (error) {
- console.error(error);
- } finally {
- currentDir.loading = false;
+ console.error('Failed to load root directory:', error);
+ isLoading = false;
+ }
+ })();
+ });
+
+ function getDirByPath(path: string): Directory | null {
+ const segments = path.split('/').filter((s) => s !== '');
+ let node: Directory | null = directories[0] ?? null;
+ for (const seg of segments) {
+ const next = node?.children?.find((d) => d.title === seg) ?? null;
+ if (!next) return null;
+ node = next;
+ }
+ return node;
+ }
+
+ async function loadPath(path: string) {
+ // skip loading if this directory was done
+ const targetDir = getDirByPath(path);
+ if (!targetDir || targetDir.fileCount !== undefined) return;
+
+ if (!targetDir.children) {
+ targetDir.children = [];
+ }
+
+ if (inFlightPaths.has(path)) return;
+ inFlightPaths.add(path);
+ targetDir.loading = true;
+
+ try {
+ const { fileCount, directories: contentDirectories } = await fetchContents(path);
+
+ if (contentDirectories.length === 0) {
+ targetDir.hasChildren = false;
+ targetDir.children = [];
+ expandedPaths = [...new Set([...expandedPaths, path])];
+ return;
+ }
+
+ targetDir.fileCount = fileCount;
+
+ // set logo only for the current folder, not for the children
+ const detectedIcon = await detectRuntimeOrFramework(path);
+ if (detectedIcon) {
+ targetDir.thumbnailUrl = detectedIcon;
+ }
+
+ ensureChildren(path, contentDirectories);
+ detectIconsForChildren(path);
+
+ expandedPaths = [...new Set([...expandedPaths, path])];
+ } catch (error) {
+ console.error('Failed to load directory:', error);
+ } finally {
+ targetDir.loading = false;
+ inFlightPaths.delete(path);
+ }
+ }
+
+ async function expandToPath(path: string) {
+ const normalized = normalizePath(path);
+ const segments = normalized.split('/').filter((s) => s !== '');
+
+ const pathsToExpand = ['/'];
+
+ let currentDir = directories[0];
+ let walkPath = '/';
+
+ for (const segment of segments) {
+ walkPath = walkPath === '/' ? `/${segment}` : `${walkPath}/${segment}`;
+ pathsToExpand.push(walkPath);
+
+ if (!currentDir.children) {
+ currentDir.children = [];
}
+
+ let nextDir = currentDir.children.find((d) => d.title === segment);
+ if (!nextDir) {
+ nextDir = {
+ title: segment,
+ fullPath: walkPath,
+ fileCount: undefined,
+ thumbnailUrl: $iconPath('empty', 'grayscale'),
+ children: [],
+ hasChildren: true
+ };
+ currentDir.children = [...currentDir.children, nextDir];
+ }
+
+ currentDir = nextDir;
}
+
+ expandedPaths = [...new Set([...expandedPaths, ...pathsToExpand])];
+
+ // ensure each segment loads in order so deeper children appear
+ for (const pathToLoad of pathsToExpand) {
+ // eslint-disable-next-line no-await-in-loop
+ await loadPath(pathToLoad);
+ }
+
+ currentPath = normalized;
+ }
+
+ $effect(() => {
+ if (show && !initialized && !isLoading) {
+ initialized = true;
+ const normalized = normalizePath(rootDir || '/');
+ initialPath = normalized;
+ currentPath = normalized;
+ expandToPath(normalized);
+ }
+ });
+
+ // reset state when modal closes
+ $effect(() => {
+ if (!show && initialized) {
+ initialized = false;
+ }
+ });
+
+ function handleSelect(detail: { fullPath: string }) {
+ loadPath(detail.fullPath);
}
function handleSubmit() {
@@ -155,13 +393,13 @@
+ expanded={expandedStore}
+ bind:selected={currentPath}
+ openTo={initialPath}
+ onSelect={handleSelect} />
-
+
diff --git a/src/lib/components/git/types.ts b/src/lib/components/git/types.ts
new file mode 100644
index 0000000000..04fb88738d
--- /dev/null
+++ b/src/lib/components/git/types.ts
@@ -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;
+};