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; +};