mirror of
https://github.com/appwrite/console.git
synced 2026-06-06 19:27:48 +00:00
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:
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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