Merge pull request #2453 from appwrite/fix-SER-428-Quality-Check-root-drectory-modal-issue

Fix: improve root directory modal behavior and UX
This commit is contained in:
Harsh Mahajan
2026-02-20 16:26:03 +05:30
committed by GitHub
4 changed files with 744 additions and 113 deletions
+247
View File
@@ -0,0 +1,247 @@
<script lang="ts">
import { getContext } from 'svelte';
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';
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 = $state<Array<HTMLInputElement | undefined>>([]);
let value = $state<string | undefined>(undefined);
let thumbnailStates = $state<Array<{ loading: boolean; error: boolean }>>([]);
$effect(() => {
if (!directories) return;
if (thumbnailStates.length < directories.length) {
thumbnailStates = [
...thumbnailStates,
...Array.from({ length: directories.length - thumbnailStates.length }, () => ({
loading: true,
error: false
}))
];
} else if (thumbnailStates.length > directories.length) {
thumbnailStates = thumbnailStates.slice(0, directories.length);
}
});
function handleThumbnailLoad(index: number) {
if (!thumbnailStates[index]) return;
thumbnailStates[index].loading = false;
thumbnailStates[index].error = false;
}
function handleThumbnailError(index: number) {
if (!thumbnailStates[index]) return;
thumbnailStates[index].loading = false;
thumbnailStates[index].error = true;
}
const {
elements: { item, group },
helpers: { isExpanded }
} = getContext<ReturnType<typeof createTreeView>>('tree');
const paddingLeftStyle = `padding-left: ${32 * level + 8}px`;
$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
})}
<div class="directory-item-container">
<button
class="folder"
type="button"
style={paddingLeftStyle}
onclick={() => {
if (radioInputs[i]) radioInputs[i].checked = true;
onSelect?.({ title, fullPath, hasChildren });
}}
{...__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">
<div>
<Layout.Stack direction="row" gap="xxs" alignItems="center">
<Radio
group="directory"
name="directory"
size="s"
bind:value
bind:radioInput={radioInputs[i]} />
<div
class:folder-open={$isExpanded(fullPath)}
class:disabled={!hasChildren}
class="chevron-container">
<Icon
icon={IconChevronRight}
size="s"
color="--fgcolor-neutral-tertiary" />
</div>
</Layout.Stack>
</div>
<span
class="title"
style={containerWidth
? `max-width: ${containerWidth - 100 - level * 40}px`
: ''}>{title}</span>
{#if fileCount !== undefined}
<div class="fileCount">
<Typography.Text variant="m-400" color="--fgcolor-neutral-tertiary"
>({fileCount} files)</Typography.Text>
</div>
{/if}
</Layout.Stack>
{#if showThumbnail}
{#if loading || (thumbnailStates[i]?.loading && !thumbnailIcon && !thumbnailHtml)}
<Spinner />
{/if}
{#if thumbnailStates[i]?.error}
<div class="thumbnail-fallback"></div>
{:else if thumbnailUrl}
<img
src={thumbnailUrl}
alt="Directory thumbnail"
class="thumbnail"
class:hidden={thumbnailStates[i]?.loading}
onload={() => handleThumbnailLoad(i)}
onerror={() => handleThumbnailError(i)} />
{:else if thumbnailIcon}
<div class="thumbnail">
<Icon icon={thumbnailIcon} size="l" />
</div>
{:else if thumbnailHtml}
<div class="thumbnail">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html thumbnailHtml}
</div>
{/if}
{/if}
</Layout.Stack>
</button>
{#if children}
<div {...__MELTUI_BUILDER_1__} use:__MELTUI_BUILDER_1__.action>
<DirectoryItemSelf
directories={children}
level={level + 1}
{containerWidth}
{selectedPath}
{onSelect} />
</div>
{/if}
</div>
{/each}
<style>
.directory-item-container {
width: 100%;
}
.folder {
display: flex;
width: 100%;
flex-direction: row;
padding: var(--space-3, 6px) var(--space-4, 8px);
justify-content: space-between;
align-items: center;
cursor: pointer;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
&:hover,
&:focus {
border-radius: var(--border-radius-s, 8px);
background: var(--bgcolor-neutral-secondary, #f4f4f7);
}
}
.chevron-container {
width: var(--space-7);
height: var(--space-7);
transition: transform ease-in-out 0.1s;
}
.folder-open {
transform: rotate(90deg);
}
.disabled {
color: var(--fgcolor-neutral-tertiary);
}
.title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-grow: 0;
}
.fileCount {
display: none;
@media (min-width: 1024px) {
display: block;
}
}
.hidden {
display: none;
}
.thumbnail {
width: var(--icon-size-l, 24px);
height: var(--icon-size-l, 24px);
flex-shrink: 0;
border-radius: var(--border-radius-circle, 99999px);
}
.thumbnail-fallback {
width: var(--icon-size-l, 24px);
height: var(--icon-size-l, 24px);
flex-shrink: 0;
border-radius: var(--border-radius-circle, 99999px);
border: var(--border-width-s, 1px) dashed var(--border-neutral-strong, #d8d8db);
background: var(--bgcolor-neutral-primary, #fff);
}
</style>
@@ -0,0 +1,132 @@
<script lang="ts">
import { createTreeView } from '@melt-ui/svelte';
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';
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);
const {
elements: { tree }
} = ctx;
let rootContainer = $state<HTMLDivElement | undefined>(undefined);
let containerWidth = $state<number | undefined>(undefined);
let internalSelected = $state<string | undefined>(undefined);
$effect(() => {
internalSelected = selected;
});
onMount(() => {
updateWidth();
if (openTo) {
const pathSegments = openTo.split('/').filter(Boolean);
const pathsToExpand: string[] = [];
let currentPath = '';
for (const segment of pathSegments) {
currentPath += '/' + segment;
pathsToExpand.push(currentPath);
}
if (pathsToExpand.length > 0) {
expanded?.update((current) => {
const next = [...current];
pathsToExpand.forEach((path) => {
if (!next.includes(path)) {
next.push(path);
}
});
return next;
});
}
}
});
function updateWidth() {
containerWidth = rootContainer ? rootContainer.getBoundingClientRect().width : undefined;
}
function handleSelect(detail: { fullPath: string; hasChildren: boolean; title: string }) {
internalSelected = detail.fullPath;
selected = internalSelected;
if (onChange) onChange({ fullPath: detail.fullPath });
if (onSelect) onSelect(detail);
}
$effect(() => {
containerWidth = rootContainer ? rootContainer.getBoundingClientRect().width : undefined;
});
</script>
<svelte:window onresize={updateWidth} />
<div class="directory-container" class:isLoading {...$tree} bind:this={rootContainer}>
{#if isLoading}
<div class="loading-container">
<Spinner /><span>Loading directory data...</span>
</div>
{:else}
<DirectoryItem
{directories}
{containerWidth}
selectedPath={internalSelected}
onSelect={handleSelect} />
{/if}
</div>
<style>
.directory-container {
width: 560px;
max-width: 100%;
height: 316px;
overflow-y: auto;
flex-shrink: 0;
display: flex;
padding: var(--space-2, 4px);
border-radius: var(--border-radius-m, 12px);
border: var(--border-width-s, 1px) solid var(--border-neutral, #ededf0);
background: var(--bgcolor-neutral-primary, #fff);
&::-webkit-scrollbar {
display: none;
}
}
.isLoading {
justify-content: center;
align-items: center;
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--gap-m);
}
</style>
+351 -113
View File
@@ -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<Directory[]>([
{
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<string[]>([]);
const expandedStore = writable<string[]>([]);
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<string>();
const contentsCache = new Map<
string,
{ fileCount: number; directories: Array<{ name: string }> }
>();
const iconCache = new Map<string, string | null>();
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<string | null> {
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;
}
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
};
});
}
async function prefetchPath(path: string) {
const normalized = normalizePath(path);
const segments = normalized.split('/').filter((s) => s !== '');
const pathsToLoad = ['/'];
let currentPath = '/';
for (const segment of segments) {
currentPath = currentPath === '/' ? `/${segment}` : `${currentPath}/${segment}`;
pathsToLoad.push(currentPath);
}
for (const pathToLoad of pathsToLoad) {
const { fileCount, directories } = await fetchContents(pathToLoad);
const targetDir = getDirByPath(pathToLoad);
if (targetDir) {
targetDir.fileCount = fileCount;
}
ensureChildren(pathToLoad, directories);
}
}
$effect(() => {
if (!isLoading) return;
(async () => {
try {
const content = await fetchContents('/');
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;
}
isLoading = false;
expandedPaths = [...new Set([...expandedPaths, '/'])];
prefetchPath(rootDir || '/');
detectIconsForChildren('/');
} catch (error) {
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);
}
});
async function fetchContents(e: CustomEvent) {
const path = e.detail.fullPath as string;
currentPath = path;
const pathSegments = path.split('/').filter((segment) => segment !== '.' && segment !== '');
let traversedDir = directories[0]; // Start at root
for (const segment of pathSegments) {
const nextDir = traversedDir.children?.find((dir) => dir.title === segment);
if (!nextDir) break;
traversedDir = nextDir;
// reset state when modal closes
$effect(() => {
if (!show && initialized) {
initialized = false;
}
});
currentDir = traversedDir;
if (!currentDir.fileCount) {
currentDir.loading = true;
directories = [...directories];
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;
}
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'
);
});
}
directories = [...directories];
$expanded = [...$expanded, path];
} catch (error) {
console.error(error);
} finally {
currentDir.loading = false;
}
}
function handleSelect(detail: { fullPath: string }) {
loadPath(detail.fullPath);
}
function handleSubmit() {
@@ -155,13 +393,13 @@
<DirectoryPicker
{directories}
{isLoading}
bind:expanded
selected={currentPath}
openTo={currentPath}
on:select={fetchContents} />
expanded={expandedStore}
bind:selected={currentPath}
openTo={initialPath}
onSelect={handleSelect} />
<svelte:fragment slot="footer">
<Button secondary on:click={() => (show = false)}>Cancel</Button>
<Button submit disabled={isLoading}>Save</Button>
<Button submit disabled={isLoading || !hasChanges}>Save</Button>
</svelte:fragment>
</Modal>
+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;
};